From 890704f432c5b3267b4e7ceedfa9a2edf613bf42 Mon Sep 17 00:00:00 2001 From: Alexander Droste Date: Wed, 1 Jul 2026 17:07:28 +0100 Subject: [PATCH 001/104] feat: add CUDA cuDF convenience API (#8624) See the `README.md` added as part of this PR how to use pyVortex CUDA. Signed-off-by: Alexander Droste --- vortex-cuda/src/arrow/canonical.rs | 273 ++++++++++++++++-- vortex-cuda/src/device_buffer.rs | 72 +++++ vortex-cuda/src/stream.rs | 64 +++- vortex-python-cuda/README.md | 62 ++++ .../python/vortex_cuda/__init__.py | 85 +++++- .../python/vortex_cuda/_lib.pyi | 4 + vortex-python-cuda/src/lib.rs | 86 ++++++ vortex-python-cuda/test/test_cuda.py | 208 ++++++++++++- vortex-python-cuda/test/test_native_bridge.py | 89 ++++++ vortex-python/python/vortex/_lib/dtype.pyi | 1 + vortex-python/src/dtype/mod.rs | 5 + 11 files changed, 915 insertions(+), 34 deletions(-) create mode 100644 vortex-python-cuda/README.md diff --git a/vortex-cuda/src/arrow/canonical.rs b/vortex-cuda/src/arrow/canonical.rs index 109e343e424..d5fa1c26682 100644 --- a/vortex-cuda/src/arrow/canonical.rs +++ b/vortex-cuda/src/arrow/canonical.rs @@ -75,6 +75,7 @@ use crate::arrow::arrow_schema_for_array; use crate::arrow::cuda_decimal_value_type; use crate::arrow::list_view::export_device_list_view; use crate::cub::exclusive_sum_i32; +use crate::device_buffer::CUDF_VALIDITY_BUFFER_PADDING; use crate::executor::CudaArrayExt; use crate::executor::execute_validity_cuda; @@ -764,7 +765,7 @@ fn gather_binary_values( /// /// Returns `None` for the buffer when Arrow can omit validity because all rows are valid. /// -/// Returned buffers use zeroed 4-byte padding so cuDF's word-sized mask reads stay in bounds. +/// Returned buffers use zeroed cuDF-sized padding so mask reads stay in bounds. /// Bits at positions `>= len + arrow_offset` within the final data byte are unspecified, as /// Arrow permits. pub(super) async fn export_arrow_validity_buffer( @@ -773,6 +774,11 @@ pub(super) async fn export_arrow_validity_buffer( arrow_offset: usize, ctx: &mut CudaExecutionCtx, ) -> VortexResult<(Option, i64)> { + // Empty arrays do not need a validity buffer; avoid zero-sized CUDA allocations. + if len == 0 { + return Ok((None, 0)); + } + // Validity is exported separately from the array data. Decode it here so Arrow // gets a device-resident validity buffer alongside the array it belongs to. let validity = execute_validity_cuda(validity, len, ctx).await?; @@ -796,16 +802,18 @@ pub(super) async fn export_arrow_validity_buffer( })?; let BoolDataParts { bits, meta } = array.into_data().into_parts(len); let bitmap = ctx.ensure_on_device(bits).await?; - // ArrowDeviceArray uses ArrowArray layout with its buffers being device pointers. - // - // Validity is one bit per row, addressed via the Arrow array offset. Reuse the bitmap - // when Vortex's validity offset already matches Arrow's; otherwise repack on the GPU - // so row i is at Arrow bit `arrow_offset + i`. - let bitmap = if meta.offset() == arrow_offset { - bitmap - } else { - repack_arrow_validity_buffer(&bitmap, meta.offset(), len, arrow_offset, ctx)? - }; + let bitmap = + match export_arrow_validity_bitmap(&bitmap, meta.offset(), len, arrow_offset, ctx)? + { + Some(bitmap) => bitmap, + None => repack_arrow_validity_buffer( + &bitmap, + meta.offset(), + len, + arrow_offset, + ctx, + )?, + }; // Keep nullable exports self-describing for consumers that require exact null counts. let null_count = count_arrow_validity_nulls(&bitmap, len, arrow_offset, ctx)?; Ok((Some(bitmap), null_count)) @@ -826,12 +834,78 @@ fn device_zeroed_byte_buffer( byte_len: usize, ctx: &mut CudaExecutionCtx, ) -> VortexResult { - let allocation_len = byte_len.next_multiple_of(size_of::()).max(1); + vortex_ensure!( + byte_len > 0, + "zero-length validity buffers should be omitted" + ); + let allocation_len = byte_len.next_multiple_of(CUDF_VALIDITY_BUFFER_PADDING); let mut buffer = ctx.device_alloc::(allocation_len)?; ctx.stream() .memset_zeros(&mut buffer) .map_err(|err| vortex_err!("Failed to zero Arrow validity buffer: {err}"))?; - Ok(BufferHandle::new_device(Arc::new(CudaDeviceBuffer::new(buffer))).slice(0..byte_len)) + // The memset above zeroed the whole allocation, including cuDF tail padding. + Ok( + BufferHandle::new_device(Arc::new(CudaDeviceBuffer::new_with_zeroed_tail(buffer, 0)?)) + .slice(0..byte_len), + ) +} + +/// Exports a matching-offset bitmap by reusing it or copying it into zero-padded storage. +fn export_arrow_validity_bitmap( + bitmap: &BufferHandle, + input_offset: usize, + len: usize, + arrow_offset: usize, + ctx: &mut CudaExecutionCtx, +) -> VortexResult> { + if input_offset != arrow_offset { + return Ok(None); + } + + let output_bytes = validity_bitmap_byte_len(len, arrow_offset)?; + let allocation_bytes = output_bytes.next_multiple_of(CUDF_VALIDITY_BUFFER_PADDING); + if bitmap.has_zeroed_tail_padding(output_bytes, allocation_bytes)? { + return Ok(Some(bitmap.slice(0..output_bytes))); + } + + copy_arrow_validity_buffer(bitmap, output_bytes, ctx).map(Some) +} + +/// Copies a validity bitmap into a new cuDF-padded buffer without shifting bits. +fn copy_arrow_validity_buffer( + input_buffer: &BufferHandle, + output_bytes: usize, + ctx: &mut CudaExecutionCtx, +) -> VortexResult { + vortex_ensure!( + output_bytes > 0, + "zero-length validity buffers should be omitted" + ); + vortex_ensure!( + input_buffer.len() >= output_bytes, + "Arrow validity bitmap has {} bytes, expected at least {output_bytes}", + input_buffer.len() + ); + + let allocation_bytes = output_bytes.next_multiple_of(CUDF_VALIDITY_BUFFER_PADDING); + let mut output = ctx.device_alloc::(allocation_bytes)?; + ctx.stream() + .memset_zeros(&mut output) + .map_err(|err| vortex_err!("Failed to zero Arrow validity buffer padding: {err}"))?; + + let input_view = input_buffer.cuda_view::()?.slice(0..output_bytes); + let mut output_view = output.slice_mut(0..output_bytes); + ctx.stream() + .memcpy_dtod(&input_view, &mut output_view) + .map_err(|err| vortex_err!("Failed to copy Arrow validity buffer: {err}"))?; + + Ok( + BufferHandle::new_device(Arc::new(CudaDeviceBuffer::new_with_zeroed_tail( + output, + output_bytes, + )?)) + .slice(0..output_bytes), + ) } pub fn count_arrow_validity_nulls( @@ -894,8 +968,8 @@ pub fn count_arrow_validity_nulls( /// /// Vortex bitmaps may start at any bit offset. Arrow exposes only a byte-addressed validity buffer /// plus an array offset, so sliced compact exports need a GPU rewrite when either side has a -/// bit-level offset. The kernel writes the output one 64-bit word at a time, funnel-shifting two -/// adjacent input words, so the allocation is padded to whole words (zeroed by the edge masks). +/// bit-level offset. The output handle keeps Arrow's logical byte length, while the backing +/// allocation is zero-padded to cuDF's mask allocation size for consumers that read full masks. pub fn repack_arrow_validity_buffer( input_buffer: &BufferHandle, input_offset: usize, @@ -904,7 +978,18 @@ pub fn repack_arrow_validity_buffer( ctx: &mut CudaExecutionCtx, ) -> VortexResult { let output_bytes = validity_bitmap_byte_len(len, arrow_offset)?; + vortex_ensure!( + output_bytes > 0, + "zero-length validity buffers should be omitted" + ); + // The CUDA kernel writes the bitmap as u64 words, so round the logical byte length up to the + // number of words that cover the exported Arrow bytes. let output_words = output_bytes.div_ceil(size_of::()); + // `device_alloc::` takes a word count, while the padding policy is expressed in bytes. + // Round up so the padded byte allocation is fully represented by whole u64 words. + let allocation_words = output_bytes + .next_multiple_of(CUDF_VALIDITY_BUFFER_PADDING) + .div_ceil(size_of::()); // The kernel loads the input bitmap as 64-bit words. if !input_buffer @@ -914,8 +999,14 @@ pub fn repack_arrow_validity_buffer( vortex_bail!("Arrow validity repack requires an 8-byte aligned device buffer"); } - let output = ctx.device_alloc::(output_words.max(1))?; - let output_device = CudaDeviceBuffer::new(output); + let mut output = ctx.device_alloc::(allocation_words.max(1))?; + // The repack kernel writes only the logical bitmap words. Zero the whole backing allocation so + // cuDF's padded mask reads see invalid rows, not uninitialized CUDA memory. + ctx.stream() + .memset_zeros(&mut output) + .map_err(|err| vortex_err!("Failed to zero Arrow validity buffer padding: {err}"))?; + // The memset above zeroed all allocation bytes after the logical output. + let output_device = CudaDeviceBuffer::new_with_zeroed_tail(output, output_bytes)?; if output_words > 0 { let input_view = input_buffer.cuda_view::()?; @@ -1331,6 +1422,7 @@ mod tests { use vortex::error::vortex_bail; use vortex::extension::datetime::TimeUnit; + use crate::CudaBufferExt; use crate::CudaExecutionCtx; use crate::arrow::ARROW_DEVICE_CUDA; use crate::arrow::ArrowArray; @@ -1339,6 +1431,7 @@ mod tests { use crate::arrow::PrivateData; use crate::arrow::canonical::export_arrow_validity_buffer; use crate::arrow::canonical::repack_arrow_validity_buffer; + use crate::device_buffer::CUDF_VALIDITY_BUFFER_PADDING; use crate::device_buffer::cuda_backing_allocation; use crate::session::CudaSession; @@ -2955,13 +3048,152 @@ mod tests { let backing_bytes = backing.to_host_sync(); assert_eq!( backing_bytes.len(), - output_bytes.next_multiple_of(size_of::()) + output_bytes.next_multiple_of(CUDF_VALIDITY_BUFFER_PADDING) + ); + assert!(backing_bytes[output_bytes..].iter().all(|byte| *byte == 0)); + + Ok(()) + } + + #[crate::test] + async fn test_export_validity_buffer_pads_matching_offset() -> VortexResult<()> { + let mut ctx = CudaSession::create_execution_ctx(&crate::cuda_session()) + .vortex_expect("failed to create execution context"); + + let len = 3; + let arrow_offset = 0; + let (buffer, null_count) = export_arrow_validity_buffer( + Validity::from(BitBuffer::from_iter([true, false, true])), + len, + arrow_offset, + &mut ctx, + ) + .await?; + ctx.synchronize_stream()?; + + assert_eq!(null_count, 1); + let buffer = buffer.vortex_expect("nullable validity should export a null buffer"); + let output_bytes = (len + arrow_offset).div_ceil(8); + assert_eq!(buffer.len(), output_bytes); + let actual = BitBuffer::new(buffer.to_host_sync(), len + arrow_offset) + .iter() + .collect::>(); + assert_eq!(actual, [true, false, true]); + + let backing = cuda_backing_allocation(&buffer)?; + let backing_bytes = backing.to_host_sync(); + assert_eq!( + backing_bytes.len(), + output_bytes.next_multiple_of(CUDF_VALIDITY_BUFFER_PADDING) ); assert!(backing_bytes[output_bytes..].iter().all(|byte| *byte == 0)); Ok(()) } + #[crate::test] + async fn test_export_validity_buffer_reuses_matching_padded_device_bitmap() -> VortexResult<()> + { + let mut ctx = CudaSession::create_execution_ctx(&crate::cuda_session()) + .vortex_expect("failed to create execution context"); + + let len = 3; + let source = BitBuffer::from_iter([true, false, true]); + let (input_offset, _, input_buffer) = source.into_inner(); + let input_buffer = ctx + .ensure_on_device(BufferHandle::new_host(input_buffer)) + .await?; + let input_ptr = input_buffer.cuda_device_ptr()?; + let validity = BoolArray::new_handle( + input_buffer.clone(), + input_offset, + len, + Validity::NonNullable, + ) + .into_array(); + + let (buffer, null_count) = + export_arrow_validity_buffer(Validity::Array(validity), len, input_offset, &mut ctx) + .await?; + ctx.synchronize_stream()?; + + assert_eq!(null_count, 1); + let buffer = buffer.vortex_expect("nullable validity should export a null buffer"); + assert_eq!(buffer.cuda_device_ptr()?, input_ptr); + assert_eq!(buffer.len(), (len + input_offset).div_ceil(8)); + let actual = BitBuffer::new(buffer.to_host_sync(), len + input_offset) + .iter() + .collect::>(); + let expected = std::iter::repeat_n(false, input_offset) + .chain([true, false, true]) + .collect::>(); + assert_eq!(actual, expected); + + Ok(()) + } + + #[crate::test] + async fn test_export_validity_buffer_repacks_matching_offset_without_tail_padding() + -> VortexResult<()> { + let mut ctx = CudaSession::create_execution_ctx(&crate::cuda_session()) + .vortex_expect("failed to create execution context"); + + let len = 3; + let source = BitBuffer::from_iter((0..80).map(|idx| idx % 3 != 1)); + let (input_offset, _, input_buffer) = source.into_inner(); + let input_buffer = ctx + .ensure_on_device(BufferHandle::new_host(input_buffer)) + .await?; + let input_ptr = input_buffer.cuda_device_ptr()?; + let validity = BoolArray::new_handle( + input_buffer.clone(), + input_offset, + len, + Validity::NonNullable, + ) + .into_array(); + + let (buffer, null_count) = + export_arrow_validity_buffer(Validity::Array(validity), len, input_offset, &mut ctx) + .await?; + ctx.synchronize_stream()?; + + assert_eq!(null_count, 1); + let buffer = buffer.vortex_expect("nullable validity should export a null buffer"); + assert_ne!(buffer.cuda_device_ptr()?, input_ptr); + let output_bytes = (len + input_offset).div_ceil(8); + assert_eq!(buffer.len(), output_bytes); + let actual = BitBuffer::new(buffer.to_host_sync(), len + input_offset) + .iter() + .collect::>(); + let expected = std::iter::repeat_n(false, input_offset) + .chain([true, false, true]) + .collect::>(); + assert_eq!(actual, expected); + + let backing = cuda_backing_allocation(&buffer)?; + assert_eq!( + backing.len(), + output_bytes.next_multiple_of(CUDF_VALIDITY_BUFFER_PADDING) + ); + + Ok(()) + } + + #[crate::test] + async fn test_export_empty_validity_buffer_is_omitted() -> VortexResult<()> { + let mut ctx = CudaSession::create_execution_ctx(&crate::cuda_session()) + .vortex_expect("failed to create execution context"); + + let (buffer, null_count) = + export_arrow_validity_buffer(Validity::AllInvalid, 0, 0, &mut ctx).await?; + + assert_eq!(null_count, 0); + assert!(buffer.is_none()); + + Ok(()) + } + #[crate::test] async fn test_export_all_false_validity_buffer_is_zeroed_on_device() -> VortexResult<()> { let mut ctx = CudaSession::create_execution_ctx(&crate::cuda_session()) @@ -2983,6 +3215,11 @@ mod tests { let bytes = buffer.to_host_sync(); assert_eq!(bytes.len(), (len + arrow_offset).div_ceil(8)); assert!(bytes.iter().all(|byte| *byte == 0)); + let backing = cuda_backing_allocation(&buffer)?; + assert_eq!( + backing.len(), + bytes.len().next_multiple_of(CUDF_VALIDITY_BUFFER_PADDING) + ); Ok(()) } diff --git a/vortex-cuda/src/device_buffer.rs b/vortex-cuda/src/device_buffer.rs index bc07cd5daaa..954731fab44 100644 --- a/vortex-cuda/src/device_buffer.rs +++ b/vortex-cuda/src/device_buffer.rs @@ -24,11 +24,16 @@ use vortex::buffer::ByteBuffer; use vortex::buffer::ByteBufferMut; use vortex::error::VortexExpect; use vortex::error::VortexResult; +use vortex::error::vortex_ensure; use vortex::error::vortex_err; use vortex::error::vortex_panic; use crate::stream::await_stream_callback; +/// cuDF may read Arrow validity masks through a 64-byte padded extent, so keep +/// exported masks logically sliced but zero-pad their backing allocation. +pub(crate) const CUDF_VALIDITY_BUFFER_PADDING: usize = 64; + /// A [`DeviceBuffer`] wrapping a CUDA GPU allocation. /// /// Like the host `BufferHandle` variant, all slicing/referencing works in terms of byte units. @@ -43,6 +48,12 @@ pub struct CudaDeviceBuffer { device_ptr: u64, /// Minimum required alignment of the buffer. alignment: Alignment, + /// Allocation-relative byte offset where zeroed tail padding begins. + /// + /// `None` means the tail contents are not tracked. Arrow validity export uses this to decide + /// whether a sliced logical bitmap can safely reuse this allocation for cuDF's padded mask + /// reads without copying or repacking. + zeroed_tail_start: Option, } mod private { @@ -101,9 +112,25 @@ impl CudaDeviceBuffer { len, device_ptr, alignment: Alignment::of::(), + zeroed_tail_start: None, } } + /// Wraps a CUDA allocation and records that bytes from `zeroed_tail_start` are already zeroed. + pub(crate) fn new_with_zeroed_tail( + cuda_slice: CudaSlice, + zeroed_tail_start: usize, + ) -> VortexResult { + let mut buffer = Self::new(cuda_slice); + vortex_ensure!( + zeroed_tail_start <= buffer.len, + "zeroed tail start {zeroed_tail_start} exceeds CUDA allocation length {}", + buffer.len + ); + buffer.zeroed_tail_start = Some(zeroed_tail_start); + Ok(buffer) + } + /// Returns the byte offset within the allocated buffer. pub fn offset(&self) -> usize { self.offset @@ -149,6 +176,7 @@ pub(crate) fn cuda_backing_allocation(handle: &BufferHandle) -> VortexResult VortexResult; + + /// Returns whether this buffer has zeroed tail padding for the requested extent. + /// + /// # Errors + /// + /// Returns an error if the buffer is not a CUDA buffer. + fn has_zeroed_tail_padding(&self, logical_len: usize, padded_len: usize) -> VortexResult; } impl CudaBufferExt for BufferHandle { @@ -197,6 +232,40 @@ impl CudaBufferExt for BufferHandle { Ok(ptr) } + + fn has_zeroed_tail_padding(&self, logical_len: usize, padded_len: usize) -> VortexResult { + let device_buffer = self + .as_device_opt() + .ok_or_else(|| vortex_err!("Buffer is not on device"))?; + + let cuda_buf = device_buffer + .as_any() + .downcast_ref::() + .ok_or_else(|| vortex_err!("expected CudaDeviceBuffer, was {device_buffer:?}"))?; + + if logical_len > padded_len || logical_len > cuda_buf.len { + return Ok(false); + } + + let Some(required_end) = cuda_buf.offset.checked_add(padded_len) else { + return Ok(false); + }; + if required_end > cuda_buf.allocation.as_bytes_view().len() { + return Ok(false); + } + + if logical_len == padded_len { + return Ok(true); + } + + let Some(zeroed_tail_start) = cuda_buf.zeroed_tail_start else { + return Ok(false); + }; + let Some(logical_end) = cuda_buf.offset.checked_add(logical_len) else { + return Ok(false); + }; + Ok(zeroed_tail_start <= logical_end) + } } impl Debug for CudaDeviceBuffer { @@ -206,6 +275,7 @@ impl Debug for CudaDeviceBuffer { .field("device_ptr", &self.device_ptr) .field("offset", &self.offset) .field("len", &self.len) + .field("zeroed_tail_start", &self.zeroed_tail_start) .finish() } } @@ -345,6 +415,7 @@ impl DeviceBuffer for CudaDeviceBuffer { len: new_len, device_ptr: self.device_ptr, alignment: self.alignment, + zeroed_tail_start: self.zeroed_tail_start, }) } @@ -361,6 +432,7 @@ impl DeviceBuffer for CudaDeviceBuffer { len: self.len, device_ptr: self.device_ptr, alignment, + zeroed_tail_start: self.zeroed_tail_start, })) } else if alignment > Alignment::new(256) { vortex_panic!("we do not support alignment greater than 256") diff --git a/vortex-cuda/src/stream.rs b/vortex-cuda/src/stream.rs index 6342c39ab28..4d31a7937f9 100644 --- a/vortex-cuda/src/stream.rs +++ b/vortex-cuda/src/stream.rs @@ -23,6 +23,11 @@ use vortex::error::vortex_ensure; use vortex::error::vortex_err; use crate::CudaDeviceBuffer; +use crate::device_buffer::CUDF_VALIDITY_BUFFER_PADDING; + +// cuDF imports Arrow validity masks into padded buffers and kernels may read through that +// padded extent. Keep copied device buffers padded and zero-tailed so Arrow validity exports +// can safely reuse matching bitmaps without repacking. #[derive(Clone)] pub struct VortexCudaStream(pub(crate) Arc); @@ -68,8 +73,8 @@ impl VortexCudaStream { /// (guaranteed by the returned future capturing it). /// /// The returned [`BufferHandle`] keeps the source byte length, while its - /// CUDA allocation may include zeroed tail padding. This is needed for - /// Arrow validity buffers passed to cuDF, which reads masks as 32-bit words. + /// CUDA allocation may include zeroed tail padding for consumers such as cuDF + /// that read validity masks through padded extents. pub(crate) fn copy_to_device( &self, data: D, @@ -90,7 +95,8 @@ impl VortexCudaStream { zero_padding(self, &mut cuda_slice, host_slice.len())?; - let cuda_buf = CudaDeviceBuffer::new(cuda_slice); + // `zero_padding` zeroed all allocation bytes after `byte_count`. + let cuda_buf = CudaDeviceBuffer::new_with_zeroed_tail(cuda_slice, byte_count)?; let buffer = BufferHandle::new_device(Arc::new(cuda_buf)).slice(0..byte_count); let stream = Arc::clone(&self.0); @@ -131,29 +137,30 @@ impl VortexCudaStream { zero_padding(self, &mut cuda_slice, data.len())?; - let cuda_buf = CudaDeviceBuffer::new(cuda_slice); + // `zero_padding` zeroed all allocation bytes after `byte_count`. + let cuda_buf = CudaDeviceBuffer::new_with_zeroed_tail(cuda_slice, byte_count)?; Ok(BufferHandle::new_device(Arc::new(cuda_buf)).slice(0..byte_count)) } } /// Returns the typed CUDA allocation length for `byte_count`. /// -/// The backing allocation is padded for cuDF's 32-bit validity mask reads. -/// The returned length is in `T` elements. +/// The backing allocation is padded for consumers such as cuDF that read validity masks +/// through padded extents. The returned length is in `T` elements. fn padded_device_allocation_len(byte_count: usize) -> VortexResult { let element_size = size_of::(); vortex_ensure!( element_size != 0, "cannot copy zero-sized values to CUDA device" ); - let min_allocation_bytes = byte_count.next_multiple_of(size_of::()); + let min_allocation_bytes = byte_count.next_multiple_of(CUDF_VALIDITY_BUFFER_PADDING); Ok(min_allocation_bytes.div_ceil(element_size)) } /// Zeroes the allocation tail after the copied values. /// /// Returned handles are sliced to the copied byte count; the trailing padding -/// exists so a final 32-bit mask read stays within the backing allocation. +/// exists so padded mask reads stay within the backing allocation. fn zero_padding( stream: &VortexCudaStream, cuda_slice: &mut CudaSlice, @@ -250,19 +257,38 @@ fn register_stream_callback(stream: &CudaStream) -> VortexResult VortexResult<()> { assert_eq!(padded_device_allocation_len::(0)?, 0); - assert_eq!(padded_device_allocation_len::(1)?, 4); - assert_eq!(padded_device_allocation_len::(4)?, 4); - assert_eq!(padded_device_allocation_len::(5)?, 8); - assert_eq!(padded_device_allocation_len::(1)?, 1); - assert_eq!(padded_device_allocation_len::(5)?, 2); + assert_eq!( + padded_device_allocation_len::(1)?, + CUDF_VALIDITY_BUFFER_PADDING + ); + assert_eq!( + padded_device_allocation_len::(4)?, + CUDF_VALIDITY_BUFFER_PADDING + ); + assert_eq!( + padded_device_allocation_len::(5)?, + CUDF_VALIDITY_BUFFER_PADDING + ); + assert_eq!( + padded_device_allocation_len::(1)?, + CUDF_VALIDITY_BUFFER_PADDING / size_of::() + ); + assert_eq!( + padded_device_allocation_len::(5)?, + CUDF_VALIDITY_BUFFER_PADDING / size_of::() + ); Ok(()) } @@ -275,6 +301,12 @@ mod tests { let host = handle.try_to_host()?.await?; assert_eq!(host.as_slice(), &[0xab]); + let backing = cuda_backing_allocation(&handle)?; + assert_eq!(backing.len(), CUDF_VALIDITY_BUFFER_PADDING); + let backing_host = backing.try_to_host()?.await?; + assert_eq!(backing_host[0], 0xab); + assert!(backing_host[1..].iter().all(|byte| *byte == 0)); + Ok(()) } @@ -287,6 +319,12 @@ mod tests { let host = handle.try_to_host()?.await?; assert_eq!(host.as_slice(), &[1, 2, 3, 4, 5]); + let backing = cuda_backing_allocation(&handle)?; + assert_eq!(backing.len(), CUDF_VALIDITY_BUFFER_PADDING); + let backing_host = backing.try_to_host()?.await?; + assert_eq!(&backing_host[..5], &[1, 2, 3, 4, 5]); + assert!(backing_host[5..].iter().all(|byte| *byte == 0)); + Ok(()) } } diff --git a/vortex-python-cuda/README.md b/vortex-python-cuda/README.md new file mode 100644 index 00000000000..808c10ccbb0 --- /dev/null +++ b/vortex-python-cuda/README.md @@ -0,0 +1,62 @@ +# vortex-data-cuda + +CUDA extension for [Vortex](https://vortex.dev). Exports a `vortex.Array` to +[RAPIDS cuDF](https://docs.rapids.ai/api/cudf/stable/) or any +[Arrow C Device](https://arrow.apache.org/docs/format/CDeviceDataInterface.html) consumer, on the +GPU. Imported as `vortex_cuda`. + +## Install + +```bash +pip install vortex-data vortex-data-cuda # versions must match; CUDA device required +``` + +`to_cudf` also needs RAPIDS `cudf` and `pylibcudf` in the environment. + +## Export to cuDF + +`to_cudf` converts via the Arrow C Device interface: struct arrays become a `cudf.DataFrame`, +everything else a `cudf.Series`. Importing `vortex_cuda` installs it as `vortex.Array.to_cudf`. + +```python +import vortex, vortex_cuda +import pyarrow as pa + +s = vortex.array([1, None, 3]).to_cudf() # -> cudf.Series +df = vortex_cuda.to_cudf( # struct -> cudf.DataFrame + vortex.Array.from_arrow(pa.table({"x": [1, None, 3], "y": [4.0, 5.0, 6.0]})) +) +``` + +Buffers are imported zero-copy; host arrays are moved to the GPU as part of the export. cuDF keeps +shared ownership for the lifetime of the result and any view derived from it, so no extra +bookkeeping is needed. + +Signature: `to_cudf(obj, *, fallback="error")`. Only `fallback="error"` is supported +(`NotImplementedError` otherwise); raises `TypeError` for a non-`vortex.Array`, `RuntimeError` +without a CUDA device, `ImportError` if cuDF/pylibcudf are missing. + +## Export an Arrow C Device array + +`vortex.Array` exposes the standard `__arrow_c_device_array__` protocol (installed when CUDA is +available), so any Arrow-C-Device consumer can ingest it zero-copy: + +```python +import vortex, vortex_cuda, pylibcudf + +array = vortex.array([1, None, 3]) +column = pylibcudf.Column.from_arrow(array) # via the protocol + +schema_capsule, device_array_capsule = vortex_cuda.export_device_array(array) # raw capsules +``` + +`export_device_array` returns `PyCapsule`s named `"arrow_schema"` and `"arrow_device_array"`. The +consumer owns the exported structs and runs the Arrow release callbacks when done (libcudf does +this automatically); Vortex's device buffers stay alive until then. + +## Notes + +- Integer, float, bool, and string arrays (incl. nullable) are supported; nulls are preserved. +- Struct arrays without top-level nulls are supported as cuDF DataFrames. Nullable top-level + structs are rejected because cuDF DataFrames cannot represent a separate row-level struct mask. +- A CUDA device is required; there is no CPU fallback. diff --git a/vortex-python-cuda/python/vortex_cuda/__init__.py b/vortex-python-cuda/python/vortex_cuda/__init__.py index c5d1610724e..985516e6204 100644 --- a/vortex-python-cuda/python/vortex_cuda/__init__.py +++ b/vortex-python-cuda/python/vortex_cuda/__init__.py @@ -1,12 +1,93 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright the Vortex contributors -# pyright: reportMissingModuleSource=false, reportPrivateUsage=false +# pyright: reportAttributeAccessIssue=false, reportMissingModuleSource=false, reportPrivateUsage=false, reportUnknownMemberType=false, reportUnknownVariableType=false + +import importlib from . import _lib +# Private debug hooks used by CUDA bridge tests. _debug_array_metadata_dtype = _lib._debug_array_metadata_dtype _debug_array_metadata_display_values = _lib._debug_array_metadata_display_values +_debug_arrow_device_array_capsule_summary = _lib._debug_arrow_device_array_capsule_summary +_debug_consume_arrow_device_array_capsules = _lib._debug_consume_arrow_device_array_capsules + +# Public native bindings exposed by this extension module. cuda_available = _lib.cuda_available export_device_array = _lib.export_device_array -__all__ = ["cuda_available", "export_device_array"] +_SUPPORTED_FALLBACKS = frozenset({"error"}) + + +def _Array_to_cudf(self: object, *, fallback: str = "error") -> object: + return to_cudf(self, fallback=fallback) + + +def _Array___arrow_c_device_array__( + self: object, + requested_schema: object | None = None, + **kwargs: object, +) -> tuple[object, object]: + return export_device_array(self, requested_schema, **kwargs) + + +def _install_vortex_array_methods() -> None: + import vortex + + setattr(vortex.Array, "to_cudf", _Array_to_cudf) + if cuda_available(): + setattr(vortex.Array, "__arrow_c_device_array__", _Array___arrow_c_device_array__) + + +def _import_cudf_modules() -> tuple[object, object]: + try: + cudf = importlib.import_module("cudf") + pylibcudf = importlib.import_module("pylibcudf") + except ImportError as err: + raise ImportError("vortex_cuda.to_cudf requires RAPIDS cuDF and pylibcudf to be installed") from err + return cudf, pylibcudf + + +def to_cudf(obj: object, *, fallback: str = "error") -> object: + """Convert a Vortex array to a cuDF object through the Arrow Device interface. + + pylibcudf imports the exported Arrow Device array zero-copy and keeps shared ownership of + Vortex's device buffers (via libcudf's ``arrow_column``) for the lifetime of the returned + cuDF object and any view derived from it, so no extra keepalive is required here. + + ``fallback`` is reserved for future policy choices. The initial implementation + supports only ``fallback="error"`` and never falls back to host Arrow conversion. + """ + if fallback not in _SUPPORTED_FALLBACKS: + raise NotImplementedError("vortex_cuda.to_cudf currently supports only fallback='error'") + + import vortex + + if not isinstance(obj, vortex.Array): + raise TypeError(f"vortex_cuda.to_cudf expected a vortex.Array, got {type(obj).__name__}") + + if not cuda_available(): + raise RuntimeError("CUDA is not available; vortex_cuda.to_cudf requires a CUDA device") + + dtype = obj.dtype + if isinstance(dtype, vortex.StructDType) and dtype.is_nullable(): + raise NotImplementedError( + "vortex_cuda.to_cudf cannot preserve top-level nulls for struct arrays as a cuDF DataFrame" + ) + + cudf, pylibcudf = _import_cudf_modules() + + if isinstance(dtype, vortex.StructDType): + table = pylibcudf.Table.from_arrow(obj) + dataframe = cudf.DataFrame.from_pylibcudf(table) + dataframe.columns = dtype.names() + return dataframe + + column = pylibcudf.Column.from_arrow(obj) + return cudf.Series.from_pylibcudf(column) + + +_install_vortex_array_methods() + + +__all__ = ["cuda_available", "export_device_array", "to_cudf"] diff --git a/vortex-python-cuda/python/vortex_cuda/_lib.pyi b/vortex-python-cuda/python/vortex_cuda/_lib.pyi index dad7628363d..51b4e100fe7 100644 --- a/vortex-python-cuda/python/vortex_cuda/_lib.pyi +++ b/vortex-python-cuda/python/vortex_cuda/_lib.pyi @@ -3,6 +3,10 @@ def _debug_array_metadata_dtype(array: object) -> str: ... def _debug_array_metadata_display_values(array: object) -> str: ... +def _debug_arrow_device_array_capsule_summary(schema: object, device_array: object) -> dict[str, object]: ... +def _debug_consume_arrow_device_array_capsules( + schema: object, device_array: object +) -> tuple[bool, bool, bool, bool, bool, bool]: ... def cuda_available() -> bool: ... def export_device_array( array: object, requested_schema: object | None = None, **kwargs: object diff --git a/vortex-python-cuda/src/lib.rs b/vortex-python-cuda/src/lib.rs index 13107a39acb..2ac3166b103 100644 --- a/vortex-python-cuda/src/lib.rs +++ b/vortex-python-cuda/src/lib.rs @@ -462,6 +462,84 @@ fn release_exported(exported: &mut ArrowDeviceArrayWithSchema) { release_device_array(&mut exported.array); } +/// Return non-owning details from Arrow Device capsules for Python-side smoke consumers. +#[pyfunction] +fn _debug_arrow_device_array_capsule_summary<'py>( + py: Python<'py>, + schema: Bound<'py, PyCapsule>, + device_array: Bound<'py, PyCapsule>, +) -> PyResult> { + let schema = unsafe { + schema + .pointer_checked(Some(ARROW_SCHEMA_CAPSULE_NAME))? + .cast::() + .as_ref() + }; + let device_array = unsafe { + device_array + .pointer_checked(Some(ARROW_DEVICE_ARRAY_CAPSULE_NAME))? + .cast::() + .as_ref() + }; + + let summary = PyDict::new(py); + summary.set_item("schema_live", schema.release.is_some())?; + summary.set_item("array_live", device_array.array.release.is_some())?; + summary.set_item("is_cuda", device_array.device_type == ARROW_DEVICE_CUDA)?; + summary.set_item("device_type", device_array.device_type)?; + summary.set_item("device_id", device_array.device_id)?; + summary.set_item("length", device_array.array.length)?; + summary.set_item("null_count", device_array.array.null_count)?; + summary.set_item("n_buffers", device_array.array.n_buffers)?; + summary.set_item("n_children", device_array.array.n_children)?; + Ok(summary) +} + +/// Simulate a Python Arrow Device consumer taking ownership from the returned capsules. +#[pyfunction] +fn _debug_consume_arrow_device_array_capsules( + schema: Bound<'_, PyCapsule>, + device_array: Bound<'_, PyCapsule>, +) -> PyResult<(bool, bool, bool, bool, bool, bool)> { + let mut schema_ptr = schema + .pointer_checked(Some(ARROW_SCHEMA_CAPSULE_NAME))? + .cast::(); + let mut device_array_ptr = device_array + .pointer_checked(Some(ARROW_DEVICE_ARRAY_CAPSULE_NAME))? + .cast::(); + + let schema_ref = unsafe { schema_ptr.as_mut() }; + let device_array_ref = unsafe { device_array_ptr.as_mut() }; + let schema_had_release = schema_ref.release.is_some(); + let array_had_release = device_array_ref.array.release.is_some(); + + release_schema(schema_ref); + release_device_array(device_array_ref); + + let schema_release_cleared = schema_ref.release.is_none(); + let array_release_cleared = device_array_ref.array.release.is_none(); + + set_capsule_name(&schema, USED_ARROW_SCHEMA_CAPSULE_NAME)?; + set_capsule_name(&device_array, USED_ARROW_DEVICE_ARRAY_CAPSULE_NAME)?; + + Ok(( + schema_had_release, + array_had_release, + schema_release_cleared, + array_release_cleared, + schema.is_valid_checked(Some(USED_ARROW_SCHEMA_CAPSULE_NAME)), + device_array.is_valid_checked(Some(USED_ARROW_DEVICE_ARRAY_CAPSULE_NAME)), + )) +} + +fn set_capsule_name(capsule: &Bound<'_, PyCapsule>, name: &CStr) -> PyResult<()> { + let result = unsafe { ffi::PyCapsule_SetName(capsule.as_ptr(), name.as_ptr()) }; + if result != 0 { + return Err(PyErr::fetch(capsule.py())); + } + Ok(()) +} + fn schema_capsule<'py>( py: Python<'py>, schema: FFI_ArrowSchema, @@ -574,6 +652,14 @@ fn _lib(m: &Bound) -> PyResult<()> { m.add_function(wrap_pyfunction!(cuda_available, m)?)?; m.add_function(wrap_pyfunction!(_debug_array_metadata_dtype, m)?)?; m.add_function(wrap_pyfunction!(_debug_array_metadata_display_values, m)?)?; + m.add_function(wrap_pyfunction!( + _debug_arrow_device_array_capsule_summary, + m + )?)?; + m.add_function(wrap_pyfunction!( + _debug_consume_arrow_device_array_capsules, + m + )?)?; m.add_function(wrap_pyfunction!(export_device_array, m)?)?; Ok(()) } diff --git a/vortex-python-cuda/test/test_cuda.py b/vortex-python-cuda/test/test_cuda.py index de269164317..56678ab1f41 100644 --- a/vortex-python-cuda/test/test_cuda.py +++ b/vortex-python-cuda/test/test_cuda.py @@ -1,10 +1,15 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright the Vortex contributors +# pyright: reportAny=false, reportExplicitAny=false +import gc +import sys import tomllib +import types from pathlib import Path -from typing import cast +from typing import Any, cast +import pytest import vortex_cuda import vortex @@ -27,3 +32,204 @@ def test_extension_exact_pins_base_package(): pyproject = tomllib.loads((Path(__file__).parents[1] / "pyproject.toml").read_text()) assert pyproject["project"]["dependencies"] == [f"vortex-data=={workspace_version()}"] + + +def test_to_cudf_is_exported(): + assert "to_cudf" in vortex_cuda.__all__ + + +def test_import_installs_array_to_cudf(monkeypatch: pytest.MonkeyPatch): + array = vortex.Array.from_range(range(0, 3)) + calls: list[tuple[object, str]] = [] + + def fake_to_cudf(obj: object, *, fallback: str = "error") -> object: + calls.append((obj, fallback)) + return "cudf-object" + + monkeypatch.setattr(vortex_cuda, "to_cudf", fake_to_cudf) + + assert cast(Any, array).to_cudf(fallback="error") == "cudf-object" + assert calls == [(array, "error")] + + +def test_import_installs_array_arrow_c_device_array_on_cuda(monkeypatch: pytest.MonkeyPatch): + array = vortex.Array.from_range(range(0, 3)) + + if not vortex_cuda.cuda_available(): + assert not hasattr(array, "__arrow_c_device_array__") + return + + calls: list[tuple[object, object | None, dict[str, object]]] = [] + + def fake_export_device_array( + exported_array: object, + requested_schema: object | None = None, + **kwargs: object, + ) -> tuple[object, object]: + calls.append((exported_array, requested_schema, kwargs)) + return "schema", "device_array" + + monkeypatch.setattr(vortex_cuda, "export_device_array", fake_export_device_array) + + assert cast(Any, array).__arrow_c_device_array__("requested", future=None) == ("schema", "device_array") + assert calls == [(array, "requested", {"future": None})] + + +def test_to_cudf_rejects_unsupported_fallback(): + with pytest.raises(NotImplementedError, match="fallback='error'"): + _ = vortex_cuda.to_cudf(vortex.Array.from_range(range(0, 3)), fallback="host") + + +def test_to_cudf_rejects_non_vortex_array(): + with pytest.raises(TypeError, match="vortex.Array"): + _ = vortex_cuda.to_cudf(object()) + + +def test_to_cudf_cuda_unavailable_rejects_without_importing_cudf(monkeypatch: pytest.MonkeyPatch): + def fail_import_cudf_modules() -> tuple[object, object]: + raise AssertionError("unexpected import of cuDF modules") + + monkeypatch.setattr(vortex_cuda, "cuda_available", lambda: False) + monkeypatch.setattr("vortex_cuda._import_cudf_modules", fail_import_cudf_modules) + + with pytest.raises(RuntimeError, match="CUDA"): + _ = vortex_cuda.to_cudf(vortex.Array.from_range(range(0, 3))) + + +def test_to_cudf_non_struct_uses_pylibcudf_column_from_arrow(monkeypatch: pytest.MonkeyPatch): + array = vortex.Array.from_range(range(0, 3)) + fake_column = object() + + class FakeSeries: + pass + + fake_series = FakeSeries() + + class FakePylibcudfColumn: + @staticmethod + def from_arrow(obj: object) -> object: + assert obj is array + return fake_column + + class FakePylibcudfTable: + @staticmethod + def from_arrow(_obj: object) -> object: + raise AssertionError("non-struct array should not import through pylibcudf.Table") + + class FakeCudfSeries: + @staticmethod + def from_pylibcudf(column: object) -> object: + assert column is fake_column + return fake_series + + monkeypatch.setattr(vortex_cuda, "cuda_available", lambda: True) + monkeypatch.setitem( + sys.modules, + "pylibcudf", + types.SimpleNamespace(Column=FakePylibcudfColumn, Table=FakePylibcudfTable), + ) + monkeypatch.setitem(sys.modules, "cudf", types.SimpleNamespace(Series=FakeCudfSeries)) + + assert vortex_cuda.to_cudf(array) is fake_series + + +def test_to_cudf_struct_uses_pylibcudf_table_from_arrow(monkeypatch: pytest.MonkeyPatch): + import pyarrow as pa + + array = vortex.Array.from_arrow(pa.table({"x": [1, None, 3], "y": [4.0, 5.0, 6.0]})) + fake_table = object() + + class FakeDataFrame: + columns: object = None + + fake_dataframe = FakeDataFrame() + + class FakePylibcudfColumn: + @staticmethod + def from_arrow(_obj: object) -> object: + raise AssertionError("struct array should not import through pylibcudf.Column") + + class FakePylibcudfTable: + @staticmethod + def from_arrow(obj: object) -> object: + assert obj is array + return fake_table + + class FakeCudfDataFrame: + @staticmethod + def from_pylibcudf(table: object) -> object: + assert table is fake_table + return fake_dataframe + + monkeypatch.setattr(vortex_cuda, "cuda_available", lambda: True) + monkeypatch.setitem( + sys.modules, + "pylibcudf", + types.SimpleNamespace(Column=FakePylibcudfColumn, Table=FakePylibcudfTable), + ) + monkeypatch.setitem(sys.modules, "cudf", types.SimpleNamespace(DataFrame=FakeCudfDataFrame)) + + assert vortex_cuda.to_cudf(array) is fake_dataframe + assert fake_dataframe.columns == ["x", "y"] + + +def test_to_cudf_nullable_struct_rejects_without_importing_cudf(monkeypatch: pytest.MonkeyPatch): + import pyarrow as pa + + array = vortex.Array.from_arrow(pa.array([{"x": 1}, None], type=pa.struct([("x", pa.int64())]))) + + def fail_import_cudf_modules() -> tuple[object, object]: + raise AssertionError("unexpected import of cuDF modules") + + monkeypatch.setattr(vortex_cuda, "cuda_available", lambda: True) + monkeypatch.setattr("vortex_cuda._import_cudf_modules", fail_import_cudf_modules) + + with pytest.raises(NotImplementedError, match="top-level nulls"): + _ = vortex_cuda.to_cudf(array) + + +def test_to_cudf_real_cudf_smoke(): + cudf_module = cast(object, pytest.importorskip("cudf")) + pylibcudf_module = cast(object, pytest.importorskip("pylibcudf")) + assert cudf_module is not None + assert pylibcudf_module is not None + + if not vortex_cuda.cuda_available(): + pytest.skip("CUDA device is not available") + + import pyarrow as pa + + int_series = cast(Any, vortex.array([1, 2, 3])).to_cudf() + assert type(int_series).__name__ == "Series" + assert int_series.to_arrow().to_pylist() == [1, 2, 3] + + nullable_int_series = cast(Any, vortex.array([1, None, 3])).to_cudf() + assert type(nullable_int_series).__name__ == "Series" + assert nullable_int_series.to_arrow().to_pylist() == [1, None, 3] + assert "" in repr(nullable_int_series) + + nullable_bool_series = cast(Any, vortex.array([True, None, False])).to_cudf() + assert type(nullable_bool_series).__name__ == "Series" + assert nullable_bool_series.to_arrow().to_pylist() == [True, None, False] + assert "" in repr(nullable_bool_series) + + string_series = cast(Any, vortex.array(["alpha", "beta", "gamma"])).to_cudf() + assert type(string_series).__name__ == "Series" + assert string_series.to_arrow().to_pylist() == ["alpha", "beta", "gamma"] + + struct_array = vortex.Array.from_arrow(pa.table({"x": [1, None, 3], "y": [4.0, 5.0, 6.0]})) + dataframe = cast(Any, struct_array).to_cudf() + assert type(dataframe).__name__ == "DataFrame" + assert list(dataframe.columns) == ["x", "y"] + assert len(dataframe) == 3 + assert "" in repr(dataframe) + assert {name: str(dtype) for name, dtype in dataframe.dtypes.items()} == { + "x": "int64", + "y": "float64", + } + + x_series = dataframe["x"] + del dataframe + _ = gc.collect() + assert x_series.to_arrow().to_pylist() == [1, None, 3] + assert "" in repr(x_series) diff --git a/vortex-python-cuda/test/test_native_bridge.py b/vortex-python-cuda/test/test_native_bridge.py index c98e70a11d5..960467ea25c 100644 --- a/vortex-python-cuda/test/test_native_bridge.py +++ b/vortex-python-cuda/test/test_native_bridge.py @@ -2,12 +2,38 @@ # SPDX-FileCopyrightText: Copyright the Vortex contributors # pyright: reportPrivateUsage=false +import gc + import pytest import vortex_cuda import vortex +def _require_cuda() -> None: + if not vortex_cuda.cuda_available(): + pytest.skip("CUDA device is not available") + + +def _assert_exported_device_array( + array: object, *, length: int, null_count: int, n_children: int +) -> tuple[object, object]: + schema, device_array = vortex_cuda.export_device_array(array) + summary = vortex_cuda._debug_arrow_device_array_capsule_summary(schema, device_array) + + assert summary["schema_live"] is True + assert summary["array_live"] is True + assert summary["is_cuda"] is True + assert summary["length"] == length + assert summary["null_count"] == null_count + assert summary["n_children"] == n_children + n_buffers = summary["n_buffers"] + assert isinstance(n_buffers, int) + assert n_buffers >= 0 + + return schema, device_array + + def test_debug_array_metadata_dtype_reads_base_vortex_array(): array = vortex.Array.from_range(range(0, 3)) @@ -64,3 +90,66 @@ def test_export_device_array_returns_capsules_or_clean_cuda_error(): schema, device_array = vortex_cuda.export_device_array(array) assert type(schema).__name__ == "PyCapsule" assert type(device_array).__name__ == "PyCapsule" + + +def test_arrow_device_export_primitive_array(): + _require_cuda() + + _ = _assert_exported_device_array(vortex.array([1, 2, 3]), length=3, null_count=0, n_children=0) + + +def test_arrow_device_export_nullable_primitive_array(): + _require_cuda() + + _ = _assert_exported_device_array(vortex.array([1, None, 3]), length=3, null_count=1, n_children=0) + + +def test_arrow_device_export_nullable_bool_array(): + _require_cuda() + + _ = _assert_exported_device_array(vortex.array([True, None, False]), length=3, null_count=1, n_children=0) + + +def test_arrow_device_export_string_array(): + _require_cuda() + + _ = _assert_exported_device_array( + vortex.array(["alpha", "beta", "a longer string that should use the varbin data buffer"]), + length=3, + null_count=0, + n_children=0, + ) + + +def test_arrow_device_export_struct_array(): + import pyarrow as pa + + _require_cuda() + + arrow_table = pa.table({"a": [1, 2, 3], "b": [4.0, 5.0, 6.0]}) + struct_array = vortex.Array.from_arrow( + pa.StructArray.from_arrays( # pyright: ignore[reportUnknownMemberType] + [arrow_table.column("a").combine_chunks(), arrow_table.column("b").combine_chunks()], + names=["a", "b"], + ) + ) + + _ = _assert_exported_device_array(struct_array, length=3, null_count=0, n_children=2) + + +def test_arrow_device_capsules_drop_unconsumed(): + _require_cuda() + + schema, device_array = _assert_exported_device_array(vortex.array([1, 2, 3]), length=3, null_count=0, n_children=0) + del schema, device_array + _ = gc.collect() + + +def test_arrow_device_capsules_consumer_release_and_used_names(): + _require_cuda() + + schema, device_array = _assert_exported_device_array(vortex.array([1, 2, 3]), length=3, null_count=0, n_children=0) + consume_result = vortex_cuda._debug_consume_arrow_device_array_capsules(schema, device_array) + assert consume_result == (True, True, True, True, True, True) + del schema, device_array + _ = gc.collect() diff --git a/vortex-python/python/vortex/_lib/dtype.pyi b/vortex-python/python/vortex/_lib/dtype.pyi index bbbfb9fcf6d..9ba6489b0c5 100644 --- a/vortex-python/python/vortex/_lib/dtype.pyi +++ b/vortex-python/python/vortex/_lib/dtype.pyi @@ -6,6 +6,7 @@ from typing import Literal, final import pyarrow as pa class DType: + def is_nullable(self) -> bool: ... def to_arrow_schema(self) -> pa.Schema: ... def to_arrow_type(self) -> pa.DataType: ... @classmethod diff --git a/vortex-python/src/dtype/mod.rs b/vortex-python/src/dtype/mod.rs index 59cee1fc857..bea8f7d7fda 100644 --- a/vortex-python/src/dtype/mod.rs +++ b/vortex-python/src/dtype/mod.rs @@ -190,6 +190,11 @@ impl PyDType { self.0.python_repr().to_string() } + /// Return whether this data type allows null values. + fn is_nullable(&self) -> bool { + self.0.is_nullable() + } + /// Construct a Vortex data type from an Arrow data type. #[classmethod] #[pyo3(signature = (arrow_dtype, *, non_nullable = false))] From a21bf76d791e5a0697922265db47f59010f18a4a Mon Sep 17 00:00:00 2001 From: Nemo Yu <83347615+HarukiMoriarty@users.noreply.github.com> Date: Thu, 2 Jul 2026 06:41:42 -0400 Subject: [PATCH 002/104] feat(vortex-bench): wire SpatialBench into the bench orchestrator (#8607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Wires SpatialBench into the `vx-bench` / `bench-orchestrator` pipeline so it can be run end-to-end like the other benchmarks (datagen → Parquet → Vortex conversion → query). It builds on the WKB datagen landed in #8598. Running command: ``` uv run --project bench-orchestrator vx-bench run spatialbench --engine duckdb --format parquet,vortex --opt scale-factor=N --queries 1,2,3,4,5,6,7,8,9 --iterations 3 ``` ## Limitation - DuckDB-only. For now SpatialBench queries use DuckDB-specific ST_* spatial SQL that DataFusion has no functions for yet. There is a a single ad-hoc entry in `BENCHMARK_ENGINES = { SPATIALBENCH: {DUCKDB} }`. - No dictionary encoding / compaction on the WKB column. WKB geometry blobs are large and effectively unique, so running the dictionary builder over them balloons memory (tens of GB) for zero compression gain. The normal compaction path is preserved for every other column on every other benchmark. - Queries 10, 11, 12 is timeout simply because DuckDB poorly support on Spatial index. ## Performance SF=1.0 ``` ┏━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ ┃ Query ┃ duckdb:parquet (base) ┃ duckdb:vortex ┃ ┡━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩ │ 1 │ 39.1ms │ 14.6ms (0.37x) │ │ 2 │ 72.1ms │ 24.9ms (0.35x) │ │ 3 │ 57.7ms │ 20.1ms (0.35x) │ │ 4 │ 113.1ms │ 70.6ms (0.62x) │ │ 5 │ 354.2ms │ 288.4ms (0.81x) │ │ 6 │ 169.5ms │ 91.6ms (0.54x) │ │ 7 │ 156.7ms │ 71.5ms (0.46x) │ │ 8 │ 196.5ms │ 80.3ms (0.41x) │ │ 9 │ 20.3ms │ 18.7ms (0.92x) │ └───────┴───────────────────────┴─────────────────┘ ``` SF=3 ``` ┏━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ ┃ Query ┃ duckdb:parquet (base) ┃ duckdb:vortex ┃ ┡━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩ │ 1 │ 50.7ms │ 31.5ms (0.62x) │ │ 2 │ 126.2ms │ 60.3ms (0.48x) │ │ 3 │ 71.9ms │ 42.9ms (0.60x) │ │ 4 │ 539.9ms │ 64.9ms (0.12x) │ │ 5 │ 948.7ms │ 874.5ms (0.92x) │ │ 6 │ 656.2ms │ 121.7ms (0.19x) │ │ 7 │ 256.6ms │ 232.1ms (0.90x) │ │ 8 │ 273.8ms │ 244.6ms (0.89x) │ │ 9 │ 35.7ms │ 27.9ms (0.78x) │ └───────┴───────────────────────┴─────────────────┘ ``` SF=10 ``` ┏━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓ ┃ Query ┃ duckdb:parquet (base) ┃ duckdb:vortex ┃ ┡━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩ │ 1 │ 158.6ms │ 114.4ms (0.72x) │ │ 2 │ 255.1ms │ 219.3ms (0.86x) │ │ 3 │ 229.2ms │ 181.5ms (0.79x) │ │ 4 │ 184.5ms │ 134.3ms (0.73x) │ │ 5 │ 3.30s │ 3.08s (0.93x) │ │ 6 │ 476.4ms │ 348.9ms (0.73x) │ │ 7 │ 918.2ms │ 961.2ms (1.05x) │ │ 8 │ 980.6ms │ 926.7ms (0.94x) │ │ 9 │ 33.7ms │ 33.6ms (1.00x) │ └───────┴───────────────────────┴─────────────────┘ ``` --------- Signed-off-by: Nemo Yu --- Cargo.lock | 1 + bench-orchestrator/bench_orchestrator/cli.py | 8 +- .../bench_orchestrator/config.py | 27 +++- bench-orchestrator/tests/test_config.py | 26 ++++ vortex-bench/Cargo.toml | 1 + vortex-bench/src/conversions.rs | 127 +++++++++++++++--- vortex-bench/src/spatialbench/datagen/wkb.rs | 49 +++++++ 7 files changed, 218 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 173843bbaad..0f70678a143 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9919,6 +9919,7 @@ dependencies = [ "vortex", "vortex-geo", "vortex-tensor", + "wkb", ] [[package]] diff --git a/bench-orchestrator/bench_orchestrator/cli.py b/bench-orchestrator/bench_orchestrator/cli.py index a9e37e70309..b9d7f9bb2ef 100644 --- a/bench-orchestrator/bench_orchestrator/cli.py +++ b/bench-orchestrator/bench_orchestrator/cli.py @@ -85,9 +85,11 @@ def run_ref_auto_complete() -> list[str]: return list(map(lambda x: x.run_id, ResultStore().list_runs(limit=None))) -def targets_from_axes(engine: str, format: str) -> tuple[list[BenchmarkTarget], list[str]]: +def targets_from_axes( + engine: str, format: str, benchmark: Benchmark | None = None +) -> tuple[list[BenchmarkTarget], list[str]]: """Resolve legacy engine/format axes into explicit benchmark targets.""" - return resolve_axis_targets(parse_engines(engine), parse_formats(format)) + return resolve_axis_targets(parse_engines(engine), parse_formats(format), benchmark) def backends_for_engines(engines: list[Engine]) -> list[Engine]: @@ -260,7 +262,7 @@ def run( targets = parse_targets_json(targets_json) warnings: list[str] = [] else: - targets, warnings = targets_from_axes(engine, format) + targets, warnings = targets_from_axes(engine, format, benchmark) except ValueError as exc: console.print(f"[red]{exc}[/red]") raise typer.Exit(1) from exc diff --git a/bench-orchestrator/bench_orchestrator/config.py b/bench-orchestrator/bench_orchestrator/config.py index c597e84c6be..e358bf18f01 100644 --- a/bench-orchestrator/bench_orchestrator/config.py +++ b/bench-orchestrator/bench_orchestrator/config.py @@ -52,6 +52,7 @@ class Benchmark(Enum): POLARSIGNALS = "polarsignals" PUBLIC_BI = "public-bi" STATPOPGEN = "statpopgen" + SPATIALBENCH = "spatialbench" # Engine to supported formats mapping. @@ -72,6 +73,19 @@ class Benchmark(Enum): Engine.LANCE: [Format.LANCE], } +# Engines each benchmark can run on. Benchmarks default to *every* engine; list one here only to +# restrict it. SpatialBench's queries use DuckDB-specific `ST_*` spatial SQL that DataFusion has no +# functions for yet. +BENCHMARK_ENGINES: dict[Benchmark, frozenset[Engine]] = { + Benchmark.SPATIALBENCH: frozenset({Engine.DUCKDB}), +} + + +def engines_for_benchmark(benchmark: Benchmark) -> frozenset[Engine]: + """Return the engines `benchmark` supports, defaulting to every engine when unrestricted.""" + return BENCHMARK_ENGINES.get(benchmark, frozenset(Engine)) + + T = TypeVar("T") @@ -175,13 +189,16 @@ def parse_formats_json(value: str) -> list[Format]: def resolve_axis_targets( - engines: Iterable[Engine], formats: Iterable[Format] + engines: Iterable[Engine], formats: Iterable[Format], benchmark: Benchmark | None = None ) -> tuple[list[BenchmarkTarget], list[str]]: """Expand engine/format axes into supported explicit targets.""" warnings: list[str] = [] targets: list[BenchmarkTarget] = [] for engine in engines: + if benchmark is not None and engine not in engines_for_benchmark(benchmark): + warnings.append(f"Benchmark {benchmark.value} does not support engine {engine.value}") + continue for fmt in formats: target = BenchmarkTarget(engine=engine, format=fmt).normalized() if not target.is_supported(): @@ -200,7 +217,9 @@ def group_targets_by_backend(targets: Iterable[BenchmarkTarget]) -> dict[Engine, return groups -def validate_targets(targets: Iterable[BenchmarkTarget], options: dict[str, str]) -> list[str]: +def validate_targets( + targets: Iterable[BenchmarkTarget], options: dict[str, str], benchmark: Benchmark | None = None +) -> list[str]: """Validate explicit targets against benchmark runner constraints.""" errors: list[str] = [] @@ -208,6 +227,8 @@ def validate_targets(targets: Iterable[BenchmarkTarget], options: dict[str, str] for target in normalized_targets: if not target.is_supported(): errors.append(f"Format {target.format.value} is not supported by engine {target.engine.value}") + if benchmark is not None and target.engine not in engines_for_benchmark(benchmark): + errors.append(f"Benchmark {benchmark.value} does not support engine {target.engine.value}") if options.get("remote-data-dir") and any(target.format == Format.LANCE for target in normalized_targets): errors.append("Lance format is not supported for remote storage benchmarks.") @@ -242,7 +263,7 @@ def backends(self) -> list[Engine]: def validate(self) -> list[str]: """Validate the configuration and return any errors.""" - return validate_targets(self.targets, self.options) + return validate_targets(self.targets, self.options, self.benchmark) @dataclass diff --git a/bench-orchestrator/tests/test_config.py b/bench-orchestrator/tests/test_config.py index c7e2d6bb291..f900048f87b 100644 --- a/bench-orchestrator/tests/test_config.py +++ b/bench-orchestrator/tests/test_config.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright the Vortex contributors from bench_orchestrator.config import ( + Benchmark, BenchmarkTarget, Engine, Format, @@ -39,6 +40,31 @@ def test_resolve_axis_targets_filters_unsupported_combinations() -> None: assert warnings == ["Format arrow is not supported by engine duckdb"] +def test_resolve_axis_targets_skips_engines_a_benchmark_cannot_run() -> None: + # SpatialBench is DuckDB-only (ST_* spatial SQL), so the DataFusion axis is dropped with a warning. + targets, warnings = resolve_axis_targets( + [Engine.DATAFUSION, Engine.DUCKDB], + [Format.PARQUET, Format.VORTEX], + Benchmark.SPATIALBENCH, + ) + + assert targets == [ + BenchmarkTarget(engine=Engine.DUCKDB, format=Format.PARQUET), + BenchmarkTarget(engine=Engine.DUCKDB, format=Format.VORTEX), + ] + assert warnings == ["Benchmark spatialbench does not support engine datafusion"] + + +def test_validate_targets_rejects_engine_a_benchmark_cannot_run() -> None: + errors = validate_targets( + [BenchmarkTarget(engine=Engine.DATAFUSION, format=Format.PARQUET)], + {}, + Benchmark.SPATIALBENCH, + ) + + assert errors == ["Benchmark spatialbench does not support engine datafusion"] + + def test_validate_targets_rejects_remote_lance() -> None: errors = validate_targets( [BenchmarkTarget(engine=Engine.DATAFUSION, format=Format.LANCE)], diff --git a/vortex-bench/Cargo.toml b/vortex-bench/Cargo.toml index 9cd7fc92d30..f655b5213bb 100644 --- a/vortex-bench/Cargo.toml +++ b/vortex-bench/Cargo.toml @@ -69,6 +69,7 @@ tracing-subscriber = { workspace = true, features = [ ] } url = { workspace = true } uuid = { workspace = true, features = ["v4"] } +wkb = { workspace = true } [dev-dependencies] insta = { workspace = true } diff --git a/vortex-bench/src/conversions.rs b/vortex-bench/src/conversions.rs index ad84ef61b1d..b7c367ef8f6 100644 --- a/vortex-bench/src/conversions.rs +++ b/vortex-bench/src/conversions.rs @@ -4,6 +4,7 @@ use std::fs; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; use futures::StreamExt; use futures::TryStreamExt; @@ -22,29 +23,43 @@ use tracing::info; use tracing::trace; use vortex::VortexSessionDefault; use vortex::array::ArrayRef; +use vortex::array::ExecutionCtx; use vortex::array::IntoArray; use vortex::array::VortexSessionExecute; use vortex::array::arrays::ChunkedArray; use vortex::array::arrays::ExtensionArray; use vortex::array::arrays::Struct; use vortex::array::arrays::StructArray; +use vortex::array::arrays::VarBinViewArray; use vortex::array::arrays::struct_::StructArrayExt; use vortex::array::arrow::FromArrowArray; use vortex::array::builders::builder_with_capacity; use vortex::array::stream::ArrayStreamAdapter; use vortex::array::stream::ArrayStreamExt; +use vortex::compressor::BtrBlocksCompressorBuilder; use vortex::dtype::DType; +use vortex::dtype::FieldPath; use vortex::dtype::StructFields; use vortex::dtype::arrow::FromArrowType; use vortex::dtype::extension::ExtDType; use vortex::dtype::extension::ExtDTypeRef; use vortex::error::VortexResult; use vortex::error::vortex_err; +use vortex::file::VortexWriteOptions; use vortex::file::WriteOptionsSessionExt; +use vortex::file::WriteStrategyBuilder; +use vortex::layout::LayoutStrategy; +use vortex::layout::layouts::chunked::writer::ChunkedLayoutStrategy; +use vortex::layout::layouts::compressed::CompressingStrategy; +use vortex::layout::layouts::flat::writer::FlatLayoutStrategy; use vortex::session::VortexSession; use vortex::utils::aliases::hash_set::HashSet; use vortex_geo::extension::GeoMetadata; use vortex_geo::extension::WellKnownBinary; +use wkb::Endianness; +use wkb::reader::read_wkb; +use wkb::writer::WriteOptions; +use wkb::writer::write_geometry; use crate::CompactionStrategy; use crate::Format; @@ -142,8 +157,7 @@ pub async fn convert_parquet_file_to_vortex( .open(output_path) .await?; - compaction - .apply_options(SESSION.write_options()) + write_options_for(compaction, &dtype, is_spatialbench(parquet_path)) .write( &mut output_file, ArrayStreamExt::boxed(ArrayStreamAdapter::new(dtype, stream)), @@ -153,6 +167,54 @@ pub async fn convert_parquet_file_to_vortex( Ok(()) } +/// Whether `path` points at SpatialBench data. +fn is_spatialbench(path: &Path) -> bool { + path.components() + .any(|component| component.as_os_str() == "spatialbench") +} + +/// Vortex write options for converting `dtype`-shaped data. +/// +/// For SpatialBench (`skip_binary_dict`), the geometry blobs are large and +/// unique, so the dictionary builder balloons memory (tens of GB) for zero gain. +fn write_options_for( + compaction: CompactionStrategy, + dtype: &DType, + skip_binary_dict: bool, +) -> VortexWriteOptions { + let binary_fields: Vec<_> = match dtype { + DType::Struct(fields, _) if skip_binary_dict => fields + .names() + .iter() + .zip(fields.fields()) + .filter(|(_, field)| matches!(field, DType::Binary(_))) + .map(|(name, _)| name.clone()) + .collect(), + _ => Vec::new(), + }; + if binary_fields.is_empty() { + return compaction.apply_options(SESSION.write_options()); + } + + let mut builder = WriteStrategyBuilder::default(); + if matches!(compaction, CompactionStrategy::Compact) { + builder = + builder.with_btrblocks_builder(BtrBlocksCompressorBuilder::default().with_compact()); + } + for name in binary_fields { + builder = builder.with_field_writer(FieldPath::from_name(name), no_dict_layout()); + } + SESSION.write_options().with_strategy(builder.build()) +} + +/// A chunked + compressed layout that skips dictionary encoding for opaque `Binary` blobs. +fn no_dict_layout() -> Arc { + Arc::new(CompressingStrategy::new( + ChunkedLayoutStrategy::new(FlatLayoutStrategy::default()), + BtrBlocksCompressorBuilder::default().build(), + )) +} + /// Convert all Parquet files in a directory to Vortex format. /// /// This function reads Parquet files from `{input_path}/parquet/` and writes Vortex files to @@ -251,7 +313,7 @@ pub async fn add_geoparquet_metadata(parquet_path: &Path, geo_json: &str) -> any return Ok(()); } - let schema = std::sync::Arc::clone(builder.schema()); + let schema = Arc::clone(builder.schema()); let mut reader = builder.build()?; let tmp_path = parquet_path.with_extension("parquet.tmp"); @@ -314,7 +376,8 @@ fn tag_geo_dtype(dtype: DType, geo_columns: &HashSet) -> VortexResult
) -> VortexResult { if geo_columns.is_empty() { return Ok(chunk); @@ -325,17 +388,51 @@ fn tag_geo_array(chunk: ArrayRef, geo_columns: &HashSet) -> VortexResult let names = struct_array.names().clone(); let validity = struct_array.struct_validity(); let len = struct_array.len(); - let tagged = names - .iter() - .zip(struct_array.iter_unmasked_fields()) - .map(|(name, field)| { - if geo_columns.contains(name.as_ref()) && field.dtype().is_binary() { - let ext = wkb_ext_dtype(field.dtype())?; - Ok(ExtensionArray::try_new(ext, field.clone())?.into_array()) - } else { - Ok(field.clone()) + let mut ctx = SESSION.create_execution_ctx(); + let mut tagged = Vec::with_capacity(names.len()); + for (name, field) in names.iter().zip(struct_array.iter_unmasked_fields()) { + if geo_columns.contains(name.as_ref()) && field.dtype().is_binary() { + let ext = wkb_ext_dtype(field.dtype())?; + let little_endian = wkb_field_to_little_endian(field, &mut ctx)?; + tagged.push(ExtensionArray::try_new(ext, little_endian)?.into_array()); + } else { + tagged.push(field.clone()); + } + } + Ok(StructArray::try_new(names, tagged, len, validity)?.into_array()) +} + +/// Re-encode a binary `field`'s WKB values as little-endian (NDR), the only byte order DuckDB's +/// `GEOMETRY` accepts. Byte order is uniform within a column, so the array is returned untouched (no +/// copy) unless its first non-null value is big-endian; only then is each value re-encoded. +fn wkb_field_to_little_endian(field: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + let values = field.clone().execute::(ctx)?; + let len = values.len(); + let validity = values.validity()?.execute_mask(len, ctx)?; + let is_big_endian = (0..len) + .find(|&i| validity.value(i)) + .is_some_and(|i| values.bytes_at(i).as_slice().first() == Some(&0x00)); + if !is_big_endian { + return Ok(field.clone()); + } + let little_endian: Vec>> = (0..len) + .map(|i| { + if !validity.value(i) { + return Ok(None); } + let wkb = values.bytes_at(i); + let geometry = read_wkb(wkb.as_slice()).map_err(|e| vortex_err!("invalid WKB: {e}"))?; + let mut encoded = Vec::with_capacity(wkb.len()); + write_geometry( + &mut encoded, + &geometry, + &WriteOptions { + endianness: Endianness::LittleEndian, + }, + ) + .map_err(|e| vortex_err!("re-encoding WKB as little-endian: {e}"))?; + Ok(Some(encoded)) }) - .collect::>>()?; - Ok(StructArray::try_new(names, tagged, len, validity)?.into_array()) + .collect::>()?; + Ok(VarBinViewArray::from_iter_nullable_bin(little_endian).into_array()) } diff --git a/vortex-bench/src/spatialbench/datagen/wkb.rs b/vortex-bench/src/spatialbench/datagen/wkb.rs index 422505a3623..e0160ea6f9c 100644 --- a/vortex-bench/src/spatialbench/datagen/wkb.rs +++ b/vortex-bench/src/spatialbench/datagen/wkb.rs @@ -5,9 +5,11 @@ //! Geometry is emitted as WKB, which DuckDB reads directly as `GEOMETRY` via `ST_GeomFromWKB`. use std::fs; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use anyhow::Context; use anyhow::Result; // spatialbench emits arrow-56 batches, so they must be written with its matching arrow-56 // parquet crate, not the workspace's arrow-58 one. The parquet file itself is version-neutral. @@ -23,6 +25,7 @@ use spatialbench_parquet::basic::Compression; use spatialbench_parquet::file::properties::WriterProperties; use spatialbench_parquet::format::KeyValue; use tokio::fs::File as TokioFile; +use tokio::process::Command; use tracing::info; use super::table::Table; @@ -109,6 +112,52 @@ pub async fn generate_tables(scale_factor: &str, output_dir: PathBuf) -> Result< } } + // `zone` isn't generated in-process (`Table::is_generated` is false); it comes from the upstream + // `spatialbench-cli` (install it, or set `SPATIALBENCH_CLI` to its binary). + generate_zone(scale_factor, &parquet_dir).await?; + + Ok(()) +} + +/// Generate the externally-sourced `zone` table by shelling out to the upstream +/// `spatialbench-cli`. +async fn generate_zone(scale_factor: f64, parquet_dir: &Path) -> Result<()> { + let cli = std::env::var("SPATIALBENCH_CLI").unwrap_or_else(|_| "spatialbench-cli".to_string()); + idempotent_async(parquet_dir.join("zone_0.parquet"), |zone_path| async move { + info!( + scale_factor, + cli, "Generating SpatialBench zone table via spatialbench-cli" + ); + // The CLI writes a fixed `zone.parquet` name into an output directory, so + // generate into a scratch dir and move the produced parquet onto `zone_path`. + let scratch = zone_path.with_extension("zone-scratch"); + fs::create_dir_all(&scratch)?; + let status = Command::new(&cli) + .arg("-s") + .arg(scale_factor.to_string()) + .args(["-T", "zone", "-f", "parquet", "-o"]) + .arg(&scratch) + .status() + .await + .with_context(|| format!("failed to spawn `{cli}` (is it installed / on PATH?)"))?; + anyhow::ensure!( + status.success(), + "`{cli}` exited with {status} while generating zone" + ); + let produced = glob::glob(&scratch.join("**/*.parquet").to_string_lossy())? + .flatten() + .next() + .with_context(|| { + format!( + "`{cli}` produced no zone parquet under {}", + scratch.display() + ) + })?; + fs::rename(&produced, &zone_path)?; + fs::remove_dir_all(&scratch).ok(); + Ok(()) + }) + .await?; Ok(()) } From 574326f3779346fcdd6b72ca6bcc5a248988d14f Mon Sep 17 00:00:00 2001 From: Nemo Yu <83347615+HarukiMoriarty@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:24:05 -0400 Subject: [PATCH 003/104] feat(vortex-geo): add MultiPolygon extension type + WKB export for native geometry (#8608) ## Summary Adds a native `MultiPolygon` geometry extension type to `vortex-geo`, and a WKB-export helper (`to_wkb`) for the native geometry types (`Point`, `Polygon`, `MultiPolygon`). `MultiPolygon` (`vortex.geo.multipolygon`) mirrors the existing `Polygon` type, mainly for SpatialBench `z_boundary` column, which is mixture of `Polygon` and `MultiPolygon`. `to_wkb` serializes a native geometry array to WKB (via `geoarrow-cast`), the form DuckDB's `GEOMETRY` vectors carry, for communicate with DuckDB. ## What APIs are changed? Are there any user-facing changes? Purely **additive**, new public API in `vortex-geo`: - `extension::MultiPolygon`, the new extension type (with its `ExtVTable` + Arrow import/export). - `extension::{PointData, PolygonData}` + `to_wkb` on the existing `Point`/`Polygon` types. --------- Signed-off-by: Nemo Yu --- Cargo.lock | 14 + Cargo.toml | 1 + vortex-geo/Cargo.toml | 1 + vortex-geo/src/extension/mod.rs | 18 ++ vortex-geo/src/extension/multipolygon.rs | 363 +++++++++++++++++++++++ vortex-geo/src/extension/point.rs | 44 ++- vortex-geo/src/extension/polygon.rs | 40 ++- vortex-geo/src/lib.rs | 4 + vortex-geo/src/tests/mod.rs | 1 + vortex-geo/src/tests/multipolygon.rs | 94 ++++++ 10 files changed, 566 insertions(+), 14 deletions(-) create mode 100644 vortex-geo/src/extension/multipolygon.rs create mode 100644 vortex-geo/src/tests/multipolygon.rs diff --git a/Cargo.lock b/Cargo.lock index 0f70678a143..7979d78234f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4238,6 +4238,19 @@ dependencies = [ "wkt 0.14.0", ] +[[package]] +name = "geoarrow-cast" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41c308d653690a4e8ef3cbba69696056bd819e624766ece66d64cc26a638acc1" +dependencies = [ + "arrow-schema 58.3.0", + "geo-traits", + "geoarrow-array", + "geoarrow-schema", + "wkt 0.14.0", +] + [[package]] name = "geoarrow-schema" version = "0.8.0" @@ -10397,6 +10410,7 @@ dependencies = [ "geo-traits", "geo-types", "geoarrow", + "geoarrow-cast", "prost 0.14.4", "rstest", "vortex-array", diff --git a/Cargo.toml b/Cargo.toml index bf5057432a6..ba54a50a2ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -165,6 +165,7 @@ geo = "0.31.0" geo-traits = "0.3.0" geo-types = "0.7.19" geoarrow = "0.8.0" +geoarrow-cast = "0.8.0" get_dir = "0.5.0" glob = "0.3.2" goldenfile = "1" diff --git a/vortex-geo/Cargo.toml b/vortex-geo/Cargo.toml index e2f7e4dc10f..2f0583b49e6 100644 --- a/vortex-geo/Cargo.toml +++ b/vortex-geo/Cargo.toml @@ -20,6 +20,7 @@ geo = { workspace = true } geo-traits = { workspace = true } geo-types = { workspace = true } geoarrow = { workspace = true } +geoarrow-cast = { workspace = true } prost = { workspace = true } vortex-array = { workspace = true } vortex-error = { workspace = true } diff --git a/vortex-geo/src/extension/mod.rs b/vortex-geo/src/extension/mod.rs index 684c83bade0..37f903aa0ca 100644 --- a/vortex-geo/src/extension/mod.rs +++ b/vortex-geo/src/extension/mod.rs @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors pub(crate) mod coordinate; +mod multipolygon; mod point; mod polygon; mod wkb; @@ -10,8 +11,13 @@ use std::fmt::Display; use std::sync::Arc; use geo_types::Geometry; +use geoarrow::array::GeoArrowArray; use geoarrow::datatypes::Crs; +use geoarrow::datatypes::GeoArrowType; use geoarrow::datatypes::Metadata; +use geoarrow::datatypes::WkbType; +use geoarrow_cast::cast::cast; +pub use multipolygon::*; pub use point::*; pub use polygon::*; use vortex_array::ArrayRef; @@ -20,6 +26,7 @@ use vortex_array::IntoArray; use vortex_array::arrays::ConstantArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::extension::ExtensionArrayExt; +use vortex_array::arrow::FromArrowArray; use vortex_array::scalar::Scalar; use vortex_error::VortexResult; use vortex_error::vortex_bail; @@ -46,6 +53,8 @@ pub(crate) fn geometries( point_geometries(&storage, ctx) } else if ext.is::() { polygon_geometries(&storage, ctx) + } else if ext.is::() { + multipolygon_geometries(&storage, ctx) } else { vortex_bail!("geo: unsupported geometry extension {}", array.dtype()) } @@ -95,6 +104,15 @@ pub(crate) fn geoarrow_metadata(geo_metadata: &GeoMetadata) -> Arc { )) } +/// Serialize a native geometry array to WKB (a `WkbView` array) via geoarrow's cast. +/// Shared by the `to_wkb` methods on the geometry extension types. +pub(crate) fn geoarrow_to_wkb(geo_array: &dyn GeoArrowArray) -> VortexResult { + let wkb_type = GeoArrowType::WkbView(WkbType::new(geoarrow_metadata(&GeoMetadata::default()))); + let wkb = cast(geo_array, &wkb_type) + .map_err(|e| vortex_err!("failed to cast geometry to WKB: {e}"))?; + ArrayRef::from_arrow(wkb.to_array_ref().as_ref(), false) +} + /// Recover [`GeoMetadata`] from GeoArrow metadata. pub(crate) fn geo_metadata_from_arrow(metadata: &Metadata) -> GeoMetadata { let crs = metadata.crs().crs_value().map(|value| { diff --git a/vortex-geo/src/extension/multipolygon.rs b/vortex-geo/src/extension/multipolygon.rs new file mode 100644 index 00000000000..67dac438970 --- /dev/null +++ b/vortex-geo/src/extension/multipolygon.rs @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! The [`MultiPolygon`] extension type (`vortex.geo.multipolygon`), stored as +//! `List>>>` (polygons → rings → coordinates) and tagged with +//! [`GeoMetadata`]. A single `Polygon` is a one-element multipolygon. + +use std::sync::Arc; + +use arrow_array::ArrayRef as ArrowArrayRef; +use arrow_schema::DataType; +use arrow_schema::Field; +use arrow_schema::extension::ExtensionType; +use geo_traits::to_geo::ToGeoGeometry; +use geo_types::Geometry; +use geoarrow::array::GeoArrowArrayAccessor; +use geoarrow::array::IntoArrow; +use geoarrow::array::MultiPolygonArray; +use geoarrow::datatypes::CoordType; +use geoarrow::datatypes::MultiPolygonType; +use prost::Message; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::arrays::ExtensionArray; +use vortex_array::arrays::extension::ExtensionArrayExt; +use vortex_array::arrow::ArrowExport; +use vortex_array::arrow::ArrowExportVTable; +use vortex_array::arrow::ArrowImport; +use vortex_array::arrow::ArrowImportVTable; +use vortex_array::arrow::ArrowSession; +use vortex_array::arrow::ArrowSessionExt; +use vortex_array::arrow::FromArrowArray; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::arrow::FromArrowType; +use vortex_array::dtype::extension::ExtDType; +use vortex_array::dtype::extension::ExtId; +use vortex_array::dtype::extension::ExtVTable; +use vortex_array::scalar::ScalarValue; +use vortex_error::VortexError; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; +use vortex_error::vortex_ensure; +use vortex_error::vortex_err; +use vortex_session::registry::CachedId; +use vortex_session::registry::Id; + +use super::GeoMetadata; +use super::coordinate::Dimension; +use super::coordinate::coordinate_dimension; +use super::coordinate::coordinate_storage_dtype; +use super::geo_metadata_from_arrow; +use super::geoarrow_metadata; +use super::geoarrow_to_wkb; + +/// A multipolygon (`geoarrow.multipolygon`); a single `Polygon` is a one-element multipolygon. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct MultiPolygon; + +impl ExtVTable for MultiPolygon { + type Metadata = GeoMetadata; + // No cheap owned value like Point's `Coordinate`; expose the raw storage scalar. + type NativeValue<'a> = &'a ScalarValue; + + fn id(&self) -> ExtId { + static ID: CachedId = CachedId::new("vortex.geo.multipolygon"); + *ID + } + + fn serialize_metadata(&self, metadata: &Self::Metadata) -> VortexResult> { + Ok(metadata.encode_to_vec()) + } + + fn deserialize_metadata(&self, metadata: &[u8]) -> VortexResult { + Ok(GeoMetadata::decode(metadata)?) + } + + fn validate_dtype(ext_dtype: &ExtDType) -> VortexResult<()> { + multipolygon_dimension(ext_dtype.storage_dtype()).map(|_| ()) + } + + fn unpack_native<'a>( + _ext_dtype: &'a ExtDType, + storage_value: &'a ScalarValue, + ) -> VortexResult<&'a ScalarValue> { + Ok(storage_value) + } +} + +/// Storage `List>>`: polygons → rings → coordinates. +pub(crate) fn multipolygon_storage_dtype(dim: Dimension, nullability: Nullability) -> DType { + let coords = coordinate_storage_dtype(dim, Nullability::NonNullable); + let ring = DType::List(Arc::new(coords), Nullability::NonNullable); + let polygon = DType::List(Arc::new(ring), Nullability::NonNullable); + DType::List(Arc::new(polygon), nullability) +} + +/// Validate `dtype` is `List>>` and return its [`Dimension`]. +pub(crate) fn multipolygon_dimension(dtype: &DType) -> VortexResult { + let DType::List(polygon, _) = dtype else { + vortex_bail!("multipolygon storage must be a List of polygons, was {dtype}"); + }; + let DType::List(ring, _) = polygon.as_ref() else { + vortex_bail!("multipolygon polygon storage must be a List of rings, was {polygon}"); + }; + let DType::List(coords, _) = ring.as_ref() else { + vortex_bail!("multipolygon ring storage must be a List of coordinates, was {ring}"); + }; + coordinate_dimension(coords) +} + +static ARROW_MULTIPOLYGON: CachedId = CachedId::new(MultiPolygonType::NAME); + +/// The `geoarrow.multipolygon` type for `dimension`, with separated (struct) coordinates. +fn multipolygon_type(geo_metadata: &GeoMetadata, dimension: Dimension) -> MultiPolygonType { + MultiPolygonType::new(dimension.into(), geoarrow_metadata(geo_metadata)) +} + +/// Decode storage to `geo_types` for the geo scalar functions (CRS is irrelevant to planar ops). +pub(crate) fn multipolygon_geometries( + storage: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult>> { + multipolygon_array(storage, ctx)? + .iter() + .map(|geometry| -> VortexResult> { + Ok(geometry + .ok_or_else(|| vortex_err!("geo: null geometry is not supported"))? + .map_err(|e| vortex_err!("geo: geometry access failed: {e}"))? + .to_geometry()) + }) + .collect() +} + +/// Build a geoarrow `MultiPolygonArray` from the `MultiPolygon` storage. +fn multipolygon_array( + storage: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let multipolygon_type = multipolygon_type( + &GeoMetadata::default(), + multipolygon_dimension(storage.dtype())?, + ); + let session = ctx.session().clone(); + let arrow = session.arrow().execute_arrow(storage.clone(), None, ctx)?; + MultiPolygonArray::try_from((arrow.as_ref(), multipolygon_type)) + .map_err(|e| vortex_err!("failed to construct MultiPolygonArray: {e}")) +} + +/// A validated `MultiPolygon` array (`try_from` checks the extension type). +pub struct MultiPolygonData(ExtensionArray); + +impl TryFrom for MultiPolygonData { + type Error = VortexError; + + fn try_from(ext: ExtensionArray) -> Result { + vortex_ensure!( + ext.ext_dtype().is::(), + "expected a MultiPolygon extension array" + ); + Ok(MultiPolygonData(ext)) + } +} + +impl MultiPolygonData { + /// Serialize multipolygons to WKB (a view array) — the form DuckDB `GEOMETRY` takes. + pub fn to_wkb(&self, ctx: &mut ExecutionCtx) -> VortexResult { + geoarrow_to_wkb(&multipolygon_array(self.0.storage_array(), ctx)?) + } +} + +impl ArrowExportVTable for MultiPolygon { + fn arrow_ext_id(&self) -> Id { + *ARROW_MULTIPOLYGON + } + + fn vortex_id(&self) -> Id { + self.id() + } + + fn to_arrow_field( + &self, + name: &str, + dtype: &DType, + session: &ArrowSession, + ) -> VortexResult> { + let ext_type = dtype.as_extension(); + let geo_metadata = ext_type.metadata::(); + let dimension = multipolygon_dimension(ext_type.storage_dtype())?; + + let mut field = session.to_arrow_field(name, ext_type.storage_dtype())?; + field.try_with_extension_type(multipolygon_type(geo_metadata, dimension))?; + + Ok(Some(field)) + } + + fn execute_arrow( + &self, + array: ArrayRef, + target: &Field, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + let is_multipolygon = array + .dtype() + .as_extension_opt() + .map(|ext| ext.is::()) + .unwrap_or(false); + if !is_multipolygon { + return Ok(ArrowExport::Unsupported(array)); + } + + let Ok(multipolygon_meta) = target.try_extension_type::() else { + return Ok(ArrowExport::Unsupported(array)); + }; + if multipolygon_meta.coord_type() != CoordType::Separated { + return Ok(ArrowExport::Unsupported(array)); + } + + let executed = array.execute::(ctx)?; + let storage = executed.storage_array().clone(); + + let storage_field = Field::new( + String::new(), + target.data_type().clone(), + target.is_nullable(), + ); + let session = ctx.session().clone(); + let arrow_storage = session + .arrow() + .execute_arrow(storage, Some(&storage_field), ctx)?; + + let multipolygons = + MultiPolygonArray::try_from((arrow_storage.as_ref(), multipolygon_meta)) + .map_err(|e| vortex_err!("failed to construct MultiPolygonArray: {e}"))?; + + Ok(ArrowExport::Exported(Arc::new(multipolygons.into_arrow()))) + } +} + +impl ArrowImportVTable for MultiPolygon { + fn arrow_ext_id(&self) -> Id { + *ARROW_MULTIPOLYGON + } + + /// Import a `geoarrow.multipolygon` field (matched by GeoArrow name). Accepts the full + /// `MultiPolygonType`, or a metadata-less literal (name only), inferring the dimension. + fn from_arrow_field(&self, field: &Field) -> VortexResult> { + let (dimension, metadata) = + if let Ok(multipolygon_meta) = field.try_extension_type::() { + vortex_ensure!( + multipolygon_meta.coord_type() == CoordType::Separated, + "geoarrow.multipolygon with interleaved coordinates is not supported; \ + re-encode with separated (struct) coordinates" + ); + ( + multipolygon_meta.dimension().into(), + geo_metadata_from_arrow(multipolygon_meta.metadata()), + ) + } else { + // Literal: peel the three `List` layers to the coordinate struct and read its + // dimension from the field names (the canonical check rejects nullable coordinates). + if field.extension_type_name() != Some(MultiPolygonType::NAME) { + return Ok(None); + } + let DType::List(polygon, _) = DType::from_arrow(field) else { + return Ok(None); + }; + let DType::List(ring, _) = polygon.as_ref() else { + return Ok(None); + }; + let DType::List(coords, _) = ring.as_ref() else { + return Ok(None); + }; + let DType::Struct(fields, _) = coords.as_ref() else { + return Ok(None); + }; + let Ok(dimension) = Dimension::from_field_names(fields.names()) else { + return Ok(None); + }; + (dimension, GeoMetadata::default()) + }; + + let storage_dtype = multipolygon_storage_dtype(dimension, field.is_nullable().into()); + Ok(Some(DType::Extension( + ExtDType::try_with_vtable(MultiPolygon, metadata, storage_dtype)?.erased(), + ))) + } + + fn from_arrow_array( + &self, + array: ArrowArrayRef, + field: &Field, + dtype: &DType, + ) -> VortexResult { + let Some(ext_dtype) = dtype.as_extension_opt() else { + return Ok(ArrowImport::Unsupported(array)); + }; + if !ext_dtype.is::() + || field.try_extension_type::().is_err() + || !matches!(array.data_type(), DataType::List(_)) + { + return Ok(ArrowImport::Unsupported(array)); + } + + let storage = ArrayRef::from_arrow(array.as_ref(), field.is_nullable())?; + Ok(ArrowImport::Imported( + ExtensionArray::try_new(ext_dtype.clone(), storage)?.into_array(), + )) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use rstest::rstest; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::dtype::extension::ExtDType; + use vortex_error::VortexResult; + + use super::MultiPolygon; + use super::multipolygon_storage_dtype; + use crate::extension::GeoMetadata; + use crate::extension::coordinate::Dimension; + use crate::extension::coordinate::coordinate_storage_dtype; + + fn geo_meta() -> GeoMetadata { + GeoMetadata { + crs: Some("EPSG:4326".to_string()), + } + } + + /// `MultiPolygon` accepts the canonical `List>>` storage of every + /// dimension. + #[rstest] + #[case::xy(Dimension::Xy)] + #[case::xyz(Dimension::Xyz)] + #[case::xym(Dimension::Xym)] + #[case::xyzm(Dimension::Xyzm)] + fn multipolygon_validates_every_dimension(#[case] dim: Dimension) -> VortexResult<()> { + let storage = multipolygon_storage_dtype(dim, Nullability::NonNullable); + ExtDType::::try_new(geo_meta(), storage)?; + Ok(()) + } + + /// Non-multipolygon storage is rejected at dtype construction: a bare struct (point) and a + /// double list (polygon) both fail. + #[test] + fn multipolygon_rejects_invalid_storage() -> VortexResult<()> { + let primitive = DType::Primitive(PType::F64, Nullability::NonNullable); + assert!(ExtDType::::try_new(geo_meta(), primitive).is_err()); + + // A double list (polygon) is not a multipolygon. + let coords = coordinate_storage_dtype(Dimension::Xy, Nullability::NonNullable); + let ring = DType::List(Arc::new(coords), Nullability::NonNullable); + let polygon = DType::List(Arc::new(ring), Nullability::NonNullable); + assert!(ExtDType::::try_new(geo_meta(), polygon).is_err()); + Ok(()) + } +} diff --git a/vortex-geo/src/extension/point.rs b/vortex-geo/src/extension/point.rs index 389da97ad2b..6371105ca25 100644 --- a/vortex-geo/src/extension/point.rs +++ b/vortex-geo/src/extension/point.rs @@ -37,6 +37,7 @@ use vortex_array::dtype::extension::ExtId; use vortex_array::dtype::extension::ExtVTable; use vortex_array::scalar::Scalar; use vortex_array::scalar::ScalarValue; +use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_error::vortex_ensure; use vortex_error::vortex_err; @@ -51,6 +52,7 @@ use super::coordinate::coordinate_from_struct; use super::coordinate::coordinate_storage_dtype; use super::geo_metadata_from_arrow; use super::geoarrow_metadata; +use super::geoarrow_to_wkb; /// A single location: `geoarrow.point`, stored as `Struct` of non-nullable `f64`. #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] @@ -97,20 +99,46 @@ fn point_type(geo_metadata: &GeoMetadata, dimension: Dimension) -> PointType { PointType::new(dimension.into(), geoarrow_metadata(geo_metadata)) } -/// Decode `Point` storage to `geo_types` points, for the geo scalar functions. -pub(crate) fn point_geometries( - storage: &ArrayRef, - ctx: &mut ExecutionCtx, -) -> VortexResult>> { +pub struct PointData(ExtensionArray); + +impl TryFrom for PointData { + type Error = VortexError; + + fn try_from(ext: ExtensionArray) -> Result { + vortex_ensure!( + ext.ext_dtype().is::(), + "expected a Point extension array" + ); + Ok(PointData(ext)) + } +} + +impl PointData { + /// Serialize points to WKB (a view array) — the form DuckDB `GEOMETRY` takes. + pub fn to_wkb(&self, ctx: &mut ExecutionCtx) -> VortexResult { + geoarrow_to_wkb(&point_array(self.0.storage_array(), ctx)?) + } +} + +/// Build a geoarrow `PointArray` from a `Point`'s `Struct` storage, shared by WKB export +/// and `geo_types` decoding. +fn point_array(storage: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { let point_type = point_type( &GeoMetadata::default(), coordinate_dimension(storage.dtype())?, ); let session = ctx.session().clone(); let arrow = session.arrow().execute_arrow(storage.clone(), None, ctx)?; - let points = PointArray::try_from((arrow.as_ref(), point_type)) - .map_err(|e| vortex_err!("failed to construct PointArray: {e}"))?; - points + PointArray::try_from((arrow.as_ref(), point_type)) + .map_err(|e| vortex_err!("failed to construct PointArray: {e}")) +} + +/// Decode `Point` storage to `geo_types` points, for the geo scalar functions. +pub(crate) fn point_geometries( + storage: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult>> { + point_array(storage, ctx)? .iter() .map(|geometry| -> VortexResult> { Ok(geometry diff --git a/vortex-geo/src/extension/polygon.rs b/vortex-geo/src/extension/polygon.rs index 83ec6601158..aeda30931a2 100644 --- a/vortex-geo/src/extension/polygon.rs +++ b/vortex-geo/src/extension/polygon.rs @@ -38,6 +38,7 @@ use vortex_array::dtype::extension::ExtDType; use vortex_array::dtype::extension::ExtId; use vortex_array::dtype::extension::ExtVTable; use vortex_array::scalar::ScalarValue; +use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_error::vortex_bail; use vortex_error::vortex_ensure; @@ -51,6 +52,7 @@ use super::coordinate::coordinate_dimension; use super::coordinate::coordinate_storage_dtype; use super::geo_metadata_from_arrow; use super::geoarrow_metadata; +use super::geoarrow_to_wkb; /// A polygon: `geoarrow.polygon`, stored as `List>>` (rings of vertices). #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] @@ -118,12 +120,7 @@ pub(crate) fn polygon_geometries( storage: &ArrayRef, ctx: &mut ExecutionCtx, ) -> VortexResult>> { - let polygon_type = polygon_type(&GeoMetadata::default(), polygon_dimension(storage.dtype())?); - let session = ctx.session().clone(); - let arrow = session.arrow().execute_arrow(storage.clone(), None, ctx)?; - let polygons = PolygonArray::try_from((arrow.as_ref(), polygon_type)) - .map_err(|e| vortex_err!("failed to construct PolygonArray: {e}"))?; - polygons + polygon_array(storage, ctx)? .iter() .map(|geometry| -> VortexResult> { Ok(geometry @@ -134,6 +131,37 @@ pub(crate) fn polygon_geometries( .collect() } +/// Build a geoarrow `PolygonArray` from a `Polygon`'s `List>` storage. +fn polygon_array(storage: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + let polygon_type = polygon_type(&GeoMetadata::default(), polygon_dimension(storage.dtype())?); + let session = ctx.session().clone(); + let arrow = session.arrow().execute_arrow(storage.clone(), None, ctx)?; + PolygonArray::try_from((arrow.as_ref(), polygon_type)) + .map_err(|e| vortex_err!("failed to construct PolygonArray: {e}")) +} + +/// A validated `Polygon` array (`try_from` checks the extension type). +pub struct PolygonData(ExtensionArray); + +impl TryFrom for PolygonData { + type Error = VortexError; + + fn try_from(ext: ExtensionArray) -> Result { + vortex_ensure!( + ext.ext_dtype().is::(), + "expected a Polygon extension array" + ); + Ok(PolygonData(ext)) + } +} + +impl PolygonData { + /// Serialize polygons to WKB (a view array) — the form DuckDB `GEOMETRY` takes. + pub fn to_wkb(&self, ctx: &mut ExecutionCtx) -> VortexResult { + geoarrow_to_wkb(&polygon_array(self.0.storage_array(), ctx)?) + } +} + impl ArrowExportVTable for Polygon { fn arrow_ext_id(&self) -> Id { *ARROW_POLYGON diff --git a/vortex-geo/src/lib.rs b/vortex-geo/src/lib.rs index 951d93b7b4f..2cc8004efc5 100644 --- a/vortex-geo/src/lib.rs +++ b/vortex-geo/src/lib.rs @@ -8,6 +8,7 @@ use vortex_array::dtype::session::DTypeSessionExt; use vortex_array::scalar_fn::session::ScalarFnSessionExt; use vortex_session::VortexSession; +use crate::extension::MultiPolygon; use crate::extension::Point; use crate::extension::Polygon; use crate::extension::WellKnownBinary; @@ -32,6 +33,9 @@ pub fn initialize(session: &VortexSession) { session.dtypes().register(Polygon); session.arrow().register_exporter(Arc::new(Polygon)); session.arrow().register_importer(Arc::new(Polygon)); + session.dtypes().register(MultiPolygon); + session.arrow().register_exporter(Arc::new(MultiPolygon)); + session.arrow().register_importer(Arc::new(MultiPolygon)); // Register the geometry scalar functions. session.scalar_fns().register(GeoDistance); diff --git a/vortex-geo/src/tests/mod.rs b/vortex-geo/src/tests/mod.rs index 546de758eba..87b25ed1293 100644 --- a/vortex-geo/src/tests/mod.rs +++ b/vortex-geo/src/tests/mod.rs @@ -4,6 +4,7 @@ //! Arrow interop tests for the geospatial extension types, exercising the session wiring set up //! by [`crate::initialize`]. +mod multipolygon; mod point; mod wkb; diff --git a/vortex-geo/src/tests/multipolygon.rs b/vortex-geo/src/tests/multipolygon.rs new file mode 100644 index 00000000000..38f2543a96a --- /dev/null +++ b/vortex-geo/src/tests/multipolygon.rs @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Arrow interop for the `vortex.geo.multipolygon` extension type (`geoarrow.multipolygon`). + +use std::sync::Arc; + +use arrow_schema::DataType; +use arrow_schema::Field; +use arrow_schema::extension::ExtensionType as _; +use geoarrow::datatypes::CoordType; +use geoarrow::datatypes::Crs; +use geoarrow::datatypes::Dimension as GeoArrowDimension; +use geoarrow::datatypes::Metadata; +use geoarrow::datatypes::MultiPolygonType; +use vortex_array::arrow::ArrowSessionExt; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_error::VortexResult; + +use super::SESSION; +use crate::extension::MultiPolygon; + +/// A `geoarrow.multipolygon` Arrow field with separated (struct) XY coordinates. +fn multipolygon_field(name: &str, nullable: bool, crs: Option<&str>) -> Field { + let crs = crs + .map(|crs| Crs::from_unknown_crs_type(crs.to_string())) + .unwrap_or_default(); + let metadata = Arc::new(Metadata::new(crs, None)); + MultiPolygonType::new(GeoArrowDimension::XY, metadata).to_field(name, nullable) +} + +/// An imported `geoarrow.multipolygon` field maps to the MultiPolygon extension dtype, recovering the +/// CRS, the `List>>>` storage, and nullability. +#[test] +fn import_field_recovers_extension() -> VortexResult<()> { + let field = multipolygon_field("geom", true, Some("EPSG:4326")); + let dtype = SESSION.arrow().from_arrow_field(&field)?; + + let DType::Extension(ext) = &dtype else { + panic!("expected Extension dtype, got {dtype}"); + }; + assert!(ext.is::()); + assert_eq!( + ext.metadata::().crs.as_deref(), + Some("EPSG:4326") + ); + + // Storage peels three List layers (multipolygon → polygons → rings) to the coordinate struct. + let DType::List(polygons, nullability) = ext.storage_dtype() else { + panic!("expected List storage, got {}", ext.storage_dtype()); + }; + assert_eq!(*nullability, Nullability::Nullable); + let DType::List(rings, _) = polygons.as_ref() else { + panic!("expected List of polygons"); + }; + let DType::List(coords, _) = rings.as_ref() else { + panic!("expected List of rings"); + }; + let DType::Struct(fields, _) = coords.as_ref() else { + panic!("expected coordinate Struct"); + }; + let names: Vec<&str> = fields.names().iter().map(|n| n.as_ref()).collect(); + assert_eq!(names, vec!["x", "y"]); + Ok(()) +} + +/// A field with interleaved (`FixedSizeList`) coordinates fails to import. +#[test] +fn import_interleaved_field_fails() { + let multipolygon_type = MultiPolygonType::new(GeoArrowDimension::XY, Default::default()) + .with_coord_type(CoordType::Interleaved); + let field = multipolygon_type.to_field("geom", false); + assert!(SESSION.arrow().from_arrow_field(&field).is_err()); +} + +/// A field imported to the MultiPolygon dtype and exported back carries the `geoarrow.multipolygon` +/// extension over its `List` storage. +#[test] +fn export_field_carries_extension() -> VortexResult<()> { + let imported = + SESSION + .arrow() + .from_arrow_field(&multipolygon_field("geom", false, Some("EPSG:4326")))?; + let field = SESSION.arrow().to_arrow_field("geom", &imported)?; + + assert_eq!(field.extension_type_name(), Some(MultiPolygonType::NAME)); + assert!( + matches!(field.data_type(), DataType::List(_)), + "expected List storage, got {}", + field.data_type() + ); + Ok(()) +} From b7b1408841f2e9a2212e88742af04849dfd28068 Mon Sep 17 00:00:00 2001 From: Nemo Yu <83347615+HarukiMoriarty@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:42:47 -0400 Subject: [PATCH 004/104] feat(vortex-bench): support vortex native geo types into SpatialBench, and wire into benchmark orchestrator (#8623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Wires vortex geo native format into SpatialBench, and wire into the `vx-bench` / `bench-orchestrator` pipeline so it can be run end-to-end like the other benchmarks. Running command: ``` vx-bench run spatialbench -e duckdb -f parquet,vortex,vortex-native --opt scale-factor=1.0 --queries 1,2,3,4,5,6,7,8,9 ``` ## Limitation - DuckDB-only as before. For now SpatialBench queries use DuckDB-specific ST_* spatial SQL that DataFusion has no functions for yet. There is a a single ad-hoc entry in `BENCHMARK_ENGINES = { SPATIALBENCH: {DUCKDB} }`. - Queries 10, 11, 12 is timeout simply because DuckDB poorly support on Spatial index. ## Performance SF=1.0 ``` ┏━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Query ┃ duckdb:parquet (base) ┃ duckdb:vortex ┃ duckdb:vortex-native ┃ ┡━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━┩ │ 1 │ 40.8ms │ 13.8ms (0.34x) │ 23.2ms (0.57x) │ │ 2 │ 181.2ms │ 26.3ms (0.14x) │ 35.3ms (0.20x) │ │ 3 │ 57.8ms │ 19.8ms (0.34x) │ 30.9ms (0.53x) │ │ 4 │ 331.9ms │ 61.8ms (0.19x) │ 111.0ms (0.33x) │ │ 5 │ 356.1ms │ 294.3ms (0.83x) │ 299.3ms (0.84x) │ │ 6 │ 441.9ms │ 86.3ms (0.20x) │ 135.1ms (0.31x) │ │ 7 │ 157.2ms │ 73.7ms (0.47x) │ 93.1ms (0.59x) │ │ 8 │ 197.8ms │ 78.1ms (0.39x) │ 91.9ms (0.46x) │ │ 9 │ 20.3ms │ 18.6ms (0.91x) │ 20.7ms (1.02x) │ └───────┴───────────────────────┴─────────────────┴──────────────────────┘ ``` SF=3 ``` ┏━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Query ┃ duckdb:parquet (base) ┃ duckdb:vortex ┃ duckdb:vortex-native ┃ ┡━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━┩ │ 1 │ 51.5ms │ 26.6ms (0.52x) │ 61.6ms (1.20x) │ │ 2 │ 127.6ms │ 56.2ms (0.44x) │ 93.1ms (0.73x) │ │ 3 │ 69.1ms │ 46.3ms (0.67x) │ 80.0ms (1.16x) │ │ 4 │ 543.4ms │ 64.5ms (0.12x) │ 119.6ms (0.22x) │ │ 5 │ 980.6ms │ 881.6ms (0.90x) │ 894.4ms (0.91x) │ │ 6 │ 660.9ms │ 133.8ms (0.20x) │ 233.2ms (0.35x) │ │ 7 │ 255.7ms │ 240.6ms (0.94x) │ 264.9ms (1.04x) │ │ 8 │ 267.6ms │ 247.4ms (0.92x) │ 299.7ms (1.12x) │ │ 9 │ 30.1ms │ 28.8ms (0.96x) │ 30.6ms (1.02x) │ └───────┴───────────────────────┴─────────────────┴──────────────────────┘ ``` SF=10 ``` ┏━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Query ┃ duckdb:parquet (base) ┃ duckdb:vortex ┃ duckdb:vortex-native ┃ ┡━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━┩ │ 1 │ 160.4ms │ 128.5ms (0.80x) │ 214.8ms (1.34x) │ │ 2 │ 254.3ms │ 239.8ms (0.94x) │ 325.9ms (1.28x) │ │ 3 │ 231.0ms │ 198.7ms (0.86x) │ 284.4ms (1.23x) │ │ 4 │ 188.0ms │ 124.2ms (0.66x) │ 147.9ms (0.79x) │ │ 5 │ 3.14s │ 3.05s (0.97x) │ 2.92s (0.93x) │ │ 6 │ 480.5ms │ 361.7ms (0.75x) │ 467.7ms (0.97x) │ │ 7 │ 992.8ms │ 1.02s (1.02x) │ 915.2ms (0.92x) │ │ 8 │ 1.07s │ 961.8ms (0.90x) │ 1.02s (0.95x) │ │ 9 │ 34.2ms │ 34.1ms (1.00x) │ 43.5ms (1.27x) │ └───────┴───────────────────────┴─────────────────┴──────────────────────┘ ``` Takeaways: for now we do not have only pushdown to execute predicate in vortex kernel, so geo native types pay the tax of converting to geo-binary (talk to duckdb) but no gains, so in SF=10 vortex-native is less efficient compared with vortex-wkb. --------- Signed-off-by: Nemo Yu --- Cargo.lock | 2 + .../bench_orchestrator/config.py | 2 + bench-orchestrator/tests/test_config.py | 34 ++++ bench-orchestrator/tests/test_executor.py | 13 ++ benchmarks/datafusion-bench/src/lib.rs | 7 +- benchmarks/duckdb-bench/src/lib.rs | 5 +- benchmarks/duckdb-bench/src/main.rs | 1 + vortex-bench/Cargo.toml | 2 + vortex-bench/src/benchmark.rs | 9 + vortex-bench/src/lib.rs | 12 +- vortex-bench/src/spatialbench/benchmark.rs | 57 ++++-- vortex-bench/src/spatialbench/datagen/mod.rs | 7 +- .../src/spatialbench/datagen/native.rs | 165 ++++++++++++++++++ .../src/spatialbench/datagen/table.rs | 44 ++++- vortex-bench/src/spatialbench/datagen/wkb.rs | 6 +- vortex-duckdb/src/convert/dtype.rs | 14 +- vortex-duckdb/src/exporter/extension.rs | 18 ++ vortex-duckdb/src/exporter/geo.rs | 34 ++++ 18 files changed, 400 insertions(+), 32 deletions(-) create mode 100644 vortex-bench/src/spatialbench/datagen/native.rs diff --git a/Cargo.lock b/Cargo.lock index 7979d78234f..d99d2124e45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9897,6 +9897,8 @@ dependencies = [ "bzip2", "clap", "futures", + "geoarrow", + "geoarrow-cast", "get_dir", "glob", "humansize", diff --git a/bench-orchestrator/bench_orchestrator/config.py b/bench-orchestrator/bench_orchestrator/config.py index e358bf18f01..cd4c6c81bfe 100644 --- a/bench-orchestrator/bench_orchestrator/config.py +++ b/bench-orchestrator/bench_orchestrator/config.py @@ -35,6 +35,7 @@ class Format(Enum): PARQUET = "parquet" VORTEX = "vortex" VORTEX_COMPACT = "vortex-compact" + VORTEX_NATIVE = "vortex-geo-native" DUCKDB = "duckdb" LANCE = "lance" @@ -68,6 +69,7 @@ class Benchmark(Enum): Format.PARQUET, Format.VORTEX, Format.VORTEX_COMPACT, + Format.VORTEX_NATIVE, Format.DUCKDB, ], Engine.LANCE: [Format.LANCE], diff --git a/bench-orchestrator/tests/test_config.py b/bench-orchestrator/tests/test_config.py index f900048f87b..fa3be9a2df6 100644 --- a/bench-orchestrator/tests/test_config.py +++ b/bench-orchestrator/tests/test_config.py @@ -26,6 +26,23 @@ def test_parse_formats_json_accepts_ci_format_arrays() -> None: assert formats == [Format.PARQUET, Format.VORTEX, Format.DUCKDB] +def test_parse_formats_json_accepts_vortex_native() -> None: + formats = parse_formats_json('["parquet","vortex","vortex-geo-native"]') + + assert formats == [Format.PARQUET, Format.VORTEX, Format.VORTEX_NATIVE] + + +def test_resolve_axis_targets_offers_vortex_native_on_duckdb_only() -> None: + # vortex-geo-native is a DuckDB-only lane; the DataFusion axis is dropped as unsupported. + targets, warnings = resolve_axis_targets( + [Engine.DATAFUSION, Engine.DUCKDB], + [Format.VORTEX_NATIVE], + ) + + assert targets == [BenchmarkTarget(engine=Engine.DUCKDB, format=Format.VORTEX_NATIVE)] + assert warnings == ["Format vortex-geo-native is not supported by engine datafusion"] + + def test_resolve_axis_targets_filters_unsupported_combinations() -> None: targets, warnings = resolve_axis_targets( [Engine.DATAFUSION, Engine.DUCKDB], @@ -55,6 +72,23 @@ def test_resolve_axis_targets_skips_engines_a_benchmark_cannot_run() -> None: assert warnings == ["Benchmark spatialbench does not support engine datafusion"] +def test_resolve_axis_targets_expands_spatialbench_three_lanes() -> None: + # The single-command three-lane comparison: parquet, WKB vortex, and native-geometry vortex, all + # on DuckDB. + targets, warnings = resolve_axis_targets( + [Engine.DUCKDB], + [Format.PARQUET, Format.VORTEX, Format.VORTEX_NATIVE], + Benchmark.SPATIALBENCH, + ) + + assert targets == [ + BenchmarkTarget(engine=Engine.DUCKDB, format=Format.PARQUET), + BenchmarkTarget(engine=Engine.DUCKDB, format=Format.VORTEX), + BenchmarkTarget(engine=Engine.DUCKDB, format=Format.VORTEX_NATIVE), + ] + assert warnings == [] + + def test_validate_targets_rejects_engine_a_benchmark_cannot_run() -> None: errors = validate_targets( [BenchmarkTarget(engine=Engine.DATAFUSION, format=Format.PARQUET)], diff --git a/bench-orchestrator/tests/test_executor.py b/bench-orchestrator/tests/test_executor.py index dd3253a22ff..86e92b22a3b 100644 --- a/bench-orchestrator/tests/test_executor.py +++ b/bench-orchestrator/tests/test_executor.py @@ -33,6 +33,19 @@ def test_build_command_adds_duckdb_cleanup_flag() -> None: assert "scale-factor=1.0" in cmd +def test_build_command_serializes_vortex_native_format() -> None: + executor = BenchmarkExecutor(Path("/tmp/duckdb-bench"), Engine.DUCKDB) + + cmd = executor.build_command( + benchmark=Benchmark.SPATIALBENCH, + formats=[Format.PARQUET, Format.VORTEX, Format.VORTEX_NATIVE], + iterations=1, + options={"scale-factor": "1.0"}, + ) + + assert "parquet,vortex,vortex-geo-native" in cmd + + def test_build_command_omits_formats_for_lance_backend() -> None: executor = BenchmarkExecutor(Path("/tmp/lance-bench"), Engine.LANCE) diff --git a/benchmarks/datafusion-bench/src/lib.rs b/benchmarks/datafusion-bench/src/lib.rs index 1100d38d24e..c45aa99be2c 100644 --- a/benchmarks/datafusion-bench/src/lib.rs +++ b/benchmarks/datafusion-bench/src/lib.rs @@ -111,10 +111,9 @@ pub fn format_to_df_format(format: Format) -> Arc { Format::Csv => Arc::new(CsvFormat::default()) as _, Format::Arrow => Arc::new(ArrowFormat), Format::Parquet => Arc::new(ParquetFormat::new()), - Format::OnDiskVortex | Format::VortexCompact => Arc::new(VortexFormat::new_with_options( - SESSION.clone(), - vortex_table_options(), - )), + Format::OnDiskVortex | Format::VortexCompact | Format::VortexNative => Arc::new( + VortexFormat::new_with_options(SESSION.clone(), vortex_table_options()), + ), Format::OnDiskDuckDB | Format::Lance => { unimplemented!("Format {format} cannot be turned into a DataFusion `FileFormat`") } diff --git a/benchmarks/duckdb-bench/src/lib.rs b/benchmarks/duckdb-bench/src/lib.rs index 4ec1efd0993..bf64f123956 100644 --- a/benchmarks/duckdb-bench/src/lib.rs +++ b/benchmarks/duckdb-bench/src/lib.rs @@ -169,7 +169,10 @@ impl DuckClient { file_format: Format, ) -> Result<()> { let object_type = match file_format { - Format::Parquet | Format::OnDiskVortex | Format::VortexCompact => "VIEW", + Format::Parquet + | Format::OnDiskVortex + | Format::VortexCompact + | Format::VortexNative => "VIEW", Format::OnDiskDuckDB => "TABLE", Format::Lance => { anyhow::bail!( diff --git a/benchmarks/duckdb-bench/src/main.rs b/benchmarks/duckdb-bench/src/main.rs index cf4fa071067..c496929f6a5 100644 --- a/benchmarks/duckdb-bench/src/main.rs +++ b/benchmarks/duckdb-bench/src/main.rs @@ -142,6 +142,7 @@ fn main() -> anyhow::Result<()> { // OnDiskDuckDB tables are created during register_tables by loading from Parquet _ => {} } + benchmark.prepare_format(format, &base_path).await?; } anyhow::Ok(()) diff --git a/vortex-bench/Cargo.toml b/vortex-bench/Cargo.toml index f655b5213bb..069f33a57e9 100644 --- a/vortex-bench/Cargo.toml +++ b/vortex-bench/Cargo.toml @@ -34,6 +34,8 @@ async-trait = { workspace = true } bzip2 = { workspace = true } clap = { workspace = true, features = ["derive"] } futures = { workspace = true } +geoarrow = { workspace = true } +geoarrow-cast = { workspace = true } get_dir = { workspace = true } glob = { workspace = true } humansize = { workspace = true } diff --git a/vortex-bench/src/benchmark.rs b/vortex-bench/src/benchmark.rs index fe16df5cd3d..d59708886a6 100644 --- a/vortex-bench/src/benchmark.rs +++ b/vortex-bench/src/benchmark.rs @@ -3,6 +3,8 @@ //! Core benchmark trait and types. +use std::path::Path; + use arrow_schema::Schema; use glob::Pattern; use url::Url; @@ -47,6 +49,13 @@ pub trait Benchmark: Send + Sync { /// call this method to ensure base data exists, then perform their own format conversion. async fn generate_base_data(&self) -> anyhow::Result<()>; + /// Prepare benchmark- and format-specific data beyond the Parquet base that + /// [`Benchmark::generate_base_data`] produced. Called once per requested format, after the base + /// data exists. Default: nothing. + async fn prepare_format(&self, _format: Format, _base_path: &Path) -> anyhow::Result<()> { + Ok(()) + } + /// Get expected row counts for validation (optional) /// If None, no validation will be performed fn expected_row_counts(&self) -> Option> { diff --git a/vortex-bench/src/lib.rs b/vortex-bench/src/lib.rs index daef12c7e78..f245d24e738 100644 --- a/vortex-bench/src/lib.rs +++ b/vortex-bench/src/lib.rs @@ -76,8 +76,11 @@ use vortex::session::VortexSession; #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; -pub static SESSION: LazyLock = - LazyLock::new(|| VortexSession::default().with_tokio()); +pub static SESSION: LazyLock = LazyLock::new(|| { + let session = VortexSession::default().with_tokio(); + vortex_geo::initialize(&session); + session +}); #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct Target { @@ -146,6 +149,9 @@ pub enum Format { #[clap(name = "vortex-compact")] #[serde(rename = "vortex-compact")] VortexCompact, + #[clap(name = "vortex-geo-native")] + #[serde(rename = "vortex-geo-native")] + VortexNative, #[clap(name = "duckdb")] #[serde(rename = "duckdb")] OnDiskDuckDB, @@ -185,6 +191,7 @@ impl Format { Format::Parquet => "parquet", Format::OnDiskVortex => "vortex-file-compressed", Format::VortexCompact => "vortex-compact", + Format::VortexNative => "vortex-geo-native", Format::OnDiskDuckDB => "duckdb", Format::Lance => "lance", } @@ -197,6 +204,7 @@ impl Format { Format::Parquet => "parquet", Format::OnDiskVortex => "vortex", Format::VortexCompact => "vortex", + Format::VortexNative => "vortex", Format::OnDiskDuckDB => "duckdb", Format::Lance => "lance", } diff --git a/vortex-bench/src/spatialbench/benchmark.rs b/vortex-bench/src/spatialbench/benchmark.rs index 1f5ae37fc53..d7b9d94061d 100644 --- a/vortex-bench/src/spatialbench/benchmark.rs +++ b/vortex-bench/src/spatialbench/benchmark.rs @@ -4,6 +4,7 @@ //! SpatialBench benchmark implementation use std::fs; +use std::path::Path; use url::Url; @@ -17,6 +18,9 @@ use crate::spatialbench::datagen::Table; use crate::utils::file::resolve_data_url; use crate::workspace_root; +/// Data-dir subfolder for the native-geometry Vortex files (the `vortex-geo-native` lane). +pub const NATIVE_DIR: &str = "vortex-geo-native"; + /// SpatialBench geospatial benchmark (Apache Sedona): a `trip` point table, `building` polygons, and /// a `customer` attribute table, queried with spatial filters and joins. `zone` polygons are sourced /// externally and registered when present. See . @@ -35,6 +39,21 @@ impl SpatialBenchBenchmark { scale_factor, }) } + + /// Tables to materialize and register: the in-process base tables (`trip`, `building`, + /// `customer`) plus the externally-sourced `zone` when its parquet is present. Shared by native + /// data-gen and table registration so both lanes cover the same set. + fn base_tables(&self) -> Vec { + let mut tables = vec![Table::Trip, Table::Building, Table::Customer]; + let zone_present = match self.data_url.to_file_path() { + Ok(base) => zone_parquet_present(&base.join(Format::Parquet.name())), + Err(()) => true, + }; + if zone_present { + tables.push(Table::Zone); + } + tables + } } #[async_trait::async_trait] @@ -79,10 +98,33 @@ impl Benchmark for SpatialBenchBenchmark { Ok(()) } + /// The `vortex-geo-native` lane decodes each table's WKB geometry to native GeoArrow once, into the + /// `vortex-geo-native` dir, so its queries read DuckDB `GEOMETRY` directly. Idempotent. + async fn prepare_format(&self, format: Format, base_path: &Path) -> anyhow::Result<()> { + if format == Format::VortexNative { + let parquet_dir = base_path.join(Format::Parquet.name()); + let native_dir = base_path.join(NATIVE_DIR); + for table in self.base_tables() { + datagen::write_native_vortex(table, &parquet_dir, &native_dir).await?; + } + } + Ok(()) + } + fn data_url(&self) -> &Url { &self.data_url } + /// The `vortex-geo-native` lane reads the native-geometry Vortex dir; every other format reads its + /// own `{format}` subfolder. + fn format_path(&self, format: Format, base_url: &Url) -> anyhow::Result { + let dir = match format { + Format::VortexNative => NATIVE_DIR, + other => other.name(), + }; + Ok(base_url.join(&format!("{dir}/"))?) + } + fn expected_row_counts(&self) -> Option> { // Indexed by `query_idx` (1-based), so index 0 is a dummy and Q1's count is at index 1 (TPC-H // convention). Only SF1.0 and SF10.0 are validated (like TPC-H); other scale factors return @@ -110,16 +152,11 @@ impl Benchmark for SpatialBenchBenchmark { format!("spatialbench(sf={})", self.scale_factor) } + /// Both lanes register the same tables (WKB reads `parquet`/`vortex`, native reads + /// `vortex-geo-native`); `zone` is externally sourced and optional, registered only when present. fn table_specs(&self) -> Vec { - // `zone` is externally sourced and optional; register it only when present so queries that - // don't need it don't fail on the missing glob. - let zone_present = match self.data_url.to_file_path() { - Ok(base) => zone_parquet_present(&base.join(Format::Parquet.name())), - Err(()) => true, - }; - Table::ALL - .into_iter() - .filter(|table| !matches!(table, Table::Zone) || zone_present) + self.base_tables() + .iter() .map(|table| TableSpec::new(table.name(), None)) .collect() } @@ -146,7 +183,7 @@ impl Benchmark for SpatialBenchBenchmark { /// Whether an externally-sourced `zone_*.parquet` exists under `parquet_dir` (generated by the /// upstream `spatialbench-cli`; see the module docs). -fn zone_parquet_present(parquet_dir: &std::path::Path) -> bool { +fn zone_parquet_present(parquet_dir: &Path) -> bool { glob::glob(&parquet_dir.join("zone_*.parquet").to_string_lossy()) .map(|mut paths| paths.next().is_some()) .unwrap_or(false) diff --git a/vortex-bench/src/spatialbench/datagen/mod.rs b/vortex-bench/src/spatialbench/datagen/mod.rs index 8ebd1b35d86..7808e06cbc3 100644 --- a/vortex-bench/src/spatialbench/datagen/mod.rs +++ b/vortex-bench/src/spatialbench/datagen/mod.rs @@ -1,11 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -//! SpatialBench data preparation. [`wkb`] generates the canonical WKB base tables (Parquet + Vortex); -//! the [`table`] catalog is the single source of truth for the base tables. +//! SpatialBench data preparation. [`wkb`] generates the canonical WKB base tables; [`native`] derives +//! native-geometry Vortex files from them for `points=native`. The [`table`] catalog is the single +//! source of truth for the base tables both stages share. +pub mod native; pub mod table; pub mod wkb; +pub use native::write_native_vortex; pub use table::Table; pub use wkb::generate_tables; diff --git a/vortex-bench/src/spatialbench/datagen/native.rs b/vortex-bench/src/spatialbench/datagen/native.rs new file mode 100644 index 00000000000..1d600a8b8cf --- /dev/null +++ b/vortex-bench/src/spatialbench/datagen/native.rs @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Native-geometry prep for `points=native`: decode a table's WKB geometry to native +//! `vortex.geo.{point,polygon,multipolygon}` via `geoarrow_cast` (so Vortex never decodes WKB), then +//! write a Vortex file. A one-time cost; queries then see DuckDB `GEOMETRY` directly. + +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Context; +use arrow_array::RecordBatch; +use arrow_schema::DataType; +use arrow_schema::Schema; +use futures::TryStreamExt; +use geoarrow::array::GenericWkbArray; +use geoarrow::array::GeoArrowArray; +use geoarrow::array::WkbViewArray; +use geoarrow::datatypes::CoordType; +use geoarrow::datatypes::Crs; +use geoarrow::datatypes::Dimension; +use geoarrow::datatypes::GeoArrowType; +use geoarrow::datatypes::Metadata; +use geoarrow::datatypes::MultiPolygonType; +use geoarrow::datatypes::PointType; +use geoarrow::datatypes::PolygonType; +use geoarrow::datatypes::WkbType; +use geoarrow_cast::cast::cast; +use parquet::arrow::ParquetRecordBatchStreamBuilder; +use tokio::fs::File as TokioFile; +use vortex::array::ArrayRef; +use vortex::array::IntoArray; +use vortex::array::arrays::ChunkedArray; +use vortex::array::arrow::ArrowSessionExt; +use vortex::file::WriteOptionsSessionExt; + +use super::table::GeometryKind; +use super::table::Table; +use crate::SESSION; +use crate::utils::file::idempotent_async; + +fn geo_metadata() -> Arc { + Arc::new(Metadata::new(Crs::default(), None)) +} + +/// Write `{native_dir}/{table}_0.vortex` with native geometry columns from the WKB parquet. Idempotent. +pub async fn write_native_vortex( + table: Table, + parquet_dir: &Path, + native_dir: &Path, +) -> anyhow::Result { + idempotent_async( + native_dir.join(format!("{}_0.vortex", table.name())), + |path| async move { + let chunks = map_source_batches(parquet_dir, table, |b| native_chunk(b, table)).await?; + + let dtype = chunks[0].dtype().clone(); + let chunked = ChunkedArray::try_new(chunks, dtype)?.into_array(); + let mut file = TokioFile::create(&path).await?; + SESSION + .write_options() + .write(&mut file, chunked.to_array_stream()) + .await?; + tracing::info!(path = %path.display(), table = table.name(), "wrote native geometry table"); + Ok(()) + }, + ) + .await +} + +/// Apply `f` to every batch of `table`'s base WKB parquet parts. All columns are kept; only the +/// geometry columns are rewritten to native types. +async fn map_source_batches( + parquet_dir: &Path, + table: Table, + mut f: impl FnMut(RecordBatch) -> anyhow::Result, +) -> anyhow::Result> { + let pattern = parquet_dir.join(format!("{}_*.parquet", table.name())); + let mut files: Vec = + glob::glob(&pattern.to_string_lossy())?.collect::>()?; + files.sort(); + anyhow::ensure!(!files.is_empty(), "no parquet matching {pattern:?}"); + + let mut out = Vec::new(); + for file in files { + let builder = ParquetRecordBatchStreamBuilder::new(TokioFile::open(&file).await?).await?; + let mut stream = builder.build()?; + while let Some(batch) = stream.try_next().await? { + out.push(f(batch)?); + } + } + Ok(out) +} + +/// Rewrite each of `table`'s WKB geometry columns to its native-lane type, tagging the field with +/// the matching `geoarrow.*` extension. +fn native_record_batch(batch: RecordBatch, table: Table) -> anyhow::Result { + let schema = batch.schema(); + let mut fields = schema.fields().to_vec(); + let mut columns = batch.columns().to_vec(); + + for geom in table.geometry_columns() { + let idx = schema.index_of(geom.name)?; + let column = batch.column(idx).as_ref(); + let wkb_type = WkbType::new(geo_metadata()); + + // Wrap the source WKB. SpatialBench tables emit `Binary`; the external `zone` parquet uses + // `BinaryView`. + let wkb: Box = match column.data_type() { + DataType::Binary => Box::new(GenericWkbArray::::try_from((column, wkb_type))?), + DataType::LargeBinary => { + Box::new(GenericWkbArray::::try_from((column, wkb_type))?) + } + DataType::BinaryView => Box::new(WkbViewArray::try_from((column, wkb_type))?), + other => anyhow::bail!("{}: unsupported WKB column type {other}", geom.name), + }; + + // Decode to a native, separated-XY GeoArrow type. The columnar round-trip also normalizes + // WKB endianness (Overture ships big-endian; native types carry none). + let native: Arc = match geom.kind { + GeometryKind::Point => cast( + wkb.as_ref(), + &GeoArrowType::Point( + PointType::new(Dimension::XY, geo_metadata()) + .with_coord_type(CoordType::Separated), + ), + )?, + GeometryKind::Polygon => cast( + wkb.as_ref(), + &GeoArrowType::Polygon( + PolygonType::new(Dimension::XY, geo_metadata()) + .with_coord_type(CoordType::Separated), + ), + )?, + // Polygon promotes to a one-element multipolygon, so this also covers the mixed + // `Polygon`/`MultiPolygon` zone boundaries. + GeometryKind::MultiPolygon => cast( + wkb.as_ref(), + &GeoArrowType::MultiPolygon( + MultiPolygonType::new(Dimension::XY, geo_metadata()) + .with_coord_type(CoordType::Separated), + ), + )?, + }; + + columns[idx] = native.to_array_ref(); + fields[idx] = Arc::new(native.data_type().to_field(geom.name, false)); + } + + Ok(RecordBatch::try_new( + Arc::new(Schema::new(fields)), + columns, + )?) +} + +/// Convert a WKB batch to a Vortex struct chunk with `table`'s geometry columns as native types. +fn native_chunk(batch: RecordBatch, table: Table) -> anyhow::Result { + let native_batch = native_record_batch(batch, table)?; + let native_schema = native_batch.schema(); + SESSION + .arrow() + .from_arrow_record_batch(native_batch, &native_schema) + .context("importing native batch") +} diff --git a/vortex-bench/src/spatialbench/datagen/table.rs b/vortex-bench/src/spatialbench/datagen/table.rs index c84de192fdf..d45624eec10 100644 --- a/vortex-bench/src/spatialbench/datagen/table.rs +++ b/vortex-bench/src/spatialbench/datagen/table.rs @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -//! The shared SpatialBench table catalog: the single source of truth for the table set, consumed by -//! both [`super::wkb`] data generation and benchmark table registration. +//! The shared SpatialBench table catalog: one source of truth for the base tables, used by both the +//! WKB generation ([`super::wkb`]) and the native geometry conversion ([`super::native`]). /// A SpatialBench table. #[derive(Clone, Copy)] @@ -13,6 +13,20 @@ pub enum Table { Zone, } +/// A geometry column and the geometry type its WKB bytes decode to. +pub(crate) struct GeometryColumn { + pub(crate) name: &'static str, + pub(crate) kind: GeometryKind, +} + +/// Geometry types a column can decode to on the native lane. +#[derive(Clone, Copy, Debug)] +pub(crate) enum GeometryKind { + Point, + Polygon, + MultiPolygon, +} + impl Table { /// Every SpatialBench table, in registration order. pub(crate) const ALL: [Table; 4] = [Table::Trip, Table::Building, Table::Customer, Table::Zone]; @@ -32,13 +46,29 @@ impl Table { !matches!(self, Table::Zone) } - /// Geometry columns SpatialBench tables. - pub(crate) fn geometry_columns(self) -> &'static [&'static str] { + /// Geometry columns to decode from WKB to native, with their geometry type. Empty for tables with + /// no geometry (e.g. `customer`). + pub(crate) fn geometry_columns(self) -> &'static [GeometryColumn] { match self { - Table::Trip => &["t_pickuploc", "t_dropoffloc"], - Table::Building => &["b_boundary"], - Table::Zone => &["z_boundary"], + Table::Trip => &[ + GeometryColumn { + name: "t_pickuploc", + kind: GeometryKind::Point, + }, + GeometryColumn { + name: "t_dropoffloc", + kind: GeometryKind::Point, + }, + ], + Table::Building => &[GeometryColumn { + name: "b_boundary", + kind: GeometryKind::Polygon, + }], Table::Customer => &[], + Table::Zone => &[GeometryColumn { + name: "z_boundary", + kind: GeometryKind::MultiPolygon, + }], } } } diff --git a/vortex-bench/src/spatialbench/datagen/wkb.rs b/vortex-bench/src/spatialbench/datagen/wkb.rs index e0160ea6f9c..51b34bf3016 100644 --- a/vortex-bench/src/spatialbench/datagen/wkb.rs +++ b/vortex-bench/src/spatialbench/datagen/wkb.rs @@ -167,9 +167,9 @@ pub(crate) fn geo_parquet_metadata(table: Table) -> Option { let primary = geometry_columns.first()?; let columns: serde_json::Map = geometry_columns .iter() - .map(|&column| { + .map(|column| { ( - column.to_string(), + column.name.to_string(), serde_json::json!({ "encoding": "WKB", "geometry_types": [] }), ) }) @@ -177,7 +177,7 @@ pub(crate) fn geo_parquet_metadata(table: Table) -> Option { Some( serde_json::json!({ "version": "1.0.0", - "primary_column": primary, + "primary_column": primary.name, "columns": columns, }) .to_string(), diff --git a/vortex-duckdb/src/convert/dtype.rs b/vortex-duckdb/src/convert/dtype.rs index 03aed0631aa..d05fdb9f22e 100644 --- a/vortex-duckdb/src/convert/dtype.rs +++ b/vortex-duckdb/src/convert/dtype.rs @@ -58,6 +58,9 @@ use vortex::extension::datetime::Time; use vortex::extension::datetime::TimeUnit; use vortex::extension::datetime::Timestamp; use vortex_geo::extension::GeoMetadata; +use vortex_geo::extension::MultiPolygon; +use vortex_geo::extension::Point; +use vortex_geo::extension::Polygon; use vortex_geo::extension::WellKnownBinary; use crate::cpp::DUCKDB_TYPE; @@ -245,9 +248,14 @@ impl TryFrom<&DType> for LogicalType { return temporal_to_duckdb(temporal); } - if let Some(wkb) = ext_dtype.metadata_opt::() { - let crs = wkb.crs.as_ref(); - return LogicalType::geometry_type(crs.map(|crs| crs.as_str())); + // Native Point/Polygon and WKB all surface to DuckDB as GEOMETRY so `ST_*` bind. + if let Some(geo) = ext_dtype + .metadata_opt::() + .or_else(|| ext_dtype.metadata_opt::()) + .or_else(|| ext_dtype.metadata_opt::()) + .or_else(|| ext_dtype.metadata_opt::()) + { + return LogicalType::geometry_type(geo.crs.as_deref()); } vortex_bail!("Unsupported extension type \"{}\"", ext_dtype.id()); diff --git a/vortex-duckdb/src/exporter/extension.rs b/vortex-duckdb/src/exporter/extension.rs index 221dc92a85f..30eb3715cc2 100644 --- a/vortex-duckdb/src/exporter/extension.rs +++ b/vortex-duckdb/src/exporter/extension.rs @@ -8,6 +8,12 @@ use vortex::array::arrays::extension::ExtensionArrayExt; use vortex::array::extension::datetime::AnyTemporal; use vortex::error::VortexResult; use vortex::error::vortex_bail; +use vortex_geo::extension::MultiPolygon; +use vortex_geo::extension::MultiPolygonData; +use vortex_geo::extension::Point; +use vortex_geo::extension::PointData; +use vortex_geo::extension::Polygon; +use vortex_geo::extension::PolygonData; use vortex_geo::extension::WellKnownBinary; use vortex_geo::extension::WellKnownBinaryData; @@ -27,5 +33,17 @@ pub(crate) fn new_exporter( return geo::new_wkb_exporter(WellKnownBinaryData::try_from(ext)?, ctx); } + if ext.ext_dtype().is::() { + return geo::new_point_exporter(PointData::try_from(ext)?, ctx); + } + + if ext.ext_dtype().is::() { + return geo::new_polygon_exporter(PolygonData::try_from(ext)?, ctx); + } + + if ext.ext_dtype().is::() { + return geo::new_multipolygon_exporter(MultiPolygonData::try_from(ext)?, ctx); + } + vortex_bail!("no non-temporal extension exporter") } diff --git a/vortex-duckdb/src/exporter/geo.rs b/vortex-duckdb/src/exporter/geo.rs index 1287ed019e2..fc6634c121c 100644 --- a/vortex-duckdb/src/exporter/geo.rs +++ b/vortex-duckdb/src/exporter/geo.rs @@ -4,6 +4,9 @@ use vortex::array::ExecutionCtx; use vortex::array::arrays::VarBinViewArray; use vortex::error::VortexResult; +use vortex_geo::extension::MultiPolygonData; +use vortex_geo::extension::PointData; +use vortex_geo::extension::PolygonData; use vortex_geo::extension::WellKnownBinaryData; use crate::exporter::ColumnExporter; @@ -17,3 +20,34 @@ pub(crate) fn new_wkb_exporter( let values = array.wkb_values().clone().execute::(ctx)?; new_exporter(values, ctx) } + +/// Create an exporter for a native `Point` column. DuckDB `GEOMETRY` vectors carry WKB, so the +/// points are serialized to WKB via [`PointData::to_wkb`] (only for rows DuckDB materializes — +/// with predicate pushdown that's just the survivors). +pub(crate) fn new_point_exporter( + point: PointData, + ctx: &mut ExecutionCtx, +) -> VortexResult> { + let values = point.to_wkb(ctx)?.execute::(ctx)?; + new_exporter(values, ctx) +} + +/// Create an exporter for a native `Polygon` column. Like [`new_point_exporter`], DuckDB `GEOMETRY` +/// vectors carry WKB, so the polygons are serialized to WKB via [`PolygonData::to_wkb`]. +pub(crate) fn new_polygon_exporter( + polygon: PolygonData, + ctx: &mut ExecutionCtx, +) -> VortexResult> { + let values = polygon.to_wkb(ctx)?.execute::(ctx)?; + new_exporter(values, ctx) +} + +/// Create an exporter for a native `MultiPolygon` column, serialized to WKB via +/// [`MultiPolygonData::to_wkb`] (see [`new_point_exporter`]). +pub(crate) fn new_multipolygon_exporter( + multipolygon: MultiPolygonData, + ctx: &mut ExecutionCtx, +) -> VortexResult> { + let values = multipolygon.to_wkb(ctx)?.execute::(ctx)?; + new_exporter(values, ctx) +} From d9ee09f631e876e998d84acba37a864aad898317 Mon Sep 17 00:00:00 2001 From: Adam Gutglick Date: Thu, 2 Jul 2026 16:54:50 +0100 Subject: [PATCH 005/104] Decouple optional features from pulling `vortex-file` features for top crate (#8642) ## Rationale for this change `vortex-files` pull a lot of encodings which makes it a very big dependency. Some systems only need the core APIs, but enabling "tokio", "zstd" or "object-store" currently pulls in the full `vortex-file`. ## What changes are included in this PR? Make the features of "vortex" use a weak link to "vortex-file", so it doesn't pull it in by default. ## What APIs are changed? Are there any user-facing changes? Some feature configurations will require adjustment. --------- Signed-off-by: Adam Gutglick --- vortex/Cargo.toml | 11 ++++++++--- vortex/examples/tracing_vortex.rs | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/vortex/Cargo.toml b/vortex/Cargo.toml index adaca75632c..75fe5501199 100644 --- a/vortex/Cargo.toml +++ b/vortex/Cargo.toml @@ -71,9 +71,14 @@ vortex-tensor = { workspace = true } default = ["files", "zstd"] files = ["dep:vortex-file"] memmap2 = ["vortex-buffer/memmap2"] -object_store = ["vortex-file/object_store", "vortex-io/object_store"] -tokio = ["vortex-file/tokio"] -zstd = ["dep:vortex-zstd", "vortex-file/zstd"] +object_store = ["vortex-file?/object_store", "vortex-io/object_store"] +tokio = [ + "vortex-error/tokio", + "vortex-file?/tokio", + "vortex-io/tokio", + "vortex-layout/tokio", +] +zstd = ["dep:vortex-zstd", "vortex-file?/zstd"] pretty = ["vortex-array/table-display"] serde = ["vortex-array/serde", "vortex-buffer/serde", "vortex-mask/serde"] # This feature enabled unstable encodings for which we don't guarantee stability. diff --git a/vortex/examples/tracing_vortex.rs b/vortex/examples/tracing_vortex.rs index f1227e2e017..c3876f89e20 100644 --- a/vortex/examples/tracing_vortex.rs +++ b/vortex/examples/tracing_vortex.rs @@ -6,7 +6,7 @@ //! This example demonstrates a real-world use case: implementing a `tracing` subscriber //! that writes all log events and spans to Vortex files. //! -//! Run with: cargo run --example tracing_vortex --features tokio +//! Run with: cargo run --example tracing_vortex --features files,tokio #![expect( clippy::disallowed_types, From 0026d3a7386ec6a11505c7f60121366cccc8deff Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Thu, 2 Jul 2026 17:05:36 +0100 Subject: [PATCH 006/104] Remove execute_boolean fallback to arrow (#8644) It's unclear why we left it, we implement the boolean logic on vortex boolean arrays always --- vortex-array/src/scalar_fn/fns/between/mod.rs | 2 +- .../src/scalar_fn/fns/binary/boolean.rs | 60 ++++--------------- vortex-array/src/scalar_fn/fns/binary/mod.rs | 4 +- 3 files changed, 14 insertions(+), 52 deletions(-) diff --git a/vortex-array/src/scalar_fn/fns/between/mod.rs b/vortex-array/src/scalar_fn/fns/between/mod.rs index 2151a310dde..01c4fe06d31 100644 --- a/vortex-array/src/scalar_fn/fns/between/mod.rs +++ b/vortex-array/src/scalar_fn/fns/between/mod.rs @@ -153,7 +153,7 @@ fn between_canonical( upper.clone(), Operator::from(options.upper_strict.to_compare_operator()), )?; - execute_boolean(&lower_cmp, &upper_cmp, Operator::And, ctx) + execute_boolean(lower_cmp, upper_cmp, Operator::And, ctx) } /// An optimized scalar expression to compute whether values fall between two bounds. diff --git a/vortex-array/src/scalar_fn/fns/binary/boolean.rs b/vortex-array/src/scalar_fn/fns/binary/boolean.rs index b7f838c6d13..3fb91016e3e 100644 --- a/vortex-array/src/scalar_fn/fns/binary/boolean.rs +++ b/vortex-array/src/scalar_fn/fns/binary/boolean.rs @@ -3,7 +3,6 @@ use std::iter::repeat_n; -use arrow_array::cast::AsArray; use vortex_buffer::BitBuffer; use vortex_buffer::BufferMut; use vortex_buffer::read_u64_le; @@ -27,8 +26,6 @@ use crate::arrays::ScalarFn; use crate::arrays::scalar_fn::ExactScalarFn; use crate::arrays::scalar_fn::ScalarFnArrayExt; use crate::arrays::scalar_fn::ScalarFnArrayView; -use crate::arrow::ArrowSessionExt; -use crate::arrow::FromArrowArray; use crate::builtins::ArrayBuiltins; use crate::dtype::DType; use crate::dtype::Nullability; @@ -116,67 +113,32 @@ pub fn or_kleene(lhs: &ArrayRef, rhs: &ArrayRef) -> VortexResult { /// This is the entry point for boolean operations from the binary expression. /// Handles constants and canonical boolean arrays directly, otherwise falls back to Arrow. pub(crate) fn execute_boolean( - lhs: &ArrayRef, - rhs: &ArrayRef, + lhs: ArrayRef, + rhs: ArrayRef, op: Operator, ctx: &mut ExecutionCtx, ) -> VortexResult { - let nullable = boolean_nullability(lhs, rhs); + let nullable = boolean_nullability(&lhs, &rhs); if lhs.is_empty() { return Ok(Canonical::empty(&DType::Bool(nullable)).into_array()); } - if let Some(result) = constant_boolean(lhs, rhs, op)? { + if let Some(result) = constant_boolean(&lhs, &rhs, op)? { return Ok(result); } - if let Some(lhs) = lhs.as_opt::() - && let Some(result) = ::boolean(lhs, rhs, op, ctx)? - { - return Ok(result); - } - - if let Some(rhs) = rhs.as_opt::() - && let Some(result) = ::boolean(rhs, lhs, op, ctx)? - { + let lhs = lhs.execute::(ctx)?; + if let Some(result) = ::boolean(lhs.as_view(), &rhs, op, ctx)? { return Ok(result); } - arrow_execute_boolean(lhs.clone(), rhs.clone(), op, ctx) -} - -/// Arrow implementation for Kleene boolean operations using [`Operator`]. -fn arrow_execute_boolean( - lhs: ArrayRef, - rhs: ArrayRef, - op: Operator, - ctx: &mut ExecutionCtx, -) -> VortexResult { - let nullable = boolean_nullability(&lhs, &rhs); - let session = ctx.session().clone(); - - let lhs = session - .arrow() - .execute_arrow(lhs, None, ctx)? - .as_boolean_opt() - .ok_or_else(|| vortex_err!("expected lhs to be boolean"))? - .clone(); - - let rhs = session - .arrow() - .execute_arrow(rhs, None, ctx)? - .as_boolean_opt() - .ok_or_else(|| vortex_err!("expected rhs to be boolean"))? - .clone(); - - let array = match op { - Operator::And => arrow_arith::boolean::and_kleene(&lhs, &rhs)?, - Operator::Or => arrow_arith::boolean::or_kleene(&lhs, &rhs)?, - other => vortex_bail!("Not a boolean operator: {other}"), + let rhs = rhs.execute::(ctx)?; + let Some(result) = ::boolean(rhs.as_view(), &lhs.into_array(), op, ctx)? + else { + vortex_bail!("No boolean kernel for two BoolArrays"); }; - - ArrayRef::from_arrow(&array, nullable == Nullability::Nullable) + Ok(result) } /// Handles boolean operations where at least one operand is a constant array. diff --git a/vortex-array/src/scalar_fn/fns/binary/mod.rs b/vortex-array/src/scalar_fn/fns/binary/mod.rs index 156540a2820..361c4fab240 100644 --- a/vortex-array/src/scalar_fn/fns/binary/mod.rs +++ b/vortex-array/src/scalar_fn/fns/binary/mod.rs @@ -155,8 +155,8 @@ impl ScalarFnVTable for Binary { Operator::Lte => execute_compare(&lhs, &rhs, CompareOperator::Lte, ctx), Operator::Gt => execute_compare(&lhs, &rhs, CompareOperator::Gt, ctx), Operator::Gte => execute_compare(&lhs, &rhs, CompareOperator::Gte, ctx), - Operator::And => execute_boolean(&lhs, &rhs, Operator::And, ctx), - Operator::Or => execute_boolean(&lhs, &rhs, Operator::Or, ctx), + Operator::And => execute_boolean(lhs, rhs, Operator::And, ctx), + Operator::Or => execute_boolean(lhs, rhs, Operator::Or, ctx), Operator::Add => execute_numeric(&lhs, &rhs, NumericOperator::Add, ctx), Operator::Sub => execute_numeric(&lhs, &rhs, NumericOperator::Sub, ctx), Operator::Mul => execute_numeric(&lhs, &rhs, NumericOperator::Mul, ctx), From 7d170d5a9828355eabc1ca6fddc44792968fe7e4 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Thu, 2 Jul 2026 18:38:33 +0100 Subject: [PATCH 007/104] Disallow the legacy global session and allowlist existing call sites (#8637) This changes LEGACY_SESSION top level lazy static into a method. We also disallow this method from being used in the codebase and enshrine existing call sites with exceptions --- clippy.toml | 1 + encodings/alp/src/alp_rd/array.rs | 2 ++ encodings/alp/src/alp_rd/compute/mask.rs | 1 + .../fastlanes/src/delta/vtable/validity.rs | 5 ++-- encodings/fsst/src/array.rs | 5 ++-- encodings/runend/src/arbitrary.rs | 5 ++-- encodings/runend/src/array.rs | 5 ++-- encodings/runend/src/arrow.rs | 5 ++-- encodings/zstd/src/zstd_buffers.rs | 6 +++-- vortex-array/src/array/erased.rs | 8 ++++--- vortex-array/src/array/typed.rs | 5 ++-- .../src/arrays/constant/compute/cast.rs | 5 ++-- .../src/arrays/constant/compute/take.rs | 5 ++-- vortex-array/src/arrays/dict/array.rs | 5 ++-- vortex-array/src/arrays/dict/compute/rules.rs | 6 +++-- .../src/arrays/filter/execute/varbinview.rs | 7 +++--- .../src/arrays/fixed_size_list/array.rs | 5 ++-- vortex-array/src/arrays/list/array.rs | 8 ++++--- vortex-array/src/arrays/listview/array.rs | 11 +++++---- vortex-array/src/arrays/masked/array.rs | 5 ++-- vortex-array/src/arrays/masked/vtable/mod.rs | 5 ++-- vortex-array/src/arrays/patched/array.rs | 7 +++--- .../src/arrays/primitive/array/top_value.rs | 5 ++-- .../src/arrays/scalar_fn/vtable/validity.rs | 5 ++-- vortex-array/src/arrays/varbin/array.rs | 8 ++++--- vortex-array/src/arrays/varbin/builder.rs | 7 +++--- vortex-array/src/arrays/varbinview/array.rs | 5 ++-- vortex-array/src/arrays/varbinview/compact.rs | 5 ++-- vortex-array/src/arrow/datum.rs | 7 +++--- vortex-array/src/arrow/executor/byte.rs | 5 ++-- vortex-array/src/arrow/mod.rs | 11 ++++++--- vortex-array/src/builders/bool.rs | 5 ++-- vortex-array/src/builders/decimal.rs | 5 ++-- vortex-array/src/builders/fixed_size_list.rs | 5 ++-- vortex-array/src/builders/list.rs | 5 ++-- vortex-array/src/builders/listview.rs | 7 +++--- vortex-array/src/builders/primitive.rs | 5 ++-- vortex-array/src/builders/struct_.rs | 5 ++-- vortex-array/src/builders/varbinview.rs | 5 ++-- vortex-array/src/display/mod.rs | 7 +++--- vortex-array/src/lib.rs | 8 ++++++- vortex-array/src/patches.rs | 22 +++++++++++------- vortex-array/src/search_sorted.rs | 5 ++-- vortex-array/src/validity.rs | 8 ++++--- vortex-array/src/variants.rs | 20 +++++++++------- vortex-cuda/src/canonical.rs | 7 +++--- vortex-cxx/src/read.rs | 9 ++++++-- vortex-ffi/src/array.rs | 23 ++++++++++++------- vortex-layout/src/layouts/file_stats.rs | 5 ++-- vortex-layout/src/layouts/zoned/builder.rs | 5 ++-- vortex-python/src/iter/mod.rs | 5 ++-- 51 files changed, 214 insertions(+), 127 deletions(-) diff --git a/clippy.toml b/clippy.toml index 9f6d5bd21b8..08c0b720379 100644 --- a/clippy.toml +++ b/clippy.toml @@ -17,4 +17,5 @@ disallowed-methods = [ { path = "std::thread::available_parallelism", reason = "This function might do an unbounded amount of work, use `vortex_utils::parallelism::get_available_parallelism instead" }, { path = "vortex_session::registry::Id::new", reason = "Interning a static id on every call grabs the interner lock (#8380). Use a `CachedId` static for static ids; for a dynamic string, annotate the call with `#[expect(clippy::disallowed_methods)]`.", allow-invalid = true }, { path = "vortex_session::registry::Id::new_static", reason = "Interning a static id on every call grabs the interner lock; use a `CachedId` static instead (#8380).", allow-invalid = true }, + { path = "vortex_array::legacy_session", reason = "Relies on the hidden global session; thread an explicit `VortexSession`/`ExecutionCtx` through instead" }, ] diff --git a/encodings/alp/src/alp_rd/array.rs b/encodings/alp/src/alp_rd/array.rs index 28ca30f909c..dcaeb0fde79 100644 --- a/encodings/alp/src/alp_rd/array.rs +++ b/encodings/alp/src/alp_rd/array.rs @@ -159,6 +159,7 @@ impl VTable for ALPRD { )) } + #[allow(clippy::disallowed_methods)] fn deserialize( &self, dtype: &DType, @@ -470,6 +471,7 @@ fn patches_from_slots( PatchesData::patches_from_slots(patches_data, len, slots, LP_PATCH_SLOTS) } +#[allow(clippy::disallowed_methods)] fn validate_parts( dtype: &DType, len: usize, diff --git a/encodings/alp/src/alp_rd/compute/mask.rs b/encodings/alp/src/alp_rd/compute/mask.rs index 2773387c738..3a631d6417f 100644 --- a/encodings/alp/src/alp_rd/compute/mask.rs +++ b/encodings/alp/src/alp_rd/compute/mask.rs @@ -14,6 +14,7 @@ use crate::ALPRD; use crate::ALPRDArrayExt; impl MaskReduce for ALPRD { + #[allow(clippy::disallowed_methods)] fn mask(array: ArrayView<'_, Self>, mask: &ArrayRef) -> VortexResult> { let masked_left_parts = MaskExpr.try_new_array( array.left_parts().len(), diff --git a/encodings/fastlanes/src/delta/vtable/validity.rs b/encodings/fastlanes/src/delta/vtable/validity.rs index d6a87ff5956..c8d8b7b4dc0 100644 --- a/encodings/fastlanes/src/delta/vtable/validity.rs +++ b/encodings/fastlanes/src/delta/vtable/validity.rs @@ -2,8 +2,8 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors use vortex_array::ArrayView; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; +use vortex_array::legacy_session; use vortex_array::validity::Validity; use vortex_array::vtable::ValidityVTable; use vortex_error::VortexResult; @@ -13,13 +13,14 @@ use crate::bit_transpose::untranspose_validity; use crate::delta::array::DeltaArrayExt; impl ValidityVTable for Delta { + #[allow(clippy::disallowed_methods)] fn validity(array: ArrayView<'_, Delta>) -> VortexResult { let start = array.offset(); let end = start + array.len(); let validity = untranspose_validity( &array.deltas().validity()?, - &mut LEGACY_SESSION.create_execution_ctx(), + &mut legacy_session().create_execution_ctx(), )?; validity.slice(start..end) } diff --git a/encodings/fsst/src/array.rs b/encodings/fsst/src/array.rs index 79e6dcb2f0c..362d3e6d967 100644 --- a/encodings/fsst/src/array.rs +++ b/encodings/fsst/src/array.rs @@ -25,7 +25,6 @@ use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::TypedArrayRef; use vortex_array::VortexSessionExecute; use vortex_array::arrays::VarBin; @@ -37,6 +36,7 @@ use vortex_array::builders::VarBinViewBuilder; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; +use vortex_array::legacy_session; use vortex_array::serde::ArrayChildren; use vortex_array::smallvec::smallvec; use vortex_array::validity::Validity; @@ -114,6 +114,7 @@ impl VTable for FSST { *ID } + #[allow(clippy::disallowed_methods)] fn validate( &self, data: &Self::TypedArrayData, @@ -122,7 +123,7 @@ impl VTable for FSST { slots: &[Option], ) -> VortexResult<()> { // TODO(ctx): trait fixes - VTable::validate has a fixed signature. - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = legacy_session().create_execution_ctx(); data.validate(dtype, len, slots, &mut ctx) } diff --git a/encodings/runend/src/arbitrary.rs b/encodings/runend/src/arbitrary.rs index baa15e0fd13..e0e34df984f 100644 --- a/encodings/runend/src/arbitrary.rs +++ b/encodings/runend/src/arbitrary.rs @@ -5,7 +5,6 @@ use arbitrary::Arbitrary; use arbitrary::Result; use arbitrary::Unstructured; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::arbitrary::ArbitraryArray; @@ -14,6 +13,7 @@ use vortex_array::arrays::arbitrary::ArbitraryWith; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; +use vortex_array::legacy_session; use vortex_array::validity::Validity; use vortex_buffer::Buffer; use vortex_error::VortexExpect; @@ -39,12 +39,13 @@ impl ArbitraryRunEndArray { /// Generate an arbitrary RunEndArray with the given dtype for values. /// /// The dtype must be a primitive or boolean type. + #[allow(clippy::disallowed_methods)] pub fn with_dtype(u: &mut Unstructured, dtype: &DType, len: Option) -> Result { // Number of runs (values/ends pairs) let num_runs = u.int_in_range(0..=20)?; // TODO(ctx): trait fixes - Arbitrary::arbitrary has a fixed signature. - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = legacy_session().create_execution_ctx(); if num_runs == 0 { // Empty RunEndArray let ends = PrimitiveArray::from_iter(Vec::::new()).into_array(); diff --git a/encodings/runend/src/array.rs b/encodings/runend/src/array.rs index 64d0798ebee..9dba648e248 100644 --- a/encodings/runend/src/array.rs +++ b/encodings/runend/src/array.rs @@ -19,7 +19,6 @@ use vortex_array::EqMode; use vortex_array::ExecutionCtx; use vortex_array::ExecutionResult; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::TypedArrayRef; use vortex_array::VortexSessionExecute; use vortex_array::arrays::Primitive; @@ -28,6 +27,7 @@ use vortex_array::buffer::BufferHandle; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; +use vortex_array::legacy_session; use vortex_array::scalar::PValue; use vortex_array::search_sorted::SearchSorted; use vortex_array::search_sorted::SearchSortedSide; @@ -86,6 +86,7 @@ impl VTable for RunEnd { *ID } + #[allow(clippy::disallowed_methods)] fn validate( &self, data: &Self::TypedArrayData, @@ -100,7 +101,7 @@ impl VTable for RunEnd { .as_ref() .vortex_expect("RunEndArray values slot"); // TODO(ctx): trait fixes - VTable::validate has a fixed signature. - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = legacy_session().create_execution_ctx(); RunEndData::validate_parts(ends, values, data.offset, len, &mut ctx)?; vortex_ensure!( values.dtype() == dtype, diff --git a/encodings/runend/src/arrow.rs b/encodings/runend/src/arrow.rs index 193937122ef..c927daaaffe 100644 --- a/encodings/runend/src/arrow.rs +++ b/encodings/runend/src/arrow.rs @@ -5,12 +5,12 @@ use arrow_array::RunArray; use arrow_array::types::RunEndIndexType; use vortex_array::ArrayRef; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::primitive::PrimitiveArrayExt; use vortex_array::arrow::FromArrowArray; use vortex_array::dtype::NativePType; +use vortex_array::legacy_session; use vortex_array::scalar::PValue; use vortex_array::search_sorted::SearchSorted; use vortex_array::search_sorted::SearchSortedSide; @@ -25,6 +25,7 @@ impl FromArrowArray<&RunArray> for RunEndData where R::Native: NativePType, { + #[allow(clippy::disallowed_methods)] fn from_arrow(array: &RunArray, nullable: bool) -> VortexResult { let offset = array.run_ends().offset(); let len = array.run_ends().len(); @@ -57,7 +58,7 @@ where // SAFETY: arrow-rs enforces the RunEndArray invariants, we inherit their guarantees. // TODO(ctx): trait fixes - FromArrowArray::from_arrow has a fixed signature. - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = legacy_session().create_execution_ctx(); RunEndData::validate_parts(&ends_slice, &values_slice, offset, len, &mut ctx)?; Ok(unsafe { RunEndData::new_unchecked(offset) }) } diff --git a/encodings/zstd/src/zstd_buffers.rs b/encodings/zstd/src/zstd_buffers.rs index f40a451caaf..ad55ba654a4 100644 --- a/encodings/zstd/src/zstd_buffers.rs +++ b/encodings/zstd/src/zstd_buffers.rs @@ -504,6 +504,7 @@ impl VTable for ZstdBuffers { } impl OperationsVTable for ZstdBuffers { + #[allow(clippy::disallowed_methods)] fn scalar_at( array: ArrayView<'_, ZstdBuffers>, index: usize, @@ -514,13 +515,14 @@ impl OperationsVTable for ZstdBuffers { // canonical let inner_array = ZstdBuffers::decompress_and_build_inner( &array.into_owned(), - &vortex_array::LEGACY_SESSION, + vortex_array::legacy_session(), )?; inner_array.execute_scalar(index, ctx) } } impl ValidityVTable for ZstdBuffers { + #[allow(clippy::disallowed_methods)] fn validity( array: ArrayView<'_, ZstdBuffers>, ) -> VortexResult { @@ -530,7 +532,7 @@ impl ValidityVTable for ZstdBuffers { let inner_array = ZstdBuffers::decompress_and_build_inner( &array.into_owned(), - &vortex_array::LEGACY_SESSION, + vortex_array::legacy_session(), )?; inner_array.validity() } diff --git a/vortex-array/src/array/erased.rs b/vortex-array/src/array/erased.rs index 7fb3d7baf01..c3dc2e0eed7 100644 --- a/vortex-array/src/array/erased.rs +++ b/vortex-array/src/array/erased.rs @@ -26,7 +26,6 @@ use crate::Canonical; use crate::ExecutionCtx; use crate::ExecutionResult; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VTable; use crate::VortexSessionExecute; use crate::aggregate_fn::fns::sum::sum; @@ -50,6 +49,7 @@ use crate::dtype::DType; use crate::expr::stats::Precision; use crate::expr::stats::Stat; use crate::expr::stats::StatsProviderExt; +use crate::legacy_session; use crate::matcher::Matcher; use crate::optimizer::ArrayOptimizer; use crate::scalar::Scalar; @@ -269,8 +269,9 @@ impl ArrayRef { note = "Use `execute_scalar` instead, which allows passing an execution context for more \ efficient execution when fetching multiple scalars from the same array." )] + #[allow(clippy::disallowed_methods)] pub fn scalar_at(&self, index: usize) -> VortexResult { - self.execute_scalar(index, &mut LEGACY_SESSION.create_execution_ctx()) + self.execute_scalar(index, &mut legacy_session().create_execution_ctx()) } /// Execute the array to extract a scalar at the given index. @@ -360,8 +361,9 @@ impl ArrayRef { /// Returns the canonical representation of the array. #[deprecated(note = "use `array.execute::(ctx)` instead")] + #[allow(clippy::disallowed_methods)] pub fn into_canonical(self) -> VortexResult { - self.execute(&mut LEGACY_SESSION.create_execution_ctx()) + self.execute(&mut legacy_session().create_execution_ctx()) } /// Returns the canonical representation of the array. diff --git a/vortex-array/src/array/typed.rs b/vortex-array/src/array/typed.rs index b88f3792a84..4769073b82c 100644 --- a/vortex-array/src/array/typed.rs +++ b/vortex-array/src/array/typed.rs @@ -23,12 +23,12 @@ use crate::ArrayRef; use crate::ArraySlots; use crate::ExecutionCtx; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::array::ArrayId; use crate::array::ArrayView; use crate::array::VTable; use crate::dtype::DType; +use crate::legacy_session; use crate::stats::ArrayStats; use crate::stats::StatsSet; use crate::stats::StatsSetRef; @@ -382,9 +382,10 @@ impl Array { note = "Use `execute_scalar` instead, which allows passing an execution context for more \ efficient execution when fetching multiple scalars from the same array." )] + #[allow(clippy::disallowed_methods)] pub fn scalar_at(&self, index: usize) -> VortexResult { self.inner - .execute_scalar(index, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_scalar(index, &mut legacy_session().create_execution_ctx()) } /// Execute the array to extract a scalar at the given index. diff --git a/vortex-array/src/arrays/constant/compute/cast.rs b/vortex-array/src/arrays/constant/compute/cast.rs index db3a41829f0..00a6e15295a 100644 --- a/vortex-array/src/arrays/constant/compute/cast.rs +++ b/vortex-array/src/arrays/constant/compute/cast.rs @@ -25,7 +25,6 @@ mod tests { use rstest::rstest; use crate::IntoArray; - use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::ConstantArray; use crate::builtins::ArrayBuiltins; @@ -33,6 +32,7 @@ mod tests { use crate::dtype::DType; use crate::dtype::DecimalDType; use crate::dtype::Nullability; + use crate::legacy_session; use crate::scalar::DecimalValue; use crate::scalar::Scalar; @@ -48,6 +48,7 @@ mod tests { } #[test] + #[allow(clippy::disallowed_methods)] fn test_cast_constant_i64_to_decimal() { let target_dtype = DType::Decimal(DecimalDType::new(21, 2), Nullability::NonNullable); let casted = ConstantArray::new(Scalar::from(42i64), 5) @@ -57,7 +58,7 @@ mod tests { assert_eq!(casted.dtype(), &target_dtype); let scalar = casted - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_scalar(0, &mut legacy_session().create_execution_ctx()) .unwrap(); assert_eq!( scalar.as_decimal().decimal_value(), diff --git a/vortex-array/src/arrays/constant/compute/take.rs b/vortex-array/src/arrays/constant/compute/take.rs index 9fb1373a5cf..834d4338bfb 100644 --- a/vortex-array/src/arrays/constant/compute/take.rs +++ b/vortex-array/src/arrays/constant/compute/take.rs @@ -6,7 +6,6 @@ use vortex_mask::AllOr; use crate::ArrayRef; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::array::ArrayView; use crate::arrays::Constant; @@ -14,13 +13,15 @@ use crate::arrays::ConstantArray; use crate::arrays::MaskedArray; use crate::arrays::dict::TakeReduce; use crate::arrays::dict::TakeReduceAdaptor; +use crate::legacy_session; use crate::optimizer::rules::ParentRuleSet; use crate::scalar::Scalar; use crate::validity::Validity; impl TakeReduce for Constant { + #[allow(clippy::disallowed_methods)] fn take(array: ArrayView<'_, Constant>, indices: &ArrayRef) -> VortexResult> { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = legacy_session().create_execution_ctx(); let result = match indices .validity()? .execute_mask(indices.len(), &mut ctx)? diff --git a/vortex-array/src/arrays/dict/array.rs b/vortex-array/src/arrays/dict/array.rs index f73f4965439..13539684fa4 100644 --- a/vortex-array/src/arrays/dict/array.rs +++ b/vortex-array/src/arrays/dict/array.rs @@ -15,7 +15,6 @@ use vortex_mask::AllOr; use crate::ArrayRef; use crate::ArraySlots; -use crate::LEGACY_SESSION; #[expect(deprecated)] use crate::ToCanonical as _; use crate::VortexSessionExecute; @@ -26,6 +25,7 @@ use crate::array_slots; use crate::arrays::Dict; use crate::dtype::DType; use crate::dtype::PType; +use crate::legacy_session; use crate::match_each_integer_ptype; #[derive(Clone, prost::Message)] @@ -144,11 +144,12 @@ pub trait DictArrayExt: TypedArrayRef + DictArraySlotsExt { Ok(()) } + #[allow(clippy::disallowed_methods)] fn compute_referenced_values_mask(&self, referenced: bool) -> VortexResult { let codes = self.codes(); let codes_validity = codes .validity()? - .execute_mask(codes.len(), &mut LEGACY_SESSION.create_execution_ctx())?; + .execute_mask(codes.len(), &mut legacy_session().create_execution_ctx())?; #[expect(deprecated)] let codes_primitive = self.codes().to_primitive(); let values_len = self.values().len(); diff --git a/vortex-array/src/arrays/dict/compute/rules.rs b/vortex-array/src/arrays/dict/compute/rules.rs index e4d102ee861..94e3af66df2 100644 --- a/vortex-array/src/arrays/dict/compute/rules.rs +++ b/vortex-array/src/arrays/dict/compute/rules.rs @@ -284,6 +284,7 @@ mod tests { use crate::scalar_fn::fns::not::Not; #[test] + #[allow(clippy::disallowed_methods)] fn chunked_dict_with_shared_values_pulls_values_up() -> VortexResult<()> { let values = buffer![10u32, 20, 30].into_array(); let chunk0 = DictArray::try_new(buffer![0u8, 1].into_array(), values.clone())?.into_array(); @@ -298,7 +299,7 @@ mod tests { assert!(ArrayRef::ptr_eq(dict.values(), &values)); assert_eq!(codes.nchunks(), 2); - let mut ctx = crate::LEGACY_SESSION.create_execution_ctx(); + let mut ctx = crate::legacy_session().create_execution_ctx(); assert_arrays_eq!( optimized, PrimitiveArray::from_iter([10u32, 20, 30, 10, 20]), @@ -309,6 +310,7 @@ mod tests { } #[test] + #[allow(clippy::disallowed_methods)] fn chunked_dict_with_distinct_values_stays_chunked() -> VortexResult<()> { let values0 = buffer![10u32, 20, 30].into_array(); let values1 = buffer![10u32, 20, 30].into_array(); @@ -321,7 +323,7 @@ mod tests { let optimized = array.optimize()?; assert!(optimized.is::()); - let mut ctx = crate::LEGACY_SESSION.create_execution_ctx(); + let mut ctx = crate::legacy_session().create_execution_ctx(); assert_arrays_eq!( optimized, PrimitiveArray::from_iter([10u32, 20, 30, 10, 20]), diff --git a/vortex-array/src/arrays/filter/execute/varbinview.rs b/vortex-array/src/arrays/filter/execute/varbinview.rs index ded9c804cfd..95a3c216a28 100644 --- a/vortex-array/src/arrays/filter/execute/varbinview.rs +++ b/vortex-array/src/arrays/filter/execute/varbinview.rs @@ -10,12 +10,12 @@ use vortex_mask::MaskValues; use crate::ArrayRef; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::VarBinView; use crate::arrays::VarBinViewArray; use crate::arrow::ArrowSessionExt; use crate::arrow::FromArrowArray; +use crate::legacy_session; pub fn filter_varbinview(array: &VarBinViewArray, mask: &Arc) -> VarBinViewArray { // Delegate to the Arrow implementation of filter over `VarBinView`. @@ -25,16 +25,17 @@ pub fn filter_varbinview(array: &VarBinViewArray, mask: &Arc) -> Var .into_owned() } +#[allow(clippy::disallowed_methods)] fn arrow_filter_fn(array: &ArrayRef, mask: &Mask) -> vortex_error::VortexResult { let values = match &mask { Mask::Values(values) => values, Mask::AllTrue(_) | Mask::AllFalse(_) => unreachable!("check in filter invoke"), }; - let array_ref = LEGACY_SESSION.arrow().execute_arrow( + let array_ref = legacy_session().arrow().execute_arrow( array.clone(), None, - &mut LEGACY_SESSION.create_execution_ctx(), + &mut legacy_session().create_execution_ctx(), )?; let mask_array = BooleanArray::new(values.bit_buffer().clone().into(), None); let filtered = arrow_select::filter::filter(array_ref.as_ref(), &mask_array)?; diff --git a/vortex-array/src/arrays/fixed_size_list/array.rs b/vortex-array/src/arrays/fixed_size_list/array.rs index f24001c4e78..1c8954ed1e4 100644 --- a/vortex-array/src/arrays/fixed_size_list/array.rs +++ b/vortex-array/src/arrays/fixed_size_list/array.rs @@ -12,7 +12,6 @@ use vortex_error::vortex_ensure; use crate::ArrayRef; use crate::ArraySlots; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::array::Array; use crate::array::ArrayParts; @@ -21,6 +20,7 @@ use crate::array::child_to_validity; use crate::array::validity_to_child; use crate::arrays::FixedSizeList; use crate::dtype::DType; +use crate::legacy_session; use crate::validity::Validity; /// The `elements` data array, where each fixed-size list scalar is a _slice_ of the `elements` @@ -231,6 +231,7 @@ pub trait FixedSizeListArrayExt: TypedArrayRef { child_to_validity(self.as_ref().slots()[VALIDITY_SLOT].as_ref(), nullability) } + #[allow(clippy::disallowed_methods)] fn fixed_size_list_elements_at(&self, index: usize) -> VortexResult { debug_assert!( index < self.as_ref().len(), @@ -242,7 +243,7 @@ pub trait FixedSizeListArrayExt: TypedArrayRef { { debug_assert!( self.fixed_size_list_validity() - .execute_is_valid(index, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_is_valid(index, &mut legacy_session().create_execution_ctx()) .unwrap_or(false) ); } diff --git a/vortex-array/src/arrays/list/array.rs b/vortex-array/src/arrays/list/array.rs index 945991b7e03..dbe2943fc51 100644 --- a/vortex-array/src/arrays/list/array.rs +++ b/vortex-array/src/arrays/list/array.rs @@ -18,7 +18,6 @@ use crate::ArraySlots; use crate::Canonical; use crate::ExecutionCtx; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::aggregate_fn::NumericalAggregateOpts; use crate::aggregate_fn::fns::min_max::min_max; @@ -33,6 +32,7 @@ use crate::arrays::Primitive; use crate::builtins::ArrayBuiltins; use crate::dtype::DType; use crate::dtype::NativePType; +use crate::legacy_session; use crate::match_each_integer_ptype; use crate::match_each_native_ptype; use crate::scalar_fn::fns::operators::Operator; @@ -182,6 +182,7 @@ impl ListData { /// Validates the components that would be used to create a `ListArray`. /// /// This function checks all the invariants required by `ListArray::new_unchecked`. + #[allow(clippy::disallowed_methods)] pub fn validate( elements: &ArrayRef, offsets: &ArrayRef, @@ -202,7 +203,7 @@ impl ListData { // We can safely unwrap the DType as primitive now let offsets_ptype = offsets.dtype().as_ptype(); - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = legacy_session().create_execution_ctx(); // Offsets must be sorted (but not strictly sorted, zero-length lists are allowed) if let Some(is_sorted) = offsets.statistics().compute_is_sorted(&mut ctx) { @@ -296,6 +297,7 @@ pub trait ListArrayExt: TypedArrayRef { ) } + #[allow(clippy::disallowed_methods)] fn offset_at(&self, index: usize) -> VortexResult { vortex_ensure!( index <= self.as_ref().len(), @@ -309,7 +311,7 @@ pub trait ListArrayExt: TypedArrayRef { })) } else { self.offsets() - .execute_scalar(index, &mut LEGACY_SESSION.create_execution_ctx())? + .execute_scalar(index, &mut legacy_session().create_execution_ctx())? .as_primitive() .as_::() .ok_or_else(|| vortex_error::vortex_err!("offset value does not fit in usize")) diff --git a/vortex-array/src/arrays/listview/array.rs b/vortex-array/src/arrays/listview/array.rs index 1d6297ba7c1..68c15286aa0 100644 --- a/vortex-array/src/arrays/listview/array.rs +++ b/vortex-array/src/arrays/listview/array.rs @@ -18,7 +18,6 @@ use vortex_mask::Mask; use crate::ArrayRef; use crate::ArraySlots; use crate::ExecutionCtx; -use crate::LEGACY_SESSION; #[expect(deprecated)] use crate::ToCanonical as _; use crate::VortexSessionExecute; @@ -39,6 +38,7 @@ use crate::dtype::DType; use crate::dtype::IntegerPType; use crate::dtype::PType; use crate::expr::stats::Stat; +use crate::legacy_session; use crate::match_each_integer_ptype; use crate::match_each_unsigned_integer_ptype; use crate::scalar_fn::fns::operators::Operator; @@ -382,6 +382,7 @@ pub trait ListViewArrayExt: TypedArrayRef { ) } + #[allow(clippy::disallowed_methods)] fn offset_at(&self, index: usize) -> usize { assert!( index < self.as_ref().len(), @@ -393,7 +394,7 @@ pub trait ListViewArrayExt: TypedArrayRef { .map(|p| match_each_integer_ptype!(p.ptype(), |P| { p.as_slice::

()[index].as_() })) .unwrap_or_else(|| { self.offsets() - .execute_scalar(index, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_scalar(index, &mut legacy_session().create_execution_ctx()) .vortex_expect("offsets must support execute_scalar") .as_primitive() .as_::() @@ -401,6 +402,7 @@ pub trait ListViewArrayExt: TypedArrayRef { }) } + #[allow(clippy::disallowed_methods)] fn size_at(&self, index: usize) -> usize { assert!( index < self.as_ref().len(), @@ -413,7 +415,7 @@ pub trait ListViewArrayExt: TypedArrayRef { .map(|p| match_each_integer_ptype!(p.ptype(), |P| { p.as_slice::

()[index].as_() })) .unwrap_or_else(|| { self.sizes() - .execute_scalar(index, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_scalar(index, &mut legacy_session().create_execution_ctx()) .vortex_expect("sizes must support execute_scalar") .as_primitive() .as_::() @@ -740,6 +742,7 @@ where /// Helper function to validate if the `ListViewArray` components are actually zero-copyable to /// [`ListArray`](crate::arrays::ListArray). +#[allow(clippy::disallowed_methods)] fn validate_zctl( elements: &ArrayRef, offsets_primitive: PrimitiveArray, @@ -747,7 +750,7 @@ fn validate_zctl( ) -> VortexResult<()> { // Offsets must be sorted (but not strictly sorted, zero-length lists are allowed), even // if there are null views. - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = legacy_session().create_execution_ctx(); if let Some(is_sorted) = offsets_primitive.statistics().compute_is_sorted(&mut ctx) { vortex_ensure!(is_sorted, "offsets must be sorted"); } else { diff --git a/vortex-array/src/arrays/masked/array.rs b/vortex-array/src/arrays/masked/array.rs index 5ba830cc0e5..077446b3f02 100644 --- a/vortex-array/src/arrays/masked/array.rs +++ b/vortex-array/src/arrays/masked/array.rs @@ -9,7 +9,6 @@ use vortex_error::VortexResult; use vortex_error::vortex_bail; use crate::ArrayRef; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::array::Array; use crate::array::ArrayParts; @@ -18,6 +17,7 @@ use crate::array::child_to_validity; use crate::array::validity_to_child; use crate::array_slots; use crate::arrays::Masked; +use crate::legacy_session; use crate::validity::Validity; #[array_slots(Masked)] @@ -75,13 +75,14 @@ impl MaskedData { impl Array { /// Constructs a new `MaskedArray`. + #[allow(clippy::disallowed_methods)] pub fn try_new(child: ArrayRef, validity: Validity) -> VortexResult { let dtype = child.dtype().as_nullable(); let len = child.len(); let validity_slot = validity_to_child(&validity, len); let data = MaskedData::try_new( len, - child.all_valid(&mut LEGACY_SESSION.create_execution_ctx())?, + child.all_valid(&mut legacy_session().create_execution_ctx())?, validity, )?; Ok(unsafe { diff --git a/vortex-array/src/arrays/masked/vtable/mod.rs b/vortex-array/src/arrays/masked/vtable/mod.rs index 64d39f63531..c7e32a7ee3c 100644 --- a/vortex-array/src/arrays/masked/vtable/mod.rs +++ b/vortex-array/src/arrays/masked/vtable/mod.rs @@ -23,7 +23,6 @@ use crate::ArrayRef; use crate::Canonical; use crate::EqMode; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::array::Array; use crate::array::ArrayId; @@ -42,6 +41,7 @@ use crate::buffer::BufferHandle; use crate::dtype::DType; use crate::executor::ExecutionCtx; use crate::executor::ExecutionResult; +use crate::legacy_session; use crate::require_child; use crate::scalar::Scalar; use crate::serde::ArrayChildren; @@ -122,6 +122,7 @@ impl VTable for Masked { Ok(Some(vec![])) } + #[allow(clippy::disallowed_methods)] fn deserialize( &self, dtype: &DType, @@ -160,7 +161,7 @@ impl VTable for Masked { let validity_slot = validity_to_child(&validity, len); let data = MaskedData::try_new( len, - child.all_valid(&mut LEGACY_SESSION.create_execution_ctx())?, + child.all_valid(&mut legacy_session().create_execution_ctx())?, validity, )?; Ok(ArrayParts::new(self.clone(), dtype.clone(), len, data) diff --git a/vortex-array/src/arrays/patched/array.rs b/vortex-array/src/arrays/patched/array.rs index b1e5367607b..5e80545665e 100644 --- a/vortex-array/src/arrays/patched/array.rs +++ b/vortex-array/src/arrays/patched/array.rs @@ -16,7 +16,6 @@ use crate::ArraySlots; use crate::Canonical; use crate::ExecutionCtx; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::array::Array; use crate::array::ArrayParts; @@ -31,6 +30,7 @@ use crate::dtype::DType; use crate::dtype::IntegerPType; use crate::dtype::NativePType; use crate::dtype::PType; +use crate::legacy_session; use crate::match_each_native_ptype; use crate::match_each_unsigned_integer_ptype; use crate::patches::Patches; @@ -110,17 +110,18 @@ pub trait PatchedArrayExt: PatchedArraySlotsExt { } #[inline] + #[allow(clippy::disallowed_methods)] fn lane_range(&self, chunk: usize, lane: usize) -> VortexResult> { assert!(chunk * 1024 <= self.as_ref().len() + self.offset()); assert!(lane < self.n_lanes()); let start = self.lane_offsets().execute_scalar( chunk * self.n_lanes() + lane, - &mut LEGACY_SESSION.create_execution_ctx(), + &mut legacy_session().create_execution_ctx(), )?; let stop = self.lane_offsets().execute_scalar( chunk * self.n_lanes() + lane + 1, - &mut LEGACY_SESSION.create_execution_ctx(), + &mut legacy_session().create_execution_ctx(), )?; let start = start diff --git a/vortex-array/src/arrays/primitive/array/top_value.rs b/vortex-array/src/arrays/primitive/array/top_value.rs index 7ae45c89405..a9dbd6a2f37 100644 --- a/vortex-array/src/arrays/primitive/array/top_value.rs +++ b/vortex-array/src/arrays/primitive/array/top_value.rs @@ -10,16 +10,17 @@ use vortex_mask::AllOr; use vortex_mask::Mask; use vortex_utils::aliases::hash_map::HashMap; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::PrimitiveArray; use crate::arrays::primitive::NativeValue; use crate::dtype::NativePType; +use crate::legacy_session; use crate::match_each_native_ptype; use crate::scalar::PValue; impl PrimitiveArray { /// Compute most common present value of this array + #[allow(clippy::disallowed_methods)] pub fn top_value(&self) -> VortexResult> { if self.is_empty() { return Ok(None); @@ -34,7 +35,7 @@ impl PrimitiveArray { self.as_slice::

(), self.as_ref().validity()?.execute_mask( self.as_ref().len(), - &mut LEGACY_SESSION.create_execution_ctx(), + &mut legacy_session().create_execution_ctx(), )?, ); Ok(Some((top.into(), count))) diff --git a/vortex-array/src/arrays/scalar_fn/vtable/validity.rs b/vortex-array/src/arrays/scalar_fn/vtable/validity.rs index 2ac376155e3..056e8aa71dc 100644 --- a/vortex-array/src/arrays/scalar_fn/vtable/validity.rs +++ b/vortex-array/src/arrays/scalar_fn/vtable/validity.rs @@ -5,7 +5,6 @@ use vortex_error::VortexResult; use crate::ArrayRef; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::array::ArrayView; use crate::array::ValidityVTable; @@ -15,6 +14,7 @@ use crate::arrays::scalar_fn::vtable::FakeEq; use crate::arrays::scalar_fn::vtable::ScalarFn; use crate::expr::Expression; use crate::expr::lit; +use crate::legacy_session; use crate::scalar_fn::TypedScalarFnInstance; use crate::scalar_fn::VecExecutionArgs; use crate::scalar_fn::fns::literal::Literal; @@ -24,8 +24,9 @@ use crate::validity::Validity; /// Execute an expression tree recursively. /// /// This assumes all leaf expressions are either ArrayExpr (wrapping actual arrays) or Literals. +#[allow(clippy::disallowed_methods)] fn execute_expr(expr: &Expression, row_count: usize) -> VortexResult { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = legacy_session().create_execution_ctx(); // Handle Root expression - this should not happen in validity expressions if expr.is::() { diff --git a/vortex-array/src/arrays/varbin/array.rs b/vortex-array/src/arrays/varbin/array.rs index 220fe948a73..4cb74fe48b4 100644 --- a/vortex-array/src/arrays/varbin/array.rs +++ b/vortex-array/src/arrays/varbin/array.rs @@ -15,7 +15,6 @@ use vortex_error::vortex_err; use crate::ArrayRef; use crate::ArraySlots; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::array::Array; use crate::array::ArrayParts; @@ -28,6 +27,7 @@ use crate::buffer::BufferHandle; use crate::dtype::DType; use crate::dtype::IntegerPType; use crate::dtype::Nullability; +use crate::legacy_session; use crate::match_each_integer_ptype; use crate::validity::Validity; @@ -230,6 +230,7 @@ impl VarBinData { } /// Validates that every non-null value is valid UTF-8. + #[allow(clippy::disallowed_methods)] fn validate_utf8(offsets: &ArrayRef, bytes: &[u8], validity: &Validity) -> VortexResult<()> { let validate_at = |i: usize, start: usize, end: usize| -> VortexResult<()> { let string_bytes = &bytes[start..end]; @@ -242,7 +243,7 @@ impl VarBinData { Ok(()) }; - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = legacy_session().create_execution_ctx(); // TODO(joe): update the created VarBin with this decompressed Array. let primitive_offsets = offsets.clone().execute::(&mut ctx)?; @@ -335,6 +336,7 @@ pub trait VarBinArrayExt: TypedArrayRef { ) } + #[allow(clippy::disallowed_methods)] fn offset_at(&self, index: usize) -> usize { assert!( index <= self.as_ref().len(), @@ -344,7 +346,7 @@ pub trait VarBinArrayExt: TypedArrayRef { (&self .offsets() - .execute_scalar(index, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_scalar(index, &mut legacy_session().create_execution_ctx()) .vortex_expect("offsets must support execute_scalar")) .try_into() .vortex_expect("Failed to convert offset to usize") diff --git a/vortex-array/src/arrays/varbin/builder.rs b/vortex-array/src/arrays/varbin/builder.rs index 404f574af52..422139cc851 100644 --- a/vortex-array/src/arrays/varbin/builder.rs +++ b/vortex-array/src/arrays/varbin/builder.rs @@ -8,8 +8,6 @@ use vortex_error::vortex_panic; use crate::IntoArray; #[cfg(debug_assertions)] -use crate::LEGACY_SESSION; -#[cfg(debug_assertions)] use crate::VortexSessionExecute; use crate::arrays::PrimitiveArray; use crate::arrays::VarBinArray; @@ -17,6 +15,8 @@ use crate::dtype::DType; use crate::dtype::IntegerPType; use crate::expr::stats::Precision; use crate::expr::stats::Stat; +#[cfg(debug_assertions)] +use crate::legacy_session; use crate::validity::Validity; pub struct VarBinBuilder { @@ -94,6 +94,7 @@ impl VarBinBuilder { self.validity.append_n(true, num); } + #[allow(clippy::disallowed_methods)] pub fn finish(self, dtype: DType) -> VarBinArray { let offsets = PrimitiveArray::new(self.offsets.freeze(), Validity::NonNullable); let nulls = self.validity.freeze(); @@ -107,7 +108,7 @@ impl VarBinBuilder { { let offsets_are_sorted = offsets .statistics() - .compute_is_sorted(&mut LEGACY_SESSION.create_execution_ctx()) + .compute_is_sorted(&mut legacy_session().create_execution_ctx()) .unwrap_or(false); debug_assert!(offsets_are_sorted, "VarBinBuilder offsets must be sorted"); } diff --git a/vortex-array/src/arrays/varbinview/array.rs b/vortex-array/src/arrays/varbinview/array.rs index 3ba90409939..7fa66eaea7a 100644 --- a/vortex-array/src/arrays/varbinview/array.rs +++ b/vortex-array/src/arrays/varbinview/array.rs @@ -18,7 +18,6 @@ use vortex_error::vortex_err; use vortex_error::vortex_panic; use crate::ArraySlots; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::array::Array; use crate::array::ArrayParts; @@ -32,6 +31,7 @@ use crate::builders::ArrayBuilder; use crate::builders::VarBinViewBuilder; use crate::dtype::DType; use crate::dtype::Nullability; +use crate::legacy_session; use crate::validity::Validity; /// The validity bitmap indicating which elements are non-null. @@ -309,6 +309,7 @@ impl VarBinViewData { Ok(()) } + #[allow(clippy::disallowed_methods)] fn validate_views( views: &Buffer, buffers: &Arc<[ByteBuffer]>, @@ -370,7 +371,7 @@ impl VarBinViewData { // into a mask once and zip it with the views, validating only the valid (non-null) // entries. Validity::Array(_) => { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = legacy_session().create_execution_ctx(); let mask = validity.execute_mask(views.len(), &mut ctx)?; for ((idx, view), valid) in views.iter().enumerate().zip(mask.iter()) { if valid { diff --git a/vortex-array/src/arrays/varbinview/compact.rs b/vortex-array/src/arrays/varbinview/compact.rs index fc3f3477ffc..f608e1efff4 100644 --- a/vortex-array/src/arrays/varbinview/compact.rs +++ b/vortex-array/src/arrays/varbinview/compact.rs @@ -11,12 +11,12 @@ use vortex_error::VortexResult; use vortex_mask::Mask; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::VarBinViewArray; use crate::arrays::varbinview::Ref; use crate::builders::ArrayBuilder; use crate::builders::VarBinViewBuilder; +use crate::legacy_session; const DEFAULT_COMPACTION_THRESHOLD: f64 = 0.5; const MIN_RETAINED_BYTES_PER_ROW_TO_CHECK_COMPACTION: u64 = 128; @@ -69,13 +69,14 @@ impl VarBinViewArray { /// Iterates over all valid, non-inlined views, calling the provided /// closure for each one. #[inline(always)] + #[allow(clippy::disallowed_methods)] fn iter_valid_views(&self, mut f: F) -> VortexResult<()> where F: FnMut(&Ref), { match self.as_ref().validity()?.execute_mask( self.as_ref().len(), - &mut LEGACY_SESSION.create_execution_ctx(), + &mut legacy_session().create_execution_ctx(), )? { Mask::AllTrue(_) => { for &view in self.views().iter() { diff --git a/vortex-array/src/arrow/datum.rs b/vortex-array/src/arrow/datum.rs index b2f047b5948..03e1b8dc24b 100644 --- a/vortex-array/src/arrow/datum.rs +++ b/vortex-array/src/arrow/datum.rs @@ -12,13 +12,13 @@ use vortex_error::vortex_panic; use crate::ArrayRef; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::Constant; use crate::arrays::ConstantArray; use crate::arrow::ArrowSessionExt; use crate::arrow::FromArrowArray; use crate::executor::ExecutionCtx; +use crate::legacy_session; /// A wrapper around a generic Arrow array that can be used as a Datum in Arrow compute. #[derive(Debug)] @@ -106,8 +106,9 @@ impl ArrowDatum for Datum { /// /// The provided array must have length #[deprecated( - note = "Relies on the hidden global `LEGACY_SESSION`; use `from_arrow_columnar` with an explicit `ExecutionCtx` instead" + note = "Relies on the hidden global `legacy_session()`; use `from_arrow_columnar` with an explicit `ExecutionCtx` instead" )] +#[allow(clippy::disallowed_methods)] pub fn from_arrow_array_with_len(array: A, len: usize, nullable: bool) -> VortexResult where ArrayRef: FromArrowArray, @@ -128,7 +129,7 @@ where Ok(ConstantArray::new( array - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_scalar(0, &mut legacy_session().create_execution_ctx()) .vortex_expect("array of length 1 must support execute_scalar(0)"), len, ) diff --git a/vortex-array/src/arrow/executor/byte.rs b/vortex-array/src/arrow/executor/byte.rs index 2db9d3e6494..11e2e084e38 100644 --- a/vortex-array/src/arrow/executor/byte.rs +++ b/vortex-array/src/arrow/executor/byte.rs @@ -84,13 +84,13 @@ mod tests { use vortex_mask::Mask; use crate::IntoArray; - use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::array_session; use crate::arrow::ArrowArrayExecutor; use crate::arrow::executor::byte::VarBinViewArray; use crate::dtype::DType; use crate::dtype::Nullability; + use crate::legacy_session; fn make_utf8_array() -> VarBinViewArray { VarBinViewArray::from_iter_str(["hello", "world", "this is a longer string for testing"]) @@ -184,6 +184,7 @@ mod tests { } #[test] + #[allow(clippy::disallowed_methods)] fn filtered_utf8_view_export_does_not_retain_unselected_buffers() -> VortexResult<()> { let unselected = "x".repeat(1 << 20); let array = @@ -194,7 +195,7 @@ mod tests { let arrow = filtered.execute_arrow( Some(&DataType::Utf8View), - &mut LEGACY_SESSION.create_execution_ctx(), + &mut legacy_session().create_execution_ctx(), )?; assert_eq!(arrow.as_string_view().value(0), "selected"); diff --git a/vortex-array/src/arrow/mod.rs b/vortex-array/src/arrow/mod.rs index 5259005dffc..feda2a5f4d8 100644 --- a/vortex-array/src/arrow/mod.rs +++ b/vortex-array/src/arrow/mod.rs @@ -23,8 +23,8 @@ pub use null_buffer::to_null_buffer; pub use session::*; use crate::ArrayRef; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; +use crate::legacy_session; /// Construct a Vortex array from an Arrow array (or other Arrow container) of type `A`. /// @@ -66,11 +66,16 @@ pub trait IntoArrowArray { impl IntoArrowArray for ArrayRef { /// Convert this [`crate::ArrayRef`] into an Arrow [`crate::ArrayRef`] by using the array's /// preferred (cheapest) Arrow [`DataType`]. + #[allow(clippy::disallowed_methods)] fn into_arrow_preferred(self) -> VortexResult { - self.execute_arrow(None, &mut LEGACY_SESSION.create_execution_ctx()) + self.execute_arrow(None, &mut legacy_session().create_execution_ctx()) } + #[allow(clippy::disallowed_methods)] fn into_arrow(self, data_type: &DataType) -> VortexResult { - self.execute_arrow(Some(data_type), &mut LEGACY_SESSION.create_execution_ctx()) + self.execute_arrow( + Some(data_type), + &mut legacy_session().create_execution_ctx(), + ) } } diff --git a/vortex-array/src/builders/bool.rs b/vortex-array/src/builders/bool.rs index f26cbf04b57..0766854a722 100644 --- a/vortex-array/src/builders/bool.rs +++ b/vortex-array/src/builders/bool.rs @@ -13,7 +13,6 @@ use vortex_mask::Mask; use crate::ArrayRef; use crate::ExecutionCtx; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::BoolArray; use crate::arrays::bool::BoolArrayExt; @@ -25,6 +24,7 @@ use crate::canonical::Canonical; use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::Nullability; +use crate::legacy_session; use crate::scalar::Scalar; pub struct BoolBuilder { @@ -127,10 +127,11 @@ impl ArrayBuilder for BoolBuilder { Ok(()) } + #[allow(clippy::disallowed_methods)] unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { #[expect(deprecated)] let bool_array = array.to_bool(); - self.append_bool_array(&bool_array, &mut LEGACY_SESSION.create_execution_ctx()) + self.append_bool_array(&bool_array, &mut legacy_session().create_execution_ctx()) .vortex_expect("Failed to append bool array"); } diff --git a/vortex-array/src/builders/decimal.rs b/vortex-array/src/builders/decimal.rs index aa777051e39..7f3abbb9220 100644 --- a/vortex-array/src/builders/decimal.rs +++ b/vortex-array/src/builders/decimal.rs @@ -13,7 +13,6 @@ use vortex_mask::Mask; use crate::ArrayRef; use crate::IntoArray; -use crate::LEGACY_SESSION; #[expect(deprecated)] use crate::ToCanonical as _; use crate::VortexSessionExecute; @@ -28,6 +27,7 @@ use crate::dtype::DecimalDType; use crate::dtype::NativeDecimalType; use crate::dtype::Nullability; use crate::dtype::i256; +use crate::legacy_session; use crate::match_each_decimal_value; use crate::match_each_decimal_value_type; use crate::scalar::DecimalValue; @@ -195,6 +195,7 @@ impl ArrayBuilder for DecimalBuilder { Ok(()) } + #[allow(clippy::disallowed_methods)] unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { #[expect(deprecated)] let decimal_array = array.to_decimal(); @@ -213,7 +214,7 @@ impl ArrayBuilder for DecimalBuilder { .vortex_expect("validity_mask") .execute_mask( decimal_array.as_ref().len(), - &mut LEGACY_SESSION.create_execution_ctx(), + &mut legacy_session().create_execution_ctx(), ) .vortex_expect("Failed to compute validity mask"), ); diff --git a/vortex-array/src/builders/fixed_size_list.rs b/vortex-array/src/builders/fixed_size_list.rs index 9129d8cb644..b0d08daea90 100644 --- a/vortex-array/src/builders/fixed_size_list.rs +++ b/vortex-array/src/builders/fixed_size_list.rs @@ -14,7 +14,6 @@ use vortex_mask::Mask; use crate::ArrayRef; use crate::ExecutionCtx; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::FixedSizeListArray; use crate::arrays::fixed_size_list::FixedSizeListArrayExt; @@ -27,6 +26,7 @@ use crate::canonical::Canonical; use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::Nullability; +use crate::legacy_session; use crate::scalar::ListScalar; use crate::scalar::Scalar; @@ -242,6 +242,7 @@ impl ArrayBuilder for FixedSizeListBuilder { /// This will increase the capacity if extending with this `array` would go past the original /// capacity. + #[allow(clippy::disallowed_methods)] unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { #[expect(deprecated)] let fsl = array.to_fixed_size_list(); @@ -254,7 +255,7 @@ impl ArrayBuilder for FixedSizeListBuilder { &array .validity() .vortex_expect("validity_mask in extend_from_array_unchecked") - .execute_mask(array.len(), &mut LEGACY_SESSION.create_execution_ctx()) + .execute_mask(array.len(), &mut legacy_session().create_execution_ctx()) .vortex_expect("Failed to compute validity mask"), ); } diff --git a/vortex-array/src/builders/list.rs b/vortex-array/src/builders/list.rs index 12c74ba53a4..8f2bba09192 100644 --- a/vortex-array/src/builders/list.rs +++ b/vortex-array/src/builders/list.rs @@ -15,7 +15,6 @@ use crate::ArrayRef; use crate::Canonical; use crate::ExecutionCtx; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::ListArray; use crate::arrays::listview::ListViewArrayExt; @@ -30,6 +29,7 @@ use crate::dtype::DType; use crate::dtype::IntegerPType; use crate::dtype::Nullability; use crate::dtype::Nullability::NonNullable; +use crate::legacy_session; use crate::match_each_integer_ptype; use crate::scalar::ListScalar; use crate::scalar::Scalar; @@ -222,6 +222,7 @@ impl ArrayBuilder for ListBuilder { self.append_value(scalar.as_list()) } + #[allow(clippy::disallowed_methods)] unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { #[expect(deprecated)] let list = array.to_listview(); @@ -234,7 +235,7 @@ impl ArrayBuilder for ListBuilder { &array .validity() .vortex_expect("validity_mask in extend_from_array_unchecked") - .execute_mask(array.len(), &mut LEGACY_SESSION.create_execution_ctx()) + .execute_mask(array.len(), &mut legacy_session().create_execution_ctx()) .vortex_expect("Failed to compute validity mask"), ); diff --git a/vortex-array/src/builders/listview.rs b/vortex-array/src/builders/listview.rs index cb131048339..53855f2c1bf 100644 --- a/vortex-array/src/builders/listview.rs +++ b/vortex-array/src/builders/listview.rs @@ -21,7 +21,6 @@ use vortex_mask::Mask; use crate::ArrayRef; use crate::Canonical; use crate::ExecutionCtx; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::array::IntoArray; use crate::arrays::ListViewArray; @@ -38,6 +37,7 @@ use crate::builtins::ArrayBuiltins; use crate::dtype::DType; use crate::dtype::IntegerPType; use crate::dtype::Nullability; +use crate::legacy_session; use crate::match_each_integer_ptype; use crate::scalar::ListScalar; use crate::scalar::Scalar; @@ -296,12 +296,13 @@ impl ArrayBuilder for ListViewBuilder { self.append_value(list_scalar) } + #[allow(clippy::disallowed_methods)] unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { // TODO: The `ArrayBuilder` trait does not thread an `ExecutionCtx` through its extend - // methods, so we are forced to mint a fresh `LEGACY_SESSION` context here on every call + // methods, so we are forced to mint a fresh `legacy_session()` context here on every call // (which for chunked input means once per chunk). Once the trait carries a `&mut // ExecutionCtx`, the caller's session should be reused instead. - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = legacy_session().create_execution_ctx(); let listview = array .clone() diff --git a/vortex-array/src/builders/primitive.rs b/vortex-array/src/builders/primitive.rs index e7e8d3d7512..f0dfdbb3f40 100644 --- a/vortex-array/src/builders/primitive.rs +++ b/vortex-array/src/builders/primitive.rs @@ -13,7 +13,6 @@ use vortex_mask::Mask; use crate::ArrayRef; use crate::ExecutionCtx; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::PrimitiveArray; use crate::builders::ArrayBuilder; @@ -25,6 +24,7 @@ use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::NativePType; use crate::dtype::Nullability; +use crate::legacy_session; use crate::scalar::Scalar; /// The builder for building a [`PrimitiveArray`], parametrized by the `PType`. @@ -200,11 +200,12 @@ impl ArrayBuilder for PrimitiveBuilder { Ok(()) } + #[allow(clippy::disallowed_methods)] unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { #[expect(deprecated)] let array = array.to_primitive(); - self.append_primitive_array(&array, &mut LEGACY_SESSION.create_execution_ctx()) + self.append_primitive_array(&array, &mut legacy_session().create_execution_ctx()) .vortex_expect("Failed to append primitive array"); } diff --git a/vortex-array/src/builders/struct_.rs b/vortex-array/src/builders/struct_.rs index 016d42508ee..6cea7798463 100644 --- a/vortex-array/src/builders/struct_.rs +++ b/vortex-array/src/builders/struct_.rs @@ -13,7 +13,6 @@ use vortex_mask::Mask; use crate::ArrayRef; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::StructArray; use crate::arrays::struct_::StructArrayExt; @@ -27,6 +26,7 @@ use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::Nullability; use crate::dtype::StructFields; +use crate::legacy_session; use crate::scalar::Scalar; use crate::scalar::StructScalar; @@ -168,6 +168,7 @@ impl ArrayBuilder for StructBuilder { self.append_value(scalar.as_struct()) } + #[allow(clippy::disallowed_methods)] unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { #[expect(deprecated)] let array = array.to_struct(); @@ -183,7 +184,7 @@ impl ArrayBuilder for StructBuilder { &array .validity() .vortex_expect("validity_mask") - .execute_mask(array.len(), &mut LEGACY_SESSION.create_execution_ctx()) + .execute_mask(array.len(), &mut legacy_session().create_execution_ctx()) .vortex_expect("Failed to compute validity mask"), ); } diff --git a/vortex-array/src/builders/varbinview.rs b/vortex-array/src/builders/varbinview.rs index 3aabfd87c31..53bb728e7a7 100644 --- a/vortex-array/src/builders/varbinview.rs +++ b/vortex-array/src/builders/varbinview.rs @@ -22,7 +22,6 @@ use vortex_utils::aliases::hash_map::HashMap; use crate::ArrayRef; use crate::ExecutionCtx; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::VarBinViewArray; use crate::arrays::varbinview::VarBinViewArrayExt; @@ -34,6 +33,7 @@ use crate::canonical::Canonical; #[expect(deprecated)] use crate::canonical::ToCanonical as _; use crate::dtype::DType; +use crate::legacy_session; use crate::scalar::Scalar; /// The builder for building a [`VarBinViewArray`]. @@ -372,10 +372,11 @@ impl ArrayBuilder for VarBinViewBuilder { Ok(()) } + #[allow(clippy::disallowed_methods)] unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { #[expect(deprecated)] let array = array.to_varbinview(); - self.append_varbinview_array(&array, &mut LEGACY_SESSION.create_execution_ctx()) + self.append_varbinview_array(&array, &mut legacy_session().create_execution_ctx()) .vortex_expect("Failed to append varbinview array"); } diff --git a/vortex-array/src/display/mod.rs b/vortex-array/src/display/mod.rs index 0134a28558c..e13d6d5f894 100644 --- a/vortex-array/src/display/mod.rs +++ b/vortex-array/src/display/mod.rs @@ -19,8 +19,8 @@ use itertools::Itertools as _; pub use tree_display::TreeDisplay; use crate::ArrayRef; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; +use crate::legacy_session; /// Describe how to convert an array to a string. /// @@ -521,6 +521,7 @@ impl ArrayRef { DisplayArrayAs(self, DisplayOptions::TableDisplay) } + #[allow(clippy::disallowed_methods)] fn fmt_as(&self, f: &mut std::fmt::Formatter, options: &DisplayOptions) -> std::fmt::Result { match options { DisplayOptions::MetadataOnly => EncodingSummaryExtractor::write(self, f), @@ -536,7 +537,7 @@ impl ArrayRef { let is_truncated = self.len() > limit; let fmt_scalar = |i| { - self.execute_scalar(i, &mut LEGACY_SESSION.create_execution_ctx()) + self.execute_scalar(i, &mut legacy_session().create_execution_ctx()) .map_or_else(|e| format!(""), |s| s.to_string()) }; write!( @@ -587,7 +588,7 @@ impl ArrayRef { let mut builder = tabled::builder::Builder::default(); // Reuse a single execution context across all per-row accesses below. - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = legacy_session().create_execution_ctx(); // Special logic for struct arrays. let DType::Struct(sf, _) = self.dtype() else { diff --git a/vortex-array/src/lib.rs b/vortex-array/src/lib.rs index 7496c527172..e219a929453 100644 --- a/vortex-array/src/lib.rs +++ b/vortex-array/src/lib.rs @@ -184,6 +184,12 @@ pub fn array_session() -> VortexSession { // TODO(ngates): canonicalize doesn't currently take a session, therefore we cannot invoke execute // from the new array encodings to support back-compat for legacy encodings. So we hold a session // here... -pub static LEGACY_SESSION: LazyLock = LazyLock::new(array_session); +/// Returns the hidden global [`VortexSession`] used as a back-compat shim for code paths that +/// cannot yet thread an explicit session through. +#[inline] +pub fn legacy_session() -> &'static VortexSession { + static LEGACY_SESSION: LazyLock = LazyLock::new(array_session); + &LEGACY_SESSION +} pub type ArrayContext = Context; diff --git a/vortex-array/src/patches.rs b/vortex-array/src/patches.rs index 2df53a8e7f9..32d220797a6 100644 --- a/vortex-array/src/patches.rs +++ b/vortex-array/src/patches.rs @@ -23,7 +23,6 @@ use crate::ArrayRef; use crate::ArraySlots; use crate::ExecutionCtx; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::Primitive; use crate::arrays::PrimitiveArray; @@ -36,6 +35,7 @@ use crate::dtype::Nullability; use crate::dtype::Nullability::NonNullable; use crate::dtype::PType; use crate::dtype::UnsignedPType; +use crate::legacy_session; use crate::match_each_integer_ptype; use crate::match_each_unsigned_integer_ptype; use crate::scalar::PValue; @@ -238,6 +238,7 @@ pub struct Patches { } impl Patches { + #[allow(clippy::disallowed_methods)] pub fn new( array_len: usize, offset: usize, @@ -266,7 +267,7 @@ impl Patches { if indices.is_host() && values.is_host() { let max = usize::try_from(&indices.execute_scalar( indices.len() - 1, - &mut LEGACY_SESSION.create_execution_ctx(), + &mut legacy_session().create_execution_ctx(), )?) .map_err(|_| vortex_err!("indices must be a number"))?; vortex_ensure!( @@ -278,7 +279,7 @@ impl Patches { { use crate::VortexSessionExecute; use crate::aggregate_fn::fns::is_sorted::is_sorted; - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = legacy_session().create_execution_ctx(); assert!( is_sorted(&indices, &mut ctx).unwrap_or(false), "Patch indices must be sorted" @@ -381,13 +382,14 @@ impl Patches { } #[inline] + #[allow(clippy::disallowed_methods)] pub fn chunk_offset_at(&self, idx: usize) -> VortexResult { let Some(chunk_offsets) = &self.chunk_offsets else { vortex_bail!("chunk_offsets must be set to retrieve offset at index") }; chunk_offsets - .execute_scalar(idx, &mut LEGACY_SESSION.create_execution_ctx())? + .execute_scalar(idx, &mut legacy_session().create_execution_ctx())? .as_primitive() .as_::() .ok_or_else(|| vortex_err!("chunk offset does not fit in usize")) @@ -441,12 +443,13 @@ impl Patches { } /// Get the patched value at a given index if it exists. + #[allow(clippy::disallowed_methods)] pub fn get_patched(&self, index: usize) -> VortexResult> { self.search_index(index)? .to_found() .map(|patch_idx| { self.values() - .execute_scalar(patch_idx, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_scalar(patch_idx, &mut legacy_session().create_execution_ctx()) }) .transpose() } @@ -625,10 +628,11 @@ impl Patches { } /// Returns the minimum patch index + #[allow(clippy::disallowed_methods)] pub fn min_index(&self) -> VortexResult { let first = self .indices - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx())? + .execute_scalar(0, &mut legacy_session().create_execution_ctx())? .as_primitive() .as_::() .ok_or_else(|| vortex_err!("index does not fit in usize"))?; @@ -636,12 +640,13 @@ impl Patches { } /// Returns the maximum patch index + #[allow(clippy::disallowed_methods)] pub fn max_index(&self) -> VortexResult { let last = self .indices .execute_scalar( self.indices.len() - 1, - &mut LEGACY_SESSION.create_execution_ctx(), + &mut legacy_session().create_execution_ctx(), )? .as_primitive() .as_::() @@ -743,6 +748,7 @@ impl Patches { } /// Slice the patches by a range of the patched array. + #[allow(clippy::disallowed_methods)] pub fn slice(&self, range: Range) -> VortexResult> { let slice_start_idx = self.search_index(range.start)?.to_index(); let slice_end_idx = self.search_index(range.end)?.to_index(); @@ -769,7 +775,7 @@ impl Patches { .as_ref() .map(|new_chunk_offsets| -> VortexResult { let new_chunk_base = new_chunk_offsets - .execute_scalar(0, &mut LEGACY_SESSION.create_execution_ctx())? + .execute_scalar(0, &mut legacy_session().create_execution_ctx())? .as_primitive() .as_::() .ok_or_else(|| vortex_err!("chunk offset does not fit in usize"))?; diff --git a/vortex-array/src/search_sorted.rs b/vortex-array/src/search_sorted.rs index 3db5215e049..f2ceb5dc24d 100644 --- a/vortex-array/src/search_sorted.rs +++ b/vortex-array/src/search_sorted.rs @@ -13,8 +13,8 @@ use std::hint; use vortex_error::VortexResult; use crate::ArrayRef; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; +use crate::legacy_session; use crate::scalar::Scalar; #[derive(Debug, Copy, Clone, Eq, PartialEq)] @@ -270,8 +270,9 @@ fn search_sorted_side_idx VortexResult>( } impl IndexOrd for ArrayRef { + #[allow(clippy::disallowed_methods)] fn index_cmp(&self, idx: usize, elem: &Scalar) -> VortexResult> { - let scalar_a = self.execute_scalar(idx, &mut LEGACY_SESSION.create_execution_ctx())?; + let scalar_a = self.execute_scalar(idx, &mut legacy_session().create_execution_ctx())?; Ok(scalar_a.partial_cmp(elem)) } diff --git a/vortex-array/src/validity.rs b/vortex-array/src/validity.rs index 9c80e34267e..3bb47eda0ae 100644 --- a/vortex-array/src/validity.rs +++ b/vortex-array/src/validity.rs @@ -24,7 +24,6 @@ use crate::ArrayRef; use crate::Canonical; use crate::ExecutionCtx; use crate::IntoArray; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::arrays::BoolArray; use crate::arrays::ChunkedArray; @@ -33,6 +32,7 @@ use crate::arrays::scalar_fn::ScalarFnFactoryExt; use crate::builtins::ArrayBuiltins; use crate::dtype::DType; use crate::dtype::Nullability; +use crate::legacy_session; use crate::optimizer::ArrayOptimizer; use crate::patches::Patches; use crate::scalar::Scalar; @@ -186,15 +186,17 @@ impl Validity { /// Returns whether the `index` item is valid. #[deprecated(note = "use `execute_is_valid` with an explicit `ExecutionCtx`")] #[inline] + #[allow(clippy::disallowed_methods)] pub fn is_valid(&self, index: usize) -> VortexResult { - self.execute_is_valid(index, &mut LEGACY_SESSION.create_execution_ctx()) + self.execute_is_valid(index, &mut legacy_session().create_execution_ctx()) } /// Returns whether the `index` item is null. #[deprecated(note = "use `execute_is_null` with an explicit `ExecutionCtx`")] #[inline] + #[allow(clippy::disallowed_methods)] pub fn is_null(&self, index: usize) -> VortexResult { - self.execute_is_null(index, &mut LEGACY_SESSION.create_execution_ctx()) + self.execute_is_null(index, &mut legacy_session().create_execution_ctx()) } #[inline] diff --git a/vortex-array/src/variants.rs b/vortex-array/src/variants.rs index 2bc1f7fecfd..13c7a242c7a 100644 --- a/vortex-array/src/variants.rs +++ b/vortex-array/src/variants.rs @@ -12,7 +12,6 @@ use vortex_mask::Mask; use crate::ArrayRef; use crate::ExecutionCtx; -use crate::LEGACY_SESSION; use crate::VortexSessionExecute; use crate::aggregate_fn::fns::sum::sum; use crate::arrays::BoolArray; @@ -22,6 +21,7 @@ use crate::dtype::DType; use crate::dtype::FieldNames; use crate::dtype::PType; use crate::dtype::extension::ExtDTypeRef; +use crate::legacy_session; use crate::scalar::PValue; use crate::scalar::Scalar; use crate::search_sorted::IndexOrd; @@ -113,10 +113,11 @@ pub struct BoolTyped<'a>(&'a ArrayRef); impl BoolTyped<'_> { #[deprecated( - note = "Relies on the hidden global `LEGACY_SESSION`; use `sum(array, ctx)` with an explicit `ExecutionCtx` instead" + note = "Relies on the hidden global `legacy_session()`; use `sum(array, ctx)` with an explicit `ExecutionCtx` instead" )] + #[allow(clippy::disallowed_methods)] pub fn true_count(&self) -> VortexResult { - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = legacy_session().create_execution_ctx(); let true_count = sum(self.0, &mut ctx)?; Ok(true_count .as_primitive() @@ -137,24 +138,26 @@ impl PrimitiveTyped<'_> { /// Return the primitive value at the given index. #[deprecated( - note = "Relies on the hidden global `LEGACY_SESSION`; use `is_valid`/`execute_scalar` with an explicit `ExecutionCtx` instead" + note = "Relies on the hidden global `legacy_session()`; use `is_valid`/`execute_scalar` with an explicit `ExecutionCtx` instead" )] #[allow(deprecated)] + #[allow(clippy::disallowed_methods)] pub fn value(&self, idx: usize) -> VortexResult> { self.0 - .is_valid(idx, &mut LEGACY_SESSION.create_execution_ctx())? + .is_valid(idx, &mut legacy_session().create_execution_ctx())? .then(|| self.value_unchecked(idx)) .transpose() } /// Return the primitive value at the given index, ignoring nullability. #[deprecated( - note = "Relies on the hidden global `LEGACY_SESSION`; use `execute_scalar` with an explicit `ExecutionCtx` instead" + note = "Relies on the hidden global `legacy_session()`; use `execute_scalar` with an explicit `ExecutionCtx` instead" )] + #[allow(clippy::disallowed_methods)] pub fn value_unchecked(&self, idx: usize) -> VortexResult { Ok(self .0 - .execute_scalar(idx, &mut LEGACY_SESSION.create_execution_ctx())? + .execute_scalar(idx, &mut legacy_session().create_execution_ctx())? .as_primitive() .pvalue() .unwrap_or_else(|| PValue::zero(&self.ptype()))) @@ -176,10 +179,11 @@ impl IndexOrd> for PrimitiveTyped<'_> { // TODO(ngates): add generics to the `value` function and implement this over T. impl IndexOrd for PrimitiveTyped<'_> { #[allow(deprecated)] + #[allow(clippy::disallowed_methods)] fn index_cmp(&self, idx: usize, elem: &PValue) -> VortexResult> { assert!( self.0 - .all_valid(&mut LEGACY_SESSION.create_execution_ctx())? + .all_valid(&mut legacy_session().create_execution_ctx())? ); let value = self.value_unchecked(idx)?; Ok(value.partial_cmp(elem)) diff --git a/vortex-cuda/src/canonical.rs b/vortex-cuda/src/canonical.rs index 4f3d6fd37e3..9f1fce7e68e 100644 --- a/vortex-cuda/src/canonical.rs +++ b/vortex-cuda/src/canonical.rs @@ -7,7 +7,6 @@ use async_trait::async_trait; use futures::future::try_join_all; use vortex::array::Canonical; use vortex::array::IntoArray; -use vortex::array::LEGACY_SESSION; use vortex::array::VortexSessionExecute; use vortex::array::arrays::BoolArray; use vortex::array::arrays::DecimalArray; @@ -23,6 +22,7 @@ use vortex::array::arrays::struct_::StructDataParts; use vortex::array::arrays::varbinview::BinaryView; use vortex::array::arrays::varbinview::VarBinViewDataParts; use vortex::array::buffer::BufferHandle; +use vortex::array::legacy_session; use vortex::buffer::BitBuffer; use vortex::buffer::Buffer; use vortex::buffer::ByteBuffer; @@ -38,6 +38,7 @@ pub trait CanonicalCudaExt { #[async_trait] impl CanonicalCudaExt for Canonical { + #[allow(clippy::disallowed_methods)] async fn into_host(self) -> VortexResult { match self { Canonical::Struct(struct_array) => { @@ -55,7 +56,7 @@ impl CanonicalCudaExt for Canonical { host_fields.push( field .clone() - .execute::(&mut LEGACY_SESSION.create_execution_ctx())? + .execute::(&mut legacy_session().create_execution_ctx())? .into_host() .await? .into_array(), @@ -144,7 +145,7 @@ impl CanonicalCudaExt for Canonical { let host_storage = ext .storage_array() .clone() - .execute::(&mut LEGACY_SESSION.create_execution_ctx())? + .execute::(&mut legacy_session().create_execution_ctx())? .into_host() .await? .into_array(); diff --git a/vortex-cxx/src/read.rs b/vortex-cxx/src/read.rs index 4ce229b7a2b..480023084ef 100644 --- a/vortex-cxx/src/read.rs +++ b/vortex-cxx/src/read.rs @@ -15,9 +15,9 @@ use arrow_schema::Schema; use arrow_schema::SchemaRef; use futures::stream::TryStreamExt; use vortex::array::ArrayRef; -use vortex::array::LEGACY_SESSION; use vortex::array::VortexSessionExecute; use vortex::array::arrow::ArrowSessionExt; +use vortex::array::legacy_session; use vortex::buffer::Buffer; use vortex::file::OpenOptionsSessionExt; use vortex::io::runtime::BlockingRuntime; @@ -149,6 +149,7 @@ pub(crate) struct ThreadsafeCloneableReader { inner: Box, } +#[allow(clippy::disallowed_methods)] pub(crate) fn scan_builder_into_threadsafe_cloneable_reader( builder: Box, ) -> Result, Box> { @@ -167,7 +168,11 @@ pub(crate) fn scan_builder_into_threadsafe_cloneable_reader( .map(move |b| { SESSION .arrow() - .execute_arrow(b, Some(&target), &mut LEGACY_SESSION.create_execution_ctx()) + .execute_arrow( + b, + Some(&target), + &mut legacy_session().create_execution_ctx(), + ) .map(|struct_array| RecordBatch::from(struct_array.as_struct())) }) .into_stream()? diff --git a/vortex-ffi/src/array.rs b/vortex-ffi/src/array.rs index a87178446de..8ab74ef8262 100644 --- a/vortex-ffi/src/array.rs +++ b/vortex-ffi/src/array.rs @@ -14,13 +14,13 @@ use arrow_array::ffi::from_ffi; use paste::paste; use vortex::array::ArrayRef; use vortex::array::IntoArray; -use vortex::array::LEGACY_SESSION; use vortex::array::VortexSessionExecute; use vortex::array::arrays::NullArray; use vortex::array::arrays::PrimitiveArray; use vortex::array::arrays::StructArray; use vortex::array::arrays::struct_::StructArrayExt; use vortex::array::arrow::FromArrowArray; +use vortex::array::legacy_session; use vortex::array::validity::Validity; use vortex::buffer::Buffer; use vortex::dtype::DType; @@ -219,6 +219,7 @@ pub unsafe extern "C-unwind" fn vx_array_dtype(array: *const vx_array) -> *const // Returns NULL and sets error_out if index is out of bounds or array doesn't // have dtype DTYPE_STRUCT. #[unsafe(no_mangle)] +#[allow(clippy::disallowed_methods)] pub unsafe extern "C-unwind" fn vx_array_get_field( array: *const vx_array, index: usize, @@ -227,7 +228,7 @@ pub unsafe extern "C-unwind" fn vx_array_get_field( try_or_default(error_out, || { let array = vx_array::as_ref(array); - let mut ctx = LEGACY_SESSION.create_execution_ctx(); + let mut ctx = legacy_session().create_execution_ctx(); let field_array = array .clone() .execute::(&mut ctx)? @@ -258,6 +259,7 @@ pub unsafe extern "C-unwind" fn vx_array_slice( /// validity array. Sets error if index is out of bounds or underlying validity /// array is corrupted. #[unsafe(no_mangle)] +#[allow(clippy::disallowed_methods)] pub unsafe extern "C-unwind" fn vx_array_element_is_invalid( array: *const vx_array, index: usize, @@ -265,12 +267,13 @@ pub unsafe extern "C-unwind" fn vx_array_element_is_invalid( ) -> bool { try_or_default(error, || { vortex_ensure!(!array.is_null()); - vx_array::as_ref(array).is_invalid(index, &mut LEGACY_SESSION.create_execution_ctx()) + vx_array::as_ref(array).is_invalid(index, &mut legacy_session().create_execution_ctx()) }) } /// Check how many items in the array are invalid (null). #[unsafe(no_mangle)] +#[allow(clippy::disallowed_methods)] pub unsafe extern "C-unwind" fn vx_array_invalid_count( array: *const vx_array, error_out: *mut *mut vx_error, @@ -278,7 +281,7 @@ pub unsafe extern "C-unwind" fn vx_array_invalid_count( try_or_default(error_out, || { vortex_ensure!(!array.is_null()); let array = vx_array::as_ref(array); - array.invalid_count(&mut LEGACY_SESSION.create_execution_ctx()) + array.invalid_count(&mut legacy_session().create_execution_ctx()) }) } @@ -394,8 +397,9 @@ macro_rules! ffiarray_get_ptype { pub unsafe extern "C-unwind" fn [](array: *const vx_array, index: usize) -> $ptype { let array = vx_array::as_ref(array); // TODO(joe): propagate this error up instead of expecting + #[allow(clippy::disallowed_methods)] let value = array - .execute_scalar(index, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_scalar(index, &mut legacy_session().create_execution_ctx()) .vortex_expect("scalar_at failed"); // TODO(joe): propagate this error up instead of expecting value.as_primitive() @@ -407,8 +411,9 @@ macro_rules! ffiarray_get_ptype { pub unsafe extern "C-unwind" fn [](array: *const vx_array, index: usize) -> $ptype { let array = vx_array::as_ref(array); // TODO(joe): propagate this error up instead of expecting + #[allow(clippy::disallowed_methods)] let value = array - .execute_scalar(index, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_scalar(index, &mut legacy_session().create_execution_ctx()) .vortex_expect("scalar_at failed"); // TODO(joe): propagate this error up instead of expecting value.as_extension() @@ -436,6 +441,7 @@ ffiarray_get_ptype!(f64); /// Return the utf-8 string at `index` in the array. The pointer will be null if the value at `index` is null. /// The caller must free the returned pointer. #[unsafe(no_mangle)] +#[allow(clippy::disallowed_methods)] pub unsafe extern "C-unwind" fn vx_array_get_utf8( array: *const vx_array, index: u32, @@ -443,7 +449,7 @@ pub unsafe extern "C-unwind" fn vx_array_get_utf8( let array = vx_array::as_ref(array); // TODO(joe): propagate this error up instead of expecting let value = array - .execute_scalar(index as usize, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_scalar(index as usize, &mut legacy_session().create_execution_ctx()) .vortex_expect("scalar_at failed"); let utf8_scalar = value.as_utf8(); if let Some(buffer) = utf8_scalar.value() { @@ -456,6 +462,7 @@ pub unsafe extern "C-unwind" fn vx_array_get_utf8( /// Return the binary at `index` in the array. The pointer will be null if the value at `index` is null. /// The caller must free the returned pointer. #[unsafe(no_mangle)] +#[allow(clippy::disallowed_methods)] pub unsafe extern "C-unwind" fn vx_array_get_binary( array: *const vx_array, index: u32, @@ -463,7 +470,7 @@ pub unsafe extern "C-unwind" fn vx_array_get_binary( let array = vx_array::as_ref(array); // TODO(joe): propagate this error up instead of expecting let value = array - .execute_scalar(index as usize, &mut LEGACY_SESSION.create_execution_ctx()) + .execute_scalar(index as usize, &mut legacy_session().create_execution_ctx()) .vortex_expect("scalar_at failed"); let binary_scalar = value.as_binary(); if let Some(bytes) = binary_scalar.value() { diff --git a/vortex-layout/src/layouts/file_stats.rs b/vortex-layout/src/layouts/file_stats.rs index 983f9e59a58..580489d0d68 100644 --- a/vortex-layout/src/layouts/file_stats.rs +++ b/vortex-layout/src/layouts/file_stats.rs @@ -11,7 +11,6 @@ use parking_lot::Mutex; use vortex_array::ArrayRef; use vortex_array::ExecutionCtx; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::aggregate_fn::fns::sum::sum; use vortex_array::arrays::ConstantArray; @@ -26,6 +25,7 @@ use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; use vortex_array::expr::stats::Precision; use vortex_array::expr::stats::Stat; +use vortex_array::legacy_session; use vortex_array::scalar::Scalar; use vortex_array::scalar::ScalarTruncation; use vortex_array::scalar::lower_bound; @@ -236,8 +236,9 @@ struct NamedArrays { } impl NamedArrays { + #[allow(clippy::disallowed_methods)] fn all_invalid(&self) -> VortexResult { - self.arrays[0].all_invalid(&mut LEGACY_SESSION.create_execution_ctx()) + self.arrays[0].all_invalid(&mut legacy_session().create_execution_ctx()) } } diff --git a/vortex-layout/src/layouts/zoned/builder.rs b/vortex-layout/src/layouts/zoned/builder.rs index bbe359f7485..8c44113e3e1 100644 --- a/vortex-layout/src/layouts/zoned/builder.rs +++ b/vortex-layout/src/layouts/zoned/builder.rs @@ -8,7 +8,6 @@ use std::sync::Arc; use itertools::Itertools; use vortex_array::ArrayRef; use vortex_array::ExecutionCtx; -use vortex_array::LEGACY_SESSION; use vortex_array::VortexSessionExecute; use vortex_array::aggregate_fn::AggregateFnRef; use vortex_array::arrays::StructArray; @@ -16,6 +15,7 @@ use vortex_array::builders::ArrayBuilder; use vortex_array::builders::builder_with_capacity; use vortex_array::dtype::DType; use vortex_array::dtype::FieldName; +use vortex_array::legacy_session; use vortex_array::scalar::Scalar; use vortex_array::validity::Validity; use vortex_error::VortexResult; @@ -153,8 +153,9 @@ struct NamedArrays { } impl NamedArrays { + #[allow(clippy::disallowed_methods)] fn all_invalid(&self) -> VortexResult { // By convention the first array is the logical validity signal for the stat column. - self.arrays[0].all_invalid(&mut LEGACY_SESSION.create_execution_ctx()) + self.arrays[0].all_invalid(&mut legacy_session().create_execution_ctx()) } } diff --git a/vortex-python/src/iter/mod.rs b/vortex-python/src/iter/mod.rs index 89caab3e90f..bb054a01edc 100644 --- a/vortex-python/src/iter/mod.rs +++ b/vortex-python/src/iter/mod.rs @@ -20,12 +20,12 @@ use pyo3::prelude::*; use pyo3::types::PyIterator; use vortex::array::Canonical; use vortex::array::IntoArray; -use vortex::array::LEGACY_SESSION; use vortex::array::VortexSessionExecute; use vortex::array::arrow::ArrowSessionExt; use vortex::array::iter::ArrayIterator; use vortex::array::iter::ArrayIteratorAdapter; use vortex::array::iter::ArrayIteratorExt; +use vortex::array::legacy_session; use vortex::dtype::DType; use crate::arrays::PyArrayRef; @@ -114,6 +114,7 @@ impl PyArrayIterator { /// Convert the :class:`vortex.ArrayIterator` into a :class:`pyarrow.RecordBatchReader`. /// /// Note that this performs the conversion on the current thread. + #[allow(clippy::disallowed_methods)] fn to_arrow(slf: Bound) -> PyVortexResult> { let schema = Arc::new(slf.get().dtype().to_arrow_schema()?); let target = Field::new_struct("", schema.fields().clone(), false); @@ -132,7 +133,7 @@ impl PyArrayIterator { session().arrow().execute_arrow( chunk?, Some(&target), - &mut LEGACY_SESSION.create_execution_ctx(), + &mut legacy_session().create_execution_ctx(), ) }) .map(|chunk| chunk.map_err(|e| ArrowError::ExternalError(Box::new(e)))) From 5172987649dc0ed93a60a0fd8b99028853350714 Mon Sep 17 00:00:00 2001 From: Joe Isaacs Date: Thu, 2 Jul 2026 22:52:50 +0100 Subject: [PATCH 008/104] perf: branchless mask-select for listview zip (#8264) Replaces the per-element, data-dependent branch in the listview zip kernel's offset/size selection with a branchless, chunk-at-a-time mask select that the compiler can auto-vectorize. For each 64-bit mask chunk, each bit is expanded to a full-width lane mask and both sides are blended with `(t & m) | (f & !m)` via a shared `select_column` helper, so the inner loop is branch-free regardless of mask shape. `if_false` offsets are shifted into the second half of the concatenated `elements` as before; sizes are taken verbatim from the chosen side. --- vortex-array/Cargo.toml | 4 + vortex-array/benches/listview_zip.rs | 86 ++++++ .../src/arrays/listview/compute/zip.rs | 251 ++++++++++++++++-- 3 files changed, 323 insertions(+), 18 deletions(-) create mode 100644 vortex-array/benches/listview_zip.rs diff --git a/vortex-array/Cargo.toml b/vortex-array/Cargo.toml index 98de7caf95d..c83397b50d8 100644 --- a/vortex-array/Cargo.toml +++ b/vortex-array/Cargo.toml @@ -216,6 +216,10 @@ harness = false name = "primitive_zip" harness = false +[[bench]] +name = "listview_zip" +harness = false + [[bench]] name = "take_primitive" harness = false diff --git a/vortex-array/benches/listview_zip.rs b/vortex-array/benches/listview_zip.rs new file mode 100644 index 00000000000..28db66f04a9 --- /dev/null +++ b/vortex-array/benches/listview_zip.rs @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#![expect(clippy::unwrap_used)] + +use divan::Bencher; +use vortex_array::ArrayRef; +use vortex_array::IntoArray; +use vortex_array::LEGACY_SESSION; +use vortex_array::RecursiveCanonical; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::ListViewArray; +use vortex_array::builtins::ArrayBuiltins; +use vortex_array::validity::Validity; +use vortex_buffer::BufferMut; +use vortex_mask::Mask; + +fn main() { + divan::main(); +} + +// Smaller than the value-path benches: listview zip cost is dominated by element concatenation and +// per-list canonicalization. A few thousand lists already exercise the select while keeping each +// case well under a few hundred microseconds under CodSpeed's instruction-count simulation, which +// runs ~10x the local walltime. +const LEN: usize = 4_096; + +/// Fragmented (alternating) mask: the worst case for the per-element branch this kernel replaces. +/// The branchless chunked select is mask-shape-independent, so one shape suffices. +fn mask() -> Mask { + Mask::from_iter((0..LEN).map(|i| i.is_multiple_of(2))) +} + +#[divan::bench] +fn nonnull(bencher: Bencher) { + run(bencher, list_view(0, false), list_view(1_000_000, false)); +} + +#[divan::bench] +fn nullable(bencher: Bencher) { + run(bencher, list_view(0, true), list_view(1_000_000, true)); +} + +fn run(bencher: Bencher, if_true: ArrayRef, if_false: ArrayRef) { + let mask = mask(); + bencher + .with_inputs(|| { + ( + if_true.clone(), + if_false.clone(), + mask.clone().into_array(), + LEGACY_SESSION.create_execution_ctx(), + ) + }) + .bench_refs(|(t, f, m, ctx)| { + m.zip(t.clone(), f.clone()) + .unwrap() + .execute::(ctx) + .unwrap(); + }); +} + +/// `LEN` single-element lists: `list[i] = [base + i]`. When `nullable`, every 7th list is null +/// (list-level validity backed by a `BoolArray`), exercising the `zip_validity` path. +fn list_view(base: i64, nullable: bool) -> ArrayRef { + let mut elements = BufferMut::::with_capacity(LEN); + elements.extend((0..LEN as i64).map(|i| base + i)); + let offsets: BufferMut = (0..LEN as u64).collect(); + let sizes: BufferMut = std::iter::repeat_n(1u64, LEN).collect(); + + let validity = if nullable { + Validity::Array(BoolArray::from_iter((0..LEN).map(|i| !i.is_multiple_of(7))).into_array()) + } else { + Validity::NonNullable + }; + + ListViewArray::try_new( + elements.freeze().into_array(), + offsets.freeze().into_array(), + sizes.freeze().into_array(), + validity, + ) + .unwrap() + .into_array() +} diff --git a/vortex-array/src/arrays/listview/compute/zip.rs b/vortex-array/src/arrays/listview/compute/zip.rs index 46537f1fafe..6007113300c 100644 --- a/vortex-array/src/arrays/listview/compute/zip.rs +++ b/vortex-array/src/arrays/listview/compute/zip.rs @@ -1,12 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +use std::mem::MaybeUninit; use std::ops::BitAnd; use std::ops::BitOr; use std::ops::Not; use vortex_buffer::Buffer; use vortex_buffer::BufferMut; +use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_mask::Mask; @@ -85,24 +87,65 @@ impl ZipKernel for ListView { let mut offsets = BufferMut::::with_capacity(len); let mut sizes = BufferMut::::with_capacity(len); - for ((idx, (out_offsets, out_sizes)), selected) in offsets - .spare_capacity_mut() - .iter_mut() - .zip(sizes.spare_capacity_mut().iter_mut()) - .take(len) - .enumerate() - .zip(mask.iter()) { - if selected { - out_offsets.write(true_offsets[idx]); - out_sizes.write(true_sizes[idx]); - } else { - out_offsets.write(false_offsets[idx] + false_shift); - out_sizes.write(false_sizes[idx]); + let true_offsets = true_offsets.as_slice(); + let true_sizes = true_sizes.as_slice(); + let false_offsets = false_offsets.as_slice(); + let false_sizes = false_sizes.as_slice(); + + let offsets_out = offsets.spare_capacity_mut(); + let sizes_out = sizes.spare_capacity_mut(); + + // We matched `Mask::Values` above, so the bit buffer is materialized. `unaligned_chunks` + // iterates faster than `chunks`: it exposes the byte-aligned body as a plain `&[u64]` + // with no per-word reshifting, isolating any bit misalignment into a leading `prefix` + // and trailing `suffix` word. We blend both sides branchlessly per row so the compiler + // vectorizes the inner select instead of mispredicting a data-dependent branch. + let mask_bits = mask + .values() + .vortex_expect("mask is Mask::Values") + .bit_buffer(); + let unaligned = mask_bits.unaligned_chunks(); + // The prefix word's low `lead` bits are padding; shifting them out aligns row 0 to bit 0, + // after which every chunk and the suffix start cleanly on a row boundary. + let lead = unaligned.lead_padding(); + + let mut select_block = |word: u64, base: usize, n: usize| { + let end = base + n; + // `if_false` views address the second half of the concatenated elements, so shift + // their offsets by `false_shift`; sizes are taken verbatim from the chosen side. + select_column( + word, + &true_offsets[base..end], + &false_offsets[base..end], + false_shift, + &mut offsets_out[base..end], + ); + select_column( + word, + &true_sizes[base..end], + &false_sizes[base..end], + 0, + &mut sizes_out[base..end], + ); + }; + + let mut base = 0; + if let Some(prefix) = unaligned.prefix() { + let n = (64 - lead).min(len); + select_block(prefix >> lead, base, n); + base += n; + } + for &word in unaligned.chunks() { + select_block(word, base, 64); + base += 64; + } + if let Some(suffix) = unaligned.suffix() { + select_block(suffix, base, len - base); } } - // SAFETY: the loop above initialized exactly `len` slots in both buffers. + // SAFETY: `select_column` initialized exactly `len` slots in both buffers. unsafe { offsets.set_len(len); sizes.set_len(len); @@ -122,6 +165,30 @@ impl ZipKernel for ListView { } } +/// Branchlessly select one `u64` column per row from `if_true` or `if_false`. +/// +/// `word` holds the mask bits for this block, bit `j` (LSB-first) selecting row `j`: a set bit keeps +/// `true_vals[j]`, an unset bit keeps `false_vals[j] + false_add`. The bit is expanded to a +/// full-width lane mask and blended, so the inner loop is branch-free and auto-vectorizable. Inputs +/// are sliced to the output length up front so the compiler can elide bounds checks across the block. +#[inline] +fn select_column( + word: u64, + true_vals: &[u64], + false_vals: &[u64], + false_add: u64, + out: &mut [MaybeUninit], +) { + let n = out.len(); + let true_vals = &true_vals[..n]; + let false_vals = &false_vals[..n]; + for j in 0..n { + // 0 for an unset bit, `u64::MAX` for a set bit. + let lane = 0u64.wrapping_sub((word >> j) & 1); + out[j].write((true_vals[j] & lane) | ((false_vals[j] + false_add) & !lane)); + } +} + /// Appends `array`'s element chunks to `chunks`, flattening a top-level [`ChunkedArray`] so the /// concatenated elements never nest chunked arrays. fn push_element_chunks(array: ArrayRef, chunks: &mut Vec) { @@ -164,6 +231,12 @@ fn zip_validity( #[cfg(test)] mod tests { + #![allow( + clippy::cast_possible_truncation, + reason = "test fixtures use small indices that fit the target widths" + )] + + use vortex_buffer::Buffer; use vortex_buffer::buffer; use vortex_error::VortexResult; use vortex_mask::Mask; @@ -200,6 +273,7 @@ mod tests { /// `zip` of two list views selects whole lists per the mask and keeps the list encoding. #[test] fn zip_selects_lists() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); // [[1, 2], [3], [4, 5, 6]] let if_true = list_view( buffer![1i32, 2, 3, 4, 5, 6].into_array(), @@ -216,7 +290,6 @@ mod tests { ); let mask = Mask::from_iter([true, false, true]); - let mut ctx = array_session().create_execution_ctx(); let result = mask .into_array() .zip(if_true, if_false)? @@ -239,6 +312,7 @@ mod tests { /// `zip` selects list-level validity from the chosen side and widens nullability. #[test] fn zip_selects_validity() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); // [[1], null, [2]] (list-level nulls) let if_true = list_view( buffer![1i32, 2].into_array(), @@ -256,7 +330,6 @@ mod tests { // true -> if_true, false -> if_false let mask = Mask::from_iter([false, true, true]); - let mut ctx = array_session().create_execution_ctx(); let result = mask .into_array() .zip(if_true, if_false)? @@ -277,6 +350,7 @@ mod tests { /// is nullable. #[test] fn zip_out_of_order_offsets_and_widening() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); // [[5, 6], [7], [8, 9]] expressed with out-of-order offsets. let if_true = list_view( buffer![7i32, 8, 9, 5, 6].into_array(), @@ -293,7 +367,6 @@ mod tests { ); let mask = Mask::from_iter([true, true, false]); - let mut ctx = array_session().create_execution_ctx(); let result = mask .into_array() .zip(if_true, if_false)? @@ -311,10 +384,153 @@ mod tests { Ok(()) } + /// Zipping more rows than fit in a single 64-bit mask chunk exercises both the chunked select + /// loop and the trailing remainder, including the `false_shift` applied to `if_false` views. + #[test] + fn zip_spans_multiple_mask_chunks() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + // 130 single-element lists per side: `if_true[i] = [i]`, `if_false[i] = [1000 + i]`. + let len = 130usize; + let true_elements: Vec = (0..len as i32).collect(); + let false_elements: Vec = (0..len as i32).map(|i| 1000 + i).collect(); + let offsets: Vec = (0..len as u64).collect(); + let sizes: Vec = vec![1; len]; + + let if_true = list_view( + true_elements + .iter() + .copied() + .collect::>() + .into_array(), + offsets + .iter() + .copied() + .collect::>() + .into_array(), + sizes.iter().copied().collect::>().into_array(), + Validity::NonNullable, + ); + let if_false = list_view( + false_elements + .iter() + .copied() + .collect::>() + .into_array(), + offsets + .iter() + .copied() + .collect::>() + .into_array(), + sizes.iter().copied().collect::>().into_array(), + Validity::NonNullable, + ); + + // A non-trivial pattern that straddles the chunk boundary (index 63/64) and the remainder. + let mask_bits: Vec = (0..len).map(|i| i.is_multiple_of(3) || i == 64).collect(); + let mask = Mask::from_iter(mask_bits.iter().copied()); + + let result = mask + .into_array() + .zip(if_true, if_false)? + .execute::(&mut ctx)?; + assert!(result.is::()); + + // Each row collapses to a single element: `i` when the mask is set, else `1000 + i`. + let expected_elements: Vec = (0..len) + .map(|i| { + if mask_bits[i] { + i as i32 + } else { + 1000 + i as i32 + } + }) + .collect(); + let expected = list_view( + expected_elements + .iter() + .copied() + .collect::>() + .into_array(), + offsets + .iter() + .copied() + .collect::>() + .into_array(), + sizes.iter().copied().collect::>().into_array(), + Validity::NonNullable, + ); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + + /// A mask whose bit buffer starts at a non-byte-aligned offset (here from slicing a bool array) + /// has non-zero `unaligned_chunks` lead padding, exercising the prefix word alongside the + /// aligned chunk body and the suffix. + #[test] + fn zip_handles_offset_mask() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + // 200 single-element lists per side: `if_true[i] = [i]`, `if_false[i] = [1000 + i]`. With a + // 3-bit lead offset the mask spans more than 16 bytes, so `unaligned_chunks` exposes a + // non-empty aligned `chunks` body between the prefix and suffix words. + let len = 200usize; + let true_elements: Vec = (0..len as i32).collect(); + let false_elements: Vec = (0..len as i32).map(|i| 1000 + i).collect(); + let offsets: Vec = (0..len as u64).collect(); + let sizes: Vec = vec![1; len]; + + let single_element_view = |elements: &[i32]| { + list_view( + elements + .iter() + .copied() + .collect::>() + .into_array(), + offsets + .iter() + .copied() + .collect::>() + .into_array(), + sizes.iter().copied().collect::>().into_array(), + Validity::NonNullable, + ) + }; + let if_true = single_element_view(&true_elements); + let if_false = single_element_view(&false_elements); + + // Slice off the first `offset` bits so the mask's bit buffer keeps a sub-byte offset while + // remaining `len` rows long. A non-trivial pattern straddles the prefix/body and chunk + // boundaries within the sliced window. + let offset = 3usize; + let mask_bits: Vec = (0..offset + len) + .map(|i| i.is_multiple_of(3) || i == offset + 64) + .collect(); + let mask = BoolArray::from_iter(mask_bits.iter().copied()) + .into_array() + .slice(offset..offset + len)?; + + let result = mask.zip(if_true, if_false)?.execute::(&mut ctx)?; + assert!(result.is::()); + + // Each row collapses to a single element: `i` when the sliced mask is set, else `1000 + i`. + let expected_elements: Vec = (0..len) + .map(|i| { + if mask_bits[offset + i] { + i as i32 + } else { + 1000 + i as i32 + } + }) + .collect(); + let expected = single_element_view(&expected_elements); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + /// When an input's `elements` is already a [`ChunkedArray`], its chunks are spliced in rather /// than nesting a chunked array inside the concatenated elements. #[test] fn zip_flattens_chunked_elements() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); // elements [1, 2, 3] stored as two chunks; lists [[1, 2], [3]]. let chunked_elements = ChunkedArray::try_new( vec![buffer![1i32, 2].into_array(), buffer![3i32].into_array()], @@ -336,7 +552,6 @@ mod tests { ); let mask = Mask::from_iter([true, false]); - let mut ctx = array_session().create_execution_ctx(); let result = mask .into_array() .zip(if_true, if_false)? From ee2cd67b6bde05acde3b15a9114f2f4d1e56ca1b Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Thu, 2 Jul 2026 23:16:11 +0100 Subject: [PATCH 009/104] Remove variants module (#8643) This module was never fully fleshed out and we have different ways to accomplish same things. This also migrates the PValue search sorted to be purely primitive based --- .../src/decimal_byte_parts/compute/compare.rs | 5 +- encodings/runend/src/array.rs | 21 +- encodings/runend/src/arrow.rs | 38 +-- encodings/runend/src/kernel.rs | 4 +- encodings/runend/src/ops.rs | 33 ++- vortex-array/benches/listview_zip.rs | 9 +- vortex-array/src/arrays/bool/compute/zip.rs | 2 +- .../src/arrays/listview/compute/zip.rs | 2 +- .../src/arrays/primitive/compute/zip.rs | 2 +- vortex-array/src/arrays/primitive/tests.rs | 14 +- .../src/arrays/struct_/compute/zip.rs | 6 +- .../src/arrays/varbinview/compute/zip.rs | 2 +- vortex-array/src/compute/conformance/cast.rs | 2 +- vortex-array/src/lib.rs | 1 - vortex-array/src/patches.rs | 68 ++--- vortex-array/src/scalar_fn/fns/merge.rs | 4 +- .../mod.rs} | 3 + vortex-array/src/search_sorted/primitive.rs | 137 ++++++++++ vortex-array/src/variants.rs | 241 ------------------ 19 files changed, 254 insertions(+), 340 deletions(-) rename vortex-array/src/{search_sorted.rs => search_sorted/mod.rs} (99%) create mode 100644 vortex-array/src/search_sorted/primitive.rs delete mode 100644 vortex-array/src/variants.rs diff --git a/encodings/decimal-byte-parts/src/decimal_byte_parts/compute/compare.rs b/encodings/decimal-byte-parts/src/decimal_byte_parts/compute/compare.rs index 04f321cf88f..af6e98bddc2 100644 --- a/encodings/decimal-byte-parts/src/decimal_byte_parts/compute/compare.rs +++ b/encodings/decimal-byte-parts/src/decimal_byte_parts/compute/compare.rs @@ -47,10 +47,7 @@ impl CompareKernel for DecimalByteParts { .decimal_value() .vortex_expect("checked for null in entry func"); - match decimal_value_wrapper_to_primitive( - rhs_decimal, - lhs.msp().as_primitive_typed().ptype(), - ) { + match decimal_value_wrapper_to_primitive(rhs_decimal, lhs.msp().dtype().as_ptype()) { Ok(value) => { let encoded_scalar = Scalar::try_new(scalar_type, Some(value))?; let encoded_const = ConstantArray::new(encoded_scalar, rhs.len()); diff --git a/encodings/runend/src/array.rs b/encodings/runend/src/array.rs index 9dba648e248..637947bba53 100644 --- a/encodings/runend/src/array.rs +++ b/encodings/runend/src/array.rs @@ -28,9 +28,6 @@ use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; use vortex_array::legacy_session; -use vortex_array::scalar::PValue; -use vortex_array::search_sorted::SearchSorted; -use vortex_array::search_sorted::SearchSortedSide; use vortex_array::serde::ArrayChildren; use vortex_array::smallvec::smallvec; use vortex_array::validity::Validity; @@ -48,6 +45,8 @@ use crate::compress::runend_decode_primitive; use crate::compress::runend_decode_varbinview; use crate::compress::runend_encode; use crate::decompress_bool::runend_decode_bools; +use crate::ops::find_physical_index; +use crate::ops::find_slice_end_index; use crate::rules::RULES; /// A [`RunEnd`]-encoded Vortex array. @@ -230,17 +229,15 @@ pub trait RunEndArrayExt: TypedArrayRef { self.values().dtype() } - fn find_physical_index(&self, index: usize) -> VortexResult { - Ok(self - .ends() - .as_primitive_typed() - .search_sorted( - &PValue::from(index + self.offset()), - SearchSortedSide::Right, - )? - .to_ends_index(self.ends().len())) + fn find_physical_index(&self, index: usize, ctx: &mut ExecutionCtx) -> VortexResult { + find_physical_index(self.ends(), index + self.offset(), ctx) + } + + fn find_slice_end_index(&self, index: usize, ctx: &mut ExecutionCtx) -> VortexResult { + find_slice_end_index(self.ends(), index + self.offset(), ctx) } } + impl> RunEndArrayExt for T {} #[derive(Clone, Debug)] diff --git a/encodings/runend/src/arrow.rs b/encodings/runend/src/arrow.rs index c927daaaffe..1f58e21bef2 100644 --- a/encodings/runend/src/arrow.rs +++ b/encodings/runend/src/arrow.rs @@ -11,14 +11,12 @@ use vortex_array::arrays::primitive::PrimitiveArrayExt; use vortex_array::arrow::FromArrowArray; use vortex_array::dtype::NativePType; use vortex_array::legacy_session; -use vortex_array::scalar::PValue; -use vortex_array::search_sorted::SearchSorted; -use vortex_array::search_sorted::SearchSortedSide; use vortex_array::validity::Validity; use vortex_buffer::Buffer; use vortex_error::VortexResult; use crate::RunEndData; +use crate::ops::find_physical_index; use crate::ops::find_slice_end_index; impl FromArrowArray<&RunArray> for RunEndData @@ -41,14 +39,13 @@ where ends.validity()?, ) .into_array(); + let mut ctx = legacy_session().create_execution_ctx(); + let (ends_slice, values_slice) = if offset == 0 && len == array.run_ends().max_value() { (ends_array, values) } else { - let slice_begin = ends_array - .as_primitive_typed() - .search_sorted(&PValue::from(offset), SearchSortedSide::Right)? - .to_ends_index(ends_array.len()); - let slice_end = find_slice_end_index(&ends_array, offset + len)?; + let slice_begin = find_physical_index(&ends_array, offset, &mut ctx)?; + let slice_end = find_slice_end_index(&ends_array, offset + len, &mut ctx)?; ( ends_array.slice(slice_begin..slice_end)?, @@ -57,8 +54,6 @@ where }; // SAFETY: arrow-rs enforces the RunEndArray invariants, we inherit their guarantees. - // TODO(ctx): trait fixes - FromArrowArray::from_arrow has a fixed signature. - let mut ctx = legacy_session().create_execution_ctx(); RunEndData::validate_parts(&ends_slice, &values_slice, offset, len, &mut ctx)?; Ok(unsafe { RunEndData::new_unchecked(offset) }) } @@ -92,9 +87,6 @@ mod tests { use vortex_array::dtype::NativePType; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; - use vortex_array::scalar::PValue; - use vortex_array::search_sorted::SearchSorted; - use vortex_array::search_sorted::SearchSortedSide; use vortex_array::validity::Validity; use vortex_buffer::Buffer; use vortex_buffer::buffer; @@ -102,6 +94,8 @@ mod tests { use vortex_session::VortexSession; use crate::RunEnd; + use crate::RunEndArray; + use crate::ops::find_physical_index; use crate::ops::find_slice_end_index; static SESSION: LazyLock = LazyLock::new(|| { @@ -113,7 +107,7 @@ mod tests { fn decode_run_array( array: &RunArray, nullable: bool, - ) -> VortexResult + ) -> VortexResult where R::Native: NativePType, { @@ -131,14 +125,12 @@ mod tests { ends.validity()?, ) .into_array(); + let mut ctx = SESSION.create_execution_ctx(); let (ends_slice, values_slice) = if offset == 0 && len == array.run_ends().max_value() { (ends_array, values) } else { - let slice_begin = ends_array - .as_primitive_typed() - .search_sorted(&PValue::from(offset), SearchSortedSide::Right)? - .to_ends_index(ends_array.len()); - let slice_end = find_slice_end_index(&ends_array, offset + len)?; + let slice_begin = find_physical_index(&ends_array, offset, &mut ctx)?; + let slice_end = find_slice_end_index(&ends_array, offset + len, &mut ctx)?; ( ends_array.slice(slice_begin..slice_end)?, @@ -146,13 +138,7 @@ mod tests { ) }; - RunEnd::try_new_offset_length( - ends_slice, - values_slice, - offset, - array.len(), - &mut SESSION.create_execution_ctx(), - ) + RunEnd::try_new_offset_length(ends_slice, values_slice, offset, array.len(), &mut ctx) } #[test] diff --git a/encodings/runend/src/kernel.rs b/encodings/runend/src/kernel.rs index 484a4233789..13f32df07a1 100644 --- a/encodings/runend/src/kernel.rs +++ b/encodings/runend/src/kernel.rs @@ -63,8 +63,8 @@ fn slice( ) -> VortexResult { let new_length = range.len(); - let slice_begin = array.find_physical_index(range.start)?; - let slice_end = crate::ops::find_slice_end_index(array.ends(), range.end + array.offset())?; + let slice_begin = array.find_physical_index(range.start, ctx)?; + let slice_end = array.find_slice_end_index(range.end, ctx)?; // If the sliced range contains only a single run, opt to return a ConstantArray. if slice_begin + 1 == slice_end { diff --git a/encodings/runend/src/ops.rs b/encodings/runend/src/ops.rs index 56de024f988..c929f3d4251 100644 --- a/encodings/runend/src/ops.rs +++ b/encodings/runend/src/ops.rs @@ -4,10 +4,11 @@ use vortex_array::ArrayRef; use vortex_array::ArrayView; use vortex_array::ExecutionCtx; -use vortex_array::scalar::PValue; +use vortex_array::match_each_unsigned_integer_ptype; use vortex_array::scalar::Scalar; use vortex_array::search_sorted::SearchResult; use vortex_array::search_sorted::SearchSorted; +use vortex_array::search_sorted::SearchSortedPrimitiveArray; use vortex_array::search_sorted::SearchSortedSide; use vortex_array::vtable::OperationsVTable; use vortex_error::VortexResult; @@ -21,9 +22,8 @@ impl OperationsVTable for RunEnd { index: usize, ctx: &mut ExecutionCtx, ) -> VortexResult { - array - .values() - .execute_scalar(array.find_physical_index(index)?, ctx) + let physical_index = array.find_physical_index(index, ctx)?; + array.values().execute_scalar(physical_index, ctx) } } @@ -31,10 +31,15 @@ impl OperationsVTable for RunEnd { /// /// If the index exists in the array we want to take that position (as we are searching from the right) /// otherwise we want to take the next one -pub(crate) fn find_slice_end_index(array: &ArrayRef, index: usize) -> VortexResult { - let result = array - .as_primitive_typed() - .search_sorted(&PValue::from(index), SearchSortedSide::Right)?; +pub(crate) fn find_slice_end_index( + array: &ArrayRef, + index: usize, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let result = match_each_unsigned_integer_ptype!(array.dtype().as_ptype(), |T| { + SearchSortedPrimitiveArray::::new(array, ctx) + .search_sorted(&index, SearchSortedSide::Right)? + }); Ok(match result { SearchResult::Found(i) => i, SearchResult::NotFound(i) => { @@ -47,6 +52,18 @@ pub(crate) fn find_slice_end_index(array: &ArrayRef, index: usize) -> VortexResu }) } +pub(crate) fn find_physical_index( + array: &ArrayRef, + index: usize, + ctx: &mut ExecutionCtx, +) -> VortexResult { + match_each_unsigned_integer_ptype!(array.dtype().as_ptype(), |T| { + Ok(SearchSortedPrimitiveArray::::new(array, ctx) + .search_sorted(&index, SearchSortedSide::Right)? + .to_ends_index(array.len())) + }) +} + #[cfg(test)] mod tests { use std::sync::LazyLock; diff --git a/vortex-array/benches/listview_zip.rs b/vortex-array/benches/listview_zip.rs index 28db66f04a9..c50180a0e1a 100644 --- a/vortex-array/benches/listview_zip.rs +++ b/vortex-array/benches/listview_zip.rs @@ -3,23 +3,28 @@ #![expect(clippy::unwrap_used)] +use std::sync::LazyLock; + use divan::Bencher; use vortex_array::ArrayRef; use vortex_array::IntoArray; -use vortex_array::LEGACY_SESSION; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::BoolArray; use vortex_array::arrays::ListViewArray; use vortex_array::builtins::ArrayBuiltins; use vortex_array::validity::Validity; use vortex_buffer::BufferMut; use vortex_mask::Mask; +use vortex_session::VortexSession; fn main() { divan::main(); } +static SESSION: LazyLock = LazyLock::new(array_session); + // Smaller than the value-path benches: listview zip cost is dominated by element concatenation and // per-list canonicalization. A few thousand lists already exercise the select while keeping each // case well under a few hundred microseconds under CodSpeed's instruction-count simulation, which @@ -50,7 +55,7 @@ fn run(bencher: Bencher, if_true: ArrayRef, if_false: ArrayRef) { if_true.clone(), if_false.clone(), mask.clone().into_array(), - LEGACY_SESSION.create_execution_ctx(), + SESSION.create_execution_ctx(), ) }) .bench_refs(|(t, f, m, ctx)| { diff --git a/vortex-array/src/arrays/bool/compute/zip.rs b/vortex-array/src/arrays/bool/compute/zip.rs index 80e7de80c82..ad311572395 100644 --- a/vortex-array/src/arrays/bool/compute/zip.rs +++ b/vortex-array/src/arrays/bool/compute/zip.rs @@ -34,7 +34,7 @@ impl ZipKernel for Bool { }; // Null mask entries select `if_false`, matching `Zip`'s SQL ELSE semantics. - let mask = mask.try_to_mask_fill_null_false(ctx)?; + let mask = mask.clone().null_as_false().execute(ctx)?; let mask_values = match &mask { // Defer trivial masks to the generic zip, which just casts the surviving side. Mask::AllTrue(_) | Mask::AllFalse(_) => return Ok(None), diff --git a/vortex-array/src/arrays/listview/compute/zip.rs b/vortex-array/src/arrays/listview/compute/zip.rs index 6007113300c..71640d4fb21 100644 --- a/vortex-array/src/arrays/listview/compute/zip.rs +++ b/vortex-array/src/arrays/listview/compute/zip.rs @@ -50,7 +50,7 @@ impl ZipKernel for ListView { }; // Null mask entries select `if_false`, matching `Zip`'s SQL ELSE semantics. - let mask = mask.try_to_mask_fill_null_false(ctx)?; + let mask = mask.clone().null_as_false().execute(ctx)?; match &mask { // Defer the trivial masks to the generic zip, which just casts one side. Mask::AllTrue(_) | Mask::AllFalse(_) => return Ok(None), diff --git a/vortex-array/src/arrays/primitive/compute/zip.rs b/vortex-array/src/arrays/primitive/compute/zip.rs index 83f03b10614..35e49831acc 100644 --- a/vortex-array/src/arrays/primitive/compute/zip.rs +++ b/vortex-array/src/arrays/primitive/compute/zip.rs @@ -46,7 +46,7 @@ impl ZipKernel for Primitive { } // Null mask entries select `if_false`, matching `Zip`'s SQL ELSE semantics. - let mask = mask.try_to_mask_fill_null_false(ctx)?; + let mask = mask.clone().null_as_false().execute(ctx)?; match &mask { // Defer trivial masks to the generic zip, which just casts the surviving side. Mask::AllTrue(_) | Mask::AllFalse(_) => return Ok(None), diff --git a/vortex-array/src/arrays/primitive/tests.rs b/vortex-array/src/arrays/primitive/tests.rs index f623479c7b0..1f50c8edb1c 100644 --- a/vortex-array/src/arrays/primitive/tests.rs +++ b/vortex-array/src/arrays/primitive/tests.rs @@ -1,10 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +use std::sync::LazyLock; + use vortex_buffer::buffer; +use vortex_session::VortexSession; use crate::ArrayRef; use crate::IntoArray; +use crate::array_session; use crate::arrays::BoolArray; use crate::arrays::PrimitiveArray; use crate::compute::conformance::filter::test_filter_conformance; @@ -12,12 +16,15 @@ use crate::compute::conformance::mask::test_mask_conformance; use crate::compute::conformance::search_sorted::rstest_reuse::apply; use crate::compute::conformance::search_sorted::search_sorted_conformance; use crate::compute::conformance::search_sorted::*; -use crate::scalar::PValue; +use crate::executor::VortexSessionExecute; use crate::search_sorted::SearchResult; use crate::search_sorted::SearchSorted; +use crate::search_sorted::SearchSortedPrimitiveArray; use crate::search_sorted::SearchSortedSide; use crate::validity::Validity; +static SESSION: LazyLock = LazyLock::new(array_session); + #[apply(search_sorted_conformance)] fn test_search_sorted_primitive( #[case] array: ArrayRef, @@ -25,9 +32,8 @@ fn test_search_sorted_primitive( #[case] side: SearchSortedSide, #[case] expected: SearchResult, ) -> vortex_error::VortexResult<()> { - let res = array - .as_primitive_typed() - .search_sorted(&Some(PValue::from(value)), side)?; + let res = SearchSortedPrimitiveArray::::new(&array, &mut SESSION.create_execution_ctx()) + .search_sorted(&value, side)?; assert_eq!(res, expected); Ok(()) } diff --git a/vortex-array/src/arrays/struct_/compute/zip.rs b/vortex-array/src/arrays/struct_/compute/zip.rs index cf7b7b91d6d..394cc5c8424 100644 --- a/vortex-array/src/arrays/struct_/compute/zip.rs +++ b/vortex-array/src/arrays/struct_/compute/zip.rs @@ -37,7 +37,7 @@ impl ZipKernel for Struct { let fields = if_true .iter_unmasked_fields() .zip(if_false.iter_unmasked_fields()) - .map(|(t, f)| ArrayBuiltins::zip(mask, t.clone(), f.clone())) + .map(|(t, f)| mask.zip(t.clone(), f.clone())) .collect::>>()?; let v1 = if_true.validity()?; @@ -48,11 +48,11 @@ impl ZipKernel for Struct { (Validity::AllInvalid, Validity::AllInvalid) => Validity::AllInvalid, (v1, v2) => { - let mask_mask = mask.try_to_mask_fill_null_false(ctx)?; + let mask_mask = mask.clone().null_as_false().execute(ctx)?; let v1m = v1.execute_mask(if_true.len(), ctx)?; let v2m = v2.execute_mask(if_false.len(), ctx)?; - let combined = (v1m.bitand(&mask_mask)).bitor(&v2m.bitand(&mask_mask.not())); + let combined = v1m.bitand(&mask_mask).bitor(&v2m.bitand(&mask_mask.not())); Validity::from_mask( combined, if_true.dtype().nullability() | if_false.dtype().nullability(), diff --git a/vortex-array/src/arrays/varbinview/compute/zip.rs b/vortex-array/src/arrays/varbinview/compute/zip.rs index 75e30808821..193dc523823 100644 --- a/vortex-array/src/arrays/varbinview/compute/zip.rs +++ b/vortex-array/src/arrays/varbinview/compute/zip.rs @@ -57,7 +57,7 @@ impl ZipKernel for VarBinView { let true_validity = if_true.varbinview_validity().execute_mask(len, ctx)?; let false_validity = if_false.varbinview_validity().execute_mask(len, ctx)?; - let mask = mask.try_to_mask_fill_null_false(ctx)?; + let mask = mask.clone().null_as_false().execute(ctx)?; let if_false_view = if_false; match mask.slices() { AllOr::All => push_range( diff --git a/vortex-array/src/compute/conformance/cast.rs b/vortex-array/src/compute/conformance/cast.rs index c823b2a778a..922116f520a 100644 --- a/vortex-array/src/compute/conformance/cast.rs +++ b/vortex-array/src/compute/conformance/cast.rs @@ -216,7 +216,7 @@ fn test_cast_to_nullable(array: &ArrayRef) { } fn test_cast_from_floating_point_types(array: &ArrayRef) { - let ptype = array.as_primitive_typed().ptype(); + let ptype = array.dtype().as_ptype(); test_cast_to_primitive(array, PType::I8, false); test_cast_to_primitive(array, PType::U8, false); test_cast_to_primitive(array, PType::I16, false); diff --git a/vortex-array/src/lib.rs b/vortex-array/src/lib.rs index e219a929453..6456c412929 100644 --- a/vortex-array/src/lib.rs +++ b/vortex-array/src/lib.rs @@ -144,7 +144,6 @@ pub mod stream; #[cfg(any(test, feature = "_test-harness"))] pub mod test_harness; pub mod validity; -pub mod variants; pub mod flatbuffers { //! Re-exported autogenerated code from the core Vortex flatbuffer definitions. diff --git a/vortex-array/src/patches.rs b/vortex-array/src/patches.rs index 32d220797a6..f5d7b0e2d2a 100644 --- a/vortex-array/src/patches.rs +++ b/vortex-array/src/patches.rs @@ -36,12 +36,11 @@ use crate::dtype::Nullability::NonNullable; use crate::dtype::PType; use crate::dtype::UnsignedPType; use crate::legacy_session; -use crate::match_each_integer_ptype; use crate::match_each_unsigned_integer_ptype; -use crate::scalar::PValue; use crate::scalar::Scalar; use crate::search_sorted::SearchResult; use crate::search_sorted::SearchSorted; +use crate::search_sorted::SearchSortedPrimitiveArray; use crate::search_sorted::SearchSortedSide; use crate::validity::Validity; @@ -277,7 +276,6 @@ impl Patches { #[cfg(debug_assertions)] { - use crate::VortexSessionExecute; use crate::aggregate_fn::fns::is_sorted::is_sorted; let mut ctx = legacy_session().create_execution_ctx(); assert!( @@ -476,32 +474,7 @@ impl Patches { return self.search_index_chunked(index); } - Self::search_index_binary_search(&self.indices, index + self.offset) - } - - /// Binary searches for `needle` in the indices array. - /// - /// # Returns - /// [`SearchResult::Found`] with the position if needle exists, or [`SearchResult::NotFound`] - /// with the insertion point if not found. - fn search_index_binary_search(indices: &ArrayRef, needle: usize) -> VortexResult { - if let Some(primitive) = indices.as_opt::() { - match_each_integer_ptype!(primitive.ptype(), |T| { - let Ok(needle) = T::try_from(needle) else { - // If the needle is not of type T, then it cannot possibly be in this array. - // - // The needle is a non-negative integer (a usize); therefore, it must be larger - // than all values in this array. - return Ok(SearchResult::NotFound(primitive.len())); - }; - return primitive - .as_slice::() - .search_sorted(&needle, SearchSortedSide::Left); - }); - } - indices - .as_primitive_typed() - .search_sorted(&PValue::U64(needle as u64), SearchSortedSide::Left) + search_index_binary_search(&self.indices, index + self.offset) } /// Constant time searches for `index` in the indices array. @@ -549,7 +522,7 @@ impl Patches { }; let chunk_indices = self.indices.slice(patches_start_idx..patches_end_idx)?; - let result = Self::search_index_binary_search(&chunk_indices, index + self.offset)?; + let result = search_index_binary_search(&chunk_indices, index + self.offset)?; Ok(match result { SearchResult::Found(idx) => SearchResult::Found(patches_start_idx + idx), @@ -1010,6 +983,41 @@ impl Patches { } } +/// Binary searches for `needle` in the indices array. +/// +/// # Returns +/// [`SearchResult::Found`] with the position if needle exists, or [`SearchResult::NotFound`] +/// with the insertion point if not found. +fn search_index_binary_search(indices: &ArrayRef, needle: usize) -> VortexResult { + if let Some(primitive) = indices.as_opt::() { + match_each_unsigned_integer_ptype!(primitive.ptype(), |T| { + let Ok(needle) = T::try_from(needle) else { + // If the needle is not of type T, then it cannot possibly be in this array. + // + // The needle is a non-negative integer (a usize); therefore, it must be larger + // than all values in this array. + return Ok(SearchResult::NotFound(primitive.len())); + }; + return primitive + .as_slice::() + .search_sorted(&needle, SearchSortedSide::Left); + }); + } + + search_index_binary_search_scalar(indices, needle) +} + +#[allow(clippy::disallowed_methods)] +fn search_index_binary_search_scalar( + indices: &ArrayRef, + needle: usize, +) -> VortexResult { + match_each_unsigned_integer_ptype!(indices.dtype().as_ptype(), |T| { + SearchSortedPrimitiveArray::::new(indices, &mut legacy_session().create_execution_ctx()) + .search_sorted(&needle, SearchSortedSide::Left) + }) +} + #[expect(clippy::too_many_arguments)] // private function, can clean up one day fn take_map, T: NativePType>( indices: &[I], diff --git a/vortex-array/src/scalar_fn/fns/merge.rs b/vortex-array/src/scalar_fn/fns/merge.rs index c9f5783de27..23fedb5009b 100644 --- a/vortex-array/src/scalar_fn/fns/merge.rs +++ b/vortex-array/src/scalar_fn/fns/merge.rs @@ -364,7 +364,7 @@ mod tests { let actual_array = test_array.apply(&expr).unwrap(); assert_eq!( - actual_array.as_struct_typed().names(), + actual_array.dtype().as_struct_fields().names(), ["a", "b", "c", "d", "e"] ); @@ -450,7 +450,7 @@ mod tests { .into_array(); let actual_array = test_array.clone().apply(&expr).unwrap(); assert_eq!(actual_array.len(), test_array.len()); - assert_eq!(actual_array.as_struct_typed().nfields(), 0); + assert_eq!(actual_array.nchildren(), 0); } #[test] diff --git a/vortex-array/src/search_sorted.rs b/vortex-array/src/search_sorted/mod.rs similarity index 99% rename from vortex-array/src/search_sorted.rs rename to vortex-array/src/search_sorted/mod.rs index f2ceb5dc24d..cda27cfc511 100644 --- a/vortex-array/src/search_sorted.rs +++ b/vortex-array/src/search_sorted/mod.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +mod primitive; + use std::cmp::Ordering; use std::cmp::Ordering::Equal; use std::cmp::Ordering::Greater; @@ -10,6 +12,7 @@ use std::fmt::Display; use std::fmt::Formatter; use std::hint; +pub use primitive::*; use vortex_error::VortexResult; use crate::ArrayRef; diff --git a/vortex-array/src/search_sorted/primitive.rs b/vortex-array/src/search_sorted/primitive.rs new file mode 100644 index 00000000000..3da28c7be2b --- /dev/null +++ b/vortex-array/src/search_sorted/primitive.rs @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use std::cell::RefCell; +use std::cmp::Ordering; +use std::marker::PhantomData; + +use vortex_error::VortexResult; + +use crate::ArrayRef; +use crate::ExecutionCtx; +use crate::dtype::NativePType; +use crate::search_sorted::IndexOrd; + +/// A [`SearchSorted`](crate::search_sorted::SearchSorted) adapter over a sorted primitive-typed +/// array, comparing elements as the native type `T`. +/// +/// Values can be searched as `T`, `Option`, or `usize`. Searching as `T` or `usize` treats +/// null elements as `T::zero()`; use `Option` when the array may contain nulls, in which case +/// nulls sort before all non-null values. +pub struct SearchSortedPrimitiveArray<'a, T>( + &'a ArrayRef, + RefCell<&'a mut ExecutionCtx>, + PhantomData, +); + +impl<'a, T: NativePType> SearchSortedPrimitiveArray<'a, T> { + /// Wraps `array` for searching, panicking if the array's [`PType`](crate::dtype::PType) is + /// not `T::PTYPE`. + pub fn new(array: &'a ArrayRef, ctx: &'a mut ExecutionCtx) -> Self { + assert_eq!( + array.dtype().as_ptype(), + T::PTYPE, + "Array PType must match primitive type" + ); + Self(array, RefCell::new(ctx), PhantomData) + } + + /// Returns the value at `idx`, with nulls mapped to `T::zero()`. + fn value(&self, idx: usize) -> VortexResult { + Ok(self + .0 + .execute_scalar(idx, &mut self.1.borrow_mut())? + .as_primitive() + .typed_value::() + .unwrap_or_else(|| T::zero())) + } +} + +impl IndexOrd for SearchSortedPrimitiveArray<'_, T> { + fn index_cmp(&self, idx: usize, elem: &T) -> VortexResult> { + let value = self.value(idx)?; + Ok(Some(value.total_compare(*elem))) + } + + fn index_len(&self) -> usize { + self.0.len() + } +} + +impl IndexOrd> for SearchSortedPrimitiveArray<'_, T> { + fn index_cmp(&self, idx: usize, elem: &Option) -> VortexResult> { + // The borrow must end before `self.value` re-borrows the ctx. + let valid = self.0.is_valid(idx, &mut self.1.borrow_mut())?; + let value = valid.then(|| self.value(idx)).transpose()?; + + Ok(match (value, elem.as_ref()) { + (Some(l), Some(r)) => Some(l.total_compare(*r)), + (Some(_), None) => Some(Ordering::Greater), + (None, Some(_)) => Some(Ordering::Less), + (None, None) => Some(Ordering::Equal), + }) + } + + fn index_len(&self) -> usize { + self.0.len() + } +} + +impl IndexOrd for SearchSortedPrimitiveArray<'_, T> { + fn index_cmp(&self, idx: usize, elem: &usize) -> VortexResult> { + let value = self.value(idx)?; + + let Some(elem_t) = T::from_usize(*elem) else { + return Ok(Some(Ordering::Less)); + }; + + Ok(Some(value.total_compare(elem_t))) + } + + fn index_len(&self) -> usize { + self.0.len() + } +} + +#[cfg(test)] +mod tests { + use vortex_buffer::buffer; + use vortex_error::VortexResult; + + use crate::IntoArray; + use crate::array_session; + use crate::arrays::PrimitiveArray; + use crate::executor::VortexSessionExecute; + use crate::search_sorted::SearchResult; + use crate::search_sorted::SearchSorted; + use crate::search_sorted::SearchSortedPrimitiveArray; + use crate::search_sorted::SearchSortedSide; + use crate::validity::Validity; + + #[test] + fn search_sorted_optional_value() -> VortexResult<()> { + let array = PrimitiveArray::new(buffer![1i32, 2, 3, 4], Validity::AllValid).into_array(); + let mut ctx = array_session().create_execution_ctx(); + let res = SearchSortedPrimitiveArray::::new(&array, &mut ctx) + .search_sorted(&Some(3i32), SearchSortedSide::Left)?; + assert_eq!(res, SearchResult::Found(2)); + Ok(()) + } + + #[test] + fn search_sorted_optional_value_with_nulls() -> VortexResult<()> { + let array = + PrimitiveArray::from_option_iter([None, None, Some(2i32), Some(3)]).into_array(); + let mut ctx = array_session().create_execution_ctx(); + let searcher = SearchSortedPrimitiveArray::::new(&array, &mut ctx); + assert_eq!( + searcher.search_sorted(&Some(2i32), SearchSortedSide::Left)?, + SearchResult::Found(2) + ); + assert_eq!( + searcher.search_sorted(&None, SearchSortedSide::Right)?, + SearchResult::Found(2) + ); + Ok(()) + } +} diff --git a/vortex-array/src/variants.rs b/vortex-array/src/variants.rs deleted file mode 100644 index 13c7a242c7a..00000000000 --- a/vortex-array/src/variants.rs +++ /dev/null @@ -1,241 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! This module defines extension functionality specific to each Vortex DType. -use std::cmp::Ordering; - -use vortex_error::VortexExpect; -use vortex_error::VortexResult; -use vortex_error::vortex_bail; -use vortex_error::vortex_panic; -use vortex_mask::Mask; - -use crate::ArrayRef; -use crate::ExecutionCtx; -use crate::VortexSessionExecute; -use crate::aggregate_fn::fns::sum::sum; -use crate::arrays::BoolArray; -use crate::arrays::bool::BoolArrayExt; -use crate::builtins::ArrayBuiltins; -use crate::dtype::DType; -use crate::dtype::FieldNames; -use crate::dtype::PType; -use crate::dtype::extension::ExtDTypeRef; -use crate::legacy_session; -use crate::scalar::PValue; -use crate::scalar::Scalar; -use crate::search_sorted::IndexOrd; - -impl ArrayRef { - /// Downcasts the array for null-specific behavior. - pub fn as_null_typed(&self) -> NullTyped<'_> { - matches!(self.dtype(), DType::Null) - .then(|| NullTyped(self)) - .vortex_expect("Array does not have DType::Null") - } - - /// Downcasts the array for bool-specific behavior. - pub fn as_bool_typed(&self) -> BoolTyped<'_> { - matches!(self.dtype(), DType::Bool(..)) - .then(|| BoolTyped(self)) - .vortex_expect("Array does not have DType::Bool") - } - - /// Downcasts the array for primitive-specific behavior. - pub fn as_primitive_typed(&self) -> PrimitiveTyped<'_> { - matches!(self.dtype(), DType::Primitive(..)) - .then(|| PrimitiveTyped(self)) - .vortex_expect("Array does not have DType::Primitive") - } - - /// Downcasts the array for decimal-specific behavior. - pub fn as_decimal_typed(&self) -> DecimalTyped<'_> { - matches!(self.dtype(), DType::Decimal(..)) - .then(|| DecimalTyped(self)) - .vortex_expect("Array does not have DType::Decimal") - } - - /// Downcasts the array for utf8-specific behavior. - pub fn as_utf8_typed(&self) -> Utf8Typed<'_> { - matches!(self.dtype(), DType::Utf8(..)) - .then(|| Utf8Typed(self)) - .vortex_expect("Array does not have DType::Utf8") - } - - /// Downcasts the array for binary-specific behavior. - pub fn as_binary_typed(&self) -> BinaryTyped<'_> { - matches!(self.dtype(), DType::Binary(..)) - .then(|| BinaryTyped(self)) - .vortex_expect("Array does not have DType::Binary") - } - - /// Downcasts the array for struct-specific behavior. - pub fn as_struct_typed(&self) -> StructTyped<'_> { - matches!(self.dtype(), DType::Struct(..)) - .then(|| StructTyped(self)) - .vortex_expect("Array does not have DType::Struct") - } - - /// Downcasts the array for list-specific behavior. - pub fn as_list_typed(&self) -> ListTyped<'_> { - matches!(self.dtype(), DType::List(..)) - .then(|| ListTyped(self)) - .vortex_expect("Array does not have DType::List") - } - - /// Downcasts the array for extension-specific behavior. - pub fn as_extension_typed(&self) -> ExtensionTyped<'_> { - matches!(self.dtype(), DType::Extension(..)) - .then(|| ExtensionTyped(self)) - .vortex_expect("Array does not have DType::Extension") - } - - pub fn try_to_mask_fill_null_false(&self, ctx: &mut ExecutionCtx) -> VortexResult { - if !matches!(self.dtype(), DType::Bool(_)) { - vortex_bail!("mask must be bool array, has dtype {}", self.dtype()); - } - - // Convert nulls to false first in case this can be done cheaply by the encoding. - let array = self - .clone() - .fill_null(Scalar::bool(false, self.dtype().nullability()))?; - - Ok(array - .execute::(ctx)? - .to_mask_fill_null_false(ctx)) - } -} - -#[expect(dead_code)] -pub struct NullTyped<'a>(&'a ArrayRef); - -pub struct BoolTyped<'a>(&'a ArrayRef); - -impl BoolTyped<'_> { - #[deprecated( - note = "Relies on the hidden global `legacy_session()`; use `sum(array, ctx)` with an explicit `ExecutionCtx` instead" - )] - #[allow(clippy::disallowed_methods)] - pub fn true_count(&self) -> VortexResult { - let mut ctx = legacy_session().create_execution_ctx(); - let true_count = sum(self.0, &mut ctx)?; - Ok(true_count - .as_primitive() - .as_::() - .vortex_expect("true count should never be null")) - } -} - -pub struct PrimitiveTyped<'a>(&'a ArrayRef); - -impl PrimitiveTyped<'_> { - pub fn ptype(&self) -> PType { - let DType::Primitive(ptype, _) = self.0.dtype() else { - vortex_panic!("Expected Primitive DType") - }; - *ptype - } - - /// Return the primitive value at the given index. - #[deprecated( - note = "Relies on the hidden global `legacy_session()`; use `is_valid`/`execute_scalar` with an explicit `ExecutionCtx` instead" - )] - #[allow(deprecated)] - #[allow(clippy::disallowed_methods)] - pub fn value(&self, idx: usize) -> VortexResult> { - self.0 - .is_valid(idx, &mut legacy_session().create_execution_ctx())? - .then(|| self.value_unchecked(idx)) - .transpose() - } - - /// Return the primitive value at the given index, ignoring nullability. - #[deprecated( - note = "Relies on the hidden global `legacy_session()`; use `execute_scalar` with an explicit `ExecutionCtx` instead" - )] - #[allow(clippy::disallowed_methods)] - pub fn value_unchecked(&self, idx: usize) -> VortexResult { - Ok(self - .0 - .execute_scalar(idx, &mut legacy_session().create_execution_ctx())? - .as_primitive() - .pvalue() - .unwrap_or_else(|| PValue::zero(&self.ptype()))) - } -} - -impl IndexOrd> for PrimitiveTyped<'_> { - #[allow(deprecated)] - fn index_cmp(&self, idx: usize, elem: &Option) -> VortexResult> { - let value = self.value(idx)?; - Ok(value.partial_cmp(elem)) - } - - fn index_len(&self) -> usize { - self.0.len() - } -} - -// TODO(ngates): add generics to the `value` function and implement this over T. -impl IndexOrd for PrimitiveTyped<'_> { - #[allow(deprecated)] - #[allow(clippy::disallowed_methods)] - fn index_cmp(&self, idx: usize, elem: &PValue) -> VortexResult> { - assert!( - self.0 - .all_valid(&mut legacy_session().create_execution_ctx())? - ); - let value = self.value_unchecked(idx)?; - Ok(value.partial_cmp(elem)) - } - - fn index_len(&self) -> usize { - self.0.len() - } -} - -#[expect(dead_code)] -pub struct Utf8Typed<'a>(&'a ArrayRef); - -#[expect(dead_code)] -pub struct BinaryTyped<'a>(&'a ArrayRef); - -#[expect(dead_code)] -pub struct DecimalTyped<'a>(&'a ArrayRef); - -pub struct StructTyped<'a>(&'a ArrayRef); - -impl StructTyped<'_> { - pub fn names(&self) -> &FieldNames { - let DType::Struct(st, _) = self.0.dtype() else { - unreachable!() - }; - st.names() - } - - pub fn dtypes(&self) -> Vec { - let DType::Struct(st, _) = self.0.dtype() else { - unreachable!() - }; - st.fields().collect() - } - - pub fn nfields(&self) -> usize { - self.names().len() - } -} - -#[expect(dead_code)] -pub struct ListTyped<'a>(&'a ArrayRef); - -pub struct ExtensionTyped<'a>(&'a ArrayRef); - -impl ExtensionTyped<'_> { - /// Returns the extension logical [`DType`]. - pub fn ext_dtype(&self) -> &ExtDTypeRef { - let DType::Extension(ext_dtype) = self.0.dtype() else { - vortex_panic!("Expected ExtDType") - }; - ext_dtype - } -} From 229b3bdc104a029a93b7abb5bf0bbc082ad8eb8b Mon Sep 17 00:00:00 2001 From: Nemo Yu <83347615+HarukiMoriarty@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:15:37 -0400 Subject: [PATCH 010/104] feat: support `ST_DWithin` pushdown in vortex (#8625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Insert non-throwing geo predicate `vortex_dwithin` in DuckDB, which later pushdown into vortex, call `distance` scalar function on scanning, significantly improve Q1/Q3 performance in SpatialBench. ## What changes are included in this PR? 1. adding new geo predicate `vortex_dwithin` in DuckDB, which is non-throwing and can be pushdown. 2. adding SQL rewrite so for geo native type, `ST_dwithin` is text rewritten into `vortex_dwithin`. ## Performance ``` ┏━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Query ┃ duckdb:parquet (base) ┃ duckdb:vortex ┃ duckdb:vortex-native ┃ ┡━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━┩ │ 1 │ 179.6ms │ 132.1ms (0.74x) │ 29.0ms (0.16x) │ │ 2 │ 268.1ms │ 255.3ms (0.95x) │ 312.5ms (1.17x) │ │ 3 │ 247.8ms │ 216.4ms (0.87x) │ 140.2ms (0.57x) │ │ 4 │ 199.6ms │ 146.9ms (0.74x) │ 144.5ms (0.72x) │ │ 5 │ 3.40s │ 3.43s (1.01x) │ 3.03s (0.89x) │ │ 6 │ 528.1ms │ 359.2ms (0.68x) │ 455.4ms (0.86x) │ │ 7 │ 984.0ms │ 983.2ms (1.00x) │ 887.7ms (0.90x) │ │ 8 │ 1.08s │ 945.4ms (0.87x) │ 997.6ms (0.92x) │ │ 9 │ 34.2ms │ 33.5ms (0.98x) │ 43.6ms (1.27x) │ └───────┴───────────────────────┴─────────────────┴──────────────────────┘ ``` Takeaways: Q1 and Q3 is significantly improved due to the single table geo predicate is pushdown into vortex scan. Q1 is more benefit from the manually fast path without going through `geo` crate for calculation. Q3 is also potentially can be benefit from manually calculation. --------- Signed-off-by: Nemo Yu --- benchmarks/duckdb-bench/src/lib.rs | 10 + vortex-duckdb/cpp/expr.cpp | 59 ++++++ vortex-duckdb/cpp/include/expr.h | 5 + .../cpp/include/scalar_fn_pushdown.hpp | 15 ++ vortex-duckdb/cpp/scalar_fn_pushdown.cpp | 66 ++++++ vortex-duckdb/cpp/vortex_duckdb.cpp | 7 +- vortex-duckdb/src/convert/expr.rs | 199 +++++++++++++++--- vortex-duckdb/src/duckdb/database.rs | 10 + vortex-duckdb/src/table_function.rs | 2 +- vortex-geo/src/extension/mod.rs | 111 ++++++++++ vortex-geo/src/scalar_fn/distance.rs | 88 +++++++- 11 files changed, 536 insertions(+), 36 deletions(-) diff --git a/benchmarks/duckdb-bench/src/lib.rs b/benchmarks/duckdb-bench/src/lib.rs index bf64f123956..11be0dd80e5 100644 --- a/benchmarks/duckdb-bench/src/lib.rs +++ b/benchmarks/duckdb-bench/src/lib.rs @@ -78,6 +78,11 @@ impl DuckClient { for stmt in &statements { self.connection().query(stmt)?; } + // After `LOAD spatial`, shadow `ST_DWithin` so radius filters push. No-op without it. + self.db + .as_ref() + .vortex_expect("DuckClient database accessed after close") + .register_st_dwithin_override()?; self.init_sql = statements; Ok(()) } @@ -127,6 +132,11 @@ impl DuckClient { .vortex_expect("connection just opened") .query(stmt)?; } + // Re-shadow `ST_DWithin` against the fresh instance. + self.db + .as_ref() + .vortex_expect("database just opened") + .register_st_dwithin_override()?; Ok(()) } diff --git a/vortex-duckdb/cpp/expr.cpp b/vortex-duckdb/cpp/expr.cpp index afe2573adc2..22b520ec7a0 100644 --- a/vortex-duckdb/cpp/expr.cpp +++ b/vortex-duckdb/cpp/expr.cpp @@ -11,6 +11,19 @@ #include "duckdb/planner/expression/bound_operator_expression.hpp" #include "duckdb/planner/expression/bound_conjunction_expression.hpp" +#include "duckdb/catalog/catalog.hpp" +#include "duckdb/catalog/catalog_entry/scalar_function_catalog_entry.hpp" +#include "duckdb/common/error_data.hpp" +#include "duckdb/logging/logger.hpp" +#include "duckdb/main/capi/capi_internal.hpp" +#include "duckdb/main/client_context.hpp" +#include "duckdb/main/connection.hpp" +#include "duckdb/main/database_manager.hpp" +#include "duckdb/parser/parsed_data/create_scalar_function_info.hpp" +#include "duckdb/transaction/meta_transaction.hpp" + +#include + using namespace duckdb; extern "C" const char *duckdb_vx_sfunc_name(duckdb_vx_sfunc ffi_func) { @@ -21,6 +34,52 @@ extern "C" const char *duckdb_vx_sfunc_name(duckdb_vx_sfunc ffi_func) { return func->name.c_str(); } +extern "C" duckdb_state duckdb_vx_register_st_dwithin_override(duckdb_database ffi_db) { + if (!ffi_db) { + return DuckDBError; + } + const DatabaseWrapper &wrapper = *reinterpret_cast(ffi_db); + DatabaseInstance &db = *wrapper.database->instance; + try { + Connection conn(db); + ClientContext &context = *conn.context; + context.RunFunctionInTransaction([&]() { + auto &system = Catalog::GetSystemCatalog(context); + auto entry = system.GetEntry(context, + DEFAULT_SCHEMA, + "st_dwithin", + OnEntryNotFound::RETURN_NULL); + if (!entry) { + // No `spatial` loaded, so there is no `ST_DWithin` to override. + return; + } + ScalarFunctionSet set("st_dwithin"); + for (const auto &overload : entry->functions.functions) { + ScalarFunction copy = overload; + // Keep the radius as children[2]; spatial's bind folds it into private bind data. + copy.bind = nullptr; + set.AddFunction(copy); + } + CreateScalarFunctionInfo info(std::move(set)); + info.on_conflict = OnCreateConflict::REPLACE_ON_CONFLICT; + // `internal` entries are only accepted by the system catalog. + info.internal = false; + // The user catalog binds ahead of the system catalog, shadowing spatial's entry; + // `RestoreStDWithin` rebinds unpushed calls through the original. + auto &catalog = Catalog::GetCatalog(context, DatabaseManager::GetDefaultDatabase(context)); + // Durable catalogs require the modified mark; scalar function entries are never + // persisted, so this is metadata-only. + MetaTransaction::Get(context).ModifyDatabase(catalog.GetAttached(), DatabaseModificationType()); + catalog.CreateFunction(context, info); + }); + } catch (const std::exception &e) { + ErrorData data(e); + DUCKDB_LOG_ERROR(db, "Failed to register the ST_DWithin override:\t" + data.Message()); + return DuckDBError; + } + return DuckDBSuccess; +} + extern "C" const char *duckdb_vx_expr_to_string(duckdb_vx_expr ffi_expr) { if (!ffi_expr) { return nullptr; diff --git a/vortex-duckdb/cpp/include/expr.h b/vortex-duckdb/cpp/include/expr.h index 5b7997596d6..adc706540bc 100644 --- a/vortex-duckdb/cpp/include/expr.h +++ b/vortex-duckdb/cpp/include/expr.h @@ -13,6 +13,11 @@ typedef struct duckdb_vx_sfunc_ *duckdb_vx_sfunc; const char *duckdb_vx_sfunc_name(duckdb_vx_sfunc ffi_func); +/// Shadow `ST_DWithin` with a copy that keeps the radius as the third argument, so +/// radius filters can push into Vortex scans. See `RestoreStDWithin` in +/// scalar_fn_pushdown.hpp for the override/restore example. +duckdb_state duckdb_vx_register_st_dwithin_override(duckdb_database ffi_db); + typedef struct duckdb_vx_expr_ *duckdb_vx_expr; /// Return the string representation of the expression. Must be freed with `duckdb_free`. diff --git a/vortex-duckdb/cpp/include/scalar_fn_pushdown.hpp b/vortex-duckdb/cpp/include/scalar_fn_pushdown.hpp index ef590c96dcc..24c0fd8bd1a 100644 --- a/vortex-duckdb/cpp/include/scalar_fn_pushdown.hpp +++ b/vortex-duckdb/cpp/include/scalar_fn_pushdown.hpp @@ -77,6 +77,21 @@ using Projections = unordered_map; LogicalOperatorPtr TryPushdownScalarFunctions(ClientContext &context, LogicalOperatorPtr plan); +/* + * We override spatial's `ST_DWithin` with a copy that keeps the radius as a plain third + * argument (see expr.h); the original folds it into bind data at bind time. We need the + * radius visible to push the filter into a Vortex scan, but spatial's join optimizer only + * recognizes the folded 2-argument form. So this pass rebinds join conditions through + * spatial's original function and leaves filters alone: + * + * FILTER st_dwithin(t.geom, 'POINT(0 0)', 10.0) -- untouched, pushed into the scan + * JOIN ON st_dwithin(a.geom, b.geom, 10.0) -- rebound: st_dwithin(a.geom, b.geom) + * with the radius in bind data + * + * Runs in the pre-optimize hook, before any extension's optimizer pass. + */ +void RestoreStDWithin(ClientContext &context, LogicalOperator &plan); + /** * Collect fn(col) expressions i.e. expressions where a single function (not * a function chain) wraps a single bound column. If "col" is used without diff --git a/vortex-duckdb/cpp/scalar_fn_pushdown.cpp b/vortex-duckdb/cpp/scalar_fn_pushdown.cpp index 057920f79f7..8bac6d86749 100644 --- a/vortex-duckdb/cpp/scalar_fn_pushdown.cpp +++ b/vortex-duckdb/cpp/scalar_fn_pushdown.cpp @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors #include "duckdb/catalog/catalog.hpp" +#include "duckdb/catalog/catalog_entry/scalar_function_catalog_entry.hpp" +#include "duckdb/function/function_binder.hpp" #include "duckdb/planner/operator/logical_projection.hpp" #include "scalar_fn_pushdown.hpp" #include "table_function.hpp" @@ -231,3 +233,67 @@ ScalarFnReplace::ScalarFnReplace(Analyses &analyses, const Projections &projecti TableColumnStorageIndex GetAnalysis::StorageIndex(TableColumnScanIndex idx) const { return get.GetColumnIds()[idx].GetPrimaryIndex(); } + +namespace { + +// See RestoreStDWithin: rebinding through spatial's own entry lets spatial build its own bind +// data, so nothing here depends on its internals. +class StDWithinRestore final : public LogicalOperatorVisitor { +public: + explicit StDWithinRestore(ClientContext &context) : context(context) { + } + + // Restore join conditions, filters must keep the radius visible so + // DuckDB's filter pushdown can offer them to Vortex scans. + void VisitOperator(LogicalOperator &op) override { + using enum LogicalOperatorType; + switch (op.type) { + case LOGICAL_COMPARISON_JOIN: + case LOGICAL_ANY_JOIN: + case LOGICAL_DELIM_JOIN: + case LOGICAL_ASOF_JOIN: + VisitOperatorExpressions(op); + break; + default: + break; + } + VisitOperatorChildren(op); + } + + ExpressionPtr VisitReplace(BoundFunctionExpression &expr, ExpressionPtr *) override { + if (expr.children.size() != 3 || expr.function.name != "st_dwithin") { + return nullptr; // Not the override's shape: keep it and descend into its children. + } + // The system catalog holds spatial's original; the user-catalog override cannot shadow + // this lookup. + auto original = Catalog::GetSystemCatalog(context).GetEntry( + context, + DEFAULT_SCHEMA, + "st_dwithin", + OnEntryNotFound::RETURN_NULL); + if (!original) { + return nullptr; + } + vector children; + children.reserve(expr.children.size()); + for (const auto &child : expr.children) { + children.push_back(child->Copy()); + } + ErrorData error; + FunctionBinder binder(context); + auto bound = binder.BindScalarFunction(*original, std::move(children), error); + if (!bound) { + return nullptr; // No matching overload: keep the executable 3-argument form. + } + return bound; + } + +private: + ClientContext &context; +}; + +} // namespace + +void RestoreStDWithin(ClientContext &context, LogicalOperator &plan) { + StDWithinRestore(context).VisitOperator(plan); +} diff --git a/vortex-duckdb/cpp/vortex_duckdb.cpp b/vortex-duckdb/cpp/vortex_duckdb.cpp index 091f98a1703..bafa777a061 100644 --- a/vortex-duckdb/cpp/vortex_duckdb.cpp +++ b/vortex-duckdb/cpp/vortex_duckdb.cpp @@ -271,8 +271,13 @@ static void VortexOptimizeFunction(OptimizerExtensionInput &input, unique_ptr &plan) { + RestoreStDWithin(input.context, *plan); +} + struct VortexOptimizerExtension final : OptimizerExtension { - inline VortexOptimizerExtension() : OptimizerExtension(VortexOptimizeFunction, nullptr, {}) { + inline VortexOptimizerExtension() + : OptimizerExtension(VortexOptimizeFunction, VortexPreOptimizeFunction, {}) { } }; diff --git a/vortex-duckdb/src/convert/expr.rs b/vortex-duckdb/src/convert/expr.rs index 387b644fe30..32778870845 100644 --- a/vortex-duckdb/src/convert/expr.rs +++ b/vortex-duckdb/src/convert/expr.rs @@ -28,6 +28,7 @@ use vortex::expr::not; use vortex::expr::or_collect; use vortex::expr::root; use vortex::scalar::Scalar; +use vortex::scalar_fn::EmptyOptions; use vortex::scalar_fn::ScalarFnVTableExt; use vortex::scalar_fn::fns::between::Between; use vortex::scalar_fn::fns::between::BetweenOptions; @@ -37,6 +38,12 @@ use vortex::scalar_fn::fns::like::Like; use vortex::scalar_fn::fns::like::LikeOptions; use vortex::scalar_fn::fns::literal::Literal; use vortex::scalar_fn::fns::operators::Operator; +use vortex_geo::extension::MultiPolygon; +use vortex_geo::extension::Point; +use vortex_geo::extension::Polygon; +use vortex_geo::extension::WellKnownBinary; +use vortex_geo::extension::native_geometry_scalar_from_wkb; +use vortex_geo::scalar_fn::distance::GeoDistance; use crate::cpp::DUCKDB_TYPE; use crate::cpp::DUCKDB_VX_EXPR_TYPE; @@ -73,15 +80,133 @@ fn build_list_length(expr: Expression, nullability: Nullability) -> Expression { cast(list_length(expr), DType::Primitive(PType::I64, nullability)) } +/// Read an `f64` from a constant expression (the `ST_DWithin` radius); `None` for non-constants. +fn from_bound_f64(value: &duckdb::ExpressionRef) -> VortexResult> { + match value.as_class().vortex_expect("unknown class") { + BoundConstant(constant) => Ok(Some(f64::try_from(&Scalar::try_from(constant.value)?)?)), + _ => Ok(None), + } +} + +/// Context threaded through expression conversion. +#[derive(Clone, Copy)] +struct ConvertCtx<'a> { + /// Substituted for `BoundRef` references when converting scan-scoped table filters. + col_sub: Option<&'a Expression>, + /// The scan's fields, when known. + fields: Option<&'a [DuckdbField]>, +} + +/// Whether `name` is a native geometry column of the scan. The pushed `GeoDistance` cannot +/// evaluate `vortex.geo.wkb` columns, which also surface to DuckDB as `GEOMETRY`. +fn is_native_geo_column(fields: Option<&[DuckdbField]>, name: &str) -> bool { + fields + .into_iter() + .flatten() + .filter(|field| field.name == name) + .any(|field| match field.dtype.as_extension_opt() { + Some(ext) => ext.is::() || ext.is::() || ext.is::(), + None => false, + }) +} + +/// Lower a geo operand: a `GEOMETRY` literal arrives as WKB, decoded once to its native type so the +/// pushed `GeoDistance` stays native; a column must be native geometry. `None` skips the push. +fn geo_operand( + value: &duckdb::ExpressionRef, + ctx: ConvertCtx<'_>, +) -> VortexResult> { + match value.as_class() { + Some(BoundConstant(constant)) => { + let scalar = Scalar::try_from(constant.value)?; + let DType::Extension(ext_dtype) = scalar.dtype() else { + return Ok(None); + }; + if !ext_dtype.is::() { + return Ok(None); + } + let storage = scalar.as_extension().to_storage_scalar(); + let Some(buf) = storage.as_binary_opt().and_then(|b| b.value()) else { + return Ok(None); + }; + Ok(native_geometry_scalar_from_wkb(buf.as_slice())?.map(lit)) + } + Some(BoundColumnRef(col_ref)) + if is_native_geo_column(ctx.fields, col_ref.name.as_ref()) => + { + try_from_expression_inner(value, ctx) + } + _ => Ok(None), + } +} + +/// Lower geo UDFs to native Vortex geo ops so the work runs in the scan. `None` otherwise. +fn try_from_geo_function( + name: &str, + func: &BoundFunction, + ctx: ConvertCtx<'_>, +) -> VortexResult> { + // Catch-all for every bound function: reject non-geo names before touching the children. + if !is_geo_function(name) { + return Ok(None); + } + let children: Vec<_> = func.children().collect(); + let expr = match name.to_ascii_lowercase().as_str() { + // The Vortex override keeps the radius as `children[2]`; see + // `duckdb_vx_register_st_dwithin_override`. + "st_dwithin" => { + if children.len() != 3 { + return Ok(None); + } + let Some(a) = geo_operand(children[0], ctx)? else { + return Ok(None); + }; + let Some(b) = geo_operand(children[1], ctx)? else { + return Ok(None); + }; + // A non-constant radius is left for DuckDB to evaluate. + let Some(distance) = from_bound_f64(children[2])? else { + return Ok(None); + }; + let geo_distance = GeoDistance.new_expr(EmptyOptions, [a, b]); + Binary.new_expr(Operator::Lte, [geo_distance, lit(distance)]) + } + "st_distance" => { + if children.len() != 2 { + return Ok(None); + } + let Some(a) = geo_operand(children[0], ctx)? else { + return Ok(None); + }; + let Some(b) = geo_operand(children[1], ctx)? else { + return Ok(None); + }; + GeoDistance.new_expr(EmptyOptions, [a, b]) + } + _ => return Ok(None), + }; + + Ok(Some(expr)) +} + +/// Geo UDFs that `try_from_geo_function` lowers - shared with `can_push_expression` so the pushable +/// and lowered sets can't drift. +fn is_geo_function(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "st_distance" | "st_dwithin" + ) +} + fn try_from_bound_function( func: &BoundFunction, - col_sub: Option<&Expression>, + ctx: ConvertCtx<'_>, ) -> VortexResult> { let expr = match func.scalar_function.name() { "strlen" => { let children: Vec<_> = func.children().collect(); vortex_ensure!(children.len() == 1); - let Some(col) = try_from_expression_inner(children[0], col_sub)? else { + let Some(col) = try_from_expression_inner(children[0], ctx)? else { return Ok(None); }; let col = byte_length(col); @@ -95,7 +220,7 @@ fn try_from_bound_function( "struct_extract" => { let children: Vec<_> = func.children().collect(); vortex_ensure!(children.len() == 2); - let Some(child) = try_from_expression_inner(children[0], col_sub)? else { + let Some(child) = try_from_expression_inner(children[0], ctx)? else { return Ok(None); }; let field = from_bound_str(children[1])?; @@ -104,10 +229,10 @@ fn try_from_bound_function( like @ ("~~" | "!~~") => { let children: Vec<_> = func.children().collect(); vortex_ensure!(children.len() == 2); - let Some(string) = try_from_expression_inner(children[0], col_sub)? else { + let Some(string) = try_from_expression_inner(children[0], ctx)? else { return Ok(None); }; - let Some(target) = try_from_expression_inner(children[1], col_sub)? else { + let Some(target) = try_from_expression_inner(children[1], ctx)? else { return Ok(None); }; let opts = LikeOptions { @@ -119,7 +244,7 @@ fn try_from_bound_function( matchers @ ("contains" | "prefix" | "suffix") => { let children: Vec<_> = func.children().collect(); vortex_ensure!(children.len() == 2); - let Some(value) = try_from_expression_inner(children[0], col_sub)? else { + let Some(value) = try_from_expression_inner(children[0], ctx)? else { return Ok(None); }; let pattern = from_bound_str(children[1])?; @@ -137,7 +262,7 @@ fn try_from_bound_function( if children.len() != 1 { return Ok(None); } - let Some(col) = try_from_expression_inner(children[0], col_sub)? else { + let Some(col) = try_from_expression_inner(children[0], ctx)? else { return Ok(None); }; @@ -151,7 +276,7 @@ fn try_from_bound_function( let child = children[0]; if returns_a_list(child) { - let Some(col) = try_from_expression_inner(child, col_sub)? else { + let Some(col) = try_from_expression_inner(child, ctx)? else { return Ok(None); }; @@ -162,10 +287,8 @@ fn try_from_bound_function( return Ok(None); } } - _ => { - debug!("bound function {}", func.scalar_function.name()); - return Ok(None); - } + // Geo UDFs are handled here; non-geo names return `None` inside. + name => return try_from_geo_function(name, func, ctx), }; Ok(Some(expr)) @@ -173,15 +296,30 @@ fn try_from_bound_function( pub fn try_from_bound_expression( value: &duckdb::ExpressionRef, + fields: &[DuckdbField], ) -> VortexResult> { - try_from_expression_inner(value, None) + try_from_expression_inner( + value, + ConvertCtx { + col_sub: None, + fields: Some(fields), + }, + ) } pub(super) fn try_from_bound_expression_with_col_sub( value: &duckdb::ExpressionRef, col_sub: &Expression, ) -> VortexResult> { - try_from_expression_inner(value, Some(col_sub)) + // No fields: scan-time table filters never carry geo functions, because + // `can_push_expression` refuses them. + try_from_expression_inner( + value, + ConvertCtx { + col_sub: Some(col_sub), + fields: None, + }, + ) } fn is_supported_length_alias(func: &BoundFunction) -> bool { @@ -227,6 +365,9 @@ pub fn can_push_expression(value: &duckdb::ExpressionRef) -> bool { || name == "strlen" || name == "array_length" || (matches!(name, "len" | "length") && is_supported_length_alias(&func)) + // Geo functions are absent on purpose: they push only via + // `pushdown_complex_filter`, which has the scan's fields to verify the geometry + // columns are native. } ExpressionClass::BoundOperator(op) => { if !matches!( @@ -284,7 +425,7 @@ pub fn try_from_projection_expression( // can_push_expression fn try_from_expression_inner( value: &duckdb::ExpressionRef, - col_sub: Option<&Expression>, + ctx: ConvertCtx<'_>, ) -> VortexResult> { let Some(value) = value.as_class() else { debug!( @@ -295,7 +436,7 @@ fn try_from_expression_inner( }; Ok(Some(match value { BoundRef => { - let Some(col) = col_sub else { + let Some(col) = ctx.col_sub else { vortex_bail!("BoundRef requested but no column supplied"); }; col.clone() @@ -305,23 +446,23 @@ fn try_from_expression_inner( BoundComparison(compare) => { let operator: Operator = compare.op.try_into()?; - let Some(left) = try_from_expression_inner(compare.left, col_sub)? else { + let Some(left) = try_from_expression_inner(compare.left, ctx)? else { return Ok(None); }; - let Some(right) = try_from_expression_inner(compare.right, col_sub)? else { + let Some(right) = try_from_expression_inner(compare.right, ctx)? else { return Ok(None); }; Binary.new_expr(operator, [left, right]) } BoundBetween(between) => { - let Some(array) = try_from_expression_inner(between.input, col_sub)? else { + let Some(array) = try_from_expression_inner(between.input, ctx)? else { return Ok(None); }; - let Some(lower) = try_from_expression_inner(between.lower, col_sub)? else { + let Some(lower) = try_from_expression_inner(between.lower, ctx)? else { return Ok(None); }; - let Some(upper) = try_from_expression_inner(between.upper, col_sub)? else { + let Some(upper) = try_from_expression_inner(between.upper, ctx)? else { return Ok(None); }; Between.new_expr( @@ -346,7 +487,7 @@ fn try_from_expression_inner( | DUCKDB_VX_EXPR_TYPE::DUCKDB_VX_EXPR_TYPE_OPERATOR_IS_NOT_NULL => { let children: Vec<_> = operator.children().collect(); vortex_ensure!(children.len() == 1); - let Some(child) = try_from_expression_inner(children[0], col_sub)? else { + let Some(child) = try_from_expression_inner(children[0], ctx)? else { return Ok(None); }; match operator.op { @@ -359,10 +500,10 @@ fn try_from_expression_inner( } } DUCKDB_VX_EXPR_TYPE::DUCKDB_VX_EXPR_TYPE_COMPARE_IN => { - return try_from_compare_in(operator, col_sub, false); + return try_from_compare_in(operator, ctx, false); } DUCKDB_VX_EXPR_TYPE::DUCKDB_VX_EXPR_TYPE_COMPARE_NOT_IN => { - return try_from_compare_in(operator, col_sub, true); + return try_from_compare_in(operator, ctx, true); } _ => { debug!(op=?operator.op, "cannot push down operator"); @@ -370,12 +511,12 @@ fn try_from_expression_inner( } }, ExpressionClass::BoundFunction(func) => { - return try_from_bound_function(&func, col_sub); + return try_from_bound_function(&func, ctx); } BoundConjunction(conj) => { let Some(children) = conj .children() - .map(|c| try_from_expression_inner(c, col_sub)) + .map(|c| try_from_expression_inner(c, ctx)) .collect::>>>()? else { return Ok(None); @@ -395,13 +536,13 @@ fn try_from_expression_inner( fn try_from_compare_in( operator: BoundOperator, - col_sub: Option<&Expression>, + ctx: ConvertCtx<'_>, not_in: bool, ) -> VortexResult> { // First child is element, rest form the list. let children: Vec<_> = operator.children().collect(); assert!(children.len() >= 2); - let Some(element) = try_from_expression_inner(children[0], col_sub)? else { + let Some(element) = try_from_expression_inner(children[0], ctx)? else { return Ok(None); }; @@ -409,7 +550,7 @@ fn try_from_compare_in( .iter() .skip(1) .map(|c| { - let Some(value) = try_from_expression_inner(c, col_sub)? else { + let Some(value) = try_from_expression_inner(c, ctx)? else { return Ok(None); }; Ok(Some( diff --git a/vortex-duckdb/src/duckdb/database.rs b/vortex-duckdb/src/duckdb/database.rs index ab86503b291..e7033b75ee2 100644 --- a/vortex-duckdb/src/duckdb/database.rs +++ b/vortex-duckdb/src/duckdb/database.rs @@ -90,4 +90,14 @@ impl DatabaseRef { ); Ok(()) } + + /// Shadow `ST_DWithin` with a radius-visible copy so its filters push into the Vortex scan + /// (see `duckdb_vx_register_st_dwithin_override`). No-op when `spatial` is not loaded. + pub fn register_st_dwithin_override(&self) -> VortexResult<()> { + duckdb_try!( + unsafe { cpp::duckdb_vx_register_st_dwithin_override(self.as_ptr()) }, + "Failed to register the ST_DWithin override" + ); + Ok(()) + } } diff --git a/vortex-duckdb/src/table_function.rs b/vortex-duckdb/src/table_function.rs index 5151e47a464..098d8bf30ee 100644 --- a/vortex-duckdb/src/table_function.rs +++ b/vortex-duckdb/src/table_function.rs @@ -391,7 +391,7 @@ pub fn pushdown_complex_filter( ) -> VortexResult { debug!(%expr, "pushing down expression"); - let Some(expr) = try_from_bound_expression(expr)? else { + let Some(expr) = try_from_bound_expression(expr, &bind_data.column_fields)? else { debug!(%expr, "failed to push down expression"); return Ok(false); }; diff --git a/vortex-geo/src/extension/mod.rs b/vortex-geo/src/extension/mod.rs index 37f903aa0ca..4e4896aa5e5 100644 --- a/vortex-geo/src/extension/mod.rs +++ b/vortex-geo/src/extension/mod.rs @@ -10,11 +10,19 @@ mod wkb; use std::fmt::Display; use std::sync::Arc; +use ::wkb::reader::GeometryType; +use arrow_array::BinaryArray; use geo_types::Geometry; +use geoarrow::array::GenericWkbArray; use geoarrow::array::GeoArrowArray; +use geoarrow::datatypes::CoordType; use geoarrow::datatypes::Crs; +use geoarrow::datatypes::Dimension; use geoarrow::datatypes::GeoArrowType; use geoarrow::datatypes::Metadata; +use geoarrow::datatypes::MultiPolygonType; +use geoarrow::datatypes::PointType; +use geoarrow::datatypes::PolygonType; use geoarrow::datatypes::WkbType; use geoarrow_cast::cast::cast; pub use multipolygon::*; @@ -27,6 +35,8 @@ use vortex_array::arrays::ConstantArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::extension::ExtensionArrayExt; use vortex_array::arrow::FromArrowArray; +use vortex_array::dtype::extension::ExtDType; +use vortex_array::dtype::extension::ExtVTable; use vortex_array::scalar::Scalar; use vortex_error::VortexResult; use vortex_error::vortex_bail; @@ -72,6 +82,63 @@ pub(crate) fn single_geometry( .ok_or_else(|| vortex_err!("geo: constant operand decoded to no geometry")) } +/// Decode a WKB geometry literal (DuckDB's wire form for `GEOMETRY` constants) to its native +/// `Point`/`Polygon`/`MultiPolygon` scalar. `None` for unsupported types. Plan-time, one value only. +pub fn native_geometry_scalar_from_wkb(bytes: &[u8]) -> VortexResult> { + let metadata = geoarrow_metadata(&GeoMetadata::default()); + let binary = BinaryArray::from(vec![Some(bytes)]); + let wkb = GenericWkbArray::::try_from(( + &binary as &dyn arrow_array::Array, + WkbType::new(Arc::clone(&metadata)), + )) + .map_err(|e| vortex_err!("failed to read WKB literal: {e}"))?; + + // Cast the WKB value to `target`, import its native storage as a Vortex array. + let to_storage = |target: &GeoArrowType| -> VortexResult { + let native = + cast(&wkb, target).map_err(|e| vortex_err!("failed to cast WKB literal: {e}"))?; + ArrayRef::from_arrow(native.to_array_ref().as_ref(), false) + }; + + let scalar = match Wkb::try_from_bytes(bytes)?.geometry_type() { + GeometryType::Point => { + let target = GeoArrowType::Point( + PointType::new(Dimension::XY, metadata).with_coord_type(CoordType::Separated), + ); + geo_ext_scalar(Point, to_storage(&target)?)? + } + GeometryType::Polygon => { + let target = GeoArrowType::Polygon( + PolygonType::new(Dimension::XY, metadata).with_coord_type(CoordType::Separated), + ); + geo_ext_scalar(Polygon, to_storage(&target)?)? + } + GeometryType::MultiPolygon => { + let target = GeoArrowType::MultiPolygon( + MultiPolygonType::new(Dimension::XY, metadata) + .with_coord_type(CoordType::Separated), + ); + geo_ext_scalar(MultiPolygon, to_storage(&target)?)? + } + _ => return Ok(None), + }; + Ok(Some(scalar)) +} + +/// Wrap cast-from-WKB `storage` in its `vtable` extension type and pull out the single scalar. +// `scalar_at` is deprecated for `execute_scalar`, but there is no execution context at plan time. +#[allow(deprecated)] +fn geo_ext_scalar>( + vtable: V, + storage: ArrayRef, +) -> VortexResult { + let ext = ExtDType::try_with_vtable(vtable, GeoMetadata::default(), storage.dtype().clone())? + .erased(); + ExtensionArray::try_new(ext, storage)? + .into_array() + .scalar_at(0) +} + /// Extension metadata that is common to all the geospatial extension types. /// /// Currently, this is just the coordinate reference system (CRS). @@ -130,7 +197,13 @@ pub(crate) fn geo_metadata_from_arrow(metadata: &Metadata) -> GeoMetadata { #[cfg(test)] mod tests { use prost::Message; + use vortex_array::dtype::DType; + use vortex_error::VortexResult; + use vortex_error::vortex_err; + use super::Point; + use super::Polygon; + use super::native_geometry_scalar_from_wkb; use crate::extension::GeoMetadata; #[test] @@ -145,4 +218,42 @@ mod tests { let decoded = GeoMetadata::decode(bytes.as_slice()).unwrap(); assert_eq!(decoded, meta); } + + /// A little-endian WKB `POINT` literal decodes to the native `Point` extension scalar. + #[test] + fn decodes_wkb_point_to_native() -> VortexResult<()> { + let mut wkb = vec![1u8]; // little-endian byte order + wkb.extend_from_slice(&1u32.to_le_bytes()); // geometry type: point + wkb.extend_from_slice(&1.0f64.to_le_bytes()); // x + wkb.extend_from_slice(&2.0f64.to_le_bytes()); // y + + let scalar = native_geometry_scalar_from_wkb(&wkb)?.expect("a point scalar"); + let DType::Extension(ext) = scalar.dtype() else { + panic!("expected an extension dtype, got {}", scalar.dtype()); + }; + assert!(ext.is::()); + Ok(()) + } + + /// A little-endian WKB `POLYGON` literal decodes to the native `Polygon` extension scalar. + #[test] + fn decodes_wkb_polygon_to_native() -> VortexResult<()> { + let ring = [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (0.0, 0.0)]; + let mut wkb = vec![1u8]; // little-endian byte order + wkb.extend_from_slice(&3u32.to_le_bytes()); // geometry type: polygon + wkb.extend_from_slice(&1u32.to_le_bytes()); // one ring + let ring_len = u32::try_from(ring.len()).map_err(|e| vortex_err!("{e}"))?; + wkb.extend_from_slice(&ring_len.to_le_bytes()); + for (x, y) in ring { + wkb.extend_from_slice(&f64::to_le_bytes(x)); + wkb.extend_from_slice(&f64::to_le_bytes(y)); + } + + let scalar = native_geometry_scalar_from_wkb(&wkb)?.expect("a polygon scalar"); + let DType::Extension(ext) = scalar.dtype() else { + panic!("expected an extension dtype, got {}", scalar.dtype()); + }; + assert!(ext.is::()); + Ok(()) + } } diff --git a/vortex-geo/src/scalar_fn/distance.rs b/vortex-geo/src/scalar_fn/distance.rs index 6531c1dd8f2..b894946eecb 100644 --- a/vortex-geo/src/scalar_fn/distance.rs +++ b/vortex-geo/src/scalar_fn/distance.rs @@ -10,8 +10,12 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::Constant; use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::ScalarFnArray; +use vortex_array::arrays::StructArray; +use vortex_array::arrays::extension::ExtensionArrayExt; +use vortex_array::arrays::struct_::StructArrayExt; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; @@ -28,6 +32,8 @@ use vortex_error::vortex_ensure; use vortex_session::VortexSession; use vortex_session::registry::CachedId; +use crate::extension::Point; +use crate::extension::coordinate::coordinate_from_struct; use crate::extension::geometries; use crate::extension::single_geometry; @@ -101,14 +107,25 @@ impl ScalarFnVTable for GeoDistance { (Some(query), None) => distances_to_constant(&b, query.scalar(), ctx), (None, Some(query)) => distances_to_constant(&a, query.scalar(), ctx), (None, None) => { - let ag = geometries(&a, ctx)?; - let bg = geometries(&b, ctx)?; vortex_ensure!( - ag.len() == bg.len(), + a.len() == b.len(), "geo distance: operand length mismatch {} vs {}", - ag.len(), - bg.len() + a.len(), + b.len() ); + // Fast path: two Point columns, distance straight over their `x`/`y` f64 buffers. + if is_nonnull_point(a.dtype()) && is_nonnull_point(b.dtype()) { + let (xa, ya) = point_xy(&a, ctx)?; + let (xb, yb) = point_xy(&b, ctx)?; + return Ok(point_distances( + xa.as_slice::().iter().copied(), + ya.as_slice::().iter().copied(), + xb.as_slice::().iter().copied(), + yb.as_slice::().iter().copied(), + )); + } + let ag = geometries(&a, ctx)?; + let bg = geometries(&b, ctx)?; let distances = ag.iter().zip(&bg).map(|(x, y)| Euclidean.distance(x, y)); Ok(PrimitiveArray::from_iter(distances).into_array()) } @@ -123,12 +140,73 @@ fn distances_to_constant( query: &Scalar, ctx: &mut ExecutionCtx, ) -> VortexResult { + // Fast path: Point column vs constant Point, `x`/`y` f64 buffers, broadcasting the constant. + if is_nonnull_point(operand.dtype()) && is_point(query.dtype()) { + let q = coordinate_from_struct(&query.as_extension().to_storage_scalar())?; + let (xs, ys) = point_xy(operand, ctx)?; + return Ok(point_distances( + xs.as_slice::().iter().copied(), + ys.as_slice::().iter().copied(), + std::iter::repeat(q.x), + std::iter::repeat(q.y), + )); + } + let query = single_geometry(query, ctx)?; let geoms = geometries(operand, ctx)?; let distances = geoms.iter().map(|g| Euclidean.distance(g, &query)); Ok(PrimitiveArray::from_iter(distances).into_array()) } +/// Extract the `x` and `y` `f64` columns from a native `Point` operand, for the columnar fast paths. +fn point_xy( + operand: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult<(PrimitiveArray, PrimitiveArray)> { + let storage = operand + .clone() + .execute::(ctx)? + .storage_array() + .clone() + .execute::(ctx)?; + let xs = storage + .unmasked_field_by_name("x")? + .clone() + .execute::(ctx)?; + let ys = storage + .unmasked_field_by_name("y")? + .clone() + .execute::(ctx)?; + Ok((xs, ys)) +} + +/// Per-row planar distance `sqrt(dx^2 + dy^2)` over two `(x, y)` f64 streams; a constant side is fed +/// as `repeat(c)`. +fn point_distances( + xa: impl Iterator, + ya: impl Iterator, + xb: impl Iterator, + yb: impl Iterator, +) -> ArrayRef { + let distances = xa.zip(ya).zip(xb.zip(yb)).map(|((xa, ya), (xb, yb))| { + let (dx, dy) = (xa - xb, ya - yb); + (dx * dx + dy * dy).sqrt() + }); + PrimitiveArray::from_iter(distances).into_array() +} + +/// Whether `dtype` is the native `Point` extension (eligible for the columnar fast path). +fn is_point(dtype: &DType) -> bool { + dtype + .as_extension_opt() + .is_some_and(|ext| ext.is::()) +} + +/// A non-nullable native `Point`, a column operand the fast path can read straight from `x`/`y`. +fn is_nonnull_point(dtype: &DType) -> bool { + is_point(dtype) && !dtype.is_nullable() +} + #[cfg(test)] mod tests { use vortex_array::ArrayRef; From f8b18d9ab66a61540d0419f0439ccd3adbecba2a Mon Sep 17 00:00:00 2001 From: Joe Isaacs Date: Sat, 4 Jul 2026 01:46:50 +0100 Subject: [PATCH 011/104] Force initialisation of VortexSession before bench loop (#8479) Change benchmarks to force initialisation of vortex session before start of benchmarking loop --- vortex-array/benches/aggregate_grouped.rs | 1 + vortex-array/benches/aggregate_max.rs | 4 +++- vortex-array/benches/aggregate_sum.rs | 4 +++- vortex-array/benches/binary_ops.rs | 4 +++- vortex-array/benches/bool_zip.rs | 4 +++- vortex-array/benches/cast_primitive.rs | 4 +++- vortex-array/benches/chunk_array_builder.rs | 4 +++- vortex-array/benches/chunked_dict_builder.rs | 1 + vortex-array/benches/chunked_fsl_canonicalize.rs | 4 +++- vortex-array/benches/dict_compare.rs | 4 +++- vortex-array/benches/dict_compress.rs | 4 +++- vortex-array/benches/expr/case_when_bench.rs | 4 +++- vortex-array/benches/filter_bool.rs | 4 +++- vortex-array/benches/listview_rebuild.rs | 4 +++- vortex-array/benches/primitive_zip.rs | 13 +++++++------ vortex-array/benches/scalar_at_struct.rs | 4 +++- vortex-array/benches/scalar_subtract.rs | 11 +++++------ vortex-array/benches/take_filter.rs | 4 +++- vortex-array/benches/take_fsl.rs | 4 +++- vortex-array/benches/take_patches.rs | 4 +++- vortex-array/benches/take_primitive.rs | 4 +++- vortex-array/benches/take_struct.rs | 4 +++- vortex-array/benches/to_arrow.rs | 14 +++----------- vortex-array/benches/varbinview_zip.rs | 4 +++- vortex-compressor/benches/dict_encode.rs | 4 +++- vortex-row/benches/row_encode.rs | 4 +++- vortex/benches/common_encoding_tree_throughput.rs | 1 + vortex/benches/single_encoding_throughput.rs | 1 + 28 files changed, 82 insertions(+), 44 deletions(-) diff --git a/vortex-array/benches/aggregate_grouped.rs b/vortex-array/benches/aggregate_grouped.rs index fdc43dbcabd..11477e57503 100644 --- a/vortex-array/benches/aggregate_grouped.rs +++ b/vortex-array/benches/aggregate_grouped.rs @@ -28,6 +28,7 @@ use vortex_buffer::Buffer; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } diff --git a/vortex-array/benches/aggregate_max.rs b/vortex-array/benches/aggregate_max.rs index 4da7d1fc653..a47bdb28164 100644 --- a/vortex-array/benches/aggregate_max.rs +++ b/vortex-array/benches/aggregate_max.rs @@ -7,16 +7,18 @@ use divan::Bencher; use rand::prelude::*; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::PrimitiveArray; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } const N: usize = 100_000; -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); #[divan::bench] fn max_i32(bencher: Bencher) { diff --git a/vortex-array/benches/aggregate_sum.rs b/vortex-array/benches/aggregate_sum.rs index 81402b24750..98e6845edb7 100644 --- a/vortex-array/benches/aggregate_sum.rs +++ b/vortex-array/benches/aggregate_sum.rs @@ -7,17 +7,19 @@ use divan::Bencher; use rand::prelude::*; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::PrimitiveArray; use vortex_array::expr::stats::Stat; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } const N: usize = 100_000; -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); #[divan::bench] fn sum_i32(bencher: Bencher) { diff --git a/vortex-array/benches/binary_ops.rs b/vortex-array/benches/binary_ops.rs index 98c69413193..d1f664f8645 100644 --- a/vortex-array/benches/binary_ops.rs +++ b/vortex-array/benches/binary_ops.rs @@ -15,6 +15,7 @@ use vortex_array::ArrayRef; use vortex_array::Executable; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::BoolArray; use vortex_array::arrays::ConstantArray; use vortex_array::arrays::PrimitiveArray; @@ -23,10 +24,11 @@ use vortex_array::scalar_fn::fns::operators::Operator; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); const LEN: usize = 65_536; diff --git a/vortex-array/benches/bool_zip.rs b/vortex-array/benches/bool_zip.rs index 40496fd1f0b..d1fc96c457e 100644 --- a/vortex-array/benches/bool_zip.rs +++ b/vortex-array/benches/bool_zip.rs @@ -10,16 +10,18 @@ use vortex_array::ArrayRef; use vortex_array::IntoArray; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::BoolArray; use vortex_array::builtins::ArrayBuiltins; use vortex_mask::Mask; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); const LEN: usize = 65_536; diff --git a/vortex-array/benches/cast_primitive.rs b/vortex-array/benches/cast_primitive.rs index 3fd89270517..69a4c5d2cc5 100644 --- a/vortex-array/benches/cast_primitive.rs +++ b/vortex-array/benches/cast_primitive.rs @@ -10,6 +10,7 @@ use rand::prelude::*; use vortex_array::Canonical; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::PrimitiveArray; use vortex_array::builtins::ArrayBuiltins; use vortex_array::dtype::DType; @@ -19,6 +20,7 @@ use vortex_array::expr::stats::Stat; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } @@ -26,7 +28,7 @@ fn main() { // the kernel cost shows up clearly rather than being hidden by DRAM bandwidth. const SIZES: &[usize] = &[65_536]; -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); #[divan::bench(args = SIZES)] fn cast_u16_to_u32(bencher: Bencher, n: usize) { diff --git a/vortex-array/benches/chunk_array_builder.rs b/vortex-array/benches/chunk_array_builder.rs index 6582071a6e1..124db6e0f61 100644 --- a/vortex-array/benches/chunk_array_builder.rs +++ b/vortex-array/benches/chunk_array_builder.rs @@ -11,6 +11,7 @@ use vortex_array::ArrayRef; use vortex_array::Canonical; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::BoolArray; use vortex_array::arrays::ChunkedArray; use vortex_array::arrays::ConstantArray; @@ -22,6 +23,7 @@ use vortex_error::VortexExpect; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } @@ -32,7 +34,7 @@ const BENCH_ARGS: &[(usize, usize)] = &[ (1000, 10), ]; -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); #[divan::bench(args = BENCH_ARGS)] fn chunked_bool_canonical_into(bencher: Bencher, (len, chunk_count): (usize, usize)) { diff --git a/vortex-array/benches/chunked_dict_builder.rs b/vortex-array/benches/chunked_dict_builder.rs index 74722235e54..de2f40dcbf5 100644 --- a/vortex-array/benches/chunked_dict_builder.rs +++ b/vortex-array/benches/chunked_dict_builder.rs @@ -15,6 +15,7 @@ use vortex_error::VortexExpect; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } diff --git a/vortex-array/benches/chunked_fsl_canonicalize.rs b/vortex-array/benches/chunked_fsl_canonicalize.rs index 4277b33382e..9be8433ce9d 100644 --- a/vortex-array/benches/chunked_fsl_canonicalize.rs +++ b/vortex-array/benches/chunked_fsl_canonicalize.rs @@ -16,6 +16,7 @@ use divan::Bencher; use vortex_array::Canonical; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::ChunkedArray; use vortex_array::arrays::FixedSizeListArray; use vortex_array::validity::Validity; @@ -23,10 +24,11 @@ use vortex_buffer::Buffer; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); /// Number of lists in each chunk. const LISTS_PER_CHUNK: usize = 1_000; diff --git a/vortex-array/benches/dict_compare.rs b/vortex-array/benches/dict_compare.rs index 446623939f1..5b31cf51831 100644 --- a/vortex-array/benches/dict_compare.rs +++ b/vortex-array/benches/dict_compare.rs @@ -9,6 +9,7 @@ use vortex_array::Canonical; use vortex_array::IntoArray; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::ConstantArray; use vortex_array::arrays::VarBinArray; use vortex_array::arrays::VarBinViewArray; @@ -24,10 +25,11 @@ use vortex_array::scalar_fn::fns::operators::Operator; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); const LENGTH_AND_UNIQUE_VALUES: &[(usize, usize)] = &[ // length, unique_values diff --git a/vortex-array/benches/dict_compress.rs b/vortex-array/benches/dict_compress.rs index 2901d82c6b5..6dd8150dc02 100644 --- a/vortex-array/benches/dict_compress.rs +++ b/vortex-array/benches/dict_compress.rs @@ -11,6 +11,7 @@ use rand::distr::StandardUniform; use vortex_array::Canonical; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::VarBinArray; use vortex_array::arrays::VarBinViewArray; use vortex_array::arrays::dict_test::gen_primitive_for_dict; @@ -20,6 +21,7 @@ use vortex_array::dtype::NativePType; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } @@ -37,7 +39,7 @@ const BENCH_ARGS: &[(usize, usize)] = &[ (10_000, 512), ]; -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); #[divan::bench(types = [u8, f32, i64], args = BENCH_ARGS)] fn encode_primitives(bencher: Bencher, (len, unique_values): (usize, usize)) diff --git a/vortex-array/benches/expr/case_when_bench.rs b/vortex-array/benches/expr/case_when_bench.rs index 1bada8fa9e1..c7b0e96686d 100644 --- a/vortex-array/benches/expr/case_when_bench.rs +++ b/vortex-array/benches/expr/case_when_bench.rs @@ -11,6 +11,7 @@ use vortex_array::ArrayRef; use vortex_array::Canonical; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::BoolArray; use vortex_array::arrays::StructArray; use vortex_array::expr::case_when; @@ -25,9 +26,10 @@ use vortex_array::expr::root; use vortex_buffer::Buffer; use vortex_session::VortexSession; -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); fn main() { + LazyLock::force(&SESSION); divan::main(); } diff --git a/vortex-array/benches/filter_bool.rs b/vortex-array/benches/filter_bool.rs index cd31805bb77..20782a539b8 100644 --- a/vortex-array/benches/filter_bool.rs +++ b/vortex-array/benches/filter_bool.rs @@ -18,16 +18,18 @@ use rand::prelude::*; use rand_distr::Zipf; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::BoolArray; use vortex_buffer::BitBuffer; use vortex_mask::Mask; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); const SIZES: &[usize] = &[1_000, 10_000, 100_000, 250_000]; const DENSITY_SWEEP_SIZE: usize = 100_000; diff --git a/vortex-array/benches/listview_rebuild.rs b/vortex-array/benches/listview_rebuild.rs index 81157d52b5a..2e61b969244 100644 --- a/vortex-array/benches/listview_rebuild.rs +++ b/vortex-array/benches/listview_rebuild.rs @@ -12,6 +12,7 @@ use divan::Bencher; use vortex_array::Canonical; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::FixedSizeListArray; use vortex_array::arrays::ListArray; use vortex_array::arrays::ListViewArray; @@ -26,11 +27,12 @@ use vortex_buffer::Buffer; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } /// A shared session for the `ListView` rebuild benchmarks, used to create execution contexts. -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); fn make_primitive_lv(num_lists: usize, list_size: usize, step: usize) -> ListViewArray { let element_count = step * num_lists + list_size; diff --git a/vortex-array/benches/primitive_zip.rs b/vortex-array/benches/primitive_zip.rs index adcabf51d7d..93e83b372dc 100644 --- a/vortex-array/benches/primitive_zip.rs +++ b/vortex-array/benches/primitive_zip.rs @@ -10,20 +10,24 @@ use std::sync::LazyLock; use divan::Bencher; +use vortex_array::ArrayRef; use vortex_array::IntoArray; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::PrimitiveArray; use vortex_array::builtins::ArrayBuiltins; +use vortex_array::validity::Validity; use vortex_buffer::BufferMut; use vortex_mask::Mask; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); // Sized so the bench stays well under a few hundred microseconds under CodSpeed's instruction-count // simulation, which runs ~10x the local walltime; the branchless value blend is still exercised. @@ -49,7 +53,7 @@ fn nullable(bencher: Bencher) { run(bencher, if_true, if_false); } -fn run(bencher: Bencher, if_true: vortex_array::ArrayRef, if_false: vortex_array::ArrayRef) { +fn run(bencher: Bencher, if_true: ArrayRef, if_false: ArrayRef) { let mask = mask(); bencher .with_inputs(|| { @@ -71,10 +75,7 @@ fn run(bencher: Bencher, if_true: vortex_array::ArrayRef, if_false: vortex_array fn nonnull_array(base: i64) -> PrimitiveArray { let mut values = BufferMut::::with_capacity(LEN); values.extend((0..LEN as i64).map(|i| base + i)); - PrimitiveArray::new( - values.freeze(), - vortex_array::validity::Validity::NonNullable, - ) + PrimitiveArray::new(values.freeze(), Validity::NonNullable) } fn nullable_array(base: i64, null_every: usize) -> PrimitiveArray { diff --git a/vortex-array/benches/scalar_at_struct.rs b/vortex-array/benches/scalar_at_struct.rs index 3921f0a2366..8dca44e408a 100644 --- a/vortex-array/benches/scalar_at_struct.rs +++ b/vortex-array/benches/scalar_at_struct.rs @@ -13,6 +13,7 @@ use rand::rngs::StdRng; use vortex_array::ArrayRef; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::StructArray; use vortex_array::dtype::FieldNames; use vortex_array::validity::Validity; @@ -20,13 +21,14 @@ use vortex_buffer::Buffer; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } const ARRAY_SIZE: usize = 100_000; const NUM_ACCESSES: usize = 1000; -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); #[divan::bench] fn execute_scalar_struct_simple(bencher: Bencher) { diff --git a/vortex-array/benches/scalar_subtract.rs b/vortex-array/benches/scalar_subtract.rs index ee9aba1055d..ef004d2e270 100644 --- a/vortex-array/benches/scalar_subtract.rs +++ b/vortex-array/benches/scalar_subtract.rs @@ -13,18 +13,21 @@ use rand::rngs::StdRng; use vortex_array::IntoArray; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::ChunkedArray; use vortex_array::arrays::ConstantArray; use vortex_array::builtins::ArrayBuiltins; +use vortex_array::scalar::Scalar; use vortex_array::scalar_fn::fns::operators::Operator; use vortex_buffer::Buffer; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); #[divan::bench] fn scalar_subtract(bencher: Bencher) { @@ -50,11 +53,7 @@ fn scalar_subtract(bencher: Bencher) { chunked .clone() .binary( - ConstantArray::new( - vortex_array::scalar::Scalar::from(to_subtract), - chunked.len(), - ) - .into_array(), + ConstantArray::new(Scalar::from(to_subtract), chunked.len()).into_array(), Operator::Sub, ) .unwrap() diff --git a/vortex-array/benches/take_filter.rs b/vortex-array/benches/take_filter.rs index b6ec1b4040c..96852a91966 100644 --- a/vortex-array/benches/take_filter.rs +++ b/vortex-array/benches/take_filter.rs @@ -28,6 +28,7 @@ use vortex_array::ArrayRef; use vortex_array::IntoArray; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::ListArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::validity::Validity; @@ -38,6 +39,7 @@ use vortex_mask::Mask; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } @@ -57,7 +59,7 @@ const INDEX_SEED: u64 = 43; const LIST_SIZE: usize = 4; const NULL_INDEX_INTERVAL: usize = 8; -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); fn primitive_array() -> ArrayRef { PrimitiveArray::from_iter(0..ARRAY_LEN as u32).into_array() diff --git a/vortex-array/benches/take_fsl.rs b/vortex-array/benches/take_fsl.rs index 438faae4983..5dc491c28c5 100644 --- a/vortex-array/benches/take_fsl.rs +++ b/vortex-array/benches/take_fsl.rs @@ -19,16 +19,18 @@ use rand::rngs::StdRng; use vortex_array::IntoArray; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::FixedSizeListArray; use vortex_array::validity::Validity; use vortex_buffer::Buffer; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); /// Number of lists in the source array. const NUM_LISTS: usize = 500; diff --git a/vortex-array/benches/take_patches.rs b/vortex-array/benches/take_patches.rs index 5dc0c04c325..b935f97b54b 100644 --- a/vortex-array/benches/take_patches.rs +++ b/vortex-array/benches/take_patches.rs @@ -15,15 +15,17 @@ use vortex_array::IntoArray; #[expect(deprecated)] use vortex_array::ToCanonical as _; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::patches::Patches; use vortex_buffer::Buffer; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); const BENCH_ARGS: &[(f64, f64)] = &[ // patches_sparsity, index_multiple diff --git a/vortex-array/benches/take_primitive.rs b/vortex-array/benches/take_primitive.rs index 383b525dcc6..471363b99e4 100644 --- a/vortex-array/benches/take_primitive.rs +++ b/vortex-array/benches/take_primitive.rs @@ -17,11 +17,13 @@ use rand_distr::Zipf; use vortex_array::Canonical; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::DictArray; use vortex_array::arrays::PrimitiveArray; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } @@ -31,7 +33,7 @@ const NUM_INDICES: &[usize] = &[1_000, 10_000, 100_000]; /// Size of the source vector / dictionary values. const VECTOR_SIZE: &[usize] = &[16, 256, 2048, 8192]; -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); // --- DictArray canonicalization benchmarks --- diff --git a/vortex-array/benches/take_struct.rs b/vortex-array/benches/take_struct.rs index d49ce9e88a6..9f7e518cb3a 100644 --- a/vortex-array/benches/take_struct.rs +++ b/vortex-array/benches/take_struct.rs @@ -13,6 +13,7 @@ use rand::rngs::StdRng; use vortex_array::IntoArray; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::StructArray; use vortex_array::dtype::FieldNames; use vortex_array::validity::Validity; @@ -20,10 +21,11 @@ use vortex_buffer::Buffer; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); const ARRAY_SIZE: usize = 100_000; const TAKE_SIZE: usize = 1000; diff --git a/vortex-array/benches/to_arrow.rs b/vortex-array/benches/to_arrow.rs index 48e5e917476..99fc0fa4f80 100644 --- a/vortex-array/benches/to_arrow.rs +++ b/vortex-array/benches/to_arrow.rs @@ -10,6 +10,7 @@ use divan::Bencher; use vortex_array::ArrayRef; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::DecimalArray; use vortex_array::arrays::ListArray; use vortex_array::arrays::PrimitiveArray; @@ -28,10 +29,11 @@ use vortex_array::dtype::StructFields; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); fn schema() -> DType { let fields = StructFields::from_iter([ @@ -89,9 +91,6 @@ fn to_arrow_dtype(bencher: Bencher) { #[allow(non_snake_case)] #[divan::bench] fn ArrowExportVTable_to_arrow_field(bencher: Bencher) { - // Warm the ArrowSession - drop(SESSION.arrow().to_arrow_field("", &schema()).unwrap()); - bencher .with_inputs(schema) .bench_values(|dtype| SESSION.arrow().to_arrow_field("", &dtype).unwrap()) @@ -110,13 +109,6 @@ fn to_arrow_array(bencher: Bencher) { #[allow(non_snake_case)] #[divan::bench] fn ArrowExportVTable_execute_arrow(bencher: Bencher) { - // Warm the ArrowSession - drop( - SESSION - .arrow() - .execute_arrow(array(), None, &mut SESSION.create_execution_ctx()), - ); - bencher .with_inputs(|| (array(), SESSION.create_execution_ctx())) .bench_values(|(array, mut ctx)| { diff --git a/vortex-array/benches/varbinview_zip.rs b/vortex-array/benches/varbinview_zip.rs index e010291e9dc..b2d55b1b60d 100644 --- a/vortex-array/benches/varbinview_zip.rs +++ b/vortex-array/benches/varbinview_zip.rs @@ -9,6 +9,7 @@ use divan::Bencher; use vortex_array::IntoArray; use vortex_array::RecursiveCanonical; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::VarBinViewArray; use vortex_array::builtins::ArrayBuiltins; use vortex_array::dtype::DType; @@ -17,10 +18,11 @@ use vortex_mask::Mask; use vortex_session::VortexSession; fn main() { + LazyLock::force(&SESSION); divan::main(); } -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); /// Benchmarks zip on VarBinView arrays with a highly fragmented mask (worst case for per-slice lookup paths). #[divan::bench] diff --git a/vortex-compressor/benches/dict_encode.rs b/vortex-compressor/benches/dict_encode.rs index 2c4e24108a7..ec6343b6f40 100644 --- a/vortex-compressor/benches/dict_encode.rs +++ b/vortex-compressor/benches/dict_encode.rs @@ -8,6 +8,7 @@ use std::sync::LazyLock; use divan::Bencher; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::BoolArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::builders::dict::dict_encode; @@ -15,7 +16,7 @@ use vortex_array::validity::Validity; use vortex_buffer::BufferMut; use vortex_session::VortexSession; -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); fn make_array() -> PrimitiveArray { let values: BufferMut = (0..50).cycle().take(64_000).collect(); @@ -41,5 +42,6 @@ fn encode_generic(bencher: Bencher) { } fn main() { + LazyLock::force(&SESSION); divan::main() } diff --git a/vortex-row/benches/row_encode.rs b/vortex-row/benches/row_encode.rs index 0747c5facac..deea043280b 100644 --- a/vortex-row/benches/row_encode.rs +++ b/vortex-row/benches/row_encode.rs @@ -30,6 +30,7 @@ use rand::distr::Alphanumeric; use rand::rngs::StdRng; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::StructArray; use vortex_array::arrays::VarBinViewArray; @@ -41,9 +42,10 @@ static GLOBAL: MiMalloc = MiMalloc; const N: usize = 100_000; -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); fn main() { + LazyLock::force(&SESSION); divan::main(); } diff --git a/vortex/benches/common_encoding_tree_throughput.rs b/vortex/benches/common_encoding_tree_throughput.rs index 1d1dcf86956..65c2c1bc040 100644 --- a/vortex/benches/common_encoding_tree_throughput.rs +++ b/vortex/benches/common_encoding_tree_throughput.rs @@ -50,6 +50,7 @@ use vortex_session::VortexSession; static GLOBAL: MiMalloc = MiMalloc; fn main() { + LazyLock::force(&SESSION); divan::main(); } diff --git a/vortex/benches/single_encoding_throughput.rs b/vortex/benches/single_encoding_throughput.rs index b75f832a7a1..ca0a8926233 100644 --- a/vortex/benches/single_encoding_throughput.rs +++ b/vortex/benches/single_encoding_throughput.rs @@ -49,6 +49,7 @@ static GLOBAL: MiMalloc = MiMalloc; static SESSION: LazyLock = LazyLock::new(VortexSession::default); fn main() { + LazyLock::force(&SESSION); divan::main(); } From a390ad25f93c2e99827c18c61f40ea7d76f90c98 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:21:55 +0000 Subject: [PATCH 012/104] Update Rust crate cxx to v1.0.195 [SECURITY] (#8653) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [cxx](https://cxx.rs) ([source](https://redirect.github.com/dtolnay/cxx)) | dependencies | patch | `1.0.194` → `1.0.195` | --- > [!WARNING] > Some dependencies could not be looked up. Check the [Dependency Dashboard](../issues/357) for more information. --- ### `let_cxx_string!` uses uninitialized value due to exception safety violations [RUSTSEC-2026-0202](https://rustsec.org/advisories/RUSTSEC-2026-0202.html)

More information #### Details In affected versions of this crate, `let_cxx_string!` is not exception safe. After creating the `StackString`, if `match $value` panics, the content of `StackString` is not yet initialized, while the drop implementation of `StackString` unconditionally deinitializes the content, leading to use of uninitialized value. The soundness issue was fixed in version `1.0.195` by moving drop logics to separate drop guard after initializing the `StackString`. #### Severity Unknown #### References - [https://crates.io/crates/cxx](https://crates.io/crates/cxx) - [https://rustsec.org/advisories/RUSTSEC-2026-0202.html](https://rustsec.org/advisories/RUSTSEC-2026-0202.html) - [https://github.com/dtolnay/cxx/issues/1729](https://redirect.github.com/dtolnay/cxx/issues/1729) This data is provided by [OSV](https://osv.dev/vulnerability/RUSTSEC-2026-0202) and the [Rust Advisory Database](https://redirect.github.com/RustSec/advisory-db) ([CC0 1.0](https://redirect.github.com/rustsec/advisory-db/blob/main/LICENSE.txt)).
--- ### Release Notes
dtolnay/cxx (cxx) ### [`v1.0.195`](https://redirect.github.com/dtolnay/cxx/releases/tag/1.0.195) [Compare Source](https://redirect.github.com/dtolnay/cxx/compare/1.0.194...1.0.195) - Fix use of uninitialized value in `let_cxx_string!` on panic inside initialization expression ([#​1729](https://redirect.github.com/dtolnay/cxx/issues/1729), [#​1731](https://redirect.github.com/dtolnay/cxx/issues/1731))
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/vortex-data/vortex). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 50 +++++++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d99d2124e45..e232ebdeb66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,7 +125,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -136,7 +136,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1443,7 +1443,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.2.2", + "unicode-width 0.1.14", ] [[package]] @@ -1545,7 +1545,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1999,9 +1999,9 @@ dependencies = [ [[package]] name = "cxx" -version = "1.0.194" +version = "1.0.195" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "747d8437319e3a2f43d93b341c137927ca70c0f5dabeea7a005a73665e247c7e" +checksum = "201f2332bd98022973bbf50c87f2d2f8151200f43e640da370901b20f1bea9d3" dependencies = [ "cc", "cxx-build", @@ -2014,9 +2014,9 @@ dependencies = [ [[package]] name = "cxx-build" -version = "1.0.194" +version = "1.0.195" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0f4697d190a142477b16aef7da8a99bfdc41e7e8b1687583c0d23a79c7afc1e" +checksum = "02da7762e3807681f61c4561f34e1ff791417334294b225dbe22236459c9deef" dependencies = [ "cc", "codespan-reporting", @@ -2029,9 +2029,9 @@ dependencies = [ [[package]] name = "cxxbridge-cmd" -version = "1.0.194" +version = "1.0.195" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0956799fa8678d4c50eed028f2de1c0552ae183c76e976cf7ca8c4e36a7c328" +checksum = "21c216b8cd7c26c2798bbecdb13de2f8dc65239b9ed892756deacff10fc0fa64" dependencies = [ "clap", "codespan-reporting", @@ -2043,15 +2043,15 @@ dependencies = [ [[package]] name = "cxxbridge-flags" -version = "1.0.194" +version = "1.0.195" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23384a836ab4f0ad98ace7e3955ad2de39de42378ab487dc28d3990392cb283a" +checksum = "61c618f09812e388d1d065fd43fdc4340fade506ab2e0903397bf238650fce45" [[package]] name = "cxxbridge-macro" -version = "1.0.194" +version = "1.0.195" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6acc6b5822b9526adfb4fc377b67128fdd60aac757cc4a741a6278603f763cf" +checksum = "415b113deba00cc605302b9b0b4974ab3d57c41135d81737cd47b89dbaa17c38" dependencies = [ "indexmap 2.14.0", "proc-macro2", @@ -3611,7 +3611,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3797,7 +3797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4978,7 +4978,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6351,7 +6351,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8128,7 +8128,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8186,7 +8186,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -8691,7 +8691,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8803,7 +8803,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -9052,7 +9052,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -9071,7 +9071,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -11102,7 +11102,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] From 5e656ee93e48626b3fac5114ba94eb30c53d06c8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:22:38 +0000 Subject: [PATCH 013/104] Lock file maintenance (#8655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Update | Change | |---|---| | lockFileMaintenance | All locks refreshed | --- > [!WARNING] > Some dependencies could not be looked up. Check the [Dependency Dashboard](../issues/357) for more information. 🔧 This Pull Request updates lock files to use the latest dependency versions. --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/vortex-data/vortex). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- benchmarks-website/package-lock.json | 156 +++---- vortex-web/package-lock.json | 634 +++++++++++++-------------- 2 files changed, 395 insertions(+), 395 deletions(-) diff --git a/benchmarks-website/package-lock.json b/benchmarks-website/package-lock.json index 377aa90823c..7eb09dbfa84 100644 --- a/benchmarks-website/package-lock.json +++ b/benchmarks-website/package-lock.json @@ -88,9 +88,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.137.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", - "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", "dev": true, "license": "MIT", "funding": { @@ -98,9 +98,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", - "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", "cpu": [ "arm64" ], @@ -115,9 +115,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", - "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", "cpu": [ "arm64" ], @@ -132,9 +132,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", - "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", "cpu": [ "x64" ], @@ -149,9 +149,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", - "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", "cpu": [ "x64" ], @@ -166,9 +166,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", - "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", "cpu": [ "arm" ], @@ -183,9 +183,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", - "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", "cpu": [ "arm64" ], @@ -203,9 +203,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", - "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", "cpu": [ "arm64" ], @@ -223,9 +223,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", - "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", "cpu": [ "ppc64" ], @@ -243,9 +243,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", - "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", "cpu": [ "s390x" ], @@ -263,9 +263,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", - "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", "cpu": [ "x64" ], @@ -283,9 +283,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", - "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", "cpu": [ "x64" ], @@ -303,9 +303,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", - "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", "cpu": [ "arm64" ], @@ -320,9 +320,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", - "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", "cpu": [ "wasm32" ], @@ -339,9 +339,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", - "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", "cpu": [ "arm64" ], @@ -356,9 +356,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", - "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", "cpu": [ "x64" ], @@ -925,9 +925,9 @@ } }, "node_modules/lucide-react": { - "version": "1.22.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.22.0.tgz", - "integrity": "sha512-c9o3l0PiNcgOQDW4F31BEYHudE7kgxVt3o30qMl36ZPwTxXlGB4QnLilhERvVM4uh/pl5MDyY1/gzZSYcHDtBg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", + "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -960,9 +960,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -1033,13 +1033,13 @@ } }, "node_modules/rolldown": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", - "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.137.0", + "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -1049,21 +1049,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.3", - "@rolldown/binding-darwin-arm64": "1.1.3", - "@rolldown/binding-darwin-x64": "1.1.3", - "@rolldown/binding-freebsd-x64": "1.1.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", - "@rolldown/binding-linux-arm64-gnu": "1.1.3", - "@rolldown/binding-linux-arm64-musl": "1.1.3", - "@rolldown/binding-linux-ppc64-gnu": "1.1.3", - "@rolldown/binding-linux-s390x-gnu": "1.1.3", - "@rolldown/binding-linux-x64-gnu": "1.1.3", - "@rolldown/binding-linux-x64-musl": "1.1.3", - "@rolldown/binding-openharmony-arm64": "1.1.3", - "@rolldown/binding-wasm32-wasi": "1.1.3", - "@rolldown/binding-win32-arm64-msvc": "1.1.3", - "@rolldown/binding-win32-x64-msvc": "1.1.3" + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, "node_modules/rxjs": { @@ -1187,16 +1187,16 @@ "license": "0BSD" }, "node_modules/vite": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", - "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "~1.1.2", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "bin": { diff --git a/vortex-web/package-lock.json b/vortex-web/package-lock.json index 30dfa75c102..af39d6492eb 100644 --- a/vortex-web/package-lock.json +++ b/vortex-web/package-lock.json @@ -1450,9 +1450,9 @@ } }, "node_modules/@oxc-resolver/binding-android-arm-eabi": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.3.tgz", - "integrity": "sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.23.0.tgz", + "integrity": "sha512-8IJyWRLVAyhTfe9/TIEbQqSQnl5rUqYJrUOS6Dkr+Mq9FGHMxDGeiEmwkBqCvDP5KckpPh/GYSgbag66O6JsCw==", "cpu": [ "arm" ], @@ -1464,9 +1464,9 @@ ] }, "node_modules/@oxc-resolver/binding-android-arm64": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.21.3.tgz", - "integrity": "sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.23.0.tgz", + "integrity": "sha512-pprVojnNhHxupwTT2gdeUlkxll6XEvWWBk3oVicOSNVWQC99OBnDhMQDoirqnzrE1bScQSMS2JgPpqdlrhz/Fg==", "cpu": [ "arm64" ], @@ -1478,9 +1478,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-arm64": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.21.3.tgz", - "integrity": "sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.23.0.tgz", + "integrity": "sha512-mbIrWIMAJeytyee36OyUP5XH92TP7FaKaQ2m5AjokKy7STgjrhRt7SMXqpqLjhGm6Xn721Xmsg6H3Rtd9YQETw==", "cpu": [ "arm64" ], @@ -1492,9 +1492,9 @@ ] }, "node_modules/@oxc-resolver/binding-darwin-x64": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.21.3.tgz", - "integrity": "sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.23.0.tgz", + "integrity": "sha512-UnIphmZ1LazUCr9DXWaKYWtKDefPMbgLsywaoYxRqVCNHhq4MM6d2q1Nz1i9Vzxt5i+cE2nRUYpAUHr/lijNYA==", "cpu": [ "x64" ], @@ -1506,9 +1506,9 @@ ] }, "node_modules/@oxc-resolver/binding-freebsd-x64": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.21.3.tgz", - "integrity": "sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.23.0.tgz", + "integrity": "sha512-aaZ/cSEYFkSxgS2hOrobT6RQcsWNviOX8dW6CEkVx2/UYkAf9MeHbjl3W0usWV53rVV//ndBdn2nb1y7jsu4lw==", "cpu": [ "x64" ], @@ -1520,9 +1520,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.21.3.tgz", - "integrity": "sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.23.0.tgz", + "integrity": "sha512-IoJLvO5SjLSVMaq83BNTrPCb1FppvoJc1IhZ5CoUVl3PykUBku7D+LK1j0GSurhJcIc6zfjghsvaZNpq5ev6Mg==", "cpu": [ "arm" ], @@ -1534,9 +1534,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.21.3.tgz", - "integrity": "sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.23.0.tgz", + "integrity": "sha512-vskFpwg44T/LFsfjSCnVZ5ygcuqzPC1yUzVEiKa8BgHAQz0+QLQQW3EGWLPVi8EXFghzjR4EtgPBtOhCjU4jdw==", "cpu": [ "arm" ], @@ -1548,9 +1548,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.21.3.tgz", - "integrity": "sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.23.0.tgz", + "integrity": "sha512-//TcHVhrChyw5RYtgts6WO7KcWq9387c1Z5Zvhqpk/ktAbyaRYgBZrpSY1GDCFq50ASt6B6jhh+JxB1rB45IAg==", "cpu": [ "arm64" ], @@ -1565,9 +1565,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-arm64-musl": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.21.3.tgz", - "integrity": "sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.23.0.tgz", + "integrity": "sha512-ZFqlwiTf7CXLLSGyAR9tYiO33LiaeIEXW+xm42d8mnUGpDgPltyrCGYtQezyMMEXvjhOgCz1X+i7sbDTJEx+bg==", "cpu": [ "arm64" ], @@ -1582,9 +1582,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.21.3.tgz", - "integrity": "sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.23.0.tgz", + "integrity": "sha512-oZ5LeN5+H1R19dRjTAxKrxQguH+AsemHcnthEfFxf4OjmBSty2doHLeSmMunKy3zpTHJQ3lh3Af+dNS+W6dYeA==", "cpu": [ "ppc64" ], @@ -1599,9 +1599,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.21.3.tgz", - "integrity": "sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.23.0.tgz", + "integrity": "sha512-O4ciFDyX5ebQd0qkb1bjAIg8IEfiLT03GbSeylwlwlUMK9KwBWaALwrxSbc0Msaz4U6iPj+T9eRXpD5mxBfmvA==", "cpu": [ "riscv64" ], @@ -1616,9 +1616,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.21.3.tgz", - "integrity": "sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.23.0.tgz", + "integrity": "sha512-P3o8Y9kISYjcxadmbO+94ThRwLhwGuDAbA7dcdd4+YLpfeF+mmobz8fXf4NmSdfSqjyRSkceJDBRZha9NVYkiQ==", "cpu": [ "riscv64" ], @@ -1633,9 +1633,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.21.3.tgz", - "integrity": "sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.23.0.tgz", + "integrity": "sha512-oj03m1E3RmTFczKhcKJDzHaEDKJnPIsDcQFVxBJsSdXGSuIPdt5TvcM332FfMQgzI6yDJqyl4InrnFfXrmUTKQ==", "cpu": [ "s390x" ], @@ -1650,9 +1650,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-gnu": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.21.3.tgz", - "integrity": "sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.23.0.tgz", + "integrity": "sha512-BqJxbSC8FdP7mSuSpRePTGHm0hXWV+dfz//f7SjsteZncLaBgWTBmi/OZNv7sX6CyG/Pt/eJkPorP+DkMOhMwQ==", "cpu": [ "x64" ], @@ -1667,9 +1667,9 @@ ] }, "node_modules/@oxc-resolver/binding-linux-x64-musl": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.21.3.tgz", - "integrity": "sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.23.0.tgz", + "integrity": "sha512-utmw+VmUrW4K8LI5/6jhg4aGYKJHOIjQ9syYOOA6pF3w7haKu4r4enTe2U0C04/HbUvkq/Zif43xFsKW1Pnq9w==", "cpu": [ "x64" ], @@ -1684,9 +1684,9 @@ ] }, "node_modules/@oxc-resolver/binding-openharmony-arm64": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.21.3.tgz", - "integrity": "sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.23.0.tgz", + "integrity": "sha512-V6lbRrthHa4TbvsLjPtg+EkXT1tRY+s4I8rYLXUfiHlZzGx3sLv1EH9CEOOevjvUYHLsbe/gqCIc73XnQfPb9A==", "cpu": [ "arm64" ], @@ -1698,9 +1698,9 @@ ] }, "node_modules/@oxc-resolver/binding-wasm32-wasi": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.21.3.tgz", - "integrity": "sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.23.0.tgz", + "integrity": "sha512-gRoOxQPdnAmIAjxcuQNBxfihvx+wjTaQM/9/eP12xwnGNawOG/+Zz9RHN4WNSxT45b5CrscK4NB8aPh+oZQXAQ==", "cpu": [ "wasm32" ], @@ -1708,18 +1708,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.11.0", - "@emnapi/runtime": "1.11.0", - "@napi-rs/wasm-runtime": "^1.1.5" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", - "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, @@ -1729,9 +1729,9 @@ } }, "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", - "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -1751,9 +1751,9 @@ } }, "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.3.tgz", - "integrity": "sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.23.0.tgz", + "integrity": "sha512-CgTGMYsJVe1eUiCdJTpGw21svXw79ITsemN1h0hcNkiswasDbN5MoibSLY+gRMWP5syfEz5iffrjZnwEP8xeUA==", "cpu": [ "arm64" ], @@ -1765,9 +1765,9 @@ ] }, "node_modules/@oxc-resolver/binding-win32-x64-msvc": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.3.tgz", - "integrity": "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.23.0.tgz", + "integrity": "sha512-gUGJpr+Rn6zMxm5juApV0K3U845i8t47o8k+rbO0BHbi4PoJIfSPeQmrE2dgohQm2g5k6iviNFyXCGqvmaYUpw==", "cpu": [ "x64" ], @@ -1779,9 +1779,9 @@ ] }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", - "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", "cpu": [ "arm64" ], @@ -1796,9 +1796,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", - "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", "cpu": [ "arm64" ], @@ -1813,9 +1813,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", - "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", "cpu": [ "x64" ], @@ -1830,9 +1830,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", - "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", "cpu": [ "x64" ], @@ -1847,9 +1847,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", - "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", "cpu": [ "arm" ], @@ -1864,9 +1864,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", - "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", "cpu": [ "arm64" ], @@ -1884,9 +1884,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", - "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", "cpu": [ "arm64" ], @@ -1904,9 +1904,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", - "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", "cpu": [ "ppc64" ], @@ -1924,9 +1924,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", - "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", "cpu": [ "s390x" ], @@ -1944,9 +1944,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", - "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", "cpu": [ "x64" ], @@ -1964,9 +1964,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", - "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", "cpu": [ "x64" ], @@ -1984,9 +1984,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", - "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", "cpu": [ "arm64" ], @@ -2001,9 +2001,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", - "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", "cpu": [ "wasm32" ], @@ -2054,9 +2054,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", - "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", "cpu": [ "arm64" ], @@ -2071,9 +2071,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", - "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", "cpu": [ "x64" ], @@ -2317,9 +2317,9 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", - "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", "dev": true, "license": "MIT", "dependencies": { @@ -2329,37 +2329,37 @@ "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.1" + "tailwindcss": "4.3.2" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", - "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-x64": "4.3.1", - "@tailwindcss/oxide-freebsd-x64": "4.3.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-x64-musl": "4.3.1", - "@tailwindcss/oxide-wasm32-wasi": "4.3.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", - "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", "cpu": [ "arm64" ], @@ -2374,9 +2374,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", - "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", "cpu": [ "arm64" ], @@ -2391,9 +2391,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", - "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", "cpu": [ "x64" ], @@ -2408,9 +2408,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", - "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", "cpu": [ "x64" ], @@ -2425,9 +2425,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", - "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", "cpu": [ "arm" ], @@ -2442,9 +2442,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", - "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", "cpu": [ "arm64" ], @@ -2462,9 +2462,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", - "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", "cpu": [ "arm64" ], @@ -2482,9 +2482,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", - "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", "cpu": [ "x64" ], @@ -2502,9 +2502,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", - "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", "cpu": [ "x64" ], @@ -2522,9 +2522,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", - "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -2540,9 +2540,9 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" @@ -2552,18 +2552,18 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", + "version": "1.11.1", "dev": true, "inBundle": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", + "version": "1.11.1", "dev": true, "inBundle": true, "license": "MIT", @@ -2573,7 +2573,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", + "version": "1.2.2", "dev": true, "inBundle": true, "license": "MIT", @@ -2618,9 +2618,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", - "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", "cpu": [ "arm64" ], @@ -2635,9 +2635,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", - "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", "cpu": [ "x64" ], @@ -2652,15 +2652,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", - "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.1", - "@tailwindcss/oxide": "4.3.1", - "tailwindcss": "4.3.1" + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -2687,12 +2687,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.14.4", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.4.tgz", - "integrity": "sha512-dZzAQP2uCDAd+9sAehqmx/DcU+B91Q4Gb0aDSM7t9bJvWDyGF9sapFNW5r1gNLsHs4wTb6ScZENJeYaHxJLiOw==", + "version": "3.14.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.5.tgz", + "integrity": "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.17.2" + "@tanstack/virtual-core": "3.17.3" }, "funding": { "type": "github", @@ -2717,9 +2717,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.17.2", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.2.tgz", - "integrity": "sha512-w43MvWvmShpb6kIC9MOoLyUkLmRTLPjt61bHWs+X29hACSpX+n8DvgZ3qM7cUfflKlRRcHR9KVJE6TmcqnQvcA==", + "version": "3.17.3", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.3.tgz", + "integrity": "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==", "license": "MIT", "funding": { "type": "github", @@ -2961,17 +2961,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", - "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", + "integrity": "sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.62.0", - "@typescript-eslint/type-utils": "8.62.0", - "@typescript-eslint/utils": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/type-utils": "8.62.1", + "@typescript-eslint/utils": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2984,7 +2984,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.62.0", + "@typescript-eslint/parser": "^8.62.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -3000,16 +3000,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", - "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.1.tgz", + "integrity": "sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.62.0", - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0", + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3" }, "engines": { @@ -3025,14 +3025,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", - "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.1.tgz", + "integrity": "sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.62.0", - "@typescript-eslint/types": "^8.62.0", + "@typescript-eslint/tsconfig-utils": "^8.62.1", + "@typescript-eslint/types": "^8.62.1", "debug": "^4.4.3" }, "engines": { @@ -3047,14 +3047,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", - "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.1.tgz", + "integrity": "sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0" + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3065,9 +3065,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", - "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.1.tgz", + "integrity": "sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==", "dev": true, "license": "MIT", "engines": { @@ -3082,15 +3082,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", - "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.1.tgz", + "integrity": "sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0", - "@typescript-eslint/utils": "8.62.0", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -3107,9 +3107,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", - "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.1.tgz", + "integrity": "sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -3121,16 +3121,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", - "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.1.tgz", + "integrity": "sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.62.0", - "@typescript-eslint/tsconfig-utils": "8.62.0", - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/visitor-keys": "8.62.0", + "@typescript-eslint/project-service": "8.62.1", + "@typescript-eslint/tsconfig-utils": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/visitor-keys": "8.62.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3162,16 +3162,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", - "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.1.tgz", + "integrity": "sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.62.0", - "@typescript-eslint/types": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0" + "@typescript-eslint/scope-manager": "8.62.1", + "@typescript-eslint/types": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3186,13 +3186,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", - "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.1.tgz", + "integrity": "sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/types": "8.62.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3433,9 +3433,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.40", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", - "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3446,9 +3446,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": { @@ -3509,9 +3509,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", "dev": true, "funding": [ { @@ -3808,9 +3808,9 @@ "peer": true }, "node_modules/electron-to-chromium": { - "version": "1.5.380", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", - "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==", + "version": "1.5.387", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", + "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", "dev": true, "license": "ISC" }, @@ -5108,34 +5108,34 @@ } }, "node_modules/oxc-resolver": { - "version": "11.21.3", - "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.3.tgz", - "integrity": "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA==", + "version": "11.23.0", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.23.0.tgz", + "integrity": "sha512-f0+l598CJMOLnYPXsXxttJALH0ljtivdRMKtvHhxRuWa5FYmw5+qODARl8oYjMC/brpzKcrpdORsOBrTqhBZ9A==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxc-resolver/binding-android-arm-eabi": "11.21.3", - "@oxc-resolver/binding-android-arm64": "11.21.3", - "@oxc-resolver/binding-darwin-arm64": "11.21.3", - "@oxc-resolver/binding-darwin-x64": "11.21.3", - "@oxc-resolver/binding-freebsd-x64": "11.21.3", - "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3", - "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3", - "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3", - "@oxc-resolver/binding-linux-arm64-musl": "11.21.3", - "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3", - "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3", - "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3", - "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3", - "@oxc-resolver/binding-linux-x64-gnu": "11.21.3", - "@oxc-resolver/binding-linux-x64-musl": "11.21.3", - "@oxc-resolver/binding-openharmony-arm64": "11.21.3", - "@oxc-resolver/binding-wasm32-wasi": "11.21.3", - "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3", - "@oxc-resolver/binding-win32-x64-msvc": "11.21.3" + "@oxc-resolver/binding-android-arm-eabi": "11.23.0", + "@oxc-resolver/binding-android-arm64": "11.23.0", + "@oxc-resolver/binding-darwin-arm64": "11.23.0", + "@oxc-resolver/binding-darwin-x64": "11.23.0", + "@oxc-resolver/binding-freebsd-x64": "11.23.0", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.23.0", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.23.0", + "@oxc-resolver/binding-linux-arm64-gnu": "11.23.0", + "@oxc-resolver/binding-linux-arm64-musl": "11.23.0", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.23.0", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.23.0", + "@oxc-resolver/binding-linux-riscv64-musl": "11.23.0", + "@oxc-resolver/binding-linux-s390x-gnu": "11.23.0", + "@oxc-resolver/binding-linux-x64-gnu": "11.23.0", + "@oxc-resolver/binding-linux-x64-musl": "11.23.0", + "@oxc-resolver/binding-openharmony-arm64": "11.23.0", + "@oxc-resolver/binding-wasm32-wasi": "11.23.0", + "@oxc-resolver/binding-win32-arm64-msvc": "11.23.0", + "@oxc-resolver/binding-win32-x64-msvc": "11.23.0" } }, "node_modules/p-limit": { @@ -5242,9 +5242,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -5294,9 +5294,9 @@ } }, "node_modules/prettier": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.1.tgz", - "integrity": "sha512-ppiDo2CSwexck1eyZUwJHg/N3nf1+6IRCv7W/VJ5vaLnVCmB7+3CdRfMwoCHBBX6xTrREDTksZ4OZl5SSf4zXA==", + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", "dev": true, "license": "MIT", "bin": { @@ -5477,13 +5477,13 @@ } }, "node_modules/rolldown": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", - "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.137.0", + "@oxc-project/types": "=0.138.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5493,27 +5493,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.3", - "@rolldown/binding-darwin-arm64": "1.1.3", - "@rolldown/binding-darwin-x64": "1.1.3", - "@rolldown/binding-freebsd-x64": "1.1.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", - "@rolldown/binding-linux-arm64-gnu": "1.1.3", - "@rolldown/binding-linux-arm64-musl": "1.1.3", - "@rolldown/binding-linux-ppc64-gnu": "1.1.3", - "@rolldown/binding-linux-s390x-gnu": "1.1.3", - "@rolldown/binding-linux-x64-gnu": "1.1.3", - "@rolldown/binding-linux-x64-musl": "1.1.3", - "@rolldown/binding-openharmony-arm64": "1.1.3", - "@rolldown/binding-wasm32-wasi": "1.1.3", - "@rolldown/binding-win32-arm64-msvc": "1.1.3", - "@rolldown/binding-win32-x64-msvc": "1.1.3" + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, "node_modules/rolldown/node_modules/@oxc-project/types": { - "version": "0.137.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", - "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", "dev": true, "license": "MIT", "funding": { @@ -5714,9 +5714,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", - "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", "dev": true, "license": "MIT" }, @@ -5850,16 +5850,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.62.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz", - "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", + "version": "8.62.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.1.tgz", + "integrity": "sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.62.0", - "@typescript-eslint/parser": "8.62.0", - "@typescript-eslint/typescript-estree": "8.62.0", - "@typescript-eslint/utils": "8.62.0" + "@typescript-eslint/eslint-plugin": "8.62.1", + "@typescript-eslint/parser": "8.62.1", + "@typescript-eslint/typescript-estree": "8.62.1", + "@typescript-eslint/utils": "8.62.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5956,16 +5956,16 @@ } }, "node_modules/vite": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", - "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "~1.1.2", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", "tinyglobby": "^0.2.17" }, "bin": { From b0d7e8c5baa89edc3a4986a2ab7a0a377900a21d Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Mon, 6 Jul 2026 12:19:48 +0100 Subject: [PATCH 014/104] Add bit iteration benchmarks (#8656) Add benchmarks for bitbuffer iteration methods --- Cargo.lock | 1 + encodings/runend/Cargo.toml | 4 + encodings/runend/benches/run_end_filter.rs | 113 +++++++++++++++++ encodings/runend/src/compute/filter.rs | 2 +- encodings/runend/src/lib.rs | 1 + vortex-array/Cargo.toml | 4 + vortex-array/benches/validity_is_valid.rs | 71 +++++++++++ vortex-duckdb/Cargo.toml | 5 + vortex-duckdb/benches/bool_export.rs | 58 +++++++++ vortex-mask/Cargo.toml | 8 ++ vortex-mask/benches/mask_iteration.rs | 141 +++++++++++++++++++++ vortex-mask/benches/valid_counts.rs | 51 ++++++++ 12 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 encodings/runend/benches/run_end_filter.rs create mode 100644 vortex-array/benches/validity_is_valid.rs create mode 100644 vortex-duckdb/benches/bool_export.rs create mode 100644 vortex-mask/benches/mask_iteration.rs create mode 100644 vortex-mask/benches/valid_counts.rs diff --git a/Cargo.lock b/Cargo.lock index e232ebdeb66..af0d17a2c00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10217,6 +10217,7 @@ dependencies = [ "bitvec", "cbindgen", "cc", + "codspeed-divan-compat", "custom-labels", "futures", "geo-types", diff --git a/encodings/runend/Cargo.toml b/encodings/runend/Cargo.toml index 268aa6ac3ca..c7b38d2494f 100644 --- a/encodings/runend/Cargo.toml +++ b/encodings/runend/Cargo.toml @@ -56,3 +56,7 @@ harness = false [[bench]] name = "run_end_take" harness = false + +[[bench]] +name = "run_end_filter" +harness = false diff --git a/encodings/runend/benches/run_end_filter.rs b/encodings/runend/benches/run_end_filter.rs new file mode 100644 index 00000000000..395b41063b1 --- /dev/null +++ b/encodings/runend/benches/run_end_filter.rs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Benchmarks for the run-end filter inner loop (`filter_run_end_primitive`). +//! +//! This measures the kernel directly rather than going through the lazy +//! `ArrayRef::filter` (which only builds a `FilterArray` node and does not run +//! the kernel). The hot work is a per-run popcount of the predicate mask, which +//! now uses `BitBuffer::count_range` (SIMD) instead of a bit-by-bit walk. + +#![expect(clippy::cast_possible_truncation)] +#![expect(clippy::cast_precision_loss)] +#![expect(clippy::cast_sign_loss)] +#![expect(clippy::expect_used)] + +use std::fmt; + +use divan::Bencher; +use rand::SeedableRng; +use rand::rngs::StdRng; +use rand::seq::SliceRandom; +use vortex_buffer::BitBuffer; +use vortex_runend::_benchmarking::filter_run_end_primitive; + +fn main() { + divan::main(); +} + +#[derive(Clone, Copy)] +struct FilterBenchArgs { + /// Total logical length of the decoded array. + length: usize, + /// Average run length used when building the run-end array. + run_length: usize, + /// Fraction of mask bits that are set to `true`. + density: f64, +} + +impl fmt::Display for FilterBenchArgs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "len={}_run={}_density={:.1}", + self.length, self.run_length, self.density + ) + } +} + +const FILTER_ARGS: &[FilterBenchArgs] = &[ + FilterBenchArgs { + length: 4_096, + run_length: 16, + density: 0.1, + }, + FilterBenchArgs { + length: 4_096, + run_length: 16, + density: 0.5, + }, + FilterBenchArgs { + length: 4_096, + run_length: 16, + density: 0.9, + }, + FilterBenchArgs { + length: 16_384, + run_length: 16, + density: 0.1, + }, + FilterBenchArgs { + length: 16_384, + run_length: 16, + density: 0.5, + }, + FilterBenchArgs { + length: 16_384, + run_length: 16, + density: 0.9, + }, +]; + +/// Build the run-end boundaries (cumulative run lengths) for `length` rows. +fn build_run_ends(length: usize, run_length: usize) -> Vec { + let n_runs = length.div_ceil(run_length); + (0..n_runs) + .map(|r| (((r + 1) * run_length).min(length)) as u32) + .collect() +} + +/// Build a predicate mask of `length` bits with approximately `density` set bits, +/// shuffled so the set bits are spread across runs. +fn build_mask(length: usize, density: f64) -> BitBuffer { + let n_true = (length as f64 * density).round() as usize; + let mut bits = vec![false; length]; + for b in bits.iter_mut().take(n_true) { + *b = true; + } + let mut rng = StdRng::seed_from_u64(0x5eed); + bits.shuffle(&mut rng); + BitBuffer::from(bits) +} + +#[divan::bench(args = FILTER_ARGS)] +fn filter_run_end(bencher: Bencher, args: FilterBenchArgs) { + let run_ends = build_run_ends(args.length, args.run_length); + let mask = build_mask(args.length, args.density); + let length = args.length as u64; + bencher + .with_inputs(|| (run_ends.clone(), mask.clone())) + .bench_refs(|(run_ends, mask)| { + filter_run_end_primitive::(run_ends, 0, length, mask).expect("filter") + }); +} diff --git a/encodings/runend/src/compute/filter.rs b/encodings/runend/src/compute/filter.rs index ca99fdf1912..c460dd6552d 100644 --- a/encodings/runend/src/compute/filter.rs +++ b/encodings/runend/src/compute/filter.rs @@ -74,7 +74,7 @@ impl FilterKernel for RunEnd { } // Code adapted from apache arrow-rs https://github.com/apache/arrow-rs/blob/b1f5c250ebb6c1252b4e7c51d15b8e77f4c361fa/arrow-select/src/filter.rs#L425 -fn filter_run_end_primitive + AsPrimitive>( +pub fn filter_run_end_primitive + AsPrimitive>( run_ends: &[R], offset: u64, length: u64, diff --git a/encodings/runend/src/lib.rs b/encodings/runend/src/lib.rs index 8169f5d8dfc..a0b26b54dab 100644 --- a/encodings/runend/src/lib.rs +++ b/encodings/runend/src/lib.rs @@ -21,6 +21,7 @@ mod rules; #[doc(hidden)] pub mod _benchmarking { + pub use compute::filter::filter_run_end_primitive; pub use compute::take::take_indices_unchecked; use super::*; diff --git a/vortex-array/Cargo.toml b/vortex-array/Cargo.toml index c83397b50d8..4d8ef4ea2bb 100644 --- a/vortex-array/Cargo.toml +++ b/vortex-array/Cargo.toml @@ -200,6 +200,10 @@ required-features = ["_test-harness"] name = "dict_mask" harness = false +[[bench]] +name = "validity_is_valid" +harness = false + [[bench]] name = "dict_unreferenced_mask" harness = false diff --git a/vortex-array/benches/validity_is_valid.rs b/vortex-array/benches/validity_is_valid.rs new file mode 100644 index 00000000000..dc22fcb5478 --- /dev/null +++ b/vortex-array/benches/validity_is_valid.rs @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Benchmarks the cost of checking array-backed [`Validity`] per element. +//! +//! For the `Validity::Array` variant, `Validity::execute_is_valid` runs a scalar lookup through +//! the compute stack on *every* call, and the (now deprecated) `is_valid` additionally spins up a +//! fresh `ExecutionCtx` per call. Either way, calling it in a `for i in 0..n` loop is +//! pathologically slow. The fix used by callers is to materialize the validity into a `Mask` once +//! (`execute_mask`) and then do cheap O(1) bit reads via `Mask::value`. This bench contrasts the two. +//! +//! Sizes are kept small because the per-element variant is intentionally the slow one we are +//! measuring. + +#![allow(clippy::unwrap_used)] + +use std::sync::LazyLock; + +use divan::Bencher; +use vortex_array::VortexSessionExecute; +use vortex_array::array_session; +use vortex_array::validity::Validity; +use vortex_buffer::BitBuffer; +use vortex_session::VortexSession; + +fn main() { + divan::main(); +} + +const SIZES: &[usize] = &[256, 1024]; + +/// Build an array-backed validity (~10% nulls so it is `Validity::Array`). +fn array_validity(len: usize) -> Validity { + Validity::from(BitBuffer::from_iter( + (0..len).map(|i| !i.is_multiple_of(10)), + )) +} + +static SESSION: LazyLock = LazyLock::new(array_session); + +/// Per-element validity check over array-backed validity (the antipattern). This mirrors the +/// deprecated `Validity::is_valid(i)`: a fresh `ExecutionCtx` plus a scalar lookup on every call. +#[divan::bench(args = SIZES)] +fn is_valid_per_element(bencher: Bencher, len: usize) { + let validity = array_validity(len); + bencher + .with_inputs(|| (&validity, SESSION.create_execution_ctx())) + .bench_refs(|(validity, ctx)| { + let mut count = 0usize; + for i in 0..len { + count += validity.execute_is_valid(i, ctx).unwrap() as usize; + } + count + }); +} + +/// Materialize the validity into a `Mask` once, then read bits (the fix). +#[divan::bench(args = SIZES)] +fn execute_mask_then_value(bencher: Bencher, len: usize) { + let validity = array_validity(len); + bencher + .with_inputs(|| (&validity, SESSION.create_execution_ctx())) + .bench_refs(|(validity, ctx)| { + let mask = validity.execute_mask(len, ctx).unwrap(); + let mut count = 0usize; + for i in 0..len { + count += mask.value(i) as usize; + } + count + }); +} diff --git a/vortex-duckdb/Cargo.toml b/vortex-duckdb/Cargo.toml index b6896910a89..01a0e114fde 100644 --- a/vortex-duckdb/Cargo.toml +++ b/vortex-duckdb/Cargo.toml @@ -45,6 +45,7 @@ vortex-utils = { workspace = true, features = ["dashmap"] } [dev-dependencies] anyhow = { workspace = true } +divan = { workspace = true } geo-types = { workspace = true } jiff = { workspace = true } rstest = { workspace = true } @@ -54,6 +55,10 @@ vortex-runend = { workspace = true } vortex-sequence = { workspace = true } wkb = { workspace = true } +[[bench]] +name = "bool_export" +harness = false + [lints] workspace = true diff --git a/vortex-duckdb/benches/bool_export.rs b/vortex-duckdb/benches/bool_export.rs new file mode 100644 index 00000000000..f8094b554ae --- /dev/null +++ b/vortex-duckdb/benches/bool_export.rs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Microbenchmark for the bit -> byte-bool unpack performed by `BoolExporter::export`. +//! +//! DuckDB stores booleans as one byte per value (`&mut [bool]`), while Vortex stores them +//! bit-packed in a [`BitBuffer`]. On every boolean column export we unpack a slice of the +//! bit buffer into the destination byte slice. +//! +//! This bench isolates that pure unpacking logic so it can run without a live DuckDB +//! vector: a reused `Vec` stands in for the DuckDB byte-bool destination. It compares +//! the previous implementation (`.iter().collect::>()` followed by +//! `copy_from_slice`) against the new allocation-free direct zip-write. + +use divan::Bencher; +use vortex::buffer::BitBuffer; + +fn main() { + divan::main(); +} + +const SIZES: &[usize] = &[1024, 16_384]; + +/// Bit densities (true ratio), expressed as `(numerator, denominator)`. +const DENSITIES: &[(usize, usize)] = &[(1, 2), (1, 10), (9, 10)]; + +fn make_buffer(len: usize, density: (usize, usize)) -> BitBuffer { + let (num, den) = density; + // `+ 8` so we can slice off a non-byte-aligned offset, matching real exports. + BitBuffer::from_iter((0..len + 8).map(|i| (i % den) < num)) +} + +/// Previous implementation: allocate a throwaway `Vec` then copy into the destination. +#[divan::bench(args = SIZES, consts = [0usize, 1usize, 2usize])] +fn old_collect_copy(bencher: Bencher, len: usize) { + let buffer = make_buffer(len, DENSITIES[DENSITY_IDX]); + bencher + .with_inputs(|| vec![false; len]) + .bench_refs(|dst: &mut Vec| { + // Offset by 1 to exercise a non-byte-aligned slice like the export hot path. + dst.copy_from_slice(&buffer.slice(1..(1 + len)).iter().collect::>()); + divan::black_box(&dst); + }); +} + +/// New implementation: zip the sliced bit iterator directly into the destination slice. +#[divan::bench(args = SIZES, consts = [0usize, 1usize, 2usize])] +fn new_zip_write(bencher: Bencher, len: usize) { + let buffer = make_buffer(len, DENSITIES[DENSITY_IDX]); + bencher + .with_inputs(|| vec![false; len]) + .bench_refs(|dst: &mut Vec| { + for (slot, bit) in dst.iter_mut().zip(buffer.slice(1..(1 + len)).iter()) { + *slot = bit; + } + divan::black_box(&dst); + }); +} diff --git a/vortex-mask/Cargo.toml b/vortex-mask/Cargo.toml index 7aa5bb2ef85..cf681101d43 100644 --- a/vortex-mask/Cargo.toml +++ b/vortex-mask/Cargo.toml @@ -37,5 +37,13 @@ harness = false name = "rank" harness = false +[[bench]] +name = "valid_counts" +harness = false + +[[bench]] +name = "mask_iteration" +harness = false + [lints] workspace = true diff --git a/vortex-mask/benches/mask_iteration.rs b/vortex-mask/benches/mask_iteration.rs new file mode 100644 index 00000000000..50f5c321303 --- /dev/null +++ b/vortex-mask/benches/mask_iteration.rs @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Strategy comparison for "process the valid elements of a mask". +//! +//! The kernel is `sum of values[i] for every set bit i` — a stand-in for any +//! validity-gated loop. We compare: +//! +//! * `per_element_value` — `for i in 0..n { if buf.value(i) {..} }` (what callers +//! typically write after materializing validity into a mask) +//! * `bit_iterator` — arrow's `BitIterator` (tracks a word internally) +//! * `word_trailing_zeros`— iterate `u64` words, fast-path all-set/all-unset, and +//! walk set bits with `trailing_zeros` / `w &= w - 1` +//! * `set_slices` — iterate contiguous true runs (`set_slices`) +//! * `set_indices` — iterate set positions (`set_indices`) +//! +//! Run across densities to expose the dense-vs-sparse crossover. + +#![allow(clippy::unwrap_used, clippy::cast_possible_truncation)] + +use divan::Bencher; +use vortex_buffer::BitBuffer; + +fn main() { + divan::main(); +} + +const ARGS: &[(usize, f64)] = &[ + (16_384, 0.01), + (16_384, 0.10), + (16_384, 0.50), + (16_384, 0.90), + (16_384, 0.99), +]; + +fn make(len: usize, density: f64) -> (BitBuffer, Vec) { + let threshold = (density * 1000.0) as usize; + let buf = BitBuffer::from_iter((0..len).map(|i| (i * 7 + 13) % 1000 < threshold)); + let values = (0..len as u64).collect(); + (buf, values) +} + +#[divan::bench(args = ARGS)] +fn per_element_value(bencher: Bencher, (len, density): (usize, f64)) { + let (buf, values) = make(len, density); + bencher + .with_inputs(|| (&buf, &values)) + .bench_refs(|(buf, values)| { + let mut acc = 0u64; + for i in 0..len { + if buf.value(i) { + acc = acc.wrapping_add(values[i]); + } + } + acc + }); +} + +#[divan::bench(args = ARGS)] +fn bit_iterator(bencher: Bencher, (len, density): (usize, f64)) { + let (buf, values) = make(len, density); + bencher + .with_inputs(|| (&buf, &values)) + .bench_refs(|(buf, values)| { + let mut acc = 0u64; + for (i, set) in buf.iter().enumerate() { + if set { + acc = acc.wrapping_add(values[i]); + } + } + acc + }); +} + +#[divan::bench(args = ARGS)] +fn word_trailing_zeros(bencher: Bencher, (len, density): (usize, f64)) { + let (buf, values) = make(len, density); + bencher + .with_inputs(|| (&buf, &values)) + .bench_refs(|(buf, values)| { + let mut acc = 0u64; + let chunks = buf.chunks(); + let mut base = 0usize; + for word in chunks.iter() { + if word == u64::MAX { + for k in 0..64 { + acc = acc.wrapping_add(values[base + k]); + } + } else { + let mut w = word; + while w != 0 { + let b = w.trailing_zeros() as usize; + acc = acc.wrapping_add(values[base + b]); + w &= w - 1; + } + } + base += 64; + } + let mut w = chunks.remainder_bits(); + let rem = buf.len() - base; + while w != 0 { + let b = w.trailing_zeros() as usize; + if b >= rem { + break; + } + acc = acc.wrapping_add(values[base + b]); + w &= w - 1; + } + acc + }); +} + +#[divan::bench(args = ARGS)] +fn set_slices(bencher: Bencher, (len, density): (usize, f64)) { + let (buf, values) = make(len, density); + bencher + .with_inputs(|| (&buf, &values)) + .bench_refs(|(buf, values)| { + let mut acc = 0u64; + for (start, end) in buf.set_slices() { + for i in start..end { + acc = acc.wrapping_add(values[i]); + } + } + acc + }); +} + +#[divan::bench(args = ARGS)] +fn set_indices(bencher: Bencher, (len, density): (usize, f64)) { + let (buf, values) = make(len, density); + bencher + .with_inputs(|| (&buf, &values)) + .bench_refs(|(buf, values)| { + let mut acc = 0u64; + for i in buf.set_indices() { + acc = acc.wrapping_add(values[i]); + } + acc + }); +} diff --git a/vortex-mask/benches/valid_counts.rs b/vortex-mask/benches/valid_counts.rs new file mode 100644 index 00000000000..425a021fa6f --- /dev/null +++ b/vortex-mask/benches/valid_counts.rs @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Benchmarks for `Mask::valid_counts_for_indices`. +//! +//! This mirrors the hot path in the pco/zstd slice decoders, which call +//! `valid_counts_for_indices(&[slice_start, slice_stop])` to translate a row +//! range into a value range. The cost is dominated by counting set bits in the +//! prefix `[0, slice_stop)`, so a SIMD popcount over the bit buffer should beat +//! a bit-by-bit walk handily for large `slice_stop`. + +#![allow(clippy::unwrap_used, clippy::cast_possible_truncation)] + +use divan::Bencher; +use vortex_buffer::BitBuffer; +use vortex_mask::Mask; + +fn main() { + divan::main(); +} + +const BENCH_SIZES: &[(usize, f64)] = &[(4_096, 0.1), (4_096, 0.9), (16_384, 0.1), (16_384, 0.9)]; + +fn create_mask(len: usize, density: f64) -> Mask { + Mask::from_buffer(BitBuffer::from_iter((0..len).map(|i| { + let threshold = (density * 1000.0) as usize; + (i * 7 + 13) % 1000 < threshold + }))) +} + +/// The pco/zstd slice pattern: two indices bracketing a sub-range. The prefix +/// up to `slice_stop` must be counted, so `slice_stop` near the end is worst case. +#[divan::bench(args = BENCH_SIZES)] +fn slice_bounds(bencher: Bencher, (len, density): (usize, f64)) { + let mask = create_mask(len, density); + let indices = [len / 4, len - len / 8]; + bencher + .with_inputs(|| (&mask, indices)) + .bench_refs(|(mask, indices)| mask.valid_counts_for_indices(indices)); +} + +/// Many monotonically increasing indices spread across the whole mask. +#[divan::bench(args = BENCH_SIZES)] +fn many_indices(bencher: Bencher, (len, density): (usize, f64)) { + let mask = create_mask(len, density); + let stride = (len / 256).max(1); + let indices: Vec = (0..len).step_by(stride).collect(); + bencher + .with_inputs(|| (&mask, indices.as_slice())) + .bench_refs(|(mask, indices)| mask.valid_counts_for_indices(indices)); +} From 25b6b55d175795f8f1b1664868c257ed8002034a Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Mon, 6 Jul 2026 12:27:31 +0100 Subject: [PATCH 015/104] Skip CUDA codspeed benchmarks if there were no cuda changes (#8657) Since we already skip cuda build I think this should be symmetrical and also be skipped Signed-off-by: Robert Kruszewski --- .github/workflows/codspeed.yml | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 2cdcbaa9dbf..2d04d8d35f3 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -23,6 +23,25 @@ env: NIGHTLY_TOOLCHAIN: nightly-2026-02-05 jobs: + changes: + name: "Detect CUDA changes" + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + pull-requests: read + outputs: + run-cuda-benchmarks: ${{ github.event_name != 'pull_request' || steps.filter.outputs.cuda == 'true' }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4 + id: filter + if: github.event_name == 'pull_request' + with: + filters: | + cuda: + - "vortex-cuda/**" + - ".github/workflows/**" + bench-codspeed: strategy: matrix: @@ -72,7 +91,10 @@ jobs: # Getting a GPU box is slow, in the future we can build on a box without one and only run # on GPU machines. bench-codspeed-cuda-build: - if: github.repository == 'vortex-data/vortex' + needs: [changes] + if: >- + always() && github.repository == 'vortex-data/vortex' && + needs.changes.outputs.run-cuda-benchmarks == 'true' name: "Build Codspeed CUDA benchmarks" timeout-minutes: 30 runs-on: >- From a61129965201af46b246ebc89a61184478780d85 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:39:57 +0000 Subject: [PATCH 016/104] Lock file maintenance (#8654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Update | Change | |---|---| | lockFileMaintenance | All locks refreshed | --- > [!WARNING] > Some dependencies could not be looked up. Check the [Dependency Dashboard](../issues/357) for more information. 🔧 This Pull Request updates lock files to use the latest dependency versions. --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - Between 12:00 AM and 03:59 AM, only on Monday (`* 0-3 * * 1`) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/vortex-data/vortex). --------- Signed-off-by: Robert Kruszewski Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Robert Kruszewski --- Cargo.lock | 342 ++++++++++++++++++++--------------------------------- Cargo.toml | 4 +- 2 files changed, 129 insertions(+), 217 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index af0d17a2c00..459b58dded4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -125,7 +125,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -136,7 +136,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -192,9 +192,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "arrow" @@ -821,9 +821,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "zeroize", @@ -831,14 +831,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -1265,9 +1266,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.65" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "jobserver", @@ -1443,7 +1444,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.1.14", + "unicode-width 0.2.2", ] [[package]] @@ -1545,7 +1546,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1655,9 +1656,9 @@ dependencies = [ [[package]] name = "console" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", @@ -1703,9 +1704,9 @@ dependencies = [ [[package]] name = "const_for" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c50fcfdf972929aff202c16b80086aa3cfc6a3a820af714096c58c7c1d0582" +checksum = "988d3bd6bf67b6d7ae2b519c55296fa57411c27c312575e47b2975f8d1d8590b" [[package]] name = "const_format" @@ -1999,9 +2000,9 @@ dependencies = [ [[package]] name = "cxx" -version = "1.0.195" +version = "1.0.196" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "201f2332bd98022973bbf50c87f2d2f8151200f43e640da370901b20f1bea9d3" +checksum = "ac6f8908ed0826ab0aec7c8358a4de3a9b26c5ed391a5dc5135765d2fd1aa8fb" dependencies = [ "cc", "cxx-build", @@ -2014,9 +2015,9 @@ dependencies = [ [[package]] name = "cxx-build" -version = "1.0.195" +version = "1.0.196" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02da7762e3807681f61c4561f34e1ff791417334294b225dbe22236459c9deef" +checksum = "6d8ae25c0ce72ac21a77b5deec4f49b452238ef97072f697dd6fd752b1355ecd" dependencies = [ "cc", "codespan-reporting", @@ -2029,9 +2030,9 @@ dependencies = [ [[package]] name = "cxxbridge-cmd" -version = "1.0.195" +version = "1.0.196" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21c216b8cd7c26c2798bbecdb13de2f8dc65239b9ed892756deacff10fc0fa64" +checksum = "27d53791812143bd27f74ab6055f22e875f04c59bbef0ca03d714ebec6f6f484" dependencies = [ "clap", "codespan-reporting", @@ -2043,15 +2044,15 @@ dependencies = [ [[package]] name = "cxxbridge-flags" -version = "1.0.195" +version = "1.0.196" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c618f09812e388d1d065fd43fdc4340fade506ab2e0903397bf238650fce45" +checksum = "fd557619f7dc2252bf7373f6bec5600ce60a2b477e5b3b84eb4e074bb297795e" [[package]] name = "cxxbridge-macro" -version = "1.0.195" +version = "1.0.196" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "415b113deba00cc605302b9b0b4974ab3d57c41135d81737cd47b89dbaa17c38" +checksum = "7fe465fc5b9d0231ea141c4fae714a08f4f907855e1a7ca10cc90ab6c8b12ece" dependencies = [ "indexmap 2.14.0", "proc-macro2", @@ -3508,9 +3509,9 @@ checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" [[package]] name = "defmt" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -3518,12 +3519,11 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" dependencies = [ "defmt-parser", - "proc-macro-error2", "proc-macro2", "quote", "syn 2.0.118", @@ -3611,7 +3611,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3797,7 +3797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3984,9 +3984,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.3.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" dependencies = [ "autocfg", ] @@ -4326,11 +4326,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -4578,9 +4576,9 @@ dependencies = [ [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hybrid-array" @@ -4883,11 +4881,11 @@ dependencies = [ [[package]] name = "indicatif" -version = "0.18.4" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ - "console 0.16.3", + "console 0.16.4", "futures-core", "portable-atomic", "unicode-width 0.2.2", @@ -4919,7 +4917,7 @@ version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ - "console 0.16.3", + "console 0.16.4", "once_cell", "similar 2.7.0", "tempfile", @@ -4978,7 +4976,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5049,9 +5047,9 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.29" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" +checksum = "ccfe6121cbe750cf81efa362d85c0bde7ea298ec43092d3a193baca59cdbd634" dependencies = [ "defmt", "jiff-static", @@ -5065,9 +5063,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.29" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" +checksum = "e165e897f662d428f3cd3828a919dbe067c2d42bb1031eede74ef9d27ecdedd2" dependencies = [ "proc-macro2", "quote", @@ -5076,9 +5074,9 @@ dependencies = [ [[package]] name = "jiff-tzdb" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" +checksum = "6142247df1a93c2b3587402a19710be3e6e942f1581a1702e76408f2c21d6590" [[package]] name = "jiff-tzdb-platform" @@ -5142,11 +5140,11 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] @@ -5877,9 +5875,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "libc", ] @@ -6023,9 +6021,9 @@ dependencies = [ [[package]] name = "lzma-rust2" -version = "0.16.4" +version = "0.16.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce716bf1a316f47a280fc76295f6495b5bea4752bca01c3b3885e101b1c23c02" +checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" dependencies = [ "sha2 0.11.0", ] @@ -6351,7 +6349,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6370,9 +6368,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -6527,7 +6525,7 @@ dependencies = [ "parking_lot", "percent-encoding", "quick-xml", - "rand 0.10.1", + "rand 0.10.2", "reqwest 0.12.28", "ring", "rustls-pki-types", @@ -7155,28 +7153,6 @@ dependencies = [ "toml_edit", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -7439,15 +7415,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -7461,16 +7438,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7523,9 +7500,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -7593,7 +7570,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" dependencies = [ "num-traits", - "rand 0.10.1", + "rand 0.10.2", +] + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", ] [[package]] @@ -7622,7 +7608,7 @@ dependencies = [ "clap", "indicatif", "lance-bench", - "rand 0.10.1", + "rand 0.10.2", "rand_distr 0.6.0", "tabled", "tokio", @@ -8093,9 +8079,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc-hex" @@ -8128,7 +8114,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8160,9 +8146,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -8186,7 +8172,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8691,7 +8677,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8803,7 +8789,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8988,9 +8974,9 @@ dependencies = [ [[package]] name = "taffy" -version = "0.10.1" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859" +checksum = "73afc801dd6bd47529eaa7c7e90557f107527d1b7c9c7ed7d7803c7b8d0c357f" dependencies = [ "arrayvec", "grid", @@ -9052,7 +9038,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -9071,7 +9057,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9207,9 +9193,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.51" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", "js-sys", @@ -9230,9 +9216,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.30" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -9691,9 +9677,9 @@ checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" [[package]] name = "utf8-width" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" +checksum = "159a7cadce548703edd50d24069bc294c5415ecab0a480e0cd1ca06d112dc94a" [[package]] name = "utf8_iter" @@ -9753,7 +9739,7 @@ dependencies = [ "mimalloc", "parquet 58.3.0", "paste", - "rand 0.10.1", + "rand 0.10.2", "rand_distr 0.6.0", "serde_json", "tokio", @@ -9798,7 +9784,7 @@ dependencies = [ "itertools 0.14.0", "num-traits", "prost 0.14.4", - "rand 0.10.1", + "rand 0.10.2", "rstest", "rustc-hash", "vortex-array", @@ -9848,7 +9834,7 @@ dependencies = [ "pin-project-lite", "primitive-types", "prost 0.14.4", - "rand 0.10.1", + "rand 0.10.2", "rand_distr 0.6.0", "rstest", "rstest_reuse", @@ -9911,7 +9897,7 @@ dependencies = [ "parking_lot", "parquet 56.2.1", "parquet 58.3.0", - "rand 0.10.1", + "rand 0.10.2", "regex", "reqwest 0.13.4", "serde", @@ -9945,7 +9931,7 @@ dependencies = [ "itertools 0.14.0", "num-traits", "pco", - "rand 0.10.1", + "rand 0.10.2", "rstest", "test-with", "vortex-alp", @@ -9979,7 +9965,7 @@ dependencies = [ "itertools 0.14.0", "memmap2", "num-traits", - "rand 0.10.1", + "rand 0.10.2", "rstest", "serde", "simdutf8", @@ -10035,7 +10021,7 @@ dependencies = [ "itertools 0.14.0", "num-traits", "parking_lot", - "rand 0.10.1", + "rand 0.10.2", "rstest", "rustc-hash", "tracing", @@ -10059,7 +10045,7 @@ dependencies = [ "arrow-schema 58.3.0", "codspeed-divan-compat", "num-traits", - "rand 0.10.1", + "rand 0.10.2", "vortex-buffer", ] @@ -10269,7 +10255,7 @@ dependencies = [ "lending-iterator", "num-traits", "prost 0.14.4", - "rand 0.10.1", + "rand 0.10.2", "rstest", "vortex-alp", "vortex-array", @@ -10295,7 +10281,7 @@ dependencies = [ "object_store", "paste", "prost 0.14.4", - "rand 0.10.1", + "rand 0.10.2", "tempfile", "tracing", "tracing-subscriber", @@ -10367,7 +10353,7 @@ dependencies = [ "fsst-rs", "num-traits", "prost 0.14.4", - "rand 0.10.1", + "rand 0.10.2", "rstest", "test-with", "vortex-array", @@ -10694,7 +10680,7 @@ dependencies = [ "bytes", "codspeed-divan-compat", "mimalloc", - "rand 0.10.1", + "rand 0.10.2", "rstest", "smallvec", "vortex-array", @@ -10715,7 +10701,7 @@ dependencies = [ "itertools 0.14.0", "num-traits", "prost 0.14.4", - "rand 0.10.1", + "rand 0.10.2", "rstest", "vortex-array", "vortex-buffer", @@ -10816,7 +10802,7 @@ dependencies = [ "mimalloc", "num-traits", "prost 0.14.4", - "rand 0.10.1", + "rand 0.10.2", "rand_distr 0.6.0", "rstest", "vortex-array", @@ -11103,7 +11089,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -11230,7 +11216,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -11239,16 +11225,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -11266,31 +11243,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -11308,96 +11268,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.7.15" @@ -11509,9 +11421,9 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.15" +version = "0.8.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576" [[package]] name = "yansi" @@ -11660,9 +11572,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "977347db8caa080403f6b6b7c1cda9479a8e869316f7e13a59b19076a40f94e3" +checksum = "5431d5661c32445236631278f27946e444ddafe4684cac70b185272d4f9c52d5" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index ba54a50a2ab..e781ec222d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -258,14 +258,14 @@ strum = "0.28" syn = { version = "2.0.117", features = ["full"] } sysinfo = "0.38.0" tabled = { version = "0.21.0", default-features = false } -taffy = "0.10.0" +taffy = "0.12" take_mut = "0.2.2" tar = "0.4" target-lexicon = "0.13" temp-env = "0.3" tempfile = "3" termtree = { version = "1.0" } -test-with = "0.16" +test-with = "=0.16.3" thiserror = "2.0.3" tokio = { version = "1.52" } tokio-stream = "0.1.17" From 86c250a416b5e9b8fe6d9d890b5d770a4f067e46 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Mon, 6 Jul 2026 13:37:11 +0100 Subject: [PATCH 017/104] Optimize bit iteration and validity checking with SIMD popcount (#8636) recreation of #8261 which had real performance benefits for iterating valid indices and had guidelines for future agents performance work --- AGENTS.md | 44 ++++++++++ encodings/runend/src/compute/filter.rs | 22 +++-- vortex-buffer/src/bit/buf.rs | 110 +++++++++++++++++++++++++ vortex-duckdb/src/exporter/bool.rs | 17 ++-- vortex-mask/benches/mask_iteration.rs | 12 +++ vortex-mask/src/lib.rs | 24 +++--- 6 files changed, 204 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e5c3d0cc13b..30ddeeb9515 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,6 +151,47 @@ Notes: self-explanatory code. - Keep public APIs small and consistent with neighboring crates. +## Performance: avoid hidden-cost accessors in hot loops + +The most common performance trap in this codebase is calling a *per-element accessor that +hides non-trivial work* inside an `O(n)` loop, instead of doing the work once over the whole +chunk. The call site looks like a cheap getter, but each call re-pays a cost that is constant +(or amortizable) across the loop, making the loop `O(n · k)`. + +Watch for these accessors used inside `for i in 0..n { ... }`: + +| Per-element accessor (in a loop) | Hidden cost | Bulk replacement | +| --- | --- | --- | +| `Validity::is_valid(i)` / `is_null(i)` | for array-backed validity, **allocates an `ExecutionCtx` and runs a scalar lookup per call** | `validity.execute_mask(len, ctx)?` once, then `Mask::value(i)` | +| `array.scalar_at(i)` / `array.execute_scalar(i, ctx)` | per-element execution through the compute stack | canonicalize once (`execute::` / `as_slice`) then index | +| `BitBuffer::value(i)` / `Mask::value(i)` accumulated into a count | recomputes the byte address each call; defeats popcount | `true_count()`, `BitBuffer::count_range(start, end)`, `set_indices()` | +| `BitIterator::next()` to accumulate a running rank/prefix count | bit-at-a-time | `count_range` over each gap (SIMD popcount) | +| re-deriving a value inside the loop (e.g. `self.validity()?` each iteration) | re-runs the derivation `n` times | hoist it above the loop | + +Decide per site — bulk is not always the answer: + +- **Sequential / contiguous access** over an accessor that hides amortizable work → hoist and + go bulk (materialize once, then index or iterate the chunk). +- **Gather over arbitrary indices** → you cannot amortize a per-element *decode*, but you can + still materialize the backing buffer once (e.g. `execute_mask`) and then do cheap `O(1)` + random reads, avoiding per-call context/allocation. +- **The accessor is already genuinely `O(1)`** (e.g. reading an already-materialized `Mask`/ + slice, or a native bitmap) → leave it; bulk would not help. + +Even after materializing into a `Mask`/`BitBuffer`, **do not loop with `value(i)` per +element** to act on each set bit — the per-element branch dominates. Iterate a `u64` word +at a time with all-set / all-unset fast paths: use [`BitBuffer::for_each_set_index`]. It +beats `for i in 0..len { if buf.value(i) {..} }` by 2-45x (more at low density) and beats +collecting `set_indices()` by ~2x at mid/high density, while self-adapting from sparse to +dense — see `vortex-mask/benches/mask_iteration.rs`. Reach for the cached `indices()` / +`slices()` representations when you need them more than once; otherwise `for_each_set_index` +needs no materialization. + +When you touch such a loop, back the change with a benchmark (see +`vortex-array/benches/validity_is_valid.rs` for the `is_valid` case, +`vortex-mask/benches/valid_counts.rs` for the popcount case, and +`vortex-mask/benches/mask_iteration.rs` for the per-element-vs-word-vs-sparse comparison). + ## Tests - Strongly consider `rstest` cases when parameterizing repetitive test logic. @@ -179,6 +220,9 @@ Check new and modified lines against this list before finishing: - Updating expected test output to match buggy behavior without independently verifying the intended semantics. - Silently reducing the scope of an approved plan when implementation is harder than expected. +- Calling a hidden-cost per-element accessor (`Validity::is_valid`, `scalar_at`, `BitBuffer:: + value` accumulation) inside a hot loop instead of materializing once — see + "Performance: avoid hidden-cost accessors in hot loops". ## Summaries diff --git a/encodings/runend/src/compute/filter.rs b/encodings/runend/src/compute/filter.rs index c460dd6552d..6d8d4aab6ea 100644 --- a/encodings/runend/src/compute/filter.rs +++ b/encodings/runend/src/compute/filter.rs @@ -5,6 +5,7 @@ use std::cmp::min; use std::ops::AddAssign; use num_traits::AsPrimitive; +use num_traits::NumCast; use vortex_array::ArrayRef; use vortex_array::ArrayView; use vortex_array::ExecutionCtx; @@ -87,16 +88,21 @@ pub fn filter_run_end_primitive + AsPrim let mut count = R::zero(); let new_mask: Mask = BitBuffer::collect_bool(run_ends.len(), |i| { - let mut keep = false; let end = min(run_ends[i].as_() - offset, length); - // Safety: predicate must be the same length as the array the ends have been taken from - for pred in (start..end).map(|i| unsafe { - mask.value_unchecked(i.try_into().vortex_expect("index must fit in usize")) - }) { - count += >::from(pred); - keep |= pred - } + // SIMD popcount of the predicate bits in this run. The range matches the + // bit-by-bit `value_unchecked` read it replaces, so `end <= mask.len()`. + let start_usize = start + .try_into() + .vortex_expect("run start index must fit in usize"); + let end_usize = end + .try_into() + .vortex_expect("run end index must fit in usize"); + let run_trues = mask.count_range(start_usize, end_usize); + count += ::from(run_trues) + .vortex_expect("run popcount must fit in run-end native type"); + let keep = run_trues > 0; + // this is to avoid branching new_run_ends[j] = count; j += keep as usize; diff --git a/vortex-buffer/src/bit/buf.rs b/vortex-buffer/src/bit/buf.rs index a8bd9e1929c..d229329fcd2 100644 --- a/vortex-buffer/src/bit/buf.rs +++ b/vortex-buffer/src/bit/buf.rs @@ -379,6 +379,20 @@ impl BitBuffer { count_ones(self.buffer.as_slice(), self.offset, self.len) } + /// Get the number of set bits in the bit range `[start, end)`. + /// + /// Unlike `self.slice(start..end).true_count()`, this counts directly over the + /// existing backing buffer without allocating or cloning a new [`BitBuffer`], + /// making it cheap to call repeatedly over many small ranges. + /// + /// Panics if `start > end` or `end > len`. + #[inline] + pub fn count_range(&self, start: usize, end: usize) -> usize { + assert!(start <= end, "start {start} exceeds end {end}"); + assert!(end <= self.len, "end {end} exceeds len {}", self.len); + count_ones(self.buffer.as_slice(), self.offset + start, end - start) + } + /// Returns the position of the `nth` set bit (0-indexed). /// /// This is the "select" operation on a bitmap: given a rank `nth`, find @@ -410,6 +424,33 @@ impl BitBuffer { BitSliceIterator::new(self.buffer.as_slice(), self.offset, self.len) } + /// Invoke `f(index)` for every set bit, in ascending order, processing a `u64` + /// word at a time. + /// + /// This is the fast way to "do something for each set bit": it skips all-zero + /// words, fast-paths all-one words, and walks the remaining bits with + /// `trailing_zeros`. Prefer it over `for i in 0..len { if buf.value(i) { f(i) } }` + /// (which pays a branch per element) and over collecting [`Self::set_indices`] + /// (whose per-`next` iterator state does not inline as well). + #[inline] + pub fn for_each_set_index(&self, mut f: F) { + let mut base = 0usize; + for word in self.chunks().iter_padded() { + if word == u64::MAX { + for k in 0..64 { + f(base + k); + } + } else { + let mut w = word; + while w != 0 { + f(base + w.trailing_zeros() as usize); + w &= w - 1; + } + } + base += 64; + } + } + /// Created a new BitBuffer with offset reset to 0 pub fn sliced(&self) -> Self { if self.offset.is_multiple_of(8) { @@ -857,6 +898,75 @@ mod tests { } } + #[rstest] + #[case(0, 0)] + #[case(0, 64)] + #[case(5, 70)] + #[case(64, 130)] + #[case(0, 200)] + fn test_count_range(#[case] start: usize, #[case] end: usize) { + let len = 200; + let buf = BitBuffer::collect_bool(len, |i| i % 3 == 0); + let expected = (start..end).filter(|i| i % 3 == 0).count(); + assert_eq!(buf.count_range(start, end), expected); + // Must agree with slicing then counting. + assert_eq!( + buf.count_range(start, end), + buf.slice(start..end).true_count() + ); + } + + #[rstest] + #[case(3)] + #[case(7)] + fn test_count_range_with_offset(#[case] offset: usize) { + let len = 150; + let buf = BitBuffer::collect_bool(offset + len, |i| i % 2 == 0); + let view = BitBuffer::new_with_offset(buf.inner().clone(), len, offset); + for (start, end) in [(0, len), (10, 100), (1, 2), (63, 129)] { + let expected = (offset + start..offset + end) + .filter(|i| i % 2 == 0) + .count(); + assert_eq!(view.count_range(start, end), expected, "[{start}, {end})"); + } + } + + #[rstest] + #[case(0)] + #[case(1)] + #[case(63)] + #[case(64)] + #[case(65)] + #[case(200)] + #[case(1000)] + fn test_for_each_set_index_matches_set_indices(#[case] len: usize) { + let buf = BitBuffer::collect_bool(len, |i| i % 5 == 0 || i % 7 == 0); + let expected: Vec = buf.set_indices().collect(); + let mut got = Vec::new(); + buf.for_each_set_index(|i| got.push(i)); + assert_eq!(got, expected); + } + + #[rstest] + #[case(3, 200)] + #[case(7, 130)] + fn test_for_each_set_index_with_offset(#[case] offset: usize, #[case] len: usize) { + let base = BitBuffer::collect_bool(offset + len, |i| i % 3 == 0); + let view = BitBuffer::new_with_offset(base.inner().clone(), len, offset); + let expected: Vec = view.set_indices().collect(); + let mut got = Vec::new(); + view.for_each_set_index(|i| got.push(i)); + assert_eq!(got, expected); + } + + #[test] + fn test_for_each_set_index_all_set() { + let buf = BitBuffer::new_set(130); + let mut got = Vec::new(); + buf.for_each_set_index(|i| got.push(i)); + assert_eq!(got, (0..130).collect::>()); + } + #[test] fn test_map_cmp_conditional() { // map_cmp with conditional logic based on index and bit value diff --git a/vortex-duckdb/src/exporter/bool.rs b/vortex-duckdb/src/exporter/bool.rs index 4d0a2b29883..fc1dd3e10c3 100644 --- a/vortex-duckdb/src/exporter/bool.rs +++ b/vortex-duckdb/src/exporter/bool.rs @@ -46,13 +46,16 @@ impl ColumnExporter for BoolExporter { ) -> VortexResult<()> { // DuckDB uses byte bools, not bit bools. // maybe we can convert into these from a compressed array sometimes?. - unsafe { vector.as_slice_mut(len) }.copy_from_slice( - &self - .bit_buffer - .slice(offset..(offset + len)) - .iter() - .collect::>(), - ); + // Unpack the bits directly into the destination byte slice, avoiding the + // throwaway `Vec` allocation and the extra copy pass. The sliced + // iterator already accounts for the buffer's bit offset. + let dst = unsafe { vector.as_slice_mut(len) }; + for (slot, bit) in dst + .iter_mut() + .zip(self.bit_buffer.slice(offset..(offset + len)).iter()) + { + *slot = bit; + } Ok(()) } diff --git a/vortex-mask/benches/mask_iteration.rs b/vortex-mask/benches/mask_iteration.rs index 50f5c321303..84ca37991ef 100644 --- a/vortex-mask/benches/mask_iteration.rs +++ b/vortex-mask/benches/mask_iteration.rs @@ -110,6 +110,18 @@ fn word_trailing_zeros(bencher: Bencher, (len, density): (usize, f64)) { }); } +#[divan::bench(args = ARGS)] +fn for_each_set_index(bencher: Bencher, (len, density): (usize, f64)) { + let (buf, values) = make(len, density); + bencher + .with_inputs(|| (&buf, &values)) + .bench_refs(|(buf, values)| { + let mut acc = 0u64; + buf.for_each_set_index(|i| acc = acc.wrapping_add(values[i])); + acc + }); +} + #[divan::bench(args = ARGS)] fn set_slices(bencher: Bencher, (len, density): (usize, f64)) { let (buf, values) = make(len, density); diff --git a/vortex-mask/src/lib.rs b/vortex-mask/src/lib.rs index 401077f0b34..1f41c367947 100644 --- a/vortex-mask/src/lib.rs +++ b/vortex-mask/src/lib.rs @@ -628,23 +628,24 @@ impl Mask { /// Given monotonically increasing `indices` in [0, n_rows], returns the /// count of valid elements up to each index. /// - /// This is O(n_rows). + /// This is O(n_rows), but the per-gap counts are computed with a SIMD + /// popcount over the underlying bit buffer rather than walking bit-by-bit. pub fn valid_counts_for_indices(&self, indices: &[usize]) -> Vec { match self { Self::AllTrue(_) => indices.to_vec(), Self::AllFalse(_) => vec![0; indices.len()], Self::Values(values) => { - let mut bool_iter = values.bit_buffer().iter(); + let buffer = values.bit_buffer(); let mut valid_counts = Vec::with_capacity(indices.len()); let mut valid_count = 0; - let mut idx = 0; + let mut prev = 0; for &next_idx in indices { - while idx < next_idx { - idx += 1; - valid_count += bool_iter - .next() - .unwrap_or_else(|| vortex_panic!("Row indices exceed array length")) - as usize; + assert!(next_idx <= buffer.len(), "Row indices exceed array length"); + // `indices` is monotonically increasing, so each gap is counted once; + // the total work across all gaps scans the prefix `[0, last_idx)` once. + if next_idx > prev { + valid_count += buffer.count_range(prev, next_idx); + prev = next_idx; } valid_counts.push(valid_count); } @@ -779,7 +780,10 @@ impl MaskValues { } let mut indices = Vec::with_capacity(self.true_count); - indices.extend(self.buffer.set_indices()); + // Word-at-a-time set-bit walk; faster than collecting `set_indices()`, + // whose per-`next` iterator state inlines less well (see + // `vortex-mask/benches/mask_iteration.rs`). + self.buffer.for_each_set_index(|i| indices.push(i)); debug_assert!(indices.is_sorted()); assert_eq!(indices.len(), self.true_count); indices From 570f7edbf04d92f2ac59f3b0b6f5c5016ce8fc90 Mon Sep 17 00:00:00 2001 From: myrrc Date: Mon, 6 Jul 2026 14:06:29 +0100 Subject: [PATCH 018/104] Remove borrowed types from FFI; fixed_size_list constructor accepts u32 (#8658) FFI currently has two types of returned objects: ones are owned (inside of which there's an Arc), others are borrowed (because there's no Arc inside). One example of the latter is a struct field name which returns a borrowed vx_string. We initially had a comment caller shouldn't call the corresponding _free() function on these. This is not enough as caller can call a _clone() function on a borrowed vx_string which is UB because we try to increment a strong reference count on a pointer which isn't an Arc. From a callee's perspective, we can't distinguish owned and borrowed types, and this creates weird APIs like struct field dtype being owned but name being borrowed. This PR removes all borrowed/owned distinction from FFI layer. Each object now returned from an FFI function is owned. It can be freely cloned, and each must be freed explicitly with _free() functions. On another change, fixed size list constructor previously accepted a size_t (64-bit unsigned integer on most systems) but validated it against u32 because that's our file format limitation for fixed size lists. This PR changes the signature so that now it accepts uint32_t as length parameter. As the changes are significant enough to trigger bugs in our own tests, this PR also re-enables FFI sanitizer tests in CI. Signed-off-by: Mikhail Kot --- .github/workflows/rust-instrumented.yml | 18 +-- vortex-ffi/README.md | 6 +- vortex-ffi/cbindgen.toml | 6 +- vortex-ffi/cinclude/vortex.h | 169 +++++++++--------------- vortex-ffi/examples/dtype.c | 7 +- vortex-ffi/examples/scan_to_arrow.c | 2 +- vortex-ffi/src/array.rs | 25 ++-- vortex-ffi/src/data_source.rs | 20 +-- vortex-ffi/src/dtype.rs | 61 ++++----- vortex-ffi/src/error.rs | 8 +- vortex-ffi/src/expression.rs | 2 - vortex-ffi/src/lib.rs | 5 +- vortex-ffi/src/macros.rs | 48 ++----- vortex-ffi/src/scalar.rs | 60 ++++----- vortex-ffi/src/scan.rs | 16 +-- vortex-ffi/src/struct_fields.rs | 18 +-- vortex-ffi/test/array.cpp | 12 +- vortex-ffi/test/common.h | 10 +- vortex-ffi/test/scan.cpp | 33 ++++- vortex-ffi/test/struct.cpp | 3 +- 20 files changed, 224 insertions(+), 305 deletions(-) diff --git a/.github/workflows/rust-instrumented.yml b/.github/workflows/rust-instrumented.yml index bf45fc7be13..5430f1512fc 100644 --- a/.github/workflows/rust-instrumented.yml +++ b/.github/workflows/rust-instrumented.yml @@ -194,7 +194,7 @@ jobs: - sanitizer: tsan sanitizer_flags: "-Zsanitizer=thread" name: "Rust/C++ FFI tests (${{ matrix.sanitizer }})" - timeout-minutes: 30 + timeout-minutes: 5 env: ASAN_OPTIONS: "symbolize=1:check_initialization_order=1:detect_leaks=1:leak_check_at_exit=1" LSAN_OPTIONS: "report_objects=1" @@ -217,9 +217,6 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: ./.github/actions/setup-prebuild - - name: Install rustfilt - run: | - cargo install rustfilt - name: Install Rust nightly toolchain run: | rustup toolchain install $NIGHTLY_TOOLCHAIN @@ -241,8 +238,7 @@ jobs: - name: Run tests run: | set -o pipefail -# re-enable once we can fix this hang -# ./vortex-ffi/build/test/vortex_ffi_test 2>&1 | rustfilt + ./vortex-ffi/build/test/vortex_ffi_test - name: Run examples run: | set -o pipefail @@ -251,11 +247,11 @@ jobs: # error: Unable to walk dir: File system loop found rm -fr vortex-ffi/build/_deps/nanoarrow-src/python - ./vortex-ffi/build/examples/write_sample file.vortex 2>&1 | rustfilt - ./vortex-ffi/build/examples/write_sample file2.vortex 2>&1 | rustfilt - ./vortex-ffi/build/examples/dtype '*.vortex' 2>&1 | rustfilt - ./vortex-ffi/build/examples/scan '*.vortex' 2>&1 | rustfilt - ./vortex-ffi/build/examples/scan_to_arrow '*.vortex' 2>&1 | rustfilt + ./vortex-ffi/build/examples/write_sample file.vortex + ./vortex-ffi/build/examples/write_sample file2.vortex + ./vortex-ffi/build/examples/dtype '*.vortex' + ./vortex-ffi/build/examples/scan '*.vortex' + ./vortex-ffi/build/examples/scan_to_arrow '*.vortex' miri: name: "Rust tests (miri)" diff --git a/vortex-ffi/README.md b/vortex-ffi/README.md index f1273ae5527..ff3b6c94ff2 100644 --- a/vortex-ffi/README.md +++ b/vortex-ffi/README.md @@ -1,4 +1,4 @@ -# Vortex C interface +# Vortex C bindings ## Updating Headers @@ -20,7 +20,7 @@ target_link_libraries(my_target, vortex_ffi_shared) # or target_link_libraries(my_target, vortex_ffi) ``` -## Running C examples: +## Running C examples ```sh cmake -Bbuild -DBUILD_EXAMPLES=1 @@ -99,7 +99,7 @@ cargo +nightly build -Zbuild-std --target= \ 2. Build tests with target triple: ```sh -cmake -Bbuild -DWITH_ASAN=1 -DTARGET_TRIPLE= +cmake -Bbuild -DSANITIZER=asan -DTARGET_TRIPLE= ``` 3. Run the tests (ctest doesn't output failures in detail): diff --git a/vortex-ffi/cbindgen.toml b/vortex-ffi/cbindgen.toml index d1b4ffa5f48..7d28dacda63 100644 --- a/vortex-ffi/cbindgen.toml +++ b/vortex-ffi/cbindgen.toml @@ -11,9 +11,11 @@ header = """ #pragma once #include -// // THIS FILE IS AUTO-GENERATED, DO NOT MAKE EDITS DIRECTLY -// + +// All operations return owned types which need to be freed by calling a +// matching _free() function. This includes all arrays, data sources, scans, +// errors, error messages, and other allocated objects. // https://arrow.apache.org/docs/format/CDataInterface.html#structure-definitions // If you want to use your own Arrow library like nanoarrow, define this macro diff --git a/vortex-ffi/cinclude/vortex.h b/vortex-ffi/cinclude/vortex.h index 0203202e437..277985362b2 100644 --- a/vortex-ffi/cinclude/vortex.h +++ b/vortex-ffi/cinclude/vortex.h @@ -3,9 +3,11 @@ #pragma once #include -// // THIS FILE IS AUTO-GENERATED, DO NOT MAKE EDITS DIRECTLY -// + +// All operations return owned types which need to be freed by calling a +// matching _free() function. This includes all arrays, data sources, scans, +// errors, error messages, and other allocated objects. // https://arrow.apache.org/docs/format/CDataInterface.html#structure-definitions // If you want to use your own Arrow library like nanoarrow, define this macro @@ -410,7 +412,7 @@ typedef struct Primitive Primitive; * array is a cheap operation. * * Unless stated explicitly, all operations with vx_array don't take - * ownership of it, and thus it must be freed by the caller. + * ownership of it, and thus the array must be freed by the caller. */ typedef struct vx_array vx_array; @@ -475,8 +477,6 @@ typedef struct vx_error vx_error; * data. Each expression consists of an encoding (vtable), heap-allocated * metadata, and child expressions. * - * Unless stated explicitly, all expressions returned are owned and must - * be freed by the caller. * Unless stated explicitly, if an operation on const vx_expression* is * passed NULL, NULL is returned. * Operations on expressions don't take ownership of input values, and so @@ -629,8 +629,7 @@ extern "C" { #endif // __cplusplus /** - * Clone a borrowed [`vx_array`], returning an owned [`vx_array`]. - * Must be released with [`vx_array_free`]. + * Clone a vx_array */ const vx_array *vx_array_clone(const vx_array *ptr); @@ -677,10 +676,7 @@ void vx_array_get_validity(const vx_array *array, vx_validity *validity, vx_erro size_t vx_array_len(const vx_array *array); /** - * Get the [`struct@crate::dtype::vx_dtype`] of the array. - * - * The returned pointer is valid as long as the array is valid. - * Do NOT free the returned dtype pointer - it shares the lifetime of the array. + * Get array's dtype */ const vx_dtype *vx_array_dtype(const vx_array *array); @@ -738,9 +734,6 @@ const vx_array *vx_array_new_primitive(vx_ptype ptype, * `nullable` controls the top-level nullability of the resulting array's dtype. For an Arrow * record batch (which has no top-level validity) pass `false`. * - * The imported buffers are referenced zero-copy where possible; the returned array keeps the - * Arrow data alive until it is freed with [`vx_array_free`]. - * * On error, returns NULL and sets `error_out`. * * Example: @@ -820,7 +813,7 @@ const vx_array *vx_array_apply(const vx_array *array, const vx_expression *expre /** * Free an owned [`vx_array_iterator`] object. */ -void vx_array_iterator_free(vx_array_iterator *ptr); +void vx_array_iterator_free(const vx_array_iterator *ptr); /** * Attempt to advance the `current` pointer of the iterator. @@ -833,13 +826,12 @@ void vx_array_iterator_free(vx_array_iterator *ptr); const vx_array *vx_array_iterator_next(vx_array_iterator *iter, vx_error **error_out); /** - * Clone a borrowed [`vx_binary`], returning an owned [`vx_binary`]. - * Must be released with [`vx_binary_free`]. + * Clone a vx_binary. Returned handle must be release with vx_binary_free */ const vx_binary *vx_binary_clone(const vx_binary *ptr); /** - * Free an owned [`vx_binary`] object. + * Free a vx_binary */ void vx_binary_free(const vx_binary *ptr); @@ -859,21 +851,19 @@ size_t vx_binary_len(const vx_binary *ptr); const char *vx_binary_ptr(const vx_binary *ptr); /** - * Clone a borrowed [`vx_data_source`], returning an owned [`vx_data_source`]. - * Must be released with [`vx_data_source_free`]. + * Clone a vx_data_source. Returned handle must be release with vx_data_source_free */ const vx_data_source *vx_data_source_clone(const vx_data_source *ptr); /** - * Free an owned [`vx_data_source`] object. + * Free a vx_data_source */ void vx_data_source_free(const vx_data_source *ptr); /** * Create a data source. * The first matched file is opened eagerly. to read the schema. All other I/O - * is deferred until a scan is requested. The returned pointer is owned by the - * caller and must be freed with vx_data_source_free. + * is deferred until a scan is requested. * * On error, returns NULL and sets "err". */ @@ -887,17 +877,13 @@ vx_data_source_new(const vx_session *session, const vx_data_source_options *opti * The bytes are borrowed, not copied: the caller must keep "buffer" alive and * unmodified until the data source is freed. * - * The returned pointer is owned by the caller and must be freed with - * vx_data_source_free. - * * On error, returns NULL and sets "err". */ const vx_data_source * vx_data_source_new_buffer(const vx_session *session, const void *buffer, size_t buffer_len, vx_error **err); /** - * Return the schema of the data source as a non-owned dtype. - * The returned pointer is valid as long as "ds" is alive. Do not free it. + * Return data source's dtype */ const vx_dtype *vx_data_source_dtype(const vx_data_source *ds); @@ -907,8 +893,7 @@ const vx_dtype *vx_data_source_dtype(const vx_data_source *ds); void vx_data_source_get_row_count(const vx_data_source *ds, vx_estimate *row_count); /** - * Clone a borrowed [`vx_dtype`], returning an owned [`vx_dtype`]. - * Must be released with [`vx_dtype_free`]. + * Clone a vx_dtype */ const vx_dtype *vx_dtype_clone(const vx_dtype *ptr); @@ -994,26 +979,21 @@ uint8_t vx_dtype_decimal_precision(const vx_dtype *dtype); int8_t vx_dtype_decimal_scale(const vx_dtype *dtype); /** - * Return a borrowed reference to the [`vx_struct_fields`] of a struct. - * - * The returned pointer is valid as long as the struct dtype is valid. - * Do NOT free the returned pointer - it shares the lifetime of the struct dtype. + * If "dtype" is DTYPE_STRUCT, return owned vx_struct_fields for this struct, + * return NULL otherwise. Returned vx_struct_fields must be released with + * vx_dtype_free. */ const vx_struct_fields *vx_dtype_struct_dtype(const vx_dtype *dtype); /** - * Returns the element type of a list. - * - * The returned pointer is valid as long as the list dtype is valid. - * Do NOT free the returned dtype pointer - it shares the lifetime of the list dtype. + * If "dtype" is DTYPE_LIST, return its owned element dtype, return NULL + * otherwise. Returned dtype must be released with vx_dtype_free. */ const vx_dtype *vx_dtype_list_element(const vx_dtype *dtype); /** - * Returns the element type of a fixed-size list. - * - * The returned pointer is valid as long as the fixed-size list dtype is valid. - * Do NOT free the returned dtype pointer - it shares the lifetime of the fixed-size list dtype. + * If "dtype" is DTYPE_FIXED_SIZE_LIST, return its owned element dtype, return + * NULL otherwise. Returned dtype must be released with vx_dtype_free. */ const vx_dtype *vx_dtype_fixed_size_list_element(const vx_dtype *dtype); @@ -1069,20 +1049,17 @@ const vx_dtype *vx_dtype_from_arrow_schema(FFI_ArrowSchema *schema, vx_error **e /** * Free an owned [`vx_error`] object. */ -void vx_error_free(vx_error *ptr); +void vx_error_free(const vx_error *ptr); /** - * Returns the error message from the given Vortex error. - * - * The returned pointer is valid as long as the error is valid. - * Do NOT free the returned string pointer - it shares the lifetime of the error. + * Return an error message for this error */ const vx_string *vx_error_get_message(const vx_error *error); /** * Free an owned [`vx_expression`] object. */ -void vx_expression_free(vx_expression *ptr); +void vx_expression_free(const vx_expression *ptr); /** * Create a root expression. A root expression, applied to an array in @@ -1223,8 +1200,7 @@ vx_expression *vx_expression_get_item(const char *item, const vx_expression *chi vx_expression *vx_expression_list_contains(const vx_expression *list, const vx_expression *value); /** - * Clone a borrowed [`vx_file`], returning an owned [`vx_file`]. - * Must be released with [`vx_file_free`]. + * Clone a vx_file */ const vx_file *vx_file_clone(const vx_file *ptr); @@ -1248,22 +1224,17 @@ void vx_set_log_level(vx_log_level level); /** * Free an owned [`vx_scalar`] object. */ -void vx_scalar_free(vx_scalar *ptr); +void vx_scalar_free(const vx_scalar *ptr); /** - * Clone a borrowed scalar handle. - * - * The input scalar handle is not consumed. The returned scalar handle must be - * released with vx_scalar_free. Returns NULL when given a NULL scalar handle. + * Clone a scalar handle. + * If scalar is NULL, returns NULL. */ vx_scalar *vx_scalar_clone(const vx_scalar *scalar); /** - * Return the data type of a scalar. - * - * The returned data type handle borrows storage from the scalar handle, so its - * lifetime is bound to the scalar handle. It MUST NOT be freed separately. - * Returns NULL when given a NULL scalar handle. + * Return scalar's dtype. + * If scalar is NULL, returns NULL. */ const vx_dtype *vx_scalar_dtype(const vx_scalar *scalar); @@ -1358,9 +1329,11 @@ vx_scalar *vx_scalar_new_binary(const uint8_t *ptr, size_t len, bool is_nullable /** * Create a typed null scalar. * - * The data type handle is borrowed, not consumed. The returned scalar uses a - * nullable copy of that logical type, regardless of the input type's top-level - * nullability. A NULL data type handle returns NULL and writes the error output. + * "dtype" is not consumed, you can use it after calling this function. Returned + * scalar uses a nullable copy of that logical type, regardless of the input + * type's top-level nullability. + * + * Returns NULL and sets "err" on error or NULL dtype. */ vx_scalar *vx_scalar_new_null(const vx_dtype *dtype, vx_error **err); @@ -1435,10 +1408,8 @@ vx_scalar *vx_scalar_new_decimal_i256_le(const uint8_t *bytes32, /** * Create a list scalar. * - * The element data type handle is borrowed, not consumed. Child scalar handles - * are cloned into the list value, so the caller keeps ownership of the handle - * array and each scalar in it. A NULL child handle array is allowed only for an - * empty list. Child values are validated against the element logical type. + * "element_dtype" and "elements" are not consumed, you can use them after + * calling this function. If len is 0, you can pass NULL to "elements". */ vx_scalar *vx_scalar_new_list(const vx_dtype *element_dtype, const vx_scalar *const *elements, @@ -1449,27 +1420,21 @@ vx_scalar *vx_scalar_new_list(const vx_dtype *element_dtype, /** * Create a fixed-size list scalar. * - * The element data type handle is borrowed, not consumed. The number of child - * scalars becomes the fixed-size list width and must fit in a 32-bit unsigned - * integer. Child scalar handles are cloned into the list value, so the caller - * keeps ownership of the handle array and each scalar in it. A NULL child - * handle array is allowed only for an empty list. Child values are validated - * against the element logical type. + * "element_dtype" and "elements" are not consumed, you can use them after + * calling this function. If len is 0, you can pass NULL to "elements". + * "len" must fit in uint32_t. */ vx_scalar *vx_scalar_new_fixed_size_list(const vx_dtype *element_dtype, const vx_scalar *const *elements, - size_t len, + uint32_t len, bool is_nullable, vx_error **err); /** * Create a struct scalar. * - * The struct data type handle is borrowed, not consumed. Field scalar handles - * are cloned into the struct value, so the caller keeps ownership of the handle - * array and each scalar in it. Field count and field logical types are validated - * against the struct logical type. A NULL field handle array is allowed only for - * an empty struct value. + * "struct_dtype" and "fields" are not consumed, you can use them after calling + * this function. If len is 0, you can pass NULL to "fields". */ vx_scalar *vx_scalar_new_struct(const vx_dtype *struct_dtype, const vx_scalar *const *fields, @@ -1479,19 +1444,17 @@ vx_scalar *vx_scalar_new_struct(const vx_dtype *struct_dtype, /** * Free an owned [`vx_scan`] object. */ -void vx_scan_free(vx_scan *ptr); +void vx_scan_free(const vx_scan *ptr); /** * Free an owned [`vx_partition`] object. */ -void vx_partition_free(vx_partition *ptr); +void vx_partition_free(const vx_partition *ptr); /** * Scan a data source. * - * Return an owned scan that must be freed with vx_scan_free. A scan may be - * consumed only once. - * + * A scan may be consumed only once. * "options" and "estimate" may be NULL. * * If "options" is NULL, all rows and columns are returned. @@ -1506,17 +1469,14 @@ vx_scan *vx_data_source_scan(const vx_data_source *data_source, vx_error **err); /** - * Return borrowed vx_scan's dtype. + * Return scan's dtype. * This function will fail if called after vx_scan_next_partition. - * Called must not free the returned pointer as its lifetime is bound to the - * lifetime of the scan. * On error returns NULL and sets "err". */ const vx_dtype *vx_scan_dtype(const vx_scan *scan, vx_error **err); /** - * Return an owned partition from a scan. - * The returned partition must be freed with vx_partition_free. + * Return an partition from a scan. * * On success returns a partition. * On exhaustion (no more partitions in scan) returns NULL but doesn't set @@ -1555,8 +1515,7 @@ int vx_partition_scan_arrow(const vx_session *session, vx_error **err); /** - * Return an owned owned array from a partition. - * The returned array must be freed with vx_array_free. + * Return an array from a partition. * * On success returns an array. * On exhaustion (no more arrays in partition) returns NULL but doesn't set @@ -1570,7 +1529,7 @@ const vx_array *vx_partition_next(vx_partition *partition, vx_error **err); /** * Free an owned [`vx_session`] object. */ -void vx_session_free(vx_session *ptr); +void vx_session_free(const vx_session *ptr); /** * Create a new Vortex session. @@ -1608,13 +1567,12 @@ void vx_array_sink_push(vx_array_sink *sink, const vx_array *array, vx_error **e void vx_array_sink_close(vx_array_sink *sink, vx_error **error_out); /** - * Clone a borrowed [`vx_string`], returning an owned [`vx_string`]. - * Must be released with [`vx_string_free`]. + * Clone a vx_string. Returned handle must be release with vx_string_free */ const vx_string *vx_string_clone(const vx_string *ptr); /** - * Free an owned [`vx_string`] object. + * Free a vx_string */ void vx_string_free(const vx_string *ptr); @@ -1641,7 +1599,7 @@ const char *vx_string_ptr(const vx_string *ptr); /** * Free an owned [`vx_struct_column_builder`] object. */ -void vx_struct_column_builder_free(vx_struct_column_builder *ptr); +void vx_struct_column_builder_free(const vx_struct_column_builder *ptr); /** * Create a new column-wise struct array builder with given validity and a @@ -1692,7 +1650,7 @@ const vx_array *vx_struct_column_builder_finalize(vx_struct_column_builder *buil /** * Free an owned [`vx_struct_fields`] object. */ -void vx_struct_fields_free(vx_struct_fields *ptr); +void vx_struct_fields_free(const vx_struct_fields *ptr); /** * Return the number of fields in the struct dtype. @@ -1700,28 +1658,21 @@ void vx_struct_fields_free(vx_struct_fields *ptr); uint64_t vx_struct_fields_nfields(const vx_struct_fields *dtype); /** - * Return a borrowed reference to the name of the field at the given index. - * - * The returned pointer is valid as long as the struct fields is valid. - * Do NOT free the returned string pointer - it shares the lifetime of the struct fields. - * Returns null if the index is out of bounds. + * Return an owned name of the field at a given index. + * If index is out of bounds, returns NULL. */ const vx_string *vx_struct_fields_field_name(const vx_struct_fields *dtype, size_t idx); /** - * Returns an *owned* reference to the dtype of the field at the given index. - * - * The return type is owned since struct dtypes can be lazily parsed from a binary format, in - * which case it's not possible to return a borrowed reference to the field dtype. - * - * Returns null if the index is out of bounds or if the field dtype cannot be parsed. + * Return an owned dtype of the field at a given index. + * Returns NULL if index is out of bounds or if dtype cannot be parsed. */ const vx_dtype *vx_struct_fields_field_dtype(const vx_struct_fields *dtype, size_t idx); /** * Free an owned [`vx_struct_fields_builder`] object. */ -void vx_struct_fields_builder_free(vx_struct_fields_builder *ptr); +void vx_struct_fields_builder_free(const vx_struct_fields_builder *ptr); /** * Create a new struct dtype builder. diff --git a/vortex-ffi/examples/dtype.c b/vortex-ffi/examples/dtype.c index 007c3993e33..1a319a80eb5 100644 --- a/vortex-ffi/examples/dtype.c +++ b/vortex-ffi/examples/dtype.c @@ -61,9 +61,12 @@ void print_struct_dtype(const vx_dtype *dtype) { const vx_string *field_name = vx_struct_fields_field_name(fields, i); printf(" %.*s = ", (int)vx_string_len(field_name), vx_string_ptr(field_name)); print_dtype(field_dtype); + vx_string_free(field_name); vx_dtype_free(field_dtype); } printf(")"); + + vx_struct_fields_free(fields); } void print_list_dtype(const vx_dtype *dtype) { @@ -148,7 +151,9 @@ int main(int argc, char **argv) { } printf("dtype: "); - print_dtype(vx_data_source_dtype(data_source)); + const vx_dtype* dtype = vx_data_source_dtype(data_source); + print_dtype(dtype); + vx_dtype_free(dtype); vx_data_source_free(data_source); vx_session_free(session); diff --git a/vortex-ffi/examples/scan_to_arrow.c b/vortex-ffi/examples/scan_to_arrow.c index ffa83d0b123..988974aab13 100644 --- a/vortex-ffi/examples/scan_to_arrow.c +++ b/vortex-ffi/examples/scan_to_arrow.c @@ -23,7 +23,6 @@ void print_error(const char *what, const vx_error *error) { void execute_scan(vx_session *session, vx_scan *scan) { vx_error *error = NULL; - // Returned dtype is owned and mustn't be freed const vx_dtype *dtype = vx_scan_dtype(scan, &error); if (dtype == NULL) { print_error("Failed to get scan dtype", error); @@ -37,6 +36,7 @@ void execute_scan(vx_session *session, vx_scan *scan) { vx_error_free(error); return; } + vx_dtype_free(dtype); char schema_buf[1024 * 10]; const int schema_len = ArrowSchemaToString(&schema, schema_buf, sizeof schema_buf, 1); diff --git a/vortex-ffi/src/array.rs b/vortex-ffi/src/array.rs index 8ab74ef8262..8b3a59ada74 100644 --- a/vortex-ffi/src/array.rs +++ b/vortex-ffi/src/array.rs @@ -55,7 +55,7 @@ arc_wrapper!( /// array is a cheap operation. /// /// Unless stated explicitly, all operations with vx_array don't take - /// ownership of it, and thus it must be freed by the caller. + /// ownership of it, and thus the array must be freed by the caller. ArrayRef, vx_array ); @@ -206,16 +206,13 @@ pub unsafe extern "C-unwind" fn vx_array_len(array: *const vx_array) -> usize { vx_array::as_ref(array).len() } -/// Get the [`struct@crate::dtype::vx_dtype`] of the array. -/// -/// The returned pointer is valid as long as the array is valid. -/// Do NOT free the returned dtype pointer - it shares the lifetime of the array. +/// Get array's dtype #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_array_dtype(array: *const vx_array) -> *const vx_dtype { - vx_dtype::new_ref(vx_array::as_ref(array).dtype()) + vx_dtype::new(Arc::new(vx_array::as_ref(array).dtype().clone())) } -// Return an owned field for array at index. +// Return a field for array at index. // Returns NULL and sets error_out if index is out of bounds or array doesn't // have dtype DTYPE_STRUCT. #[unsafe(no_mangle)] @@ -358,9 +355,6 @@ pub extern "C-unwind" fn vx_array_new_primitive( /// `nullable` controls the top-level nullability of the resulting array's dtype. For an Arrow /// record batch (which has no top-level validity) pass `false`. /// -/// The imported buffers are referenced zero-copy where possible; the returned array keeps the -/// Arrow data alive until it is freed with [`vx_array_free`]. -/// /// On error, returns NULL and sets `error_out`. /// /// Example: @@ -520,6 +514,7 @@ mod tests { use crate::array::*; use crate::binary::vx_binary_free; + use crate::dtype::vx_dtype_free; use crate::dtype::vx_dtype_get_variant; use crate::dtype::vx_dtype_variant; use crate::error::vx_error_free; @@ -547,6 +542,7 @@ mod tests { assert_eq!(vx_array_get_i32(ffi_array, 1), 2); assert_eq!(vx_array_get_i32(ffi_array, 2), 3); + vx_dtype_free(array_dtype); vx_array_free(ffi_array); } } @@ -865,17 +861,14 @@ mod tests { let vx_arr = vx_array::new(Arc::new(array)); assert!(unsafe { vx_array_has_dtype(vx_arr, vx_dtype_variant::DTYPE_STRUCT) }); - // Get dtype reference - this is valid as long as array lives let dtype_ptr = unsafe { vx_array_dtype(vx_arr) }; let variant = unsafe { vx_dtype_get_variant(dtype_ptr) }; assert_eq!(variant, vx_dtype_variant::DTYPE_STRUCT); - // Proper usage: use dtype while array is still alive - // This demonstrates the correct lifetime pattern unsafe { vx_array_free(vx_arr) }; - - // Note: dtype_ptr is now invalid - this test documents the lifetime pattern - // In real usage, don't access dtype_ptr after freeing the array + unsafe { + vx_dtype_free(dtype_ptr); + } } #[test] diff --git a/vortex-ffi/src/data_source.rs b/vortex-ffi/src/data_source.rs index 8485be285bd..2d596be0675 100644 --- a/vortex-ffi/src/data_source.rs +++ b/vortex-ffi/src/data_source.rs @@ -95,8 +95,7 @@ unsafe fn data_source_new( /// Create a data source. /// The first matched file is opened eagerly. to read the schema. All other I/O -/// is deferred until a scan is requested. The returned pointer is owned by the -/// caller and must be freed with vx_data_source_free. +/// is deferred until a scan is requested. /// /// On error, returns NULL and sets "err". #[unsafe(no_mangle)] @@ -116,9 +115,6 @@ pub unsafe extern "C-unwind" fn vx_data_source_new( /// The bytes are borrowed, not copied: the caller must keep "buffer" alive and /// unmodified until the data source is freed. /// -/// The returned pointer is owned by the caller and must be freed with -/// vx_data_source_free. -/// /// On error, returns NULL and sets "err". #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_data_source_new_buffer( @@ -147,11 +143,10 @@ pub unsafe extern "C-unwind" fn vx_data_source_new_buffer( }) } -/// Return the schema of the data source as a non-owned dtype. -/// The returned pointer is valid as long as "ds" is alive. Do not free it. +/// Return data source's dtype #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_data_source_dtype(ds: *const vx_data_source) -> *const vx_dtype { - vx_dtype::new_ref(vx_data_source::as_ref(ds).dtype()) + vx_dtype::new(Arc::new(vx_data_source::as_ref(ds).dtype().clone())) } /// Write data source's row count estimate into "row_count". @@ -194,6 +189,7 @@ mod tests { use crate::data_source::vx_data_source_new_buffer; use crate::data_source::vx_data_source_options; use crate::dtype::vx_dtype; + use crate::dtype::vx_dtype_free; use crate::scan::vx_estimate; use crate::scan::vx_estimate_type; use crate::session::vx_session_free; @@ -254,7 +250,8 @@ mod tests { assert_no_error(error); assert!(!ds.is_null()); - let dtype = vx_dtype::as_ref(vx_data_source_dtype(ds)); + let ffi_dtype = vx_data_source_dtype(ds); + let dtype = vx_dtype::as_ref(ffi_dtype); assert_eq!(dtype, struct_array.dtype()); let mut row_count = vx_estimate::default(); @@ -262,6 +259,7 @@ mod tests { assert_eq!(row_count.r#type, vx_estimate_type::VX_ESTIMATE_EXACT); assert_eq!(row_count.estimate, SAMPLE_ROWS as u64); + vx_dtype_free(ffi_dtype); vx_data_source_free(ds); vx_session_free(session); } @@ -289,7 +287,8 @@ mod tests { assert_no_error(error); assert!(!ds.is_null()); - let dtype = vx_dtype::as_ref(vx_data_source_dtype(ds)); + let ffi_dtype = vx_data_source_dtype(ds); + let dtype = vx_dtype::as_ref(ffi_dtype); assert_eq!(dtype, struct_array.dtype()); let mut row_count = vx_estimate::default(); @@ -297,6 +296,7 @@ mod tests { assert_eq!(row_count.r#type, vx_estimate_type::VX_ESTIMATE_EXACT); assert_eq!(row_count.estimate, SAMPLE_ROWS as u64); + vx_dtype_free(ffi_dtype); vx_data_source_free(ds); vx_session_free(session); } diff --git a/vortex-ffi/src/dtype.rs b/vortex-ffi/src/dtype.rs index c74fa71b6ed..8f96d9e6e02 100644 --- a/vortex-ffi/src/dtype.rs +++ b/vortex-ffi/src/dtype.rs @@ -208,10 +208,9 @@ pub unsafe extern "C-unwind" fn vx_dtype_decimal_scale(dtype: *const vx_dtype) - .scale() } -/// Return a borrowed reference to the [`vx_struct_fields`] of a struct. -/// -/// The returned pointer is valid as long as the struct dtype is valid. -/// Do NOT free the returned pointer - it shares the lifetime of the struct dtype. +/// If "dtype" is DTYPE_STRUCT, return owned vx_struct_fields for this struct, +/// return NULL otherwise. Returned vx_struct_fields must be released with +/// vx_dtype_free. #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_dtype_struct_dtype( dtype: *const vx_dtype, @@ -219,34 +218,29 @@ pub unsafe extern "C-unwind" fn vx_dtype_struct_dtype( let Some(struct_dtype) = vx_dtype::as_ref(dtype).as_struct_fields_opt() else { return ptr::null(); }; - vx_struct_fields::new_ref(struct_dtype) + vx_struct_fields::new(struct_dtype.clone()) } -/// Returns the element type of a list. -/// -/// The returned pointer is valid as long as the list dtype is valid. -/// Do NOT free the returned dtype pointer - it shares the lifetime of the list dtype. +/// If "dtype" is DTYPE_LIST, return its owned element dtype, return NULL +/// otherwise. Returned dtype must be released with vx_dtype_free. #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_dtype_list_element(dtype: *const vx_dtype) -> *const vx_dtype { let Some(element_dtype) = vx_dtype::as_ref(dtype).as_list_element_opt() else { return ptr::null(); }; - vx_dtype::new_ref(element_dtype) + vx_dtype::new(Arc::clone(element_dtype)) } -/// Returns the element type of a fixed-size list. -/// -/// The returned pointer is valid as long as the fixed-size list dtype is valid. -/// Do NOT free the returned dtype pointer - it shares the lifetime of the fixed-size list dtype. +/// If "dtype" is DTYPE_FIXED_SIZE_LIST, return its owned element dtype, return +/// NULL otherwise. Returned dtype must be released with vx_dtype_free. #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_dtype_fixed_size_list_element( dtype: *const vx_dtype, ) -> *const vx_dtype { - // TODO(joe): propagate this error up instead of expecting - let element_dtype = vx_dtype::as_ref(dtype) - .as_fixed_size_list_element_opt() - .vortex_expect("not a fixed-size list dtype"); - vx_dtype::new_ref(element_dtype) + let Some(element_dtype) = vx_dtype::as_ref(dtype).as_fixed_size_list_element_opt() else { + return ptr::null(); + }; + vx_dtype::new(Arc::clone(element_dtype)) } /// Returns the size of a fixed-size list. @@ -398,6 +392,7 @@ mod tests { use crate::dtype::vx_dtype_variant; use crate::ptype::vx_ptype; use crate::string::vx_string; + use crate::string::vx_string_free; use crate::string::vx_string_len; use crate::string::vx_string_ptr; use crate::struct_fields::vx_struct_fields_builder_add_field; @@ -449,6 +444,9 @@ mod tests { let age = vx_struct_fields_field_name(person, 1); assert_eq!(vx_string::as_str(age), "age"); + vx_string_free(name); + vx_string_free(age); + let dtype0 = vx_struct_fields_field_dtype(person, 0); let dtype1 = vx_struct_fields_field_dtype(person, 1); assert_eq!(vx_dtype_get_variant(dtype0), vx_dtype_variant::DTYPE_UTF8); @@ -457,13 +455,9 @@ mod tests { vx_dtype_variant::DTYPE_PRIMITIVE ); - // Field names are now borrowed references - do not free them - - // Free field dtypes (owned references) vx_dtype_free(dtype0); vx_dtype_free(dtype1); - // Free struct fields vx_struct_fields_free(person); } } @@ -517,6 +511,7 @@ mod tests { vx_dtype_variant::DTYPE_PRIMITIVE ); assert_eq!(vx_dtype_primitive_ptype(element), vx_ptype::PTYPE_I32); + vx_dtype_free(element); vx_dtype_free(list_dtype); } @@ -534,15 +529,14 @@ mod tests { ); assert!(vx_dtype_is_nullable(fsl_dtype)); - // Test element accessor let element = vx_dtype_fixed_size_list_element(fsl_dtype); assert_eq!( vx_dtype_get_variant(element), vx_dtype_variant::DTYPE_PRIMITIVE ); assert_eq!(vx_dtype_primitive_ptype(element), vx_ptype::PTYPE_F64); + vx_dtype_free(element); - // Test size accessor let size = vx_dtype_fixed_size_list_size(fsl_dtype); assert_eq!(size, 3); @@ -565,6 +559,7 @@ mod tests { let element = vx_dtype_fixed_size_list_element(fsl_dtype); assert_eq!(vx_dtype_get_variant(element), vx_dtype_variant::DTYPE_UTF8); assert!(vx_dtype_is_nullable(element)); + vx_dtype_free(element); let size = vx_dtype_fixed_size_list_size(fsl_dtype); assert_eq!(size, 10); @@ -607,6 +602,8 @@ mod tests { ); assert_eq!(vx_dtype_primitive_ptype(innermost), vx_ptype::PTYPE_I32); + vx_dtype_free(innermost); + vx_dtype_free(inner); vx_dtype_free(outer_fsl); } } @@ -703,8 +700,9 @@ mod tests { let n_fields = unsafe { vx_struct_fields_nfields(struct_fields_ptr) }; assert_eq!(n_fields, 2); - // Cleanup in reverse order - this is the safest order unsafe { + vx_struct_fields_free(struct_fields_ptr); + vx_dtype_free(dtype_ptr); vx_array_free(vx_arr); } } @@ -729,9 +727,10 @@ mod tests { let name_str = str::from_utf8(name_slice).unwrap(); assert_eq!(name_str, "nums"); - // Cleanup in careful order unsafe { - // Field name is now a borrowed reference - do not free it + vx_string_free(field_name_ptr); + vx_struct_fields_free(struct_fields_ptr); + vx_dtype_free(dtype_ptr); vx_array_free(vx_arr); } } @@ -762,11 +761,12 @@ mod tests { let expected_name = if i == 0 { "nums" } else { "floats" }; assert_eq!(name_str, expected_name); - // Field name is now a borrowed reference - do not free it + unsafe { vx_string_free(field_name_ptr) }; } - // Cleanup unsafe { + vx_struct_fields_free(struct_fields_ptr); + vx_dtype_free(dtype_ptr); vx_array_free(vx_arr); } } @@ -800,6 +800,7 @@ mod tests { let f1 = vx_struct_fields_field_dtype(fields, 1); assert_eq!(vx_dtype_get_variant(f1), vx_dtype_variant::DTYPE_UTF8); assert!(vx_dtype_is_nullable(f1)); + vx_struct_fields_free(fields); vx_dtype_free(f1); vx_dtype_free(dtype); } diff --git a/vortex-ffi/src/error.rs b/vortex-ffi/src/error.rs index 3ee02c99e21..85d6984d33d 100644 --- a/vortex-ffi/src/error.rs +++ b/vortex-ffi/src/error.rs @@ -103,13 +103,10 @@ pub fn try_or( } } -/// Returns the error message from the given Vortex error. -/// -/// The returned pointer is valid as long as the error is valid. -/// Do NOT free the returned string pointer - it shares the lifetime of the error. +/// Return an error message for this error #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_error_get_message(error: *const vx_error) -> *const vx_string { - vx_string::new_ref(&vx_error::as_ref(error).message) + vx_string::new(Arc::clone(&vx_error::as_ref(error).message)) } #[cfg(test)] @@ -160,6 +157,7 @@ mod tests { vx_string::as_ref(message).as_ref(), "panic in Vortex FFI function: boom" ); + unsafe { crate::string::vx_string_free(message) }; unsafe { vx_error_free(error) }; } } diff --git a/vortex-ffi/src/expression.rs b/vortex-ffi/src/expression.rs index 269e1e5d941..80471537e0d 100644 --- a/vortex-ffi/src/expression.rs +++ b/vortex-ffi/src/expression.rs @@ -38,8 +38,6 @@ crate::box_wrapper!( /// data. Each expression consists of an encoding (vtable), heap-allocated /// metadata, and child expressions. /// - /// Unless stated explicitly, all expressions returned are owned and must - /// be freed by the caller. /// Unless stated explicitly, if an operation on const vx_expression* is /// passed NULL, NULL is returned. /// Operations on expressions don't take ownership of input values, and so diff --git a/vortex-ffi/src/lib.rs b/vortex-ffi/src/lib.rs index 43e108e6c28..dadd5701f6f 100644 --- a/vortex-ffi/src/lib.rs +++ b/vortex-ffi/src/lib.rs @@ -113,6 +113,7 @@ mod tests { use crate::sink::vx_array_sink_open_file; use crate::sink::vx_array_sink_push; use crate::string::vx_string; + use crate::string::vx_string_free; /// Panic if error is NULL. Free the error if it's not pub(crate) fn assert_error(error: *mut vx_error) { @@ -125,7 +126,9 @@ mod tests { if !error.is_null() { let message; unsafe { - message = vx_string::as_str(vx_error_get_message(error)).to_owned(); + let msg_ptr = vx_error_get_message(error); + message = vx_string::as_str(msg_ptr).to_owned(); + vx_string_free(msg_ptr); vx_error_free(error); } panic!("{message}"); diff --git a/vortex-ffi/src/macros.rs b/vortex-ffi/src/macros.rs index d4e58288b30..1e943c17ad9 100644 --- a/vortex-ffi/src/macros.rs +++ b/vortex-ffi/src/macros.rs @@ -21,7 +21,7 @@ //! Each macro provides a `free` function, and the `Arc` variants also provide a `clone` function. //! //! Converting between the raw pointer and the wrapped type is done using the generated `new`, -//! `new_ref`, `as_ref`, `as_mut`, `into_box`, and `into_arc` methods, which internally check for +//! `as_ref`, `as_mut`, `into_box`, and `into_arc` methods, which internally check for //! null pointers. //! //! ## Internals @@ -59,11 +59,6 @@ macro_rules! arc_dyn_wrapper { Box::into_raw(Box::new($ffi_ident(obj))).cast_const() } - /// Wrap a borrowed object into a raw pointer. - pub(crate) fn new_ref(obj: &std::sync::Arc<$T>) -> *const $ffi_ident { - obj as *const std::sync::Arc<$T> as *const $ffi_ident - } - /// Extract a borrowed reference from a const pointer. pub(crate) fn as_ref<'a>(ptr: *const $ffi_ident) -> &'a std::sync::Arc<$T> { use vortex::error::VortexExpect; @@ -82,8 +77,7 @@ macro_rules! arc_dyn_wrapper { } } - #[doc = r" Clone a borrowed [`" $ffi_ident "`], returning an owned [`" $ffi_ident "`].\n\n"] - #[doc = r" Must be released with [`" $ffi_ident "_free`]."] + #[doc = r" Clone a " $ffi_ident ". Returned handle must be release with " $ffi_ident "_free "] #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn [<$ffi_ident _clone>](ptr: *const $ffi_ident) -> *const $ffi_ident { if ptr.is_null() { @@ -93,7 +87,7 @@ macro_rules! arc_dyn_wrapper { $ffi_ident::new($ffi_ident::as_ref(ptr).clone()) } - #[doc = r" Free an owned [`" $ffi_ident "`] object."] + #[doc = r" Free a " $ffi_ident] #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn [<$ffi_ident _free>](ptr: *const $ffi_ident) { if ptr.is_null() { @@ -121,11 +115,6 @@ macro_rules! arc_wrapper { std::sync::Arc::into_raw(obj).cast::<$ffi_ident>() } - /// Wrap a borrowed object into a raw pointer. - pub(crate) fn new_ref(obj: &$T) -> *const $ffi_ident { - obj as *const $T as *const $ffi_ident - } - /// Extract a borrowed reference from a const pointer. pub(crate) fn as_ref(ptr: *const $ffi_ident) -> &'static $T { use vortex::error::VortexExpect; @@ -144,8 +133,7 @@ macro_rules! arc_wrapper { } } - #[doc = r" Clone a borrowed [`" $ffi_ident "`], returning an owned [`" $ffi_ident "`].\n\n"] - #[doc = r" Must be released with [`" $ffi_ident "_free`]."] + #[doc = r" Clone a " $ffi_ident] #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn [<$ffi_ident _clone>](ptr: *const $ffi_ident) -> *const $ffi_ident { if ptr.is_null() { @@ -186,21 +174,6 @@ macro_rules! box_dyn_wrapper { Box::into_raw(Box::new($ffi_ident(obj))) } - /// Wrap a borrowed object into a raw pointer. - pub(crate) fn new_ref(obj: &$T) -> *const $ffi_ident { - obj as *const $T as *const $ffi_ident - } - - /// Extract a borrowed reference from a const pointer. - pub(crate) fn as_ref<'a>(ptr: *const $ffi_ident) -> &'a $T { - use vortex::error::VortexExpect; - // TODO(joe): propagate this error up instead of expecting - unsafe { ptr.as_ref() } - .vortex_expect("null pointer") - .0 - .as_ref() - } - /// Extract a borrowed mutable reference from a mut pointer. pub(crate) fn as_mut<'a>(ptr: *mut $ffi_ident) -> &'a mut $T { use vortex::error::VortexExpect; @@ -222,11 +195,11 @@ macro_rules! box_dyn_wrapper { #[doc = r" Free an owned [`" $ffi_ident "`] object."] #[unsafe(no_mangle)] - pub unsafe extern "C-unwind" fn [<$ffi_ident _free>](ptr: *mut $ffi_ident) { + pub unsafe extern "C-unwind" fn [<$ffi_ident _free>](ptr: *const $ffi_ident) { if ptr.is_null() { vortex::error::vortex_panic!("null pointer"); } - drop($ffi_ident::into_box(ptr)) + drop($ffi_ident::into_box(ptr.cast_mut())) } } }; @@ -253,11 +226,6 @@ macro_rules! box_wrapper { Box::into_raw(Box::new(obj)).cast::<$ffi_ident>() } - /// Wrap a borrowed object into a raw pointer. - pub(crate) fn new_ref(obj: &$T) -> *const $ffi_ident { - obj as *const $T as *const $ffi_ident - } - /// Extract a borrowed reference from a const pointer. pub(crate) fn as_ref<'a>(ptr: *const $ffi_ident) -> &'a $T { use vortex::error::VortexExpect; @@ -291,11 +259,11 @@ macro_rules! box_wrapper { #[allow(clippy::missing_safety_doc)] #[allow(rustdoc::private_intra_doc_links)] #[unsafe(no_mangle)] - pub unsafe extern "C-unwind" fn [<$ffi_ident _free>](ptr: *mut $ffi_ident) { + pub unsafe extern "C-unwind" fn [<$ffi_ident _free>](ptr: *const $ffi_ident) { if ptr.is_null() { vortex::error::vortex_panic!("null pointer"); } - std::mem::drop(unsafe { Box::from_raw(ptr.cast::<$T>()) }) + std::mem::drop(unsafe { Box::from_raw(ptr.cast::<$T>().cast_mut()) }) } } }; diff --git a/vortex-ffi/src/scalar.rs b/vortex-ffi/src/scalar.rs index 2471c2c4c9f..64dd670feb0 100644 --- a/vortex-ffi/src/scalar.rs +++ b/vortex-ffi/src/scalar.rs @@ -37,10 +37,8 @@ crate::box_wrapper!( vx_scalar ); -/// Clone a borrowed scalar handle. -/// -/// The input scalar handle is not consumed. The returned scalar handle must be -/// released with vx_scalar_free. Returns NULL when given a NULL scalar handle. +/// Clone a scalar handle. +/// If scalar is NULL, returns NULL. #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_scalar_clone(scalar: *const vx_scalar) -> *mut vx_scalar { if scalar.is_null() { @@ -49,17 +47,14 @@ pub unsafe extern "C-unwind" fn vx_scalar_clone(scalar: *const vx_scalar) -> *mu vx_scalar::new(vx_scalar::as_ref(scalar).clone()) } -/// Return the data type of a scalar. -/// -/// The returned data type handle borrows storage from the scalar handle, so its -/// lifetime is bound to the scalar handle. It MUST NOT be freed separately. -/// Returns NULL when given a NULL scalar handle. +/// Return scalar's dtype. +/// If scalar is NULL, returns NULL. #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_scalar_dtype(scalar: *const vx_scalar) -> *const vx_dtype { if scalar.is_null() { return ptr::null(); } - vx_dtype::new_ref(vx_scalar::as_ref(scalar).dtype()) + vx_dtype::new(Arc::new(vx_scalar::as_ref(scalar).dtype().clone())) } /// Return whether the scalar is a typed null value. @@ -202,9 +197,11 @@ pub unsafe extern "C-unwind" fn vx_scalar_new_binary( /// Create a typed null scalar. /// -/// The data type handle is borrowed, not consumed. The returned scalar uses a -/// nullable copy of that logical type, regardless of the input type's top-level -/// nullability. A NULL data type handle returns NULL and writes the error output. +/// "dtype" is not consumed, you can use it after calling this function. Returned +/// scalar uses a nullable copy of that logical type, regardless of the input +/// type's top-level nullability. +/// +/// Returns NULL and sets "err" on error or NULL dtype. #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_scalar_new_null( dtype: *const vx_dtype, @@ -342,10 +339,8 @@ pub unsafe extern "C-unwind" fn vx_scalar_new_decimal_i256_le( /// Create a list scalar. /// -/// The element data type handle is borrowed, not consumed. Child scalar handles -/// are cloned into the list value, so the caller keeps ownership of the handle -/// array and each scalar in it. A NULL child handle array is allowed only for an -/// empty list. Child values are validated against the element logical type. +/// "element_dtype" and "elements" are not consumed, you can use them after +/// calling this function. If len is 0, you can pass NULL to "elements". #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_scalar_new_list( element_dtype: *const vx_dtype, @@ -370,30 +365,25 @@ pub unsafe extern "C-unwind" fn vx_scalar_new_list( /// Create a fixed-size list scalar. /// -/// The element data type handle is borrowed, not consumed. The number of child -/// scalars becomes the fixed-size list width and must fit in a 32-bit unsigned -/// integer. Child scalar handles are cloned into the list value, so the caller -/// keeps ownership of the handle array and each scalar in it. A NULL child -/// handle array is allowed only for an empty list. Child values are validated -/// against the element logical type. +/// "element_dtype" and "elements" are not consumed, you can use them after +/// calling this function. If len is 0, you can pass NULL to "elements". +/// "len" must fit in uint32_t. #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_scalar_new_fixed_size_list( element_dtype: *const vx_dtype, elements: *const *const vx_scalar, - len: usize, + len: u32, is_nullable: bool, err: *mut *mut vx_error, ) -> *mut vx_scalar { try_or(err, ptr::null_mut(), || { vortex_ensure!(!element_dtype.is_null(), "element dtype is null"); - let size = u32::try_from(len) - .map_err(|_| vortex_err!("fixed-size list length {len} exceeds u32::MAX"))?; let dtype = DType::FixedSizeList( Arc::new(vx_dtype::as_ref(element_dtype).clone()), - size, + len, Nullability::from(is_nullable), ); - let values = scalar_values_from_raw(elements, len)?; + let values = scalar_values_from_raw(elements, len as usize)?; Ok(vx_scalar::new(Scalar::try_new( dtype, Some(ScalarValue::Tuple(values)), @@ -403,11 +393,8 @@ pub unsafe extern "C-unwind" fn vx_scalar_new_fixed_size_list( /// Create a struct scalar. /// -/// The struct data type handle is borrowed, not consumed. Field scalar handles -/// are cloned into the struct value, so the caller keeps ownership of the handle -/// array and each scalar in it. Field count and field logical types are validated -/// against the struct logical type. A NULL field handle array is allowed only for -/// an empty struct value. +/// "struct_dtype" and "fields" are not consumed, you can use them after calling +/// this function. If len is 0, you can pass NULL to "fields". #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_scalar_new_struct( struct_dtype: *const vx_dtype, @@ -620,10 +607,12 @@ mod tests { vx_dtype_free(dtype); assert_no_error(error); assert!(vx_scalar_is_null(null_scalar)); + let scalar_dtype = vx_scalar_dtype(null_scalar); assert_eq!( - vx_dtype::as_ref(vx_scalar_dtype(null_scalar)), + vx_dtype::as_ref(scalar_dtype), &DType::Primitive(PType::I32, Nullability::Nullable) ); + vx_dtype_free(scalar_dtype); vx_scalar_free(null_scalar); } } @@ -743,11 +732,12 @@ mod tests { ); assert_no_error(error); + let len = u32::try_from(children.len()).unwrap(); assert_scalar( vx_scalar_new_fixed_size_list( element_dtype, children.as_ptr(), - children.len(), + len, false, &raw mut error, ), diff --git a/vortex-ffi/src/scan.rs b/vortex-ffi/src/scan.rs index 619f82e3f3e..2bc748edeb9 100644 --- a/vortex-ffi/src/scan.rs +++ b/vortex-ffi/src/scan.rs @@ -225,9 +225,7 @@ fn write_estimate>(estimate: Precision, out: &mut vx_estimate) { /// Scan a data source. /// -/// Return an owned scan that must be freed with vx_scan_free. A scan may be -/// consumed only once. -/// +/// A scan may be consumed only once. /// "options" and "estimate" may be NULL. /// /// If "options" is NULL, all rows and columns are returned. @@ -256,10 +254,8 @@ pub unsafe extern "C-unwind" fn vx_data_source_scan( }) } -/// Return borrowed vx_scan's dtype. +/// Return scan's dtype. /// This function will fail if called after vx_scan_next_partition. -/// Called must not free the returned pointer as its lifetime is bound to the -/// lifetime of the scan. /// On error returns NULL and sets "err". #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_scan_dtype( @@ -271,12 +267,11 @@ pub unsafe extern "C-unwind" fn vx_scan_dtype( let VxScan::Pending(scan) = scan else { vortex_bail!("dtype unavailable: scan already started"); }; - Ok(vx_dtype::new_ref(scan.dtype())) + Ok(vx_dtype::new(Arc::new(scan.dtype().clone()))) }) } -/// Return an owned partition from a scan. -/// The returned partition must be freed with vx_partition_free. +/// Return an partition from a scan. /// /// On success returns a partition. /// On exhaustion (no more partitions in scan) returns NULL but doesn't set @@ -398,8 +393,7 @@ pub unsafe extern "C-unwind" fn vx_partition_scan_arrow( }) } -/// Return an owned owned array from a partition. -/// The returned array must be freed with vx_array_free. +/// Return an array from a partition. /// /// On success returns an array. /// On exhaustion (no more arrays in partition) returns NULL but doesn't set diff --git a/vortex-ffi/src/struct_fields.rs b/vortex-ffi/src/struct_fields.rs index fa8f10aca83..ec56304cfa6 100644 --- a/vortex-ffi/src/struct_fields.rs +++ b/vortex-ffi/src/struct_fields.rs @@ -29,11 +29,8 @@ pub unsafe extern "C-unwind" fn vx_struct_fields_nfields(dtype: *const vx_struct .nfields() as u64 } -/// Return a borrowed reference to the name of the field at the given index. -/// -/// The returned pointer is valid as long as the struct fields is valid. -/// Do NOT free the returned string pointer - it shares the lifetime of the struct fields. -/// Returns null if the index is out of bounds. +/// Return an owned name of the field at a given index. +/// If index is out of bounds, returns NULL. #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_struct_fields_field_name( dtype: *const vx_struct_fields, @@ -46,16 +43,11 @@ pub unsafe extern "C-unwind" fn vx_struct_fields_field_name( return ptr::null(); } let name = struct_dtype.names()[idx].inner(); - vx_string::new_ref(name) + vx_string::new(Arc::clone(name)) } -/// Returns an *owned* reference to the dtype of the field at the given index. -/// -/// The return type is owned since struct dtypes can be lazily parsed from a binary format, in -/// which case it's not possible to return a borrowed reference to the field dtype. -/// -/// Returns null if the index is out of bounds or if the field dtype cannot be parsed. -// TODO(ngates): should StructDType cache owned fields internally? +/// Return an owned dtype of the field at a given index. +/// Returns NULL if index is out of bounds or if dtype cannot be parsed. #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_struct_fields_field_dtype( dtype: *const vx_struct_fields, diff --git a/vortex-ffi/test/array.cpp b/vortex-ffi/test/array.cpp index 4fde064a339..f558243525a 100644 --- a/vortex-ffi/test/array.cpp +++ b/vortex-ffi/test/array.cpp @@ -9,7 +9,11 @@ TEST_CASE("Null array creation", "[array]") { REQUIRE(array != nullptr); REQUIRE(vx_array_is_nullable(array)); REQUIRE(vx_array_has_dtype(array, DTYPE_NULL)); - REQUIRE(vx_dtype_get_variant(vx_array_dtype(array)) == DTYPE_NULL); + const vx_dtype *dtype = vx_array_dtype(array); + defer { + vx_dtype_free(dtype); + }; + REQUIRE(vx_dtype_get_variant(dtype) == DTYPE_NULL); REQUIRE(vx_array_len(array) == 1999); vx_array_free(array); } @@ -26,7 +30,11 @@ TEST_CASE("Primitive array creation", "[array]") { require_no_error(error); REQUIRE(array != nullptr); REQUIRE(vx_array_has_dtype(array, DTYPE_PRIMITIVE)); - REQUIRE(vx_dtype_get_variant(vx_array_dtype(array)) == DTYPE_PRIMITIVE); + const vx_dtype *dtype = vx_array_dtype(array); + REQUIRE(vx_dtype_get_variant(dtype) == DTYPE_PRIMITIVE); + defer { + vx_dtype_free(dtype); + }; REQUIRE(vx_array_is_primitive(array, PTYPE_U8)); REQUIRE(vx_array_len(array) == buffer.size()); diff --git a/vortex-ffi/test/common.h b/vortex-ffi/test/common.h index 29087e7a48d..60975d22015 100644 --- a/vortex-ffi/test/common.h +++ b/vortex-ffi/test/common.h @@ -7,22 +7,20 @@ inline std::string to_string(vx_error *err) { const vx_string *msg = vx_error_get_message(err); - return {vx_string_ptr(msg), vx_string_len(msg)}; + const std::string out {vx_string_ptr(msg), vx_string_len(msg)}; + vx_string_free(msg); + return out; } inline std::string_view to_string_view(const vx_string *msg) { return {vx_string_ptr(msg), vx_string_len(msg)}; } -inline std::string_view to_string_view(vx_error *err) { - return to_string_view(vx_error_get_message(err)); -} - inline void require_no_error(vx_error *error, bool assert = true) { if (!error) { return; } - auto message = to_string(error); + std::string message = to_string(error); vx_error_free(error); if (assert) { FAIL(message); diff --git a/vortex-ffi/test/scan.cpp b/vortex-ffi/test/scan.cpp index 6857ea82bc1..b1d4b8ce3f8 100644 --- a/vortex-ffi/test/scan.cpp +++ b/vortex-ffi/test/scan.cpp @@ -265,27 +265,36 @@ TEST_CASE("Write file and read dtypes", "[datasource]") { CHECK(row_count.estimate == SAMPLE_ROWS); const vx_dtype *data_source_dtype = vx_data_source_dtype(ds); + defer { + vx_dtype_free(data_source_dtype); + }; REQUIRE(vx_dtype_get_variant(data_source_dtype) == DTYPE_STRUCT); const vx_struct_fields *fields = vx_dtype_struct_dtype(data_source_dtype); + defer { + vx_struct_fields_free(fields); + }; const size_t len = vx_struct_fields_nfields(fields); REQUIRE(len == 2); const vx_dtype *age_dtype = vx_struct_fields_field_dtype(fields, 0); + const vx_string *age_name = vx_struct_fields_field_name(fields, 0); defer { vx_dtype_free(age_dtype); + vx_string_free(age_name); }; - const vx_string *age_name = vx_struct_fields_field_name(fields, 0); + REQUIRE(vx_dtype_get_variant(age_dtype) == DTYPE_PRIMITIVE); REQUIRE(vx_dtype_primitive_ptype(age_dtype) == PTYPE_U8); REQUIRE_FALSE(vx_dtype_is_nullable(age_dtype)); REQUIRE(to_string_view(age_name) == "age"); const vx_dtype *height_dtype = vx_struct_fields_field_dtype(fields, 1); + const vx_string *height_name = vx_struct_fields_field_name(fields, 1); defer { vx_dtype_free(height_dtype); + vx_string_free(height_name); }; - const vx_string *height_name = vx_struct_fields_field_name(fields, 1); REQUIRE(vx_dtype_get_variant(height_dtype) == DTYPE_PRIMITIVE); REQUIRE(vx_dtype_primitive_ptype(height_dtype) == PTYPE_U16); REQUIRE(vx_dtype_is_nullable(height_dtype)); @@ -294,7 +303,11 @@ TEST_CASE("Write file and read dtypes", "[datasource]") { void verify_age_field(const vx_array *age_field) { REQUIRE(vx_array_has_dtype(age_field, DTYPE_PRIMITIVE)); - REQUIRE(vx_dtype_primitive_ptype(vx_array_dtype(age_field)) == PTYPE_U8); + const vx_dtype *dtype = vx_array_dtype(age_field); + defer { + vx_dtype_free(dtype); + }; + REQUIRE(vx_dtype_primitive_ptype(dtype) == PTYPE_U8); REQUIRE(vx_array_len(age_field) == SAMPLE_ROWS); for (size_t i = 0; i < SAMPLE_ROWS; ++i) { REQUIRE(vx_array_get_u8(age_field, i) == i); @@ -303,7 +316,11 @@ void verify_age_field(const vx_array *age_field) { void verify_height_field(const vx_array *height_field) { REQUIRE(vx_array_has_dtype(height_field, DTYPE_PRIMITIVE)); - REQUIRE(vx_dtype_primitive_ptype(vx_array_dtype(height_field)) == PTYPE_U16); + const vx_dtype *dtype = vx_array_dtype(height_field); + defer { + vx_dtype_free(dtype); + }; + REQUIRE(vx_dtype_primitive_ptype(dtype) == PTYPE_U16); REQUIRE(vx_array_len(height_field) == SAMPLE_ROWS); for (size_t i = 0; i < SAMPLE_ROWS; ++i) { REQUIRE(vx_array_get_u16(height_field, i) > 0); @@ -314,7 +331,9 @@ void verify_sample_array(const vx_array *array) { REQUIRE(vx_array_len(array) == SAMPLE_ROWS); REQUIRE(vx_array_has_dtype(array, DTYPE_STRUCT)); - const vx_struct_fields *fields = vx_dtype_struct_dtype(vx_array_dtype(array)); + const vx_dtype *dtype = vx_array_dtype(array); + const vx_struct_fields *fields = vx_dtype_struct_dtype(dtype); + vx_dtype_free(dtype); size_t len = vx_struct_fields_nfields(fields); REQUIRE(len == 2); @@ -325,6 +344,7 @@ void verify_sample_array(const vx_array *array) { vx_dtype_free(age_dtype); const vx_string *age_name = vx_struct_fields_field_name(fields, 0); REQUIRE(to_string_view(age_name) == "age"); + vx_string_free(age_name); const vx_dtype *height_dtype = vx_struct_fields_field_dtype(fields, 1); REQUIRE(vx_dtype_get_variant(height_dtype) == DTYPE_PRIMITIVE); @@ -332,6 +352,9 @@ void verify_sample_array(const vx_array *array) { vx_dtype_free(height_dtype); const vx_string *height_name = vx_struct_fields_field_name(fields, 1); REQUIRE(to_string_view(height_name) == "height"); + vx_string_free(height_name); + + vx_struct_fields_free(fields); vx_error *error = nullptr; vx_validity validity = {}; diff --git a/vortex-ffi/test/struct.cpp b/vortex-ffi/test/struct.cpp index 6ccdb04c037..4a48e9390d2 100644 --- a/vortex-ffi/test/struct.cpp +++ b/vortex-ffi/test/struct.cpp @@ -54,9 +54,7 @@ TEST_CASE("Creating structs", "[struct]") { const size_t len = vx_struct_fields_nfields(fields); CHECK(len == STRUCT_LEN); for (size_t i = 0; i < len; ++i) { - // borrowed const vx_string *name = vx_struct_fields_field_name(fields, i); - // owned TODO(myrrc): that's weird API const vx_dtype *dtype = vx_struct_fields_field_dtype(fields, i); std::string_view name_view {vx_string_ptr(name), vx_string_len(name)}; @@ -73,6 +71,7 @@ TEST_CASE("Creating structs", "[struct]") { } vx_dtype_free(dtype); + vx_string_free(name); } vx_struct_fields_free(fields); From 4959939fe81b3d9ad6c531decc409e06b0c6dd32 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Mon, 6 Jul 2026 14:17:10 +0100 Subject: [PATCH 019/104] Add policy-bot configuration file (#8659) Github approval logic is not flexible enough to not be super noisy or allow writes without allowing approvals. Policy-bot will let us have arbitrary approval configuration Signed-off-by: Robert Kruszewski --- policy.yml | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 policy.yml diff --git a/policy.yml b/policy.yml new file mode 100644 index 00000000000..e669833e011 --- /dev/null +++ b/policy.yml @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + +--- +policy: + approval: + - or: + - a vortex committer has approved + - an untouched renovate pull request has an allowed approval + - claude or codex authored pull requests have two committer approvals + disapproval: + options: + methods: + disapprove: + comments: + - ":-1:" + - "-1" + - "👎" + github_review: true + revoke: + comments: + - ":+1:" + - "+1" + - "👍" + github_review: true + requires: + teams: + - "vortex-data/committers" + +approval_defaults: + options: + # Allow explicit PR comments and GitHub reviews, but not PR body text. + methods: + comments: + - ":+1:" + - "+1" + - "👍" + comment_patterns: [] + github_review: true + github_review_comment_patterns: [] + body_patterns: [] + allow_author: false + allow_contributor: false + allow_non_author_contributor: true + invalidate_on_push: false + +approval_rules: + - name: a vortex committer has approved + description: "Requires approval from the Committers team." + requires: + count: 1 + teams: + - "vortex-data/committers" + + - name: an untouched renovate pull request has an allowed approval + description: "Allows approval by renovate-approve or a committer only when every commit in the PR is still Renovate-authored or Renovate-committed." + if: + has_author_in: + users: + - "renovate[bot]" + only_has_contributors_in: + users: + - "renovate[bot]" + options: + allow_author: true + allow_contributor: true + requires: + count: 1 + users: + - "renovate-approve[bot]" + teams: + - "vortex-data/committers" + + - name: claude or codex authored pull requests have two committer approvals + if: + has_author_in: + users: + - "vortex-claude[bot]" + - "claude-code[bot]" + - "codex[bot]" + - "openai-codex[bot]" + requires: + count: 2 + teams: + - "vortex-data/committers" From 91f1ca8585a9bcbac6d43628d6e88e2f63a21636 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Mon, 6 Jul 2026 17:32:07 +0100 Subject: [PATCH 020/104] VortexSession register is public and allow_unknown takes reference (#8647) While integrating vortex into another project I realised that these are more sensible apis --- vortex-array/src/aggregate_fn/proto.rs | 5 ++--- vortex-array/src/dtype/serde/proto.rs | 4 +++- vortex-array/src/expr/proto.rs | 5 ++--- vortex-session/src/lib.rs | 2 +- vortex-session/src/session.rs | 9 ++++----- vortex-tui/src/main.rs | 3 ++- vortex-web/crate/src/lib.rs | 6 +++--- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/vortex-array/src/aggregate_fn/proto.rs b/vortex-array/src/aggregate_fn/proto.rs index 50c585233f0..92fac87892a 100644 --- a/vortex-array/src/aggregate_fn/proto.rs +++ b/vortex-array/src/aggregate_fn/proto.rs @@ -195,9 +195,8 @@ mod tests { #[test] fn unknown_aggregate_fn_id_allow_unknown() { - let session = VortexSession::empty() - .with::() - .allow_unknown(); + let session = VortexSession::empty().with::(); + session.allow_unknown(); let proto = pb::AggregateFn { id: "vortex.test.foreign_aggregate".to_string(), diff --git a/vortex-array/src/dtype/serde/proto.rs b/vortex-array/src/dtype/serde/proto.rs index 2f992dce591..3147168de90 100644 --- a/vortex-array/src/dtype/serde/proto.rs +++ b/vortex-array/src/dtype/serde/proto.rs @@ -231,6 +231,7 @@ mod tests { use std::sync::Arc; use super::*; + use crate::array_session; use crate::dtype::DType; use crate::dtype::DecimalDType; use crate::dtype::Field; @@ -502,7 +503,8 @@ mod tests { #[test] fn test_unknown_extension_allow_unknown() { - let session = crate::array_session().allow_unknown(); + let session = array_session(); + session.allow_unknown(); let proto = pb::DType { dtype_type: Some(DtypeType::Extension(Box::new(pb::Extension { id: "vortex.test.foreign_ext".to_string(), diff --git a/vortex-array/src/expr/proto.rs b/vortex-array/src/expr/proto.rs index 9f2b81bde84..0a8d71f2954 100644 --- a/vortex-array/src/expr/proto.rs +++ b/vortex-array/src/expr/proto.rs @@ -116,9 +116,8 @@ mod tests { #[test] fn unknown_expression_id_allow_unknown() { - let session = VortexSession::empty() - .with::() - .allow_unknown(); + let session = VortexSession::empty().with::(); + session.allow_unknown(); let expr_proto = pb::Expr { id: "vortex.test.foreign_scalar_fn".to_string(), diff --git a/vortex-session/src/lib.rs b/vortex-session/src/lib.rs index aee36dfbadd..f69dbc8b3ee 100644 --- a/vortex-session/src/lib.rs +++ b/vortex-session/src/lib.rs @@ -96,7 +96,7 @@ mod tests { let session = VortexSession::empty(); assert!(!session.allows_unknown()); - let session = session.allow_unknown(); + session.allow_unknown(); assert!(session.allows_unknown()); } } diff --git a/vortex-session/src/session.rs b/vortex-session/src/session.rs index 9969d0e7281..5745842f3d7 100644 --- a/vortex-session/src/session.rs +++ b/vortex-session/src/session.rs @@ -191,11 +191,11 @@ impl VortexSession { /// Inserts a session variable of type `V`, replacing any existing variable of that type. /// - /// This is the internal copy-on-write insert primitive behind [`with_some`](Self::with_some) and + /// This is the copy-on-write insert primitive behind [`with_some`](Self::with_some) and /// [`get_mut`](SessionExt::get_mut); it is not public, so a variable can only enter the type-map /// through those (or through a default inserted by [`get`](SessionExt::get)). The mutation is /// applied in place to the shared backing store, so it is visible through every clone. - fn register(&self, var: V) { + pub fn register(&self, var: V) { let var: Arc = Arc::new(var); self.0.rcu(|current| { let mut next = SessionVars::clone(current); @@ -246,9 +246,8 @@ impl VortexSession { /// Allow deserializing unknown plugin IDs as non-executable foreign placeholders. /// /// Mutates this session in place and returns it for chaining. - pub fn allow_unknown(self) -> Self { + pub fn allow_unknown(&self) { self.get_mut::().allow_unknown = true; - self } } @@ -435,7 +434,7 @@ mod tests { let session = VortexSession::empty(); assert!(!session.allows_unknown()); - let session = session.allow_unknown(); + session.allow_unknown(); assert!(session.allows_unknown()); } diff --git a/vortex-tui/src/main.rs b/vortex-tui/src/main.rs index cc1e740eb83..f7e4a0e1ff1 100644 --- a/vortex-tui/src/main.rs +++ b/vortex-tui/src/main.rs @@ -8,7 +8,8 @@ use vortex_tui::launch; #[tokio::main] async fn main() -> anyhow::Result<()> { - let session = VortexSession::default().with_tokio().allow_unknown(); + let session = VortexSession::default().with_tokio(); + session.allow_unknown(); if let Err(err) = launch(&session).await { // Defer help/version/usage errors back to clap so their formatting // and exit codes match the standalone-binary convention exactly. diff --git a/vortex-web/crate/src/lib.rs b/vortex-web/crate/src/lib.rs index 03b36ee4515..84e1b1d6004 100644 --- a/vortex-web/crate/src/lib.rs +++ b/vortex-web/crate/src/lib.rs @@ -17,7 +17,7 @@ use vortex::session::VortexSession; mod wasm; static SESSION: LazyLock = LazyLock::new(|| { - VortexSession::default() - .with_handle(WasmRuntime::handle()) - .allow_unknown() + let session = VortexSession::default().with_handle(WasmRuntime::handle()); + session.allow_unknown(); + session }); From 1743bb04b4f8c8660cff4fc8f8082c422fa98f0c Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Mon, 6 Jul 2026 17:39:20 +0100 Subject: [PATCH 021/104] Fix vortex-jni local path handling, centralise logic in vortex-file (#8650) Existing logic was duplicated between jni and duckdb with slight differences. JNI logic didn't always handle local paths. --- Cargo.lock | 1 + .../spark/VortexDataSourceWriteTest.java | 39 ++++ vortex-duckdb/src/multi_file.rs | 180 +--------------- vortex-file/Cargo.toml | 1 + vortex-file/src/multi/mod.rs | 2 + vortex-file/src/multi/uri.rs | 203 ++++++++++++++++++ vortex-jni/src/data_source.rs | 88 +------- vortex-jni/src/file.rs | 11 +- vortex-jni/src/writer.rs | 27 ++- 9 files changed, 269 insertions(+), 283 deletions(-) create mode 100644 vortex-file/src/multi/uri.rs diff --git a/Cargo.lock b/Cargo.lock index 459b58dded4..d4a9f942e97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10308,6 +10308,7 @@ dependencies = [ "rstest", "tokio", "tracing", + "url", "vortex-alp", "vortex-array", "vortex-btrblocks", diff --git a/java/vortex-spark/src/test/java/dev/vortex/spark/VortexDataSourceWriteTest.java b/java/vortex-spark/src/test/java/dev/vortex/spark/VortexDataSourceWriteTest.java index 325da0bc98c..90fb1959ade 100644 --- a/java/vortex-spark/src/test/java/dev/vortex/spark/VortexDataSourceWriteTest.java +++ b/java/vortex-spark/src/test/java/dev/vortex/spark/VortexDataSourceWriteTest.java @@ -129,6 +129,45 @@ public void testWriteAndReadVortexFiles() throws IOException { verifyDataContent(originalDf, readDf); } + @Test + @DisplayName("Write and read Vortex files with a bare path (no URI scheme)") + public void testWriteAndReadWithBarePath() throws IOException { + int numRows = 50; + Dataset originalDf = createTestDataFrame(numRows); + + // A bare filesystem path, without a file:// scheme. + String barePath = tempDir.resolve("bare_path_output").toString(); + originalDf + .write() + .format("vortex") + .option("path", barePath) + .mode(SaveMode.Overwrite) + .save(); + + assertFalse(findVortexFiles(tempDir.resolve("bare_path_output")).isEmpty(), "Write should create files"); + + // Reading a bare directory path exercises schema inference via file listing. + Dataset readDf = + spark.read().format("vortex").option("path", barePath).load(); + + assertSchemaEquals(originalDf.schema(), readDf.schema()); + assertEquals(numRows, readDf.count(), "Read DataFrame should have same number of rows as original"); + verifyDataContent(originalDf, readDf); + + // Overwriting a bare path exercises file listing and deletion of the existing files. + Dataset replacementDf = createTestDataFrame(25); + replacementDf + .write() + .format("vortex") + .option("path", barePath) + .mode(SaveMode.Overwrite) + .save(); + + Dataset reread = + spark.read().format("vortex").option("path", barePath).load(); + assertEquals(25, reread.count(), "Should have data from second write after overwrite"); + } + @Test @DisplayName("Write empty DataFrame as Vortex") public void testWriteEmptyDataFrame() throws IOException { diff --git a/vortex-duckdb/src/multi_file.rs b/vortex-duckdb/src/multi_file.rs index bb9e015af5c..247b6a19909 100644 --- a/vortex-duckdb/src/multi_file.rs +++ b/vortex-duckdb/src/multi_file.rs @@ -1,8 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -use std::path::Path; -use std::path::absolute; use std::sync::Arc; use itertools::Itertools; @@ -14,6 +12,7 @@ use vortex::error::VortexResult; use vortex::error::vortex_bail; use vortex::error::vortex_err; use vortex::file::multi::MultiFileDataSource; +use vortex::file::multi::parse_uri_or_path; use vortex::io::compat::Compat; use vortex::io::filesystem::FileSystemRef; use vortex::io::object_store::ObjectStoreFileSystem; @@ -26,38 +25,6 @@ use crate::SESSION; use crate::duckdb::BindInputRef; use crate::duckdb::ExtractedValue; -/// Parse a glob string into a [`Url`]. -/// -/// Accepts full URLs (e.g. `s3://bucket/prefix/*.vortex`, `file:///data/*.vortex`) as well as -/// bare file paths. For bare paths, the path is made absolute (without requiring it to exist) -/// so that relative paths such as `./data/*.vortex` or `../data/*.vortex` are resolved correctly. -fn parse_glob_url(glob_url_str: &str) -> VortexResult { - Url::parse(glob_url_str).or_else(|_| { - let path = absolute(Path::new(glob_url_str)) - .map_err(|e| vortex_err!("Failed making {glob_url_str} absolute: {e}"))?; - // `absolute()` does not normalize `..` components, so `/a/b/../c` stays as-is. - // Normalizing manually avoids `..` being percent-encoded in the resulting URL. - let path = normalize_path(path); - Url::from_file_path(path).map_err(|_| vortex_err!("Neither URL nor path: {glob_url_str}")) - }) -} - -/// Normalize a path by resolving `.` and `..` components without accessing the filesystem. -fn normalize_path(path: std::path::PathBuf) -> std::path::PathBuf { - use std::path::Component; - let mut normalized = std::path::PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::ParentDir => { - normalized.pop(); - } - c => normalized.push(c), - } - } - normalized -} - fn resolve_filesystem(base_url: &Url) -> VortexResult { let object_store: Arc = match base_url.scheme() { "file" => Arc::new(LocalFileSystem::new()), @@ -104,7 +71,7 @@ pub fn bind_multi_file_scan(input: &BindInputRef) -> VortexResult = Vec::with_capacity(glob_strings.len()); for glob_str in &glob_strings { - glob_urls.push(parse_glob_url(glob_str)?); + glob_urls.push(parse_uri_or_path(glob_str)?); } // Cache filesystems by base URL to avoid resolving the same filesystem multiple times. @@ -134,146 +101,3 @@ pub fn bind_multi_file_scan(input: &BindInputRef) -> VortexResult VortexResult<()> { - let url = parse_glob_url("s3://my-bucket/prefix/*.vortex")?; - assert_eq!(url.scheme(), "s3"); - assert_eq!(url.host_str(), Some("my-bucket")); - assert_eq!(url.path(), "/prefix/*.vortex"); - Ok(()) - } - - #[test] - fn test_parse_glob_url_file_scheme() -> VortexResult<()> { - let url = parse_glob_url("file:///absolute/path/data.vortex")?; - assert_eq!(url.scheme(), "file"); - assert_eq!(url.path(), "/absolute/path/data.vortex"); - Ok(()) - } - - #[test] - fn test_parse_glob_url_absolute_glob_path() -> VortexResult<()> { - let tmpdir = tempfile::tempdir()?; - let glob = format!("{}/*.vortex", tmpdir.path().display()); - let url = parse_glob_url(&glob)?; - assert_eq!(url.scheme(), "file"); - assert!(url.path().ends_with("/*.vortex")); - Ok(()) - } - - #[test] - fn test_parse_glob_url_absolute_existing_path() -> VortexResult<()> { - let tmpfile = tempfile::NamedTempFile::new()?; - let canonical = std::fs::canonicalize(tmpfile.path())?; - let path_str = canonical - .to_str() - .ok_or_else(|| vortex_err!("canonical path is not valid UTF-8"))?; - let url = parse_glob_url(path_str)?; - assert_eq!(url.scheme(), "file"); - assert_eq!(url.path(), path_str); - Ok(()) - } - - #[test] - fn test_parse_glob_url_relative_path() -> VortexResult<()> { - // Create a tempfile in the current working directory so we can refer to it - // by a relative name (just the filename, without any directory component). - let tmpfile = tempfile::NamedTempFile::new_in(".")?; - let filename = tmpfile - .path() - .file_name() - .ok_or_else(|| vortex_err!("temp file missing file name"))? - .to_str() - .ok_or_else(|| vortex_err!("temp file name is not valid UTF-8"))?; - - let url = parse_glob_url(filename)?; - assert_eq!(url.scheme(), "file"); - // The relative name must have been resolved to an absolute path. - assert!(url.path().ends_with(filename)); - assert!(url.path().starts_with('/')); - Ok(()) - } - - #[test] - fn test_parse_glob_url_relative_glob_path() -> VortexResult<()> { - // A relative path with a glob character (e.g. `./data/*.vortex`) must also resolve - // correctly. - let tmpdir = tempfile::tempdir_in(".")?; - let dir_name = tmpdir - .path() - .file_name() - .ok_or_else(|| vortex_err!("temp dir missing file name"))? - .to_str() - .ok_or_else(|| vortex_err!("temp dir name is not valid UTF-8"))?; - let glob = format!("./{dir_name}/*.vortex"); - let url = parse_glob_url(&glob)?; - assert_eq!(url.scheme(), "file"); - assert!(url.path().starts_with('/')); - assert!(url.path().ends_with("/*.vortex")); - Ok(()) - } - - #[test] - fn test_parse_glob_url_nonexistent_path() -> VortexResult<()> { - // absolute() does not require the path to exist, so a non-existent path succeeds. - let url = parse_glob_url("/nonexistent/path/file.vortex")?; - assert_eq!(url.scheme(), "file"); - assert_eq!(url.path(), "/nonexistent/path/file.vortex"); - Ok(()) - } - - #[test] - fn test_parse_glob_url_parent_relative_path() -> VortexResult<()> { - // A path starting with `..` must be resolved to an absolute path without - // percent-encoding the `..` component in the resulting URL. - let tmpfile = tempfile::NamedTempFile::new_in("..")?; - let filename = tmpfile - .path() - .file_name() - .ok_or_else(|| vortex_err!("temp file missing file name"))? - .to_str() - .ok_or_else(|| vortex_err!("temp file name is not valid UTF-8"))?; - let relative = format!("../{filename}"); - - let url = parse_glob_url(&relative)?; - assert_eq!(url.scheme(), "file"); - // The resolved path must be absolute and must not contain encoded dots. - assert!(url.path().starts_with('/')); - assert!( - !url.path().contains("%2E"), - "path must not contain percent-encoded dots" - ); - assert!(url.path().ends_with(filename)); - Ok(()) - } - - // Use absolute paths so the expected result is cwd-independent. - #[rstest] - #[case("/a/./b", "/a/b")] - #[case("/a/b/./c", "/a/b/c")] - #[case("/a/../b", "/b")] - #[case("/a/b/../c", "/a/c")] - #[case("/a/b/../../c", "/c")] - #[case("/a/./b/.././c", "/a/c")] - #[case("/a/b/../..", "/")] - fn test_parse_glob_url_dot_normalization( - #[case] input: &str, - #[case] expected_path: &str, - ) -> VortexResult<()> { - let url = parse_glob_url(input)?; - assert_eq!(url.scheme(), "file"); - assert_eq!( - url.path(), - expected_path, - "input {input:?} should normalize to {expected_path:?}" - ); - Ok(()) - } -} diff --git a/vortex-file/Cargo.toml b/vortex-file/Cargo.toml index 752ccbc753d..a7af1b293dd 100644 --- a/vortex-file/Cargo.toml +++ b/vortex-file/Cargo.toml @@ -30,6 +30,7 @@ parking_lot = { workspace = true } pin-project-lite = { workspace = true } tokio = { workspace = true, features = ["rt"], optional = true } tracing = { workspace = true } +url = { workspace = true } vortex-alp = { workspace = true } vortex-array = { workspace = true } vortex-btrblocks = { workspace = true } diff --git a/vortex-file/src/multi/mod.rs b/vortex-file/src/multi/mod.rs index 215331f0540..587768f17d0 100644 --- a/vortex-file/src/multi/mod.rs +++ b/vortex-file/src/multi/mod.rs @@ -4,6 +4,7 @@ //! Builder for constructing a [`MultiLayoutDataSource`] from multiple Vortex files. mod session; +mod uri; use std::sync::Arc; @@ -14,6 +15,7 @@ use futures::stream; pub use session::MultiFileSession; use session::MultiFileSessionExt; use tracing::debug; +pub use uri::parse_uri_or_path; use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_error::vortex_bail; diff --git a/vortex-file/src/multi/uri.rs b/vortex-file/src/multi/uri.rs new file mode 100644 index 00000000000..821eb008e7f --- /dev/null +++ b/vortex-file/src/multi/uri.rs @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Parsing of user-supplied URI-or-path strings into URLs, shared by the language bindings +//! that construct a [`MultiFileDataSource`](super::MultiFileDataSource). + +#[cfg(any( + unix, + windows, + target_os = "redox", + target_os = "wasi", + target_os = "hermit" +))] +use std::path::Component; +#[cfg(any( + unix, + windows, + target_os = "redox", + target_os = "wasi", + target_os = "hermit" +))] +use std::path::Path; +#[cfg(any( + unix, + windows, + target_os = "redox", + target_os = "wasi", + target_os = "hermit" +))] +use std::path::PathBuf; +#[cfg(any( + unix, + windows, + target_os = "redox", + target_os = "wasi", + target_os = "hermit" +))] +use std::path::absolute; + +use url::Url; +use vortex_error::VortexResult; +use vortex_error::vortex_err; + +/// Parse a URI-or-path string into a [`Url`]: +/// * full URLs (`s3://...`, `file:///...`) are used as-is, +/// * bare (relative or absolute) file paths are made absolute, have `.`/`..` components +/// normalized, and become `file://` URLs. On targets without a filesystem (e.g. +/// `wasm32-unknown-unknown`), bare paths are rejected instead. +/// +/// Glob characters are preserved, so glob patterns like `/data/*.vortex` parse as expected. +pub fn parse_uri_or_path(uri_or_path: &str) -> VortexResult { + // `Url::parse` accepts Windows absolute paths like `C:\foo` as a URL with a + // single-letter scheme (`c`). No real URL scheme is one character, so treat any + // single-letter scheme as a filesystem path instead. + if let Ok(url) = Url::parse(uri_or_path) + && url.scheme().len() > 1 + { + return Ok(url); + } + file_path_to_url(uri_or_path) +} + +/// Convert a bare filesystem path into a `file://` URL, absolutizing it and normalizing +/// `.`/`..` components. The cfg predicate matches `Url::from_file_path`'s availability in +/// the `url` crate. +#[cfg(any( + unix, + windows, + target_os = "redox", + target_os = "wasi", + target_os = "hermit" +))] +fn file_path_to_url(path: &str) -> VortexResult { + let abs = + absolute(Path::new(path)).map_err(|e| vortex_err!("failed to absolutize {path}: {e}"))?; + Url::from_file_path(normalize_path(abs)) + .map_err(|_| vortex_err!("neither URL nor path: {path}")) +} + +/// `Url::from_file_path` does not exist on targets without a filesystem, such as +/// `wasm32-unknown-unknown`, so bare paths cannot be interpreted there. +#[cfg(not(any( + unix, + windows, + target_os = "redox", + target_os = "wasi", + target_os = "hermit" +)))] +fn file_path_to_url(path: &str) -> VortexResult { + Err(vortex_err!( + "bare file paths are not supported on this platform: {path}" + )) +} + +/// Normalize `.` and `..` without touching the filesystem. +#[cfg(any( + unix, + windows, + target_os = "redox", + target_os = "wasi", + target_os = "hermit" +))] +fn normalize_path(path: PathBuf) -> PathBuf { + let mut out = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::*; + + #[test] + fn test_full_url() -> VortexResult<()> { + let url = parse_uri_or_path("s3://bucket/prefix/*.vortex")?; + assert_eq!(url.scheme(), "s3"); + assert_eq!(url.host_str(), Some("bucket")); + assert_eq!(url.path(), "/prefix/*.vortex"); + Ok(()) + } + + #[test] + fn test_file_scheme() -> VortexResult<()> { + let url = parse_uri_or_path("file:///absolute/path/data.vortex")?; + assert_eq!(url.scheme(), "file"); + assert_eq!(url.path(), "/absolute/path/data.vortex"); + Ok(()) + } + + #[test] + fn test_absolute_path() -> VortexResult<()> { + // Use a drive-prefixed input on Windows so `absolute()` doesn't inject the cwd drive + // and the expected URL path is predictable. The path does not need to exist. + #[cfg(unix)] + let (input, expected_path) = ("/tmp/data/*.vortex", "/tmp/data/*.vortex"); + #[cfg(windows)] + let (input, expected_path) = (r"C:\tmp\data\*.vortex", "/C:/tmp/data/*.vortex"); + let url = parse_uri_or_path(input)?; + assert_eq!(url.scheme(), "file"); + assert_eq!(url.path(), expected_path); + Ok(()) + } + + #[test] + fn test_relative_path_resolves_against_cwd() -> VortexResult<()> { + for input in ["data/table", "./data/table", "../data/table"] { + let url = parse_uri_or_path(input)?; + assert_eq!(url.scheme(), "file"); + assert!(url.path().starts_with('/')); + assert!(url.path().ends_with("/data/table")); + assert!( + !url.path().contains("%2E"), + "path must not contain percent-encoded dots" + ); + } + Ok(()) + } + + #[test] + fn test_single_letter_scheme_is_path() -> VortexResult<()> { + // Regression: `Url::parse("C:\\tmp")` succeeds with scheme="c"; the function must + // treat that as a filesystem path, not a URL. Exercised on all platforms because + // the check lives in `parse_uri_or_path`, not in an OS-specific branch. + let url = parse_uri_or_path(r"C:\tmp\data\*.vortex")?; + assert_eq!(url.scheme(), "file"); + assert_ne!(url.scheme(), "c"); + Ok(()) + } + + // Use absolute paths so the expected result is cwd-independent. + #[cfg(unix)] + #[rstest] + #[case("/a/./b", "/a/b")] + #[case("/a/b/./c", "/a/b/c")] + #[case("/a/../b", "/b")] + #[case("/a/b/../c", "/a/c")] + #[case("/a/b/../../c", "/c")] + #[case("/a/./b/.././c", "/a/c")] + #[case("/a/b/../..", "/")] + fn test_dot_normalization( + #[case] input: &str, + #[case] expected_path: &str, + ) -> VortexResult<()> { + let url = parse_uri_or_path(input)?; + assert_eq!(url.scheme(), "file"); + assert_eq!( + url.path(), + expected_path, + "input {input:?} should normalize to {expected_path:?}" + ); + Ok(()) + } +} diff --git a/vortex-jni/src/data_source.rs b/vortex-jni/src/data_source.rs index 5f244998f67..ab86e78a056 100644 --- a/vortex-jni/src/data_source.rs +++ b/vortex-jni/src/data_source.rs @@ -4,16 +4,10 @@ //! JNI bindings for [`vortex::scan::DataSource`] (see the equivalent types in //! `vortex-ffi/src/data_source.rs`). //! -//! Glob handling mirrors `vortex-duckdb`'s `VortexMultiFileScan`: -//! * full URLs (`s3://...`, `file:///...`) are used as-is, -//! * bare file paths are made absolute and have `.`/`..` components normalized, -//! * filesystems are cached per base URL so repeated globs against the same bucket share -//! a single client. +//! Globs are parsed with [`parse_uri_or_path`], so full URLs (`s3://...`, `file:///...`) +//! and bare file paths are both accepted. Filesystems are cached per base URL so repeated +//! globs against the same bucket share a single client. -use std::path::Component; -use std::path::Path; -use std::path::PathBuf; -use std::path::absolute; use std::sync::Arc; use jni::EnvUnowned; @@ -28,6 +22,7 @@ use vortex::error::VortexResult; use vortex::error::vortex_err; use vortex::expr::stats::Precision; use vortex::file::multi::MultiFileDataSource; +use vortex::file::multi::parse_uri_or_path; use vortex::io::filesystem::FileSystemRef; use vortex::io::runtime::BlockingRuntime; use vortex::io::session::RuntimeSessionExt; @@ -91,7 +86,7 @@ pub extern "system" fn Java_dev_vortex_jni_NativeDataSource_open( let glob_urls: Vec = glob_strings .iter() - .map(|g| parse_glob_url(g.as_str())) + .map(|g| parse_uri_or_path(g.as_str())) .collect::>()?; let mut fs_cache: HashMap = HashMap::new(); @@ -120,38 +115,6 @@ pub extern "system" fn Java_dev_vortex_jni_NativeDataSource_open( }) } -/// Parse a glob string into a [`Url`]. Accepts full URLs and bare (relative or absolute) -/// file paths — see the module docs for details. -fn parse_glob_url(glob: &str) -> VortexResult { - // `Url::parse` accepts Windows absolute paths like `C:\foo` as a URL with a - // single-letter scheme (`c`). No real URL scheme is one character, so treat any - // single-letter scheme as a filesystem path instead. - if let Ok(url) = Url::parse(glob) - && url.scheme().len() > 1 - { - return Ok(url); - } - let path = - absolute(Path::new(glob)).map_err(|e| vortex_err!("failed to absolutize {glob}: {e}"))?; - let path = normalize_path(path); - Url::from_file_path(path).map_err(|_| vortex_err!("neither URL nor path: {glob}")) -} - -/// Normalize `.` and `..` without touching the filesystem. -fn normalize_path(path: PathBuf) -> PathBuf { - let mut out = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::ParentDir => { - out.pop(); - } - c => out.push(c), - } - } - out -} - /// URL with the path cleared, used as a cache key for filesystem reuse. fn base_url(url: &Url) -> Url { let mut base = url.clone(); @@ -236,47 +199,6 @@ pub extern "system" fn Java_dev_vortex_jni_NativeDataSource_byteSize( mod tests { use super::*; - #[test] - fn test_parse_glob_url_full_url() { - let url = parse_glob_url("s3://bucket/prefix/*.vortex").unwrap(); - assert_eq!(url.scheme(), "s3"); - assert_eq!(url.host_str(), Some("bucket")); - assert_eq!(url.path(), "/prefix/*.vortex"); - } - - #[test] - fn test_parse_glob_url_absolute_path() { - // Use a drive-prefixed input on Windows so `absolute()` doesn't inject the cwd drive - // and the expected URL path is predictable. - #[cfg(unix)] - let (input, expected_path) = ("/tmp/data/*.vortex", "/tmp/data/*.vortex"); - #[cfg(windows)] - let (input, expected_path) = (r"C:\tmp\data\*.vortex", "/C:/tmp/data/*.vortex"); - let url = parse_glob_url(input).unwrap(); - assert_eq!(url.scheme(), "file"); - assert_eq!(url.path(), expected_path); - } - - #[test] - fn test_parse_glob_url_normalizes_dots() { - #[cfg(unix)] - let (input, expected_path) = ("/a/b/../c/./d", "/a/c/d"); - #[cfg(windows)] - let (input, expected_path) = (r"C:\a\b\..\c\.\d", "/C:/a/c/d"); - let url = parse_glob_url(input).unwrap(); - assert_eq!(url.path(), expected_path); - } - - #[test] - fn test_parse_glob_url_single_letter_scheme_is_path() { - // Regression: `Url::parse("C:\\tmp")` succeeds with scheme="c"; the function must - // treat that as a filesystem path, not a URL. Exercised on all platforms because - // the check lives in `parse_glob_url`, not in an OS-specific branch. - let url = parse_glob_url(r"C:\tmp\data\*.vortex").unwrap(); - assert_eq!(url.scheme(), "file"); - assert_ne!(url.scheme(), "c"); - } - #[test] fn test_base_url_strips_path() { let url = Url::parse("s3://bucket/a/b/c").unwrap(); diff --git a/vortex-jni/src/file.rs b/vortex-jni/src/file.rs index 3abb6ef9ffb..343215e533a 100644 --- a/vortex-jni/src/file.rs +++ b/vortex-jni/src/file.rs @@ -13,9 +13,9 @@ use jni::objects::JString; use jni::sys::jlong; use jni::sys::jobject; use object_store::path::Path; -use url::Url; use vortex::error::VortexResult; use vortex::error::vortex_err; +use vortex::file::multi::parse_uri_or_path; use vortex::io::runtime::BlockingRuntime; use vortex::io::session::RuntimeSessionExt; use vortex::utils::aliases::hash_map::HashMap; @@ -58,10 +58,7 @@ pub extern "system" fn Java_dev_vortex_jni_NativeFiles_listFiles( try_or_throw(&mut env, |env| { let session = unsafe { session_ref(session_ptr) }; let root_path: String = path.try_to_string(env)?; - - let Ok(url) = Url::parse(&root_path) else { - throw_runtime!("invalid URL: {root_path}"); - }; + let url = parse_uri_or_path(&root_path)?; let properties = extract_properties(env, &options)?; @@ -122,7 +119,7 @@ pub extern "system" fn Java_dev_vortex_jni_NativeFiles_delete( return Ok(()); } - let store_url = Url::parse(&delete_uris[0]).map_err(|e| vortex_err!(External: e))?; + let store_url = parse_uri_or_path(&delete_uris[0])?; let properties = extract_properties(env, &options)?; @@ -130,7 +127,7 @@ pub extern "system" fn Java_dev_vortex_jni_NativeFiles_delete( RUNTIME.block_on(async { for uri in delete_uris { - let url = Url::parse(&uri).map_err(|e| vortex_err!(External: e))?; + let url = parse_uri_or_path(&uri)?; fs.delete(url.path()).await?; } VortexResult::Ok(()) diff --git a/vortex-jni/src/writer.rs b/vortex-jni/src/writer.rs index 95d3ae1c400..78e7c268f19 100644 --- a/vortex-jni/src/writer.rs +++ b/vortex-jni/src/writer.rs @@ -27,7 +27,6 @@ use jni::sys::jboolean; use jni::sys::jlong; use object_store::ObjectStore; use object_store::path::Path as ObjectStorePath; -use url::Url; use vortex::array::ArrayRef; use vortex::array::VTable; use vortex::array::arrow::ArrowSessionExt; @@ -40,6 +39,7 @@ use vortex::error::vortex_err; use vortex::file::WriteOptionsSessionExt; use vortex::file::WriteStrategyBuilder; use vortex::file::WriteSummary; +use vortex::file::multi::parse_uri_or_path; use vortex::io::VortexWrite; use vortex::io::compat::Compat; use vortex::io::object_store::ObjectStoreWrite; @@ -71,20 +71,17 @@ fn resolve_store( url_or_path: &str, properties: &HashMap, ) -> VortexResult { - match Url::parse(url_or_path) { - Ok(url) if url.scheme() == "file" => { - let path = url - .to_file_path() - .map_err(|_| vortex_err!("invalid file URL: {url_or_path}"))?; - Ok(ResolvedStore::Path(path)) - } - Ok(url) => { - let path = ObjectStorePath::from_url_path(url.path()) - .map_err(|_| vortex_err!("invalid object_store path: {}", url.path()))?; - let store = make_object_store(&url, properties)?; - Ok(ResolvedStore::ObjectStore(store, path)) - } - Err(_) => Ok(ResolvedStore::Path(PathBuf::from(url_or_path))), + let url = parse_uri_or_path(url_or_path)?; + if url.scheme() == "file" { + let path = url + .to_file_path() + .map_err(|_| vortex_err!("invalid file URL: {url_or_path}"))?; + Ok(ResolvedStore::Path(path)) + } else { + let path = ObjectStorePath::from_url_path(url.path()) + .map_err(|_| vortex_err!("invalid object_store path: {}", url.path()))?; + let store = make_object_store(&url, properties)?; + Ok(ResolvedStore::ObjectStore(store, path)) } } From a6e3d3e6f35e2d356025f25d2c9d8e3ddc0c18d5 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Mon, 6 Jul 2026 20:36:56 +0100 Subject: [PATCH 022/104] Remove automation approval github actions (#8660) These are unnecessary now that we have policy-bot --- .github/workflows/approvals.yml | 49 --------------------------- .github/workflows/rerun-approvals.yml | 40 ---------------------- 2 files changed, 89 deletions(-) delete mode 100644 .github/workflows/approvals.yml delete mode 100644 .github/workflows/rerun-approvals.yml diff --git a/.github/workflows/approvals.yml b/.github/workflows/approvals.yml deleted file mode 100644 index 5cbfbceeedb..00000000000 --- a/.github/workflows/approvals.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: PR Approval Check - -on: - pull_request: - branches: - - "develop" - -jobs: - check-approvals: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Check required approvals - uses: actions/github-script@450193c5abd4cdb17ba9f3ffcfe8f635c4bb6c2a - with: - script: | - const pr = context.payload.pull_request; - const reviews = await github.rest.pulls.listReviews({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pr.number, - }); - - // Determine if PR author is a bot/GitHub Actions - const authorType = pr.user.type; // 'Bot' vs 'User' - const authorLogin = pr.user.login; // e.g. 'github-actions[bot]' - const isBot = authorType === 'Bot' || authorLogin.endsWith('[bot]'); - const oneApprovalBotAuthors = new Set(['renovate[bot]']); - - // Count unique approvals, including bot approvals. - const latestByUser = {}; - for (const review of reviews.data) { - latestByUser[review.user.login] = review.state; - } - const approvalCount = Object.values(latestByUser) - .filter(state => state === 'APPROVED').length; - - const required = isBot && !oneApprovalBotAuthors.has(authorLogin) ? 2 : 1; - - console.log(`PR author: ${authorLogin} (${authorType}), isBot: ${isBot}`); - console.log(`One-approval bot allowlist: ${oneApprovalBotAuthors.has(authorLogin)}`); - console.log(`Approvals: ${approvalCount} / ${required} required`); - - if (isBot && (approvalCount < required)) { - core.setFailed( - `This PR needs ${required} approval(s) but has ${approvalCount}. ` + - `(Author is ${isBot ? 'a bot' : 'human'})` - ); - } diff --git a/.github/workflows/rerun-approvals.yml b/.github/workflows/rerun-approvals.yml deleted file mode 100644 index 4ec40aea4d4..00000000000 --- a/.github/workflows/rerun-approvals.yml +++ /dev/null @@ -1,40 +0,0 @@ -# Re-runs the PR Approval Check workflow when a review is submitted or dismissed. -# Re-running creates a new attempt on the existing approvals.yml workflow run, which -# updates the `check-approvals` check_run in place. This avoids the gate getting stuck -# behind a stale failed check_run from the original `pull_request` event run. - -name: Re-run PR Approval Check on Review - -on: - pull_request_review: - types: [submitted, dismissed] - -permissions: - actions: write - -concurrency: - group: rerun-approvals-${{ github.event.pull_request.head.sha }} - cancel-in-progress: true - -jobs: - rerun: - if: github.event.pull_request.base.ref == 'develop' - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Re-run approvals workflow for this PR's head SHA - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - run_id=$(gh api \ - "repos/$REPO/actions/workflows/approvals.yml/runs?head_sha=$HEAD_SHA" \ - --jq '.workflow_runs[0].id // empty') - if [ -z "$run_id" ]; then - echo "No approvals.yml run for $HEAD_SHA - nothing to re-run." - exit 0 - fi - echo "Re-running approvals.yml run $run_id" - gh api -X POST "repos/$REPO/actions/runs/$run_id/rerun" From 8f725953ffc4b2c82391730c34342b85b5236057 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Tue, 7 Jul 2026 11:56:23 +0100 Subject: [PATCH 023/104] Hardcode constant detection into the cascading compressor (#8667) ## Summary Closes: #8666 The five per-type constant schemes (bool/int/float/string/binary) were "half builtin": they lived in `vortex-compressor`'s `builtins` module but still had to be registered. Constant handling was also scattered across three places: the compressor's own all-null short-circuit, the per-type constant schemes, and an inline `is_constant` check in `TemporalScheme`. Constant detection is now built directly into `CascadingCompressor`. A new crate-private `constant` module detects constant leaves using the cheapest available evidence per type. Changes in behavior: - The big change is that constant detection is unconditional:, as it applies even for a compressor constructed with an empty scheme list, and can no longer be disabled via `exclude_schemes`. I think this is fine because it is essentially never a good idea to not check for a constant array (except maybe for tiny arrays, but I don't think we need to worry about that), and we have no code that tries to exclude constant array. - Constant detection now covers ALL leaf types uniformly, adding decimal and extension arrays. - Constant string/binary arrays no longer pay for dict/FSST sampling before the (previously deferred, last-registered) constant check happens. - Also a small perf change: The old scheme-based path found the first valid element with a per-index `is_valid` loop that re-derived validity on every call; the new `compress_constant` function materializes the validity mask once and uses `Mask::first`. Signed-off-by: Connor Tsui Signed-off-by: Connor Tsui Co-authored-by: Claude --- vortex-btrblocks/src/builder.rs | 9 - vortex-btrblocks/src/schemes/binary/mod.rs | 1 - vortex-btrblocks/src/schemes/bool.rs | 7 - vortex-btrblocks/src/schemes/float/mod.rs | 1 - vortex-btrblocks/src/schemes/integer/mod.rs | 1 - vortex-btrblocks/src/schemes/mod.rs | 1 - vortex-btrblocks/src/schemes/string/mod.rs | 1 - vortex-btrblocks/src/schemes/temporal.rs | 14 +- .../src/builtins/constant/binary.rs | 84 ------- .../src/builtins/constant/bool.rs | 69 ------ .../src/builtins/constant/float.rs | 90 ------- .../src/builtins/constant/integer.rs | 80 ------ .../src/builtins/constant/mod.rs | 55 ----- .../src/builtins/constant/string.rs | 84 ------- vortex-compressor/src/builtins/mod.rs | 14 +- vortex-compressor/src/compressor.rs | 45 +++- vortex-compressor/src/constant.rs | 231 ++++++++++++++++++ vortex-compressor/src/estimate.rs | 2 +- vortex-compressor/src/lib.rs | 4 +- vortex-compressor/src/scheme.rs | 8 +- vortex-file/src/tests.rs | 7 +- 21 files changed, 287 insertions(+), 521 deletions(-) delete mode 100644 vortex-btrblocks/src/schemes/bool.rs delete mode 100644 vortex-compressor/src/builtins/constant/binary.rs delete mode 100644 vortex-compressor/src/builtins/constant/bool.rs delete mode 100644 vortex-compressor/src/builtins/constant/float.rs delete mode 100644 vortex-compressor/src/builtins/constant/integer.rs delete mode 100644 vortex-compressor/src/builtins/constant/mod.rs delete mode 100644 vortex-compressor/src/builtins/constant/string.rs create mode 100644 vortex-compressor/src/constant.rs diff --git a/vortex-btrblocks/src/builder.rs b/vortex-btrblocks/src/builder.rs index 6489f2d4e52..26632e70860 100644 --- a/vortex-btrblocks/src/builder.rs +++ b/vortex-btrblocks/src/builder.rs @@ -11,7 +11,6 @@ use crate::Scheme; use crate::SchemeExt; use crate::SchemeId; use crate::schemes::binary; -use crate::schemes::bool; use crate::schemes::decimal; use crate::schemes::float; use crate::schemes::integer; @@ -23,14 +22,9 @@ use crate::schemes::temporal; /// This list is order-sensitive: the builder preserves this order when constructing /// the final scheme list, so that tie-breaking is deterministic. pub const ALL_SCHEMES: &[&dyn Scheme] = &[ - //////////////////////////////////////////////////////////////////////////////////////////////// - // Bool schemes. - //////////////////////////////////////////////////////////////////////////////////////////////// - &bool::BoolConstantScheme, //////////////////////////////////////////////////////////////////////////////////////////////// // Integer schemes. //////////////////////////////////////////////////////////////////////////////////////////////// - &integer::IntConstantScheme, // NOTE: FoR must precede BitPacking to avoid unnecessary patches. &integer::FoRScheme, // NOTE: ZigZag should precede BitPacking because we don't want negative numbers. @@ -47,7 +41,6 @@ pub const ALL_SCHEMES: &[&dyn Scheme] = &[ //////////////////////////////////////////////////////////////////////////////////////////////// // Float schemes. //////////////////////////////////////////////////////////////////////////////////////////////// - &float::FloatConstantScheme, &float::ALPScheme, &float::ALPRDScheme, &float::FloatDictScheme, @@ -62,13 +55,11 @@ pub const ALL_SCHEMES: &[&dyn Scheme] = &[ &string::FSSTScheme, #[cfg(feature = "unstable_encodings")] &string::OnPairScheme, - &string::StringConstantScheme, &string::NullDominatedSparseScheme, //////////////////////////////////////////////////////////////////////////////////////////////// // Binary schemes. //////////////////////////////////////////////////////////////////////////////////////////////// &binary::BinaryDictScheme, - &binary::BinaryConstantScheme, // Decimal schemes. &decimal::DecimalScheme, // Temporal schemes. diff --git a/vortex-btrblocks/src/schemes/binary/mod.rs b/vortex-btrblocks/src/schemes/binary/mod.rs index 44f09f2daa3..af66d345cc6 100644 --- a/vortex-btrblocks/src/schemes/binary/mod.rs +++ b/vortex-btrblocks/src/schemes/binary/mod.rs @@ -9,7 +9,6 @@ mod zstd; mod zstd_buffers; // Re-export builtin schemes from vortex-compressor. -pub use vortex_compressor::builtins::BinaryConstantScheme; pub use vortex_compressor::builtins::BinaryDictScheme; #[cfg(feature = "zstd")] pub use zstd::ZstdScheme; diff --git a/vortex-btrblocks/src/schemes/bool.rs b/vortex-btrblocks/src/schemes/bool.rs deleted file mode 100644 index c27251a8599..00000000000 --- a/vortex-btrblocks/src/schemes/bool.rs +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Bool compression schemes. - -pub use vortex_compressor::builtins::BoolConstantScheme; -pub use vortex_compressor::stats::BoolStats; diff --git a/vortex-btrblocks/src/schemes/float/mod.rs b/vortex-btrblocks/src/schemes/float/mod.rs index 3713fa2eb63..1301184ac0c 100644 --- a/vortex-btrblocks/src/schemes/float/mod.rs +++ b/vortex-btrblocks/src/schemes/float/mod.rs @@ -18,7 +18,6 @@ pub use pco::PcoScheme; pub use rle::FloatRLEScheme; pub use sparse::NullDominatedSparseScheme; // Re-export builtin schemes from vortex-compressor. -pub use vortex_compressor::builtins::FloatConstantScheme; pub use vortex_compressor::builtins::FloatDictScheme; pub use vortex_compressor::stats::FloatStats; diff --git a/vortex-btrblocks/src/schemes/integer/mod.rs b/vortex-btrblocks/src/schemes/integer/mod.rs index abe5868f5c8..3aae2ae5601 100644 --- a/vortex-btrblocks/src/schemes/integer/mod.rs +++ b/vortex-btrblocks/src/schemes/integer/mod.rs @@ -30,7 +30,6 @@ pub use runend::RunEndScheme; pub use sequence::SequenceScheme; pub use sparse::SparseScheme; // Re-export builtin schemes from vortex-compressor. -pub use vortex_compressor::builtins::IntConstantScheme; pub use vortex_compressor::builtins::IntDictScheme; pub use vortex_compressor::stats::IntegerStats; pub use zigzag::ZigZagScheme; diff --git a/vortex-btrblocks/src/schemes/mod.rs b/vortex-btrblocks/src/schemes/mod.rs index 16123429e86..a0e9b042a66 100644 --- a/vortex-btrblocks/src/schemes/mod.rs +++ b/vortex-btrblocks/src/schemes/mod.rs @@ -4,7 +4,6 @@ //! Compression scheme implementations. pub mod binary; -pub mod bool; pub mod float; pub mod integer; pub mod string; diff --git a/vortex-btrblocks/src/schemes/string/mod.rs b/vortex-btrblocks/src/schemes/string/mod.rs index acb16ef4323..ac8e5b4b8df 100644 --- a/vortex-btrblocks/src/schemes/string/mod.rs +++ b/vortex-btrblocks/src/schemes/string/mod.rs @@ -19,7 +19,6 @@ pub use fsst::FSSTScheme; pub use onpair::OnPairScheme; pub use sparse::NullDominatedSparseScheme; // Re-export builtin schemes from vortex-compressor. -pub use vortex_compressor::builtins::StringConstantScheme; pub use vortex_compressor::builtins::StringDictScheme; pub use vortex_compressor::stats::StringStats; #[cfg(feature = "zstd")] diff --git a/vortex-btrblocks/src/schemes/temporal.rs b/vortex-btrblocks/src/schemes/temporal.rs index c5c0abc46aa..3ea4aaf3654 100644 --- a/vortex-btrblocks/src/schemes/temporal.rs +++ b/vortex-btrblocks/src/schemes/temporal.rs @@ -7,8 +7,6 @@ use vortex_array::ArrayRef; use vortex_array::Canonical; use vortex_array::ExecutionCtx; use vortex_array::IntoArray; -use vortex_array::aggregate_fn::fns::is_constant::is_constant; -use vortex_array::arrays::ConstantArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::TemporalArray; @@ -79,17 +77,7 @@ impl Scheme for TemporalScheme { ) -> VortexResult { let array = data.array().clone(); let ext_array = array.execute::(exec_ctx)?; - let temporal_array = TemporalArray::try_from(ext_array.clone().into_array())?; - - // Check for constant array and return early if so. - let is_constant = is_constant(&ext_array.clone().into_array(), exec_ctx)?; - - if is_constant { - return Ok( - ConstantArray::new(ext_array.execute_scalar(0, exec_ctx)?, ext_array.len()) - .into_array(), - ); - } + let temporal_array = TemporalArray::try_from(ext_array.into_array())?; let dtype = temporal_array.dtype().clone(); let TemporalParts { diff --git a/vortex-compressor/src/builtins/constant/binary.rs b/vortex-compressor/src/builtins/constant/binary.rs deleted file mode 100644 index 4ad24fe57b5..00000000000 --- a/vortex-compressor/src/builtins/constant/binary.rs +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Constant encoding for binary arrays. - -use vortex_array::ArrayRef; -use vortex_array::Canonical; -use vortex_array::ExecutionCtx; -use vortex_array::aggregate_fn::fns::is_constant::is_constant; -use vortex_error::VortexResult; - -use crate::CascadingCompressor; -use crate::builtins::constant::compress_constant_array_with_validity; -use crate::ctx::CompressorContext; -use crate::estimate::CompressionEstimate; -use crate::estimate::DeferredEstimate; -use crate::estimate::EstimateVerdict; -use crate::scheme::Scheme; -use crate::stats::ArrayAndStats; - -/// Constant encoding for binary arrays with a single distinct value. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub struct BinaryConstantScheme; - -impl Scheme for BinaryConstantScheme { - fn scheme_name(&self) -> &'static str { - "vortex.binary.constant" - } - - fn matches(&self, canonical: &Canonical) -> bool { - canonical.dtype().is_binary() - } - - fn expected_compression_ratio( - &self, - data: &ArrayAndStats, - compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, - ) -> CompressionEstimate { - // Constant detection on a sample is a false positive, since the sample being constant does - // not mean the full array is constant. - if compress_ctx.is_sample() { - return CompressionEstimate::Verdict(EstimateVerdict::Skip); - } - - let array_len = data.array().len(); - let stats = data.varbinview_stats(exec_ctx); - - // We want to use `Constant` if there are only nulls in the array. - if stats.value_count() == 0 { - debug_assert_eq!(stats.null_count() as usize, array_len); - return CompressionEstimate::Verdict(EstimateVerdict::AlwaysUse); - } - - // Since the estimated distinct count is always going to be less than or equal to the actual - // distinct count, if this is not equal to 1 the actual is definitely not equal to 1. - if stats.estimated_distinct_count().is_some_and(|c| c > 1) { - return CompressionEstimate::Verdict(EstimateVerdict::Skip); - } - - // Otherwise our best bet is to actually check if the array is constant. - // This is an expensive check, but the alternative of not compressing a constant array is - // far less preferable. - CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new( - |_compressor, data, _best_so_far, _ctx, exec_ctx| { - if is_constant(data.array(), exec_ctx)? { - Ok(EstimateVerdict::AlwaysUse) - } else { - Ok(EstimateVerdict::Skip) - } - }, - ))) - } - - fn compress( - &self, - _compressor: &CascadingCompressor, - data: &ArrayAndStats, - _compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - compress_constant_array_with_validity(data.array(), exec_ctx) - } -} diff --git a/vortex-compressor/src/builtins/constant/bool.rs b/vortex-compressor/src/builtins/constant/bool.rs deleted file mode 100644 index a3bdcb0216e..00000000000 --- a/vortex-compressor/src/builtins/constant/bool.rs +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Constant encoding for bool arrays. - -use vortex_array::ArrayRef; -use vortex_array::Canonical; -use vortex_array::ExecutionCtx; -use vortex_error::VortexResult; - -use crate::CascadingCompressor; -use crate::builtins::constant::compress_constant_array_with_validity; -use crate::ctx::CompressorContext; -use crate::estimate::CompressionEstimate; -use crate::estimate::EstimateVerdict; -use crate::scheme::Scheme; -use crate::stats::ArrayAndStats; - -/// Constant encoding for bool arrays where all valid values are the same. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub struct BoolConstantScheme; - -impl Scheme for BoolConstantScheme { - fn scheme_name(&self) -> &'static str { - "vortex.bool.constant" - } - - fn matches(&self, canonical: &Canonical) -> bool { - matches!(canonical, Canonical::Bool(_)) - } - - fn expected_compression_ratio( - &self, - data: &ArrayAndStats, - compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, - ) -> CompressionEstimate { - // Constant detection on a sample is a false positive, since the sample being constant does - // not mean the full array is constant. - if compress_ctx.is_sample() { - return CompressionEstimate::Verdict(EstimateVerdict::Skip); - } - - let array_len = data.array().len(); - let stats = data.bool_stats(exec_ctx); - - // We want to use `Constant` if there are only nulls in the array. - if stats.value_count() == 0 { - debug_assert_eq!(stats.null_count() as usize, array_len); - return CompressionEstimate::Verdict(EstimateVerdict::AlwaysUse); - } - - if stats.is_constant() { - return CompressionEstimate::Verdict(EstimateVerdict::AlwaysUse); - } - - CompressionEstimate::Verdict(EstimateVerdict::Skip) - } - - fn compress( - &self, - _compressor: &CascadingCompressor, - data: &ArrayAndStats, - _compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - compress_constant_array_with_validity(data.array(), exec_ctx) - } -} diff --git a/vortex-compressor/src/builtins/constant/float.rs b/vortex-compressor/src/builtins/constant/float.rs deleted file mode 100644 index 0480a1b7a53..00000000000 --- a/vortex-compressor/src/builtins/constant/float.rs +++ /dev/null @@ -1,90 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Constant encoding for float arrays. - -use vortex_array::ArrayRef; -use vortex_array::Canonical; -use vortex_array::ExecutionCtx; -use vortex_array::aggregate_fn::fns::is_constant::is_constant; -use vortex_error::VortexResult; - -use crate::CascadingCompressor; -use crate::builtins::constant::compress_constant_array_with_validity; -use crate::ctx::CompressorContext; -use crate::estimate::CompressionEstimate; -use crate::estimate::DeferredEstimate; -use crate::estimate::EstimateVerdict; -use crate::scheme::Scheme; -use crate::stats::ArrayAndStats; - -/// Constant encoding for float arrays with a single distinct value. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub struct FloatConstantScheme; - -impl Scheme for FloatConstantScheme { - fn scheme_name(&self) -> &'static str { - "vortex.float.constant" - } - - fn matches(&self, canonical: &Canonical) -> bool { - canonical.dtype().is_float() - } - - fn expected_compression_ratio( - &self, - data: &ArrayAndStats, - compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, - ) -> CompressionEstimate { - // Constant detection on a sample is a false positive, since the sample being constant does - // not mean the full array is constant. - if compress_ctx.is_sample() { - return CompressionEstimate::Verdict(EstimateVerdict::Skip); - } - - let array_len = data.array().len(); - let stats = data.float_stats(exec_ctx); - - // Note that we only compute distinct counts if other schemes have requested it. - if let Some(distinct_count) = stats.distinct_count() { - if distinct_count > 1 { - return CompressionEstimate::Verdict(EstimateVerdict::Skip); - } else { - debug_assert_eq!(distinct_count, 1); - return CompressionEstimate::Verdict(EstimateVerdict::AlwaysUse); - } - } - - // We want to use `Constant` if there are only nulls in the array. - if stats.value_count() == 0 { - debug_assert_eq!(stats.null_count() as usize, array_len); - return CompressionEstimate::Verdict(EstimateVerdict::AlwaysUse); - } - - // TODO(connor): Can we be smart here with the max and min like with integers? - - // Otherwise our best bet is to actually check if the array is constant. - // This is an expensive check, but in practice the distinct count is known because we often - // include dictionary encoding in our set of schemes, so we rarely call this. - CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new( - |_compressor, data, _best_so_far, _ctx, exec_ctx| { - if is_constant(data.array(), exec_ctx)? { - Ok(EstimateVerdict::AlwaysUse) - } else { - Ok(EstimateVerdict::Skip) - } - }, - ))) - } - - fn compress( - &self, - _compressor: &CascadingCompressor, - data: &ArrayAndStats, - _compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - compress_constant_array_with_validity(data.array(), exec_ctx) - } -} diff --git a/vortex-compressor/src/builtins/constant/integer.rs b/vortex-compressor/src/builtins/constant/integer.rs deleted file mode 100644 index 3f324c36e17..00000000000 --- a/vortex-compressor/src/builtins/constant/integer.rs +++ /dev/null @@ -1,80 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Constant encoding for integer arrays. - -use vortex_array::ArrayRef; -use vortex_array::Canonical; -use vortex_array::ExecutionCtx; -use vortex_error::VortexResult; - -use crate::CascadingCompressor; -use crate::builtins::constant::compress_constant_array_with_validity; -use crate::ctx::CompressorContext; -use crate::estimate::CompressionEstimate; -use crate::estimate::EstimateVerdict; -use crate::scheme::Scheme; -use crate::stats::ArrayAndStats; - -/// Constant encoding for integer arrays with a single distinct value. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub struct IntConstantScheme; - -impl Scheme for IntConstantScheme { - fn scheme_name(&self) -> &'static str { - "vortex.int.constant" - } - - fn matches(&self, canonical: &Canonical) -> bool { - canonical.dtype().is_int() - } - - fn expected_compression_ratio( - &self, - data: &ArrayAndStats, - compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, - ) -> CompressionEstimate { - // Constant detection on a sample is a false positive, since the sample being constant does - // not mean the full array is constant. - if compress_ctx.is_sample() { - return CompressionEstimate::Verdict(EstimateVerdict::Skip); - } - - let array_len = data.array().len(); - let stats = data.integer_stats(exec_ctx); - - // Note that we only compute distinct counts if other schemes have requested it. - if let Some(distinct_count) = stats.distinct_count() { - if distinct_count > 1 { - return CompressionEstimate::Verdict(EstimateVerdict::Skip); - } else { - debug_assert_eq!(distinct_count, 1); - return CompressionEstimate::Verdict(EstimateVerdict::AlwaysUse); - } - } - - // We want to use `Constant` if there are only nulls in the array. - if stats.value_count() == 0 { - debug_assert_eq!(stats.null_count() as usize, array_len); - return CompressionEstimate::Verdict(EstimateVerdict::AlwaysUse); - } - - // Otherwise, use the max and min to determine if there is a single value. - match stats.erased().max_minus_min().checked_ilog2() { - Some(_) => CompressionEstimate::Verdict(EstimateVerdict::Skip), - // If max-min == 0, then we know that there is only 1 value. - None => CompressionEstimate::Verdict(EstimateVerdict::AlwaysUse), - } - } - - fn compress( - &self, - _compressor: &CascadingCompressor, - data: &ArrayAndStats, - _compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - compress_constant_array_with_validity(data.array(), exec_ctx) - } -} diff --git a/vortex-compressor/src/builtins/constant/mod.rs b/vortex-compressor/src/builtins/constant/mod.rs deleted file mode 100644 index d5927366fdf..00000000000 --- a/vortex-compressor/src/builtins/constant/mod.rs +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Constant encoding schemes for binary, bool, float, integer, and string arrays. - -use vortex_array::ArrayRef; -use vortex_array::ExecutionCtx; -use vortex_array::IntoArray; -use vortex_array::arrays::ConstantArray; -use vortex_array::arrays::MaskedArray; -use vortex_array::scalar::Scalar; -use vortex_error::VortexExpect; -use vortex_error::VortexResult; - -mod binary; -mod bool; -mod float; -mod integer; -mod string; - -pub use binary::BinaryConstantScheme; -pub use bool::BoolConstantScheme; -pub use float::FloatConstantScheme; -pub use integer::IntConstantScheme; -pub use string::StringConstantScheme; - -/// Shared helper for compressing a constant array (binary, bool, int, float, string) into a -/// [`ConstantArray`]. -/// -/// Assumes that the source array has constant valid scalars. -/// -/// If the array has any nulls, returns a [`MaskedArray`] with a [`ConstantArray`] child.` -fn compress_constant_array_with_validity( - source: &ArrayRef, - ctx: &mut ExecutionCtx, -) -> VortexResult { - if source.all_invalid(ctx)? { - return Ok( - ConstantArray::new(Scalar::null(source.dtype().clone()), source.len()).into_array(), - ); - } - - let scalar_idx = (0..source.len()) - .position(|idx| source.is_valid(idx, ctx).unwrap_or(false)) - .vortex_expect("We checked that there exists a scalar that is not invalid"); - - let scalar = source.execute_scalar(scalar_idx, ctx)?; - let const_arr = ConstantArray::new(scalar, source.len()).into_array(); - - if !source.all_valid(ctx)? { - Ok(MaskedArray::try_new(const_arr, source.validity()?)?.into_array()) - } else { - Ok(const_arr) - } -} diff --git a/vortex-compressor/src/builtins/constant/string.rs b/vortex-compressor/src/builtins/constant/string.rs deleted file mode 100644 index f55c1661660..00000000000 --- a/vortex-compressor/src/builtins/constant/string.rs +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Constant encoding for string arrays. - -use vortex_array::ArrayRef; -use vortex_array::Canonical; -use vortex_array::ExecutionCtx; -use vortex_array::aggregate_fn::fns::is_constant::is_constant; -use vortex_error::VortexResult; - -use crate::CascadingCompressor; -use crate::builtins::constant::compress_constant_array_with_validity; -use crate::ctx::CompressorContext; -use crate::estimate::CompressionEstimate; -use crate::estimate::DeferredEstimate; -use crate::estimate::EstimateVerdict; -use crate::scheme::Scheme; -use crate::stats::ArrayAndStats; - -/// Constant encoding for string arrays with a single distinct value. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub struct StringConstantScheme; - -impl Scheme for StringConstantScheme { - fn scheme_name(&self) -> &'static str { - "vortex.string.constant" - } - - fn matches(&self, canonical: &Canonical) -> bool { - canonical.dtype().is_utf8() - } - - fn expected_compression_ratio( - &self, - data: &ArrayAndStats, - compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, - ) -> CompressionEstimate { - // Constant detection on a sample is a false positive, since the sample being constant does - // not mean the full array is constant. - if compress_ctx.is_sample() { - return CompressionEstimate::Verdict(EstimateVerdict::Skip); - } - - let array_len = data.array().len(); - let stats = data.varbinview_stats(exec_ctx); - - // We want to use `Constant` if there are only nulls in the array. - if stats.value_count() == 0 { - debug_assert_eq!(stats.null_count() as usize, array_len); - return CompressionEstimate::Verdict(EstimateVerdict::AlwaysUse); - } - - // Since the estimated distinct count is always going to be less than or equal to the actual - // distinct count, if this is not equal to 1 the actual is definitely not equal to 1. - if stats.estimated_distinct_count().is_some_and(|c| c > 1) { - return CompressionEstimate::Verdict(EstimateVerdict::Skip); - } - - // Otherwise our best bet is to actually check if the array is constant. - // This is an expensive check, but the alternative of not compressing a constant array is - // far less preferable. - CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new( - |_compressor, data, _best_so_far, _ctx, exec_ctx| { - if is_constant(data.array(), exec_ctx)? { - Ok(EstimateVerdict::AlwaysUse) - } else { - Ok(EstimateVerdict::Skip) - } - }, - ))) - } - - fn compress( - &self, - _compressor: &CascadingCompressor, - data: &ArrayAndStats, - _compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - compress_constant_array_with_validity(data.array(), exec_ctx) - } -} diff --git a/vortex-compressor/src/builtins/mod.rs b/vortex-compressor/src/builtins/mod.rs index d014f1a6cf3..11059fa6ed4 100644 --- a/vortex-compressor/src/builtins/mod.rs +++ b/vortex-compressor/src/builtins/mod.rs @@ -3,12 +3,10 @@ //! Built-in compression schemes that use only `vortex-array` encodings. //! -//! These schemes produce arrays using types already in `vortex-array` ([`ConstantArray`], -//! [`DictArray`], [`MaskedArray`], etc.) and have no external encoding crate dependencies. +//! These schemes produce arrays using types already in `vortex-array` ([`DictArray`], etc.) and +//! have no external encoding crate dependencies. //! -//! [`ConstantArray`]: vortex_array::arrays::ConstantArray //! [`DictArray`]: vortex_array::arrays::DictArray -//! [`MaskedArray`]: vortex_array::arrays::MaskedArray mod dict; @@ -18,11 +16,3 @@ pub use dict::IntDictScheme; pub use dict::StringDictScheme; pub use dict::float_dictionary_encode; pub use dict::integer_dictionary_encode; - -mod constant; - -pub use constant::BinaryConstantScheme; -pub use constant::BoolConstantScheme; -pub use constant::FloatConstantScheme; -pub use constant::IntConstantScheme; -pub use constant::StringConstantScheme; diff --git a/vortex-compressor/src/compressor.rs b/vortex-compressor/src/compressor.rs index 965c719bf4b..727aa4ed98b 100644 --- a/vortex-compressor/src/compressor.rs +++ b/vortex-compressor/src/compressor.rs @@ -9,11 +9,13 @@ use vortex_array::Canonical; use vortex_array::CanonicalValidity; use vortex_array::ExecutionCtx; use vortex_array::IntoArray; +use vortex_array::arrays::Constant; use vortex_array::arrays::ConstantArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::FixedSizeListArray; use vortex_array::arrays::ListArray; use vortex_array::arrays::ListViewArray; +use vortex_array::arrays::Masked; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::StructArray; use vortex_array::arrays::Variant; @@ -23,6 +25,7 @@ use vortex_array::arrays::fixed_size_list::FixedSizeListArrayExt; use vortex_array::arrays::list::ListArrayExt; use vortex_array::arrays::listview::ListViewArrayExt; use vortex_array::arrays::listview::list_from_list_view; +use vortex_array::arrays::masked::MaskedArraySlotsExt; use vortex_array::arrays::primitive::PrimitiveArrayExt; use vortex_array::arrays::scalar_fn::AnyScalarFn; use vortex_array::arrays::struct_::StructArrayExt; @@ -31,6 +34,7 @@ use vortex_array::scalar::Scalar; use vortex_error::VortexResult; use crate::builtins::IntDictScheme; +use crate::constant; use crate::ctx::CompressorContext; use crate::estimate::CompressionEstimate; use crate::estimate::DeferredEstimate; @@ -232,6 +236,17 @@ impl CascadingCompressor { return Ok(scheme_compressed); } + // A constant extension array (that might be masked) is already in its terminal + // representation, and compressing the storage separately cannot do better. + if scheme_compressed.is::() { + return Ok(scheme_compressed); + } + if let Some(masked) = scheme_compressed.as_opt::() + && masked.child().is::() + { + return Ok(scheme_compressed); + } + // Also compress the underlying storage array. Some extension schemes can beat the // extension storage but still lose to ordinary storage compression. let compressed_storage = self.compress(ext_array.storage_array(), exec_ctx)?; @@ -274,7 +289,8 @@ impl CascadingCompressor { /// If a winner is found and its compressed output is actually smaller, that output is /// returned. Otherwise, the original array is returned unchanged. /// - /// Empty and all-null arrays are short-circuited before any scheme evaluation. + /// Empty, all-null, and constant arrays are handled by the compressor itself before any + /// scheme evaluation (constant detection is skipped while compressing samples). /// /// [`matches`]: Scheme::matches /// [`stats_options`]: Scheme::stats_options @@ -293,7 +309,7 @@ impl CascadingCompressor { let array: ArrayRef = canonical.into(); - if eligible_schemes.is_empty() || array.is_empty() { + if array.is_empty() { return Ok(array); } @@ -314,6 +330,31 @@ impl CascadingCompressor { let data = ArrayAndStats::new(array, merged_opts); + // Constant detection is built into the compressor: a constant leaf always short-circuits + // scheme selection. Samples are exempt because a constant sample does not imply that the + // full array is constant. + if !compress_ctx.is_sample() && constant::is_constant_for_compression(&data, exec_ctx)? { + let _winner_span = + trace::winner_compress_span(constant::CONSTANT_SCHEME_ID, before_nbytes).entered(); + let compressed = constant::compress_constant(data.array(), exec_ctx)?; + + let after_nbytes = compressed.nbytes(); + let actual_ratio = + (after_nbytes != 0).then(|| before_nbytes as f64 / after_nbytes as f64); + let accepted = after_nbytes < before_nbytes; + trace::record_winner_compress_result(after_nbytes, None, actual_ratio, accepted); + + return if accepted { + Ok(compressed) + } else { + Ok(data.into_array()) + }; + } + + if eligible_schemes.is_empty() { + return Ok(data.into_array()); + } + let Some((winner, winner_estimate)) = self.choose_best_scheme(&eligible_schemes, &data, compress_ctx.clone(), exec_ctx)? else { diff --git a/vortex-compressor/src/constant.rs b/vortex-compressor/src/constant.rs new file mode 100644 index 00000000000..7e8edc73f2f --- /dev/null +++ b/vortex-compressor/src/constant.rs @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Built-in constant detection and encoding. +//! +//! Constant arrays are not compressed through a pluggable [`Scheme`]: the compressor always +//! detects constant leaf arrays itself, before evaluating any registered scheme. Detection is +//! skipped while compressing samples, since a constant sample does not imply that the full array +//! is constant. +//! +//! [`Scheme`]: crate::scheme::Scheme + +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::aggregate_fn::fns::is_constant::is_constant; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::MaskedArray; +use vortex_array::dtype::DType; +use vortex_array::scalar::Scalar; +use vortex_error::VortexResult; + +use crate::scheme::SchemeId; +use crate::stats::ArrayAndStats; + +/// Synthetic scheme ID reported in traces when the compressor's built-in constant encoding wins. +pub(crate) const CONSTANT_SCHEME_ID: SchemeId = SchemeId { + name: "vortex.compressor.constant", +}; + +/// Returns `true` if all valid values of the canonical array are equal, meaning the array can be +/// encoded by [`compress_constant`]. +/// +/// The caller must have already handled empty and all-null arrays. +/// +/// Uses the cheapest available evidence per type: distinct counts when another scheme already +/// requested them, `O(1)` conclusions from type stats where possible, and otherwise a vectorized +/// equality scan via [`is_constant`]. +/// +/// Note that for types where the check falls through to [`is_constant`] (floats without distinct +/// counts, strings, binary, decimals, and extension types), arrays that contain any nulls are +/// reported as not constant, while stats-based checks detect constant valid values under nulls. +/// This mirrors the behavior of the per-type constant schemes this module replaced. +pub(crate) fn is_constant_for_compression( + data: &ArrayAndStats, + exec_ctx: &mut ExecutionCtx, +) -> VortexResult { + let dtype = data.array().dtype(); + + if matches!(dtype, DType::Bool(_)) { + return Ok(data.bool_stats(exec_ctx).is_constant()); + } + + if dtype.is_int() { + let stats = data.integer_stats(exec_ctx); + + // Distinct counts are only computed when a registered scheme requested them. + if let Some(distinct_count) = stats.distinct_count() { + return Ok(distinct_count == 1); + } + + // If max - min == 0 over the valid values, there is only one distinct value. + return Ok(stats.erased().max_minus_min() == 0); + } + + if dtype.is_float() { + let stats = data.float_stats(exec_ctx); + + if let Some(distinct_count) = stats.distinct_count() { + return Ok(distinct_count == 1); + } + + return is_constant(data.array(), exec_ctx); + } + + if dtype.is_utf8() || dtype.is_binary() { + let stats = data.varbinview_stats(exec_ctx); + + // The estimated distinct count is a lower bound on the actual distinct count, so a value + // above 1 proves the array is not constant without scanning it. + if stats.estimated_distinct_count().is_some_and(|c| c > 1) { + return Ok(false); + } + + return is_constant(data.array(), exec_ctx); + } + + // Decimal, extension, and any other leaf type: fall back to the generic constant check. + is_constant(data.array(), exec_ctx) +} + +/// Encodes an array whose valid values are all equal. +/// +/// Returns a [`ConstantArray`], wrapped in a [`MaskedArray`] when the array has some nulls, or a +/// null [`ConstantArray`] when the array is all-null. +/// +/// # Errors +/// +/// Returns an error if computing validity or extracting the constant scalar fails. +pub(crate) fn compress_constant( + source: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let validity = source.validity()?; + let mask = validity.execute_mask(source.len(), ctx)?; + + let Some(first_valid) = mask.first() else { + return Ok( + ConstantArray::new(Scalar::null(source.dtype().clone()), source.len()).into_array(), + ); + }; + + let scalar = source.execute_scalar(first_valid, ctx)?; + let const_arr = ConstantArray::new(scalar, source.len()).into_array(); + + if mask.all_true() { + Ok(const_arr) + } else { + Ok(MaskedArray::try_new(const_arr, validity)?.into_array()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::LazyLock; + + use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::arrays::BoolArray; + use vortex_array::arrays::Constant; + use vortex_array::arrays::DecimalArray; + use vortex_array::arrays::Masked; + use vortex_array::arrays::PrimitiveArray; + use vortex_array::arrays::TemporalArray; + use vortex_array::arrays::VarBinViewArray; + use vortex_array::dtype::DecimalDType; + use vortex_array::extension::datetime::TimeUnit; + use vortex_array::validity::Validity; + use vortex_buffer::buffer; + use vortex_error::VortexResult; + use vortex_session::VortexSession; + + use crate::CascadingCompressor; + + static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); + + /// Constant detection is built into the compressor, so it must work with no schemes at all. + fn empty_compressor() -> CascadingCompressor { + CascadingCompressor::new(Vec::new()) + } + + #[test] + fn constant_int_compresses_without_schemes() -> VortexResult<()> { + let array = PrimitiveArray::new(buffer![7i64; 100], Validity::NonNullable).into_array(); + let mut ctx = SESSION.create_execution_ctx(); + + let compressed = empty_compressor().compress(&array, &mut ctx)?; + assert!(compressed.is::()); + Ok(()) + } + + #[test] + fn constant_int_with_nulls_compresses_to_masked_constant() -> VortexResult<()> { + let validity = + Validity::Array(BoolArray::from_iter((0..100).map(|i| i % 10 != 0)).into_array()); + let array = PrimitiveArray::new(buffer![7i64; 100], validity).into_array(); + let mut ctx = SESSION.create_execution_ctx(); + + let compressed = empty_compressor().compress(&array, &mut ctx)?; + assert!(compressed.is::()); + Ok(()) + } + + #[test] + fn constant_string_compresses_without_schemes() -> VortexResult<()> { + let array = VarBinViewArray::from_iter_str(std::iter::repeat_n("hello", 100)).into_array(); + let mut ctx = SESSION.create_execution_ctx(); + + let compressed = empty_compressor().compress(&array, &mut ctx)?; + assert!(compressed.is::()); + Ok(()) + } + + #[test] + fn constant_bool_compresses_without_schemes() -> VortexResult<()> { + let array = BoolArray::from_iter(std::iter::repeat_n(true, 100)).into_array(); + let mut ctx = SESSION.create_execution_ctx(); + + let compressed = empty_compressor().compress(&array, &mut ctx)?; + assert!(compressed.is::()); + Ok(()) + } + + #[test] + fn constant_decimal_compresses_without_schemes() -> VortexResult<()> { + let array = DecimalArray::new( + buffer![123_456i128; 100], + DecimalDType::new(20, 2), + Validity::NonNullable, + ) + .into_array(); + let mut ctx = SESSION.create_execution_ctx(); + + let compressed = empty_compressor().compress(&array, &mut ctx)?; + assert!(compressed.is::()); + Ok(()) + } + + #[test] + fn constant_timestamp_compresses_without_schemes() -> VortexResult<()> { + let ts = PrimitiveArray::from_iter(std::iter::repeat_n(1_704_067_200_000i64, 100)); + let array = TemporalArray::new_timestamp(ts.into_array(), TimeUnit::Milliseconds, None) + .into_array(); + let mut ctx = SESSION.create_execution_ctx(); + + let compressed = empty_compressor().compress(&array, &mut ctx)?; + assert!(compressed.is::()); + Ok(()) + } + + #[test] + fn non_constant_int_is_left_canonical_without_schemes() -> VortexResult<()> { + let array = PrimitiveArray::from_iter(0..100i64).into_array(); + let mut ctx = SESSION.create_execution_ctx(); + + let compressed = empty_compressor().compress(&array, &mut ctx)?; + assert!(!compressed.is::()); + assert_eq!(compressed.dtype(), array.dtype()); + Ok(()) + } +} diff --git a/vortex-compressor/src/estimate.rs b/vortex-compressor/src/estimate.rs index 70dd75d13b0..82d021353d2 100644 --- a/vortex-compressor/src/estimate.rs +++ b/vortex-compressor/src/estimate.rs @@ -69,7 +69,7 @@ pub enum EstimateVerdict { /// Always use this scheme, as it is definitively the best choice. /// - /// Some examples include constant detection, decimal byte parts, and temporal decomposition. + /// Some examples include decimal byte parts and temporal decomposition. /// /// The compressor will select this scheme immediately without evaluating further candidates. /// Schemes that return `AlwaysUse` must be mutually exclusive per canonical type (enforced by diff --git a/vortex-compressor/src/lib.rs b/vortex-compressor/src/lib.rs index 7e6854eacbe..86df4fc4cba 100644 --- a/vortex-compressor/src/lib.rs +++ b/vortex-compressor/src/lib.rs @@ -19,7 +19,8 @@ //! # Example //! //! A [`CascadingCompressor`] can be created directly with a fixed scheme list. With no schemes it -//! still canonicalizes supported inputs and recursively handles nested structure, but no leaf +//! still canonicalizes supported inputs, recursively handles nested structure, and encodes +//! constant leaves (constant detection is built into the compressor), but no other leaf //! compression is selected. //! //! ```rust @@ -67,6 +68,7 @@ pub mod estimate; pub mod scheme; pub mod stats; +mod constant; mod sample; mod compressor; diff --git a/vortex-compressor/src/scheme.rs b/vortex-compressor/src/scheme.rs index 6e6899a3ea1..57ebf9f2406 100644 --- a/vortex-compressor/src/scheme.rs +++ b/vortex-compressor/src/scheme.rs @@ -206,13 +206,13 @@ pub trait Scheme: Debug + Send + Sync { /// /// Note that the compressor will also use this method when compressing samples, so some /// statistics that might hold for the samples may not hold for the entire array (e.g., - /// `Constant`). Implementations should check `ctx.is_sample` to make sure that they are + /// constancy). Implementations should check `ctx.is_sample` to make sure that they are /// returning the correct information. /// /// The compressor guarantees that empty and all-null arrays are handled before this method is - /// called. Implementations may assume the array has at least one valid element. However, a - /// constant scheme should still be registered with the compressor to detect single-value arrays - /// that are not all-null. + /// called, so implementations may assume the array has at least one valid element. Outside of + /// sample compression, the compressor also encodes constant arrays itself before evaluating + /// schemes, so implementations only see constant arrays when `ctx.is_sample()` is `true`. fn expected_compression_ratio( &self, _data: &ArrayAndStats, diff --git a/vortex-file/src/tests.rs b/vortex-file/src/tests.rs index b196f68dc96..81cfb086337 100644 --- a/vortex-file/src/tests.rs +++ b/vortex-file/src/tests.rs @@ -1728,15 +1728,12 @@ async fn timestamp_unit_mismatch() -> Result<(), Box> { /// Regression test: filtering a milliseconds timestamp column with a seconds scalar should /// always error, regardless of how the internal children of `DateTimePartsArray` are encoded. /// -/// This test forces `ConstantArray` encoding for the seconds/subseconds children by using a -/// compressor with Dict excluded (which triggers distinct-value computation, letting -/// `ConstantScheme` win for `[0, 0, 0]`). The scanner should still detect the time unit +/// The compressor's built-in constant detection encodes the seconds/subseconds children +/// (`[0, 0, 0]`) as `ConstantArray`s. The scanner should still detect the time unit /// mismatch and error, not silently return wrong results. #[tokio::test] async fn timestamp_unit_mismatch_errors_with_constant_children() -> Result<(), Box> { - // Build a compressor where ConstantScheme wins for [0, 0, 0] by including Dict - // (which enables distinct-value computation). let compressor = vortex_btrblocks::BtrBlocksCompressor::default(); // Write file with MILLISECONDS timestamps using this compressor. From 40cb39636b40a6dd81945ff42792dc23331fad06 Mon Sep 17 00:00:00 2001 From: myrrc Date: Tue, 7 Jul 2026 13:01:11 +0100 Subject: [PATCH 024/104] FFI: canonicalize array, data pointer for primitives (#8665) - Add canonicalize function for vx_array. - Add data_ptr functions for canonicalized primitive and bool arrays. - Add sink abort method which doesn't write file footer and leaves file invalid. Semantics here is we want to abort the sink in C++ destructor - if user hasn't called Finalize(), file will not be written correctly. - Expose Rust's error code to FFI. Signed-off-by: Mikhail Kot --- vortex-ffi/cinclude/vortex.h | 94 +++++++++++++ vortex-ffi/src/array.rs | 254 +++++++++++++++++++++++++++++++++++ vortex-ffi/src/error.rs | 107 +++++++++++++-- vortex-ffi/src/sink.rs | 49 +++++++ 4 files changed, 494 insertions(+), 10 deletions(-) diff --git a/vortex-ffi/cinclude/vortex.h b/vortex-ffi/cinclude/vortex.h index 277985362b2..cbc263ebc38 100644 --- a/vortex-ffi/cinclude/vortex.h +++ b/vortex-ffi/cinclude/vortex.h @@ -201,6 +201,52 @@ typedef enum { VX_ESTIMATE_INEXACT = 2, } vx_estimate_type; +/** + * Error category for vx_error. + */ +typedef enum { + /** + * All other errors + */ + VX_ERROR_CODE_OTHER = 0, + /** + * Index out of bounds + */ + VX_ERROR_CODE_OUT_OF_BOUNDS = 1, + /** + * Compute kernel execute error + */ + VX_ERROR_CODE_COMPUTE = 2, + /** + * An invalid argument was provided. + */ + VX_ERROR_CODE_INVALID_ARGUMENT = 3, + /** + * Serialization/deserialization error + */ + VX_ERROR_CODE_SERIALIZATION = 4, + /** + * Unimplemented function + */ + VX_ERROR_CODE_NOT_IMPLEMENTED = 5, + /** + * Type mismatch + */ + VX_ERROR_CODE_MISMATCHED_TYPES = 6, + /** + * Assertion failed + */ + VX_ERROR_CODE_ASSERTION_FAILED = 7, + /** + * IO error + */ + VX_ERROR_CODE_IO = 8, + /** + * Panic inside FFI + */ + VX_ERROR_CODE_PANIC = 9, +} vx_error_code; + /** * Equalities, inequalities, and boolean operations over possibly null values. * For most operations, if either side is null, the result is null. @@ -803,6 +849,43 @@ const vx_string *vx_array_get_utf8(const vx_array *array, uint32_t index); */ const vx_binary *vx_array_get_binary(const vx_array *array, uint32_t index); +/** + * For a canonical Bool array, return bool at "index". + * For invalid elements returned value is unspecified, check validity via + * vx_array_get_validity. + * + * Panics if "array" is not canonical - call vx_array_canonicalize first. + * Panics if "array" is not a Bool array. + * Panics if "index" is out of bounds. + */ +bool vx_array_get_bool(const vx_array *array, size_t index); + +/** + * Decode array into its canonical form. + * + * On error returns NULL and "sets error_out". + */ +const vx_array *vx_array_canonicalize(const vx_session *session, const vx_array *array, vx_error **error_out); + +/** + * Return a pointer to the values buffer of a canonical Primitive array. + * Pointer is valid as long as "array" is valid. + * + * Errors if array is not a canonical Primitive. + */ +const void *vx_array_data_ptr_primitive(const vx_array *array, vx_error **error_out); + +/** + * Return a pointer to the bitpacked buffer of a canonical Bool array. + * Pointer is valid as long as "array" is valid. + * + * Writes bit offset of the first element into "bit_offset_out". + * "bit_offset_out" must not be NULL. + * + * Errors if array is not a canonical Bool. + */ +const void *vx_array_data_ptr_bool(const vx_array *array, size_t *bit_offset_out, vx_error **error_out); + /** * Apply the expression to the array, wrapping it with a ScalarFnArray. * This operation takes constant time as it doesn't execute the underlying @@ -1056,6 +1139,11 @@ void vx_error_free(const vx_error *ptr); */ const vx_string *vx_error_get_message(const vx_error *error); +/** + * Return category code for "error". + */ +vx_error_code vx_error_get_code(const vx_error *error); + /** * Free an owned [`vx_expression`] object. */ @@ -1566,6 +1654,12 @@ void vx_array_sink_push(vx_array_sink *sink, const vx_array *array, vx_error **e */ void vx_array_sink_close(vx_array_sink *sink, vx_error **error_out); +/** + * Abort an array sink. File footer is not written, and file is left invalid. + * Don't use sink after this call. + */ +void vx_array_sink_abort(vx_array_sink *sink); + /** * Clone a vx_string. Returned handle must be release with vx_string_free */ diff --git a/vortex-ffi/src/array.rs b/vortex-ffi/src/array.rs index 8b3a59ada74..e097fd089c1 100644 --- a/vortex-ffi/src/array.rs +++ b/vortex-ffi/src/array.rs @@ -13,11 +13,15 @@ use arrow_array::ffi::FFI_ArrowSchema; use arrow_array::ffi::from_ffi; use paste::paste; use vortex::array::ArrayRef; +use vortex::array::Canonical; use vortex::array::IntoArray; use vortex::array::VortexSessionExecute; +use vortex::array::arrays::Bool; use vortex::array::arrays::NullArray; +use vortex::array::arrays::Primitive; use vortex::array::arrays::PrimitiveArray; use vortex::array::arrays::StructArray; +use vortex::array::arrays::bool::BoolArrayExt; use vortex::array::arrays::struct_::StructArrayExt; use vortex::array::arrow::FromArrowArray; use vortex::array::legacy_session; @@ -29,16 +33,20 @@ use vortex::error::VortexExpect; use vortex::error::VortexResult; use vortex::error::vortex_ensure; use vortex::error::vortex_err; +use vortex::error::vortex_panic; use crate::arc_wrapper; use crate::binary::vx_binary; use crate::dtype::vx_dtype; use crate::dtype::vx_dtype_variant; +use crate::error::try_or; use crate::error::try_or_default; use crate::error::vx_error; use crate::error::write_error; use crate::expression::vx_expression; use crate::ptype::vx_ptype; +use crate::session::vx_session; +use crate::session::vx_session_ref; use crate::string::vx_string; arc_wrapper!( @@ -474,6 +482,100 @@ pub unsafe extern "C-unwind" fn vx_array_get_binary( } } +/// For a canonical Bool array, return bool at "index". +/// For invalid elements returned value is unspecified, check validity via +/// vx_array_get_validity. +/// +/// Panics if "array" is not canonical - call vx_array_canonicalize first. +/// Panics if "array" is not a Bool array. +/// Panics if "index" is out of bounds. +#[unsafe(no_mangle)] +pub unsafe extern "C-unwind" fn vx_array_get_bool(array: *const vx_array, index: usize) -> bool { + let array = vx_array::as_ref(array); + let bool_array = array + .as_opt::() + .vortex_expect("vx_array_get_bool requires a canonical Bool array"); + let bits = bool_array.to_bit_buffer(); + if index >= bits.len() { + vortex_panic!( + "index {index} out of bounds for array of length {}", + bits.len() + ); + } + bits.value(index) +} + +/// Decode array into its canonical form. +/// +/// On error returns NULL and "sets error_out". +#[unsafe(no_mangle)] +pub unsafe extern "C-unwind" fn vx_array_canonicalize( + session: *const vx_session, + array: *const vx_array, + error_out: *mut *mut vx_error, +) -> *const vx_array { + try_or_default(error_out, || { + let session = unsafe { vx_session_ref(session) }?; + let array = unsafe { vx_array_ref(array) }?; + let mut ctx = session.create_execution_ctx(); + let canonical = array.clone().execute::(&mut ctx)?; + Ok(vx_array::new(Arc::new(canonical.into_array()))) + }) +} + +/// Return a pointer to the values buffer of a canonical Primitive array. +/// Pointer is valid as long as "array" is valid. +/// +/// Errors if array is not a canonical Primitive. +#[unsafe(no_mangle)] +pub unsafe extern "C-unwind" fn vx_array_data_ptr_primitive( + array: *const vx_array, + error_out: *mut *mut vx_error, +) -> *const c_void { + try_or(error_out, ptr::null(), || { + let array = unsafe { vx_array_ref(array) }?; + let primitive = array.as_opt::().ok_or_else(|| { + vortex_err!( + "vx_array_data_ptr_primitive requires a canonical Primitive array, got {}", + array.encoding_id() + ) + })?; + let bytes = primitive + .buffer_handle() + .as_host_opt() + .ok_or_else(|| vortex_err!("array buffer is not in host memory"))?; + Ok(bytes.as_ptr().cast()) + }) +} + +/// Return a pointer to the bitpacked buffer of a canonical Bool array. +/// Pointer is valid as long as "array" is valid. +/// +/// Writes bit offset of the first element into "bit_offset_out". +/// "bit_offset_out" must not be NULL. +/// +/// Errors if array is not a canonical Bool. +#[unsafe(no_mangle)] +pub unsafe extern "C-unwind" fn vx_array_data_ptr_bool( + array: *const vx_array, + bit_offset_out: *mut usize, + error_out: *mut *mut vx_error, +) -> *const c_void { + try_or(error_out, ptr::null(), || { + let array = unsafe { vx_array_ref(array) }?; + vortex_ensure!(!bit_offset_out.is_null(), "null bit_offset_out"); + let bool_array = array.as_opt::().ok_or_else(|| { + vortex_err!( + "vx_array_data_ptr_bool requires a canonical Bool array, got {}", + array.encoding_id() + ) + })?; + let bits = bool_array.to_bit_buffer(); + unsafe { bit_offset_out.write(bits.offset()) }; + Ok(bits.inner().as_ptr().cast()) + }) +} + /// Apply the expression to the array, wrapping it with a ScalarFnArray. /// This operation takes constant time as it doesn't execute the underlying /// array. Executing the underlying array still takes O(n) time. @@ -495,6 +597,7 @@ pub unsafe extern "C" fn vx_array_apply( #[cfg(test)] mod tests { use std::ptr; + use std::slice::from_raw_parts; use vortex::array::IntoArray; use vortex::array::VortexSessionExecute; @@ -519,7 +622,10 @@ mod tests { use crate::dtype::vx_dtype_variant; use crate::error::vx_error_free; use crate::expression::vx_expression_free; + use crate::session::vx_session_free; + use crate::session::vx_session_new; use crate::string::vx_string_free; + use crate::tests::assert_error; use crate::tests::assert_no_error; #[test] @@ -933,4 +1039,152 @@ mod tests { vx_array_free(vx); } } + + #[test] + #[cfg_attr(miri, ignore)] + fn test_get_bool() { + let bools = BoolArray::from_iter([true, false, true]); + unsafe { + let array = vx_array::new(Arc::new(bools.into_array())); + assert!(vx_array_get_bool(array, 0)); + assert!(!vx_array_get_bool(array, 1)); + assert!(vx_array_get_bool(array, 2)); + vx_array_free(array); + } + } + + #[test] + #[cfg_attr(miri, ignore)] + fn test_canonicalize_nullable() { + let primitive = PrimitiveArray::new( + buffer![10i32, 20i32, 30i32], + Validity::from_iter([true, false, true]), + ); + + unsafe { + let session = vx_session_new(); + let mut error = ptr::null_mut(); + let mut bit_offset = usize::MAX; + + let array = vx_array::new(Arc::new(primitive.into_array())); + let canonical = vx_array_canonicalize(session, array, &raw mut error); + assert_no_error(error); + let data = vx_array_data_ptr_primitive(canonical, &raw mut error); + assert_no_error(error); + + assert_eq!(from_raw_parts(data.cast::(), 3), [10, 20, 30]); + let mut validity = vx_validity { + r#type: vx_validity_type::VX_VALIDITY_NON_NULLABLE, + array: ptr::null(), + }; + + vx_array_get_validity(canonical, &raw mut validity, &raw mut error); + assert_no_error(error); + assert!(matches!( + validity.r#type, + vx_validity_type::VX_VALIDITY_ARRAY + )); + let validity_bools = vx_array_canonicalize(session, validity.array, &raw mut error); + assert_no_error(error); + let bits = vx_array_data_ptr_bool(validity_bools, &raw mut bit_offset, &raw mut error); + assert_no_error(error); + assert_eq!( + *bits.cast::().add(bit_offset / 8) >> (bit_offset % 8) & 0b111, + 0b101 + ); + + vx_array_free(validity_bools); + vx_array_free(validity.array); + vx_array_free(canonical); + vx_array_free(array); + vx_session_free(session); + } + } + + #[test] + #[cfg_attr(miri, ignore)] + fn test_canonicalize() { + let primitive = PrimitiveArray::new(buffer![1u8, 2u8], Validity::NonNullable); + + unsafe { + let session = vx_session_new(); + let mut error = ptr::null_mut(); + let mut validity = vx_validity { + r#type: vx_validity_type::VX_VALIDITY_NON_NULLABLE, + array: ptr::null(), + }; + + let array = vx_array::new(Arc::new(primitive.into_array())); + vx_array_get_validity(array, &raw mut validity, &raw mut error); + assert_no_error(error); + assert!(matches!( + validity.r#type, + vx_validity_type::VX_VALIDITY_NON_NULLABLE + )); + assert!(validity.array.is_null()); + vx_array_free(array); + + vx_session_free(session); + } + } + + #[test] + #[cfg_attr(miri, ignore)] + fn test_data_ptr_bool() { + let bools = BoolArray::from_iter([ + true, true, false, true, false, true, true, false, true, true, + ]); + + unsafe { + let session = vx_session_new(); + let mut error = ptr::null_mut(); + let mut bit_offset = usize::MAX; + + let array = vx_array::new(Arc::new(bools.into_array())); + let sliced = vx_array_slice(array, 3, 10, &raw mut error); + assert_no_error(error); + + let canonical = vx_array_canonicalize(session, sliced, &raw mut error); + assert_no_error(error); + + let bits = vx_array_data_ptr_bool(canonical, &raw mut bit_offset, &raw mut error); + assert_no_error(error); + assert!(bit_offset < 8); + for (i, expected) in [true, false, true, true, false, true, true] + .into_iter() + .enumerate() + { + let bit = bit_offset + i; + let actual = (*bits.cast::().add(bit / 8) >> (bit % 8)) & 1 == 1; + assert_eq!(actual, expected, "bit {i}"); + } + vx_array_free(canonical); + vx_array_free(sliced); + vx_array_free(array); + + vx_session_free(session); + } + } + + #[test] + #[cfg_attr(miri, ignore)] + fn test_data_ptr_error() { + let strings = VarBinViewArray::from_iter_str(["a", "b"]); + + unsafe { + let mut error = ptr::null_mut(); + + let array = vx_array::new(Arc::new(strings.into_array())); + let data = vx_array_data_ptr_primitive(array, &raw mut error); + assert!(data.is_null()); + assert_error(error); + + let mut bit_offset = usize::MAX; + let bits = vx_array_data_ptr_bool(array, &raw mut bit_offset, &raw mut error); + assert!(bits.is_null()); + assert_error(error); + + vx_array_free(array); + } + } } diff --git a/vortex-ffi/src/error.rs b/vortex-ffi/src/error.rs index 85d6984d33d..0775d18b18c 100644 --- a/vortex-ffi/src/error.rs +++ b/vortex-ffi/src/error.rs @@ -7,35 +7,84 @@ use std::panic::catch_unwind; use std::ptr; use std::sync::Arc; +use vortex::error::VortexError; use vortex::error::VortexResult; use crate::box_wrapper; use crate::string::vx_string; -pub(crate) struct VortexError { +/// Error category for vx_error. +#[repr(C)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[expect(non_camel_case_types)] +pub enum vx_error_code { + /// All other errors + VX_ERROR_CODE_OTHER = 0, + /// Index out of bounds + VX_ERROR_CODE_OUT_OF_BOUNDS = 1, + /// Compute kernel execute error + VX_ERROR_CODE_COMPUTE = 2, + /// An invalid argument was provided. + VX_ERROR_CODE_INVALID_ARGUMENT = 3, + /// Serialization/deserialization error + VX_ERROR_CODE_SERIALIZATION = 4, + /// Unimplemented function + VX_ERROR_CODE_NOT_IMPLEMENTED = 5, + /// Type mismatch + VX_ERROR_CODE_MISMATCHED_TYPES = 6, + /// Assertion failed + VX_ERROR_CODE_ASSERTION_FAILED = 7, + /// IO error + VX_ERROR_CODE_IO = 8, + /// Panic inside FFI + VX_ERROR_CODE_PANIC = 9, +} + +fn error_code(error: &VortexError) -> vx_error_code { + match error { + VortexError::OutOfBounds(..) => vx_error_code::VX_ERROR_CODE_OUT_OF_BOUNDS, + VortexError::Compute(..) => vx_error_code::VX_ERROR_CODE_COMPUTE, + VortexError::InvalidArgument(..) => vx_error_code::VX_ERROR_CODE_INVALID_ARGUMENT, + VortexError::Serde(..) => vx_error_code::VX_ERROR_CODE_SERIALIZATION, + VortexError::NotImplemented(..) => vx_error_code::VX_ERROR_CODE_NOT_IMPLEMENTED, + VortexError::MismatchedTypes(..) => vx_error_code::VX_ERROR_CODE_MISMATCHED_TYPES, + VortexError::AssertionFailed(..) => vx_error_code::VX_ERROR_CODE_ASSERTION_FAILED, + VortexError::Io(..) => vx_error_code::VX_ERROR_CODE_IO, + VortexError::Context(_, inner) => error_code(inner), + VortexError::Shared(inner) => error_code(inner), + _ => vx_error_code::VX_ERROR_CODE_OTHER, + } +} + +pub(crate) struct VortexFFIError { message: Arc, + code: vx_error_code, } box_wrapper!( /// The error structure populated by fallible Vortex C functions. - VortexError, + VortexFFIError, vx_error ); -/// Create an owned Vortex FFI error from a message. -pub(crate) fn vx_error_new(message: &str) -> *mut vx_error { - vx_error::new(VortexError { +fn vx_error_new_with_code(message: &str, code: vx_error_code) -> *mut vx_error { + vx_error::new(VortexFFIError { message: message.into(), + code, }) } /// Write an error message to `error` which has not been populated before. /// A null `error` pointer discards the message. pub(crate) fn write_error(error: *mut *mut vx_error, message: &str) { + write_error_with_code(error, message, vx_error_code::VX_ERROR_CODE_OTHER); +} + +fn write_error_with_code(error: *mut *mut vx_error, message: &str, code: vx_error_code) { if error.is_null() { return; } - unsafe { error.write(vx_error_new(message)) }; + unsafe { error.write(vx_error_new_with_code(message, code)) }; } /// Clear `*error_out` to null unless `error_out` itself is null. @@ -68,11 +117,15 @@ pub fn try_or_default( value } Ok(Err(err)) => { - write_error(error_out, &err.to_string()); + write_error_with_code(error_out, &err.to_string(), error_code(&err)); T::default() } Err(payload) => { - write_error(error_out, &panic_message(payload.as_ref())); + write_error_with_code( + error_out, + &panic_message(payload.as_ref()), + vx_error_code::VX_ERROR_CODE_PANIC, + ); T::default() } } @@ -93,11 +146,15 @@ pub fn try_or( value } Ok(Err(err)) => { - write_error(error_out, &err.to_string()); + write_error_with_code(error_out, &err.to_string(), error_code(&err)); error_value } Err(payload) => { - write_error(error_out, &panic_message(payload.as_ref())); + write_error_with_code( + error_out, + &panic_message(payload.as_ref()), + vx_error_code::VX_ERROR_CODE_PANIC, + ); error_value } } @@ -109,6 +166,12 @@ pub unsafe extern "C-unwind" fn vx_error_get_message(error: *const vx_error) -> vx_string::new(Arc::clone(&vx_error::as_ref(error).message)) } +/// Return category code for "error". +#[unsafe(no_mangle)] +pub unsafe extern "C-unwind" fn vx_error_get_code(error: *const vx_error) -> vx_error_code { + vx_error::as_ref(error).code +} + #[cfg(test)] mod tests { use std::ptr; @@ -160,4 +223,28 @@ mod tests { unsafe { crate::string::vx_string_free(message) }; unsafe { vx_error_free(error) }; } + + #[test] + fn test_error_codes() { + let mut error: *mut vx_error = ptr::null_mut(); + + assert_eq!( + try_or(&raw mut error, -1, || Err::(vortex_err!( + OutOfBounds: 5, 0, 3 + ))), + -1 + ); + assert_eq!( + unsafe { vx_error_get_code(error) }, + vx_error_code::VX_ERROR_CODE_OUT_OF_BOUNDS + ); + unsafe { vx_error_free(error) }; + + assert_eq!(try_or(&raw mut error, -1, || panic!("panic")), -1); + assert_eq!( + unsafe { vx_error_get_code(error) }, + vx_error_code::VX_ERROR_CODE_PANIC + ); + unsafe { vx_error_free(error) }; + } } diff --git a/vortex-ffi/src/sink.rs b/vortex-ffi/src/sink.rs index 639d22196e3..063a26dc259 100644 --- a/vortex-ffi/src/sink.rs +++ b/vortex-ffi/src/sink.rs @@ -119,6 +119,16 @@ pub unsafe extern "C-unwind" fn vx_array_sink_close( }) } +/// Abort an array sink. File footer is not written, and file is left invalid. +/// Don't use sink after this call. +#[unsafe(no_mangle)] +pub unsafe extern "C-unwind" fn vx_array_sink_abort(sink: *mut vx_array_sink) { + if sink.is_null() { + return; + } + drop(unsafe { Box::from_raw(sink) }); +} + #[cfg(test)] mod tests { use std::ffi::CString; @@ -134,6 +144,8 @@ mod tests { use super::*; use crate::array::vx_array; use crate::array::vx_array_free; + use crate::data_source::vx_data_source_new; + use crate::data_source::vx_data_source_options; use crate::dtype::vx_dtype; use crate::dtype::vx_dtype_free; use crate::error::vx_error_free; @@ -261,6 +273,43 @@ mod tests { } } + #[test] + #[cfg_attr(miri, ignore)] + fn test_sink_abort() { + let dtype = DType::Primitive(vortex::dtype::PType::I32, false.into()); + let array = PrimitiveArray::new(buffer![1i32, 2i32, 3i32], Validity::NonNullable); + + let file = NamedTempFile::new().unwrap(); + let path = CString::new(file.path().to_str().unwrap()).unwrap(); + + unsafe { + let session = vx_session_new(); + let dtype = vx_dtype::new(Arc::new(dtype)); + + let mut error = std::ptr::null_mut(); + let sink = vx_array_sink_open_file(session, path.as_ptr(), dtype, &raw mut error); + assert!(error.is_null()); + + let array = vx_array::new(Arc::new(array.into_array())); + vx_array_sink_push(sink, array, &raw mut error); + assert!(error.is_null()); + vx_array_free(array); + + vx_array_sink_abort(sink); + + let opts = vx_data_source_options { + paths: path.as_ptr(), + }; + let ds = vx_data_source_new(session, &raw const opts, &raw mut error); + assert!(ds.is_null()); + assert!(!error.is_null()); + vx_error_free(error); + + vx_dtype_free(dtype); + vx_session_free(session); + } + } + #[test] #[cfg_attr(miri, ignore)] fn test_sink_null_path() { From e6c312a92519dd4b3898ee11b347010c9bd025fc Mon Sep 17 00:00:00 2001 From: myrrc Date: Tue, 7 Jul 2026 13:04:29 +0100 Subject: [PATCH 025/104] mean() for decimals (#8649) Support mean() for decimals. Support casting decimals to f64. Signed-off-by: Mikhail Kot --- vortex-array/src/aggregate_fn/fns/mean/mod.rs | 149 ++++++++++++++++-- vortex-array/src/aggregate_fn/fns/sum/mod.rs | 19 ++- .../src/arrays/decimal/compute/cast.rs | 134 +++++++++++++++- 3 files changed, 274 insertions(+), 28 deletions(-) diff --git a/vortex-array/src/aggregate_fn/fns/mean/mod.rs b/vortex-array/src/aggregate_fn/fns/mean/mod.rs index b421d69a966..20b7f15834c 100644 --- a/vortex-array/src/aggregate_fn/fns/mean/mod.rs +++ b/vortex-array/src/aggregate_fn/fns/mean/mod.rs @@ -18,10 +18,16 @@ use crate::aggregate_fn::combined::CombinedOptions; use crate::aggregate_fn::combined::PairOptions; use crate::aggregate_fn::fns::count::Count; use crate::aggregate_fn::fns::sum::Sum; +use crate::aggregate_fn::fns::sum::sum_decimal_dtype; use crate::builtins::ArrayBuiltins; use crate::dtype::DType; +use crate::dtype::DecimalDType; +use crate::dtype::MAX_PRECISION; +use crate::dtype::MAX_SCALE; use crate::dtype::Nullability; use crate::dtype::PType; +use crate::dtype::i256; +use crate::scalar::DecimalValue; use crate::scalar::Scalar; use crate::scalar_fn::fns::operators::Operator; @@ -45,10 +51,7 @@ pub fn mean(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { /// /// Implemented as `Sum / Count` via [`BinaryCombined`]. /// -/// Coercion / return type: -/// - Booleans and primitive numeric types are coerced to `f64` and the result -/// is a nullable `f64`. -/// - Decimals are kept as decimals but not implemented currently +/// Booleans and primitive numeric types are cast to f64. Decimals stay decimals. #[derive(Clone, Debug)] pub struct Mean; @@ -88,18 +91,18 @@ impl BinaryCombined for Mean { } fn finalize(&self, sum: ArrayRef, count: ArrayRef) -> VortexResult { - let target = match sum.dtype() { - DType::Decimal(..) => sum.dtype().with_nullability(Nullability::Nullable), - _ => DType::Primitive(PType::F64, Nullability::Nullable), - }; + if let DType::Decimal(..) = sum.dtype() { + vortex_bail!("grouped mean over decimals is not yet supported"); + } + let target = DType::Primitive(PType::F64, Nullability::Nullable); let sum_cast = sum.cast(target.clone())?; let count_cast = count.cast(target)?; sum_cast.binary(count_cast, Operator::Div) } fn finalize_scalar(&self, left_scalar: Scalar, right_scalar: Scalar) -> VortexResult { - if let DType::Decimal(..) = left_scalar.dtype() { - vortex_bail!("mean::finalize_scalar not yet implemented for decimal inputs"); + if let DType::Decimal(decimal_dtype, _) = *left_scalar.dtype() { + return finalize_decimal_scalar(&left_scalar, &right_scalar, decimal_dtype); } let target = DType::Primitive(PType::F64, Nullability::Nullable); @@ -140,13 +143,12 @@ impl BinaryCombined for Mean { /// - Bool stays as bool — `Sum` has a native bool path and bool → f64 isn't /// currently a direct cast in vortex. /// - Primitive numerics → `f64` so the sum and finalize work without overflow. +/// - Decimals stay as decimals fn coerced_input_dtype(input_dtype: &DType) -> Option { match input_dtype { DType::Bool(_) => Some(input_dtype.clone()), DType::Primitive(_, n) => Some(DType::Primitive(PType::F64, *n)), - DType::Decimal(..) => { - unimplemented!("mean is not implemented for decimals yet") - } + DType::Decimal(..) => Some(input_dtype.clone()), _ => None, } } @@ -156,13 +158,59 @@ fn mean_output_dtype(input_dtype: &DType) -> Option { DType::Bool(_) | DType::Primitive(..) => { Some(DType::Primitive(PType::F64, Nullability::Nullable)) } - DType::Decimal(..) => { - unimplemented!("mean for decimals is not yet implemented"); - } + DType::Decimal(decimal_dtype, _) => Some(DType::Decimal( + mean_decimal_dtype(&sum_decimal_dtype(decimal_dtype)), + Nullability::Nullable, + )), _ => None, } } +/// mean() output decimal type mimicking Spark/DataFusion/MySQL: decimal(p+4, s+4) +fn mean_decimal_dtype(sum: &DecimalDType) -> DecimalDType { + DecimalDType::new( + u8::min(MAX_PRECISION, sum.precision().saturating_sub(6)), + i8::min(MAX_SCALE, sum.scale() + 4), + ) +} + +fn finalize_decimal_scalar( + sum: &Scalar, + count: &Scalar, + sum_decimal: DecimalDType, +) -> VortexResult { + let target_decimal_dtype = mean_decimal_dtype(&sum_decimal); + let target_dtype = DType::Decimal(target_decimal_dtype, Nullability::Nullable); + + // overflow + let Some(sum_value) = sum.as_decimal().decimal_value() else { + return Ok(Scalar::null(target_dtype)); + }; + // empty input + let count = count.as_primitive().typed_value::().unwrap_or(0); + if count == 0 { + return Ok(Scalar::null(target_dtype)); + } + + let Ok(sum) = DecimalValue::rescale_i256( + sum_value.as_i256(), + sum_decimal.scale(), + target_decimal_dtype.scale(), + ) else { + return Ok(Scalar::null(target_dtype)); + }; + let mean = sum / i256::from_i128(i128::from(count)); + + let Ok(mean) = DecimalValue::try_from_i256(mean, target_decimal_dtype) else { + return Ok(Scalar::null(target_dtype)); + }; + Ok(Scalar::decimal( + mean, + target_decimal_dtype, + Nullability::Nullable, + )) +} + #[cfg(test)] mod tests { use vortex_buffer::buffer; @@ -175,7 +223,9 @@ mod tests { use crate::arrays::BoolArray; use crate::arrays::ChunkedArray; use crate::arrays::ConstantArray; + use crate::arrays::DecimalArray; use crate::arrays::PrimitiveArray; + use crate::dtype::DecimalDType; use crate::validity::Validity; #[test] @@ -273,6 +323,73 @@ mod tests { Ok(()) } + #[test] + fn mean_decimal() -> VortexResult<()> { + let dtype = DecimalDType::new(6, 2); + let array = + DecimalArray::new(buffer![100i32, 200, 300], dtype, Validity::NonNullable).into_array(); + let mut ctx = array_session().create_execution_ctx(); + let result = mean(&array, &mut ctx)?; + assert_eq!( + result.dtype(), + &DType::Decimal(DecimalDType::new(10, 6), Nullability::Nullable) + ); + // mean(1.00, 2.00, 3.00) = 2.000000 + assert_eq!( + result.as_decimal().decimal_value(), + Some(DecimalValue::I256(i256::from_i128(2_000_000))) + ); + Ok(()) + } + + #[test] + fn mean_decimal_null() -> VortexResult<()> { + let dtype = DecimalDType::new(6, 2); + let validity = Validity::from_iter([true, false, true]); + let array = DecimalArray::new(buffer![150i32, 0, 450], dtype, validity).into_array(); + let mut ctx = array_session().create_execution_ctx(); + let result = mean(&array, &mut ctx)?; + // mean(1.50, 4.50) = 3.000000 + assert_eq!( + result.as_decimal().decimal_value(), + Some(DecimalValue::I256(i256::from_i128(3_000_000))) + ); + Ok(()) + } + + #[test] + fn mean_decimal_chunked() -> VortexResult<()> { + let dtype = DecimalDType::new(6, 2); + let validity = Validity::NonNullable; + let chunk1 = DecimalArray::new(buffer![100i32, 200], dtype, validity.clone()).into_array(); + let chunk2 = DecimalArray::new(buffer![300i32, 400, 500], dtype, validity).into_array(); + let dtype = chunk1.dtype().clone(); + let chunked = ChunkedArray::try_new(vec![chunk1, chunk2], dtype)?; + let mut ctx = array_session().create_execution_ctx(); + let result = mean(&chunked.into_array(), &mut ctx)?; + // mean(1.00, 2.00, 3.00, 4.00, 5.00) = 3.000000 + assert_eq!( + result.as_decimal().decimal_value(), + Some(DecimalValue::I256(i256::from_i128(3_000_000))) + ); + Ok(()) + } + + #[test] + fn mean_decimal_33() -> VortexResult<()> { + let dtype = DecimalDType::new(6, 2); + let buf = buffer![100i32, 0, 0]; + let array = DecimalArray::new(buf, dtype, Validity::NonNullable).into_array(); + let mut ctx = array_session().create_execution_ctx(); + let result = mean(&array, &mut ctx)?; + // mean(1.00, 0.00, 0.00) = 1/3 => 0.333333 + assert_eq!( + result.as_decimal().decimal_value(), + Some(DecimalValue::I256(i256::from_i128(333_333))) + ); + Ok(()) + } + #[test] fn mean_multi_batch() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); diff --git a/vortex-array/src/aggregate_fn/fns/sum/mod.rs b/vortex-array/src/aggregate_fn/fns/sum/mod.rs index 001b0f61657..010371fee4f 100644 --- a/vortex-array/src/aggregate_fn/fns/sum/mod.rs +++ b/vortex-array/src/aggregate_fn/fns/sum/mod.rs @@ -77,6 +77,16 @@ pub fn sum(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { #[derive(Clone, Debug)] pub struct Sum; +// Both Spark and DataFusion use this heuristic. +// - https://github.com/apache/spark/blob/fcf636d9eb8d645c24be3db2d599aba2d7e2955a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/aggregate/Sum.scala#L66 +// - https://github.com/apache/datafusion/blob/4153adf2c0f6e317ef476febfdc834208bd46622/datafusion/functions-aggregate/src/sum.rs#L188 +pub(crate) fn sum_decimal_dtype(input: &DecimalDType) -> DecimalDType { + DecimalDType::new( + u8::min(MAX_PRECISION, input.precision() + 10), + input.scale(), + ) +} + impl AggregateFnVTable for Sum { type Options = NumericalAggregateOpts; type Partial = SumPartial; @@ -118,14 +128,7 @@ impl AggregateFnVTable for Sum { } }, DType::Decimal(decimal_dtype, _) => { - // Both Spark and DataFusion use this heuristic. - // - https://github.com/apache/spark/blob/fcf636d9eb8d645c24be3db2d599aba2d7e2955a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/aggregate/Sum.scala#L66 - // - https://github.com/apache/datafusion/blob/4153adf2c0f6e317ef476febfdc834208bd46622/datafusion/functions-aggregate/src/sum.rs#L188 - let precision = u8::min(MAX_PRECISION, decimal_dtype.precision() + 10); - DType::Decimal( - DecimalDType::new(precision, decimal_dtype.scale()), - Nullable, - ) + DType::Decimal(sum_decimal_dtype(decimal_dtype), Nullable) } // Unsupported types _ => return None, diff --git a/vortex-array/src/arrays/decimal/compute/cast.rs b/vortex-array/src/arrays/decimal/compute/cast.rs index 80fafde5059..af636ca0f00 100644 --- a/vortex-array/src/arrays/decimal/compute/cast.rs +++ b/vortex-array/src/arrays/decimal/compute/cast.rs @@ -1,7 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +use num_traits::AsPrimitive; use num_traits::CheckedMul; +use num_traits::ToPrimitive as NumToPrimitive; use vortex_buffer::Buffer; use vortex_buffer::BufferMut; use vortex_compute::lane_kernels::IndexedSourceExt; @@ -19,11 +21,14 @@ use crate::IntoArray; use crate::array::ArrayView; use crate::arrays::Decimal; use crate::arrays::DecimalArray; +use crate::arrays::PrimitiveArray; use crate::dtype::BigCast; use crate::dtype::DType; use crate::dtype::DecimalDType; use crate::dtype::DecimalType; use crate::dtype::NativeDecimalType; +use crate::dtype::Nullability; +use crate::dtype::PType; use crate::dtype::i256; use crate::match_each_decimal_value_type; use crate::scalar::DecimalValue; @@ -77,16 +82,19 @@ impl CastKernel for Decimal { dtype: &DType, ctx: &mut ExecutionCtx, ) -> VortexResult> { - // Early return if not casting to decimal - let DType::Decimal(to_decimal_dtype, to_nullability) = dtype else { - return Ok(None); - }; let DType::Decimal(from_decimal_dtype, _) = array.dtype() else { vortex_panic!( "DecimalArray must have decimal dtype, got {:?}", array.dtype() ); }; + if let DType::Primitive(PType::F64, nullability) = dtype { + let scale = from_decimal_dtype.scale(); + return cast_to_f64(array, scale, *nullability, ctx).map(Some); + } + let DType::Decimal(to_decimal_dtype, to_nullability) = dtype else { + return Ok(None); + }; // If the dtype is exactly the same, return self if array.dtype() == dtype { @@ -143,6 +151,57 @@ impl CastKernel for Decimal { } } +fn cast_to_f64( + array: ArrayView<'_, Decimal>, + scale: i8, + nullability: Nullability, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let source_validity = array.validity()?; + let n = array.len(); + let mask = source_validity.execute_mask(n, ctx)?; + let validity = source_validity.cast_nullability(nullability, n, ctx)?; + + let scale: i32 = scale.as_(); + let inv_factor: f64 = 10f64.powi(-scale); + + let buffer = match &mask { + Mask::AllFalse(_) => BufferMut::::zeroed(n), + Mask::AllTrue(_) => match_each_decimal_value_type!(array.values_type(), |F| { + let values = array.buffer::(); + let values = values.as_slice(); + let mut out = BufferMut::::with_capacity(n); + values.map_into(&mut out.spare_capacity_mut()[..n], |v: F| { + to_f64_lossy::(v) * inv_factor + }); + // SAFETY: map_into wrote every lane before returning. + unsafe { out.set_len(n) }; + out + }), + Mask::Values(mask_values) => match_each_decimal_value_type!(array.values_type(), |F| { + let values = array.buffer::(); + let values = values.as_slice(); + let mut out = BufferMut::::with_capacity(n); + let write_result = values.try_map_masked_into( + mask_values.bit_buffer(), + &mut out.spare_capacity_mut()[..n], + |v: F| Some(to_f64_lossy::(v) * inv_factor), + ); + debug_assert!(write_result.is_ok()); + // SAFETY: try_map_masked_into wrote every lane before returning Ok. + unsafe { out.set_len(n) }; + out + }), + }; + + Ok(PrimitiveArray::new(buffer, validity).into_array()) +} + +#[inline] +fn to_f64_lossy(value: F) -> f64 { + NumToPrimitive::to_f64(&value).unwrap_or(f64::NAN) +} + fn cast_decimal_values( array: ArrayView<'_, Decimal>, from_decimal_dtype: DecimalDType, @@ -386,6 +445,7 @@ mod tests { use vortex_buffer::buffer; use super::upcast_decimal_values; + use crate::Canonical; use crate::IntoArray; use crate::VortexSessionExecute; use crate::array_session; @@ -398,6 +458,8 @@ mod tests { use crate::dtype::DecimalDType; use crate::dtype::DecimalType; use crate::dtype::Nullability; + use crate::dtype::PType; + use crate::scalar::Scalar; use crate::validity::Validity; #[test] @@ -769,4 +831,68 @@ mod tests { .contains("Cannot downcast decimal values") ); } + + #[test] + fn cast_decimal_f64() { + let dtype = DecimalDType::new(6, 2); + let array = DecimalArray::new(buffer![125i32, 250, -375], dtype, Validity::NonNullable); + let target = DType::Primitive(PType::F64, Nullability::NonNullable); + let casted = array.into_array().cast(target.clone()).unwrap(); + assert_eq!(casted.dtype(), &target); + + let mut ctx = array_session().create_execution_ctx(); + let primitive = casted + .execute::(&mut ctx) + .unwrap() + .into_primitive(); + assert_eq!(primitive.to_buffer::().as_ref(), &[1.25, 2.5, -3.75]); + } + + #[test] + fn cast_decimal_f64_null() { + let values = [Some(100i32), None, Some(-200)]; + let array = DecimalArray::from_option_iter(values, DecimalDType::new(6, 2)); + let target = DType::Primitive(PType::F64, Nullability::Nullable); + let casted = array.into_array().cast(target.clone()).unwrap(); + assert_eq!(casted.dtype(), &target); + + let mut ctx = array_session().create_execution_ctx(); + let primitive = casted + .execute::(&mut ctx) + .unwrap() + .into_primitive(); + + assert!(primitive.is_valid(0, &mut ctx).unwrap()); + assert!(!primitive.is_valid(1, &mut ctx).unwrap()); + assert!(primitive.is_valid(2, &mut ctx).unwrap()); + + assert_eq!( + primitive.execute_scalar(0, &mut ctx).unwrap(), + Scalar::from(1f64) + ); + assert_eq!( + primitive.execute_scalar(2, &mut ctx).unwrap(), + Scalar::from(-2f64) + ); + } + + #[test] + fn cast_decimal_f64_all_null() { + let dtype = DecimalDType::new(6, 2); + let buf = buffer![i32::MAX, i32::MIN, 12345]; + let array = DecimalArray::new(buf, dtype, Validity::AllInvalid); + let target = DType::Primitive(PType::F64, Nullability::Nullable); + let casted = array.into_array().cast(target.clone()).unwrap(); + assert_eq!(casted.dtype(), &target); + + let primitive = casted + .execute::(&mut array_session().create_execution_ctx()) + .unwrap() + .into_primitive(); + let mut ctx = array_session().create_execution_ctx(); + for i in 0..2 { + assert!(!primitive.is_valid(i, &mut ctx).unwrap()); + } + assert_eq!(primitive.to_buffer::().as_ref(), &[0.0, 0.0, 0.0]); + } } From a4a11a5f4af45d1a243575f29406d7ff03e54f13 Mon Sep 17 00:00:00 2001 From: Adam Gutglick Date: Tue, 7 Jul 2026 13:58:53 +0100 Subject: [PATCH 026/104] sccache cleanup (#8669) ## Rationale for this change Make better use of sccache across our GH actions workflows. ## What changes are included in this PR? 1. Makes sccache opt in 2. When sccache is used, make sure its properly configured with runs-on. ## What APIs are changed? Are there any user-facing changes? None --------- Signed-off-by: Adam Gutglick --- .github/actions/setup-prebuild/action.yml | 6 +- .github/actions/setup-rust/action.yml | 4 +- .github/workflows/bench-pr.yml | 1 + .github/workflows/bench.yml | 1 + .github/workflows/ci.yml | 110 ++++++++++++------ .github/workflows/claude-write.yml | 2 - .../workflows/close-fixed-fuzzer-issues.yml | 3 +- .github/workflows/codspeed.yml | 10 +- .github/workflows/compat-gen-upload.yml | 8 +- .github/workflows/compat-validation.yml | 4 +- .github/workflows/cuda.yaml | 12 +- .github/workflows/docs.yml | 1 - .github/workflows/fuzz-coverage.yml | 3 +- .github/workflows/fuzzer-fix-automation.yml | 1 - .../minimize_fuzz_corpus_workflow.yml | 3 +- .github/workflows/package.yml | 8 +- .github/workflows/publish-dry-runs.yml | 11 +- .github/workflows/publish.yml | 1 - .github/workflows/release-binaries.yml | 6 +- .github/workflows/run-fuzzer.yml | 3 +- .github/workflows/sql-benchmarks.yml | 1 + .github/workflows/wasm-fuzz.yml | 1 - .github/workflows/web.yml | 2 - 23 files changed, 127 insertions(+), 75 deletions(-) diff --git a/.github/actions/setup-prebuild/action.yml b/.github/actions/setup-prebuild/action.yml index 9cfc837b680..6fdd067fab4 100644 --- a/.github/actions/setup-prebuild/action.yml +++ b/.github/actions/setup-prebuild/action.yml @@ -17,9 +17,9 @@ inputs: description: "optional targets override (e.g. wasm32-unknown-unknown)" required: false enable-sccache: - description: "Should sccache be enabled, true by default." + description: "Should sccache be enabled." required: false - default: "true" + default: "false" runs: using: "composite" @@ -46,4 +46,4 @@ runs: toolchain: ${{ inputs.toolchain }} components: ${{ inputs.components }} targets: ${{ inputs.targets }} - enable-sccache: ${{ inputs.enable-sccache }} + enable-sccache: "false" diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml index 4f42d38167a..345a2d08f7e 100644 --- a/.github/actions/setup-rust/action.yml +++ b/.github/actions/setup-rust/action.yml @@ -17,9 +17,9 @@ inputs: description: "optional targets override (e.g. wasm32-unknown-unknown)" required: false enable-sccache: - description: "Should sccache be enabled, true by default." + description: "Should sccache be enabled." required: false - default: "true" + default: "false" runs: using: "composite" diff --git a/.github/workflows/bench-pr.yml b/.github/workflows/bench-pr.yml index 8223ea80a56..aad6caf2fc6 100644 --- a/.github/workflows/bench-pr.yml +++ b/.github/workflows/bench-pr.yml @@ -46,6 +46,7 @@ jobs: - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} + enable-sccache: ${{ github.event.pull_request.head.repo.fork == false && 'true' || 'false' }} - name: Install DuckDB run: | diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index fc59a8156ea..13cfa9050ba 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -60,6 +60,7 @@ jobs: - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} + enable-sccache: ${{ github.repository == 'vortex-data/vortex' && 'true' || 'false' }} - name: Install DuckDB run: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54a3960ae28..5e6451d22b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,12 +34,12 @@ jobs: duckdb-ready: name: "DuckDB libraries available in R2" needs: duckdb-mirror - if: ${{ !cancelled() }} + if: "!cancelled()" runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Verify DuckDB mirror - if: ${{ needs.duckdb-mirror.result == 'failure' }} + if: needs.duckdb-mirror.result == 'failure' run: | echo "DuckDB mirror failed; downstream builds would 404" exit 1 @@ -68,7 +68,7 @@ jobs: name: "Python (lint)" runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/tag=python-lint', github.run_id) + && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=python-lint', github.run_id) || 'ubuntu-latest' }} timeout-minutes: 10 steps: @@ -78,6 +78,8 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" # Use uvx for ruff to avoid building the Rust extension (saves ~4.5 min) - name: Python Lint - Format run: uvx ruff format --check . @@ -95,7 +97,7 @@ jobs: name: "Python (test)" runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-large/image=ubuntu24-full-x64-pre-v2/tag=python-test', github.run_id) + && format('runs-on={0}/runner=amd64-large/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=python-test', github.run_id) || 'ubuntu-latest' }} timeout-minutes: 30 env: @@ -108,6 +110,8 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" - name: Pytest - Vortex run: | @@ -135,8 +139,7 @@ jobs: python-cuda-test: name: "Python CUDA (test)" if: github.repository == 'vortex-data/vortex' - runs-on: >- - ${{ format('runs-on={0}/runner=gpu/tag=python-cuda-test', github.run_id) }} + runs-on: runs-on=${{ github.run_id }}/runner=gpu/extras=s3-cache/tag=python-cuda-test timeout-minutes: 30 env: RUST_LOG: "info,maturin=off,uv=debug" @@ -150,6 +153,7 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} components: cargo + enable-sccache: "true" - name: Pin rustup proxy to repository toolchain run: | TOOLCHAIN="$(grep '^channel' rust-toolchain.toml | cut -d '"' -f 2)" @@ -171,7 +175,7 @@ jobs: timeout-minutes: 30 runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-small/image=ubuntu24-full-x64-pre-v2/tag=rust-docs', github.run_id) + && format('runs-on={0}/runner=amd64-small/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=rust-docs', github.run_id) || 'ubuntu-latest' }} steps: - uses: runs-on/action@v2 @@ -180,6 +184,8 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" - name: Docs env: # required to make sure docs build for features @@ -196,7 +202,7 @@ jobs: timeout-minutes: 30 runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner={1}/image=ubuntu24-full-x64-pre-v2/tag={2}', github.run_id, matrix.config.runner, matrix.config.name) + && format('runs-on={0}/runner={1}/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag={2}', github.run_id, matrix.config.runner, matrix.config.name) || 'ubuntu-latest' }} env: # disable lints for build, they will be caught in Rust lint job. @@ -228,8 +234,10 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" - name: Install wasm32 target - if: ${{ matrix.config.target == 'wasm32-unknown-unknown' }} + if: matrix.config.target == 'wasm32-unknown-unknown' run: rustup target add wasm32-unknown-unknown - uses: ./.github/actions/check-rebuild with: @@ -244,7 +252,7 @@ jobs: timeout-minutes: 30 runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/tag=rust-min-deps', github.run_id) + && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=rust-min-deps', github.run_id) || 'ubuntu-latest' }} steps: - uses: runs-on/action@v2 @@ -253,6 +261,8 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" - run: cargo minimal-versions check --direct --workspace --ignore-private rust-lint: @@ -261,7 +271,7 @@ jobs: timeout-minutes: 30 runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-large/image=ubuntu24-full-x64-pre-v2/tag=rust-lint', github.run_id) + && format('runs-on={0}/runner=amd64-large/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=rust-lint', github.run_id) || 'ubuntu-latest' }} steps: - uses: runs-on/action@v2 @@ -270,6 +280,8 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" - name: Install nightly for fmt run: rustup toolchain install $NIGHTLY_TOOLCHAIN --component rustfmt - name: Rust Lint - Format @@ -339,7 +351,7 @@ jobs: timeout-minutes: 30 runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/tag=rust-lint-no-default', github.run_id) + && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=rust-lint-no-default', github.run_id) || 'ubuntu-latest' }} steps: - uses: runs-on/action@v2 @@ -348,28 +360,21 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" - name: Rust Lint - Clippy No Default Features shell: bash run: | cargo hack --no-dev-deps --ignore-private clippy --profile ci --no-default-features -- -D warnings - rust-test-other: - name: "Rust tests (${{ matrix.os }})" + rust-test-windows: + name: "Rust tests (windows-x64)" needs: duckdb-ready timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - os: windows-x64 - runner: runs-on=${{ github.run_id }}/pool=windows-x64-pre - fallback_runner: windows-latest - - os: linux-arm64 - runner: runs-on=${{ github.run_id }}/runner=arm64-medium/image=ubuntu24-full-arm64-pre-v2/tag=rust-test-linux-arm64 runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && matrix.runner - || matrix.fallback_runner }} + && format('runs-on={0}/pool=windows-x64-pre/extras=s3-cache', github.run_id) + || 'windows-latest' }} steps: - uses: runs-on/action@v2 if: github.repository == 'vortex-data/vortex' @@ -377,12 +382,12 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Setup (Windows) - if: matrix.os == 'windows-x64' run: | echo "C:\rust\cargo\bin" >> $env:GITHUB_PATH - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" - name: Rust Tests (Windows) - if: matrix.os == 'windows-x64' run: | cargo nextest run --cargo-profile ci --locked --workspace --all-features --no-fail-fast ` --exclude vortex-bench ` @@ -393,12 +398,33 @@ jobs: --exclude lance-bench --exclude datafusion-bench --exclude random-access-bench ` --exclude compress-bench --exclude xtask --exclude vortex-datafusion ` --exclude gpu-scan-cli --exclude vortex-sqllogictest - - name: Rust Tests (Other) - if: matrix.os != 'windows-x64' + + - name: Alert incident.io + if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/develop' + uses: ./.github/actions/alert-incident-io + with: + api-key: ${{ secrets.INCIDENT_IO_ALERT_TOKEN }} + alert-title: "Rust tests (windows-x64) failed on develop" + deduplication-key: ci-rust-test-windows-x64-failure + + rust-test-linux-arm64: + name: "Rust tests (linux-arm64)" + needs: duckdb-ready + if: github.repository == 'vortex-data/vortex' + timeout-minutes: 30 + runs-on: runs-on=${{ github.run_id }}/runner=arm64-medium/image=ubuntu24-full-arm64-pre-v2/extras=s3-cache/tag=rust-test-linux-arm64 + steps: + - uses: runs-on/action@v2 + with: + sccache: s3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" + - name: Rust Tests run: | cargo nextest run --cargo-profile ci --locked --workspace --all-features --no-fail-fast --exclude vortex-bench --exclude xtask --exclude vortex-sqllogictest - uses: ./.github/actions/check-rebuild - if: matrix.os != 'windows-x64' with: command: "cargo test --profile ci --locked --workspace --all-features --no-run --exclude vortex-bench --exclude xtask --exclude vortex-sqllogictest" @@ -407,14 +433,14 @@ jobs: uses: ./.github/actions/alert-incident-io with: api-key: ${{ secrets.INCIDENT_IO_ALERT_TOKEN }} - alert-title: "Rust tests (${{ matrix.os }}) failed on develop" - deduplication-key: ci-rust-test-${{ matrix.os }}-failure + alert-title: "Rust tests (linux-arm64) failed on develop" + deduplication-key: ci-rust-test-linux-arm64-failure build-java: name: "Java" runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/pool=amd64-medium-pre-v2/tag=java', github.run_id) + && format('runs-on={0}/pool=amd64-medium-pre-v2/extras=s3-cache/tag=java', github.run_id) || 'ubuntu-latest' }} timeout-minutes: 30 steps: @@ -424,6 +450,8 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" - run: ./gradlew javadoc working-directory: ./java - run: ./gradlew check @@ -451,7 +479,7 @@ jobs: timeout-minutes: 30 runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/tag=cxx-build', github.run_id) + && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=cxx-build', github.run_id) || 'ubuntu-latest' }} steps: - uses: runs-on/action@v2 @@ -460,6 +488,8 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" - name: Build and run C++ unit tests run: | mkdir -p vortex-cxx/build @@ -480,7 +510,7 @@ jobs: needs: duckdb-ready runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/tag=sql-logic-test', github.run_id) + && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=sql-logic-test', github.run_id) || 'ubuntu-latest' }} timeout-minutes: 30 steps: @@ -490,6 +520,8 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" - name: Run sqllogictest tests run: | ./vortex-sqllogictest/slt/tpch/generate_data.sh @@ -523,7 +555,7 @@ jobs: name: "Check generated source files are up to date" runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/tag=generated-files', github.run_id) + && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=generated-files', github.run_id) || 'ubuntu-latest' }} timeout-minutes: 30 steps: @@ -533,6 +565,8 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" - name: Install nightly for cbindgen macro expansion run: rustup toolchain install $NIGHTLY_TOOLCHAIN - name: "regenerate all .fbs/.proto Rust code" @@ -563,7 +597,7 @@ jobs: timeout-minutes: 10 runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/tag=cxx-build', github.run_id) + && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=cxx-build', github.run_id) || 'ubuntu-latest' }} steps: - uses: runs-on/action@v2 @@ -572,6 +606,8 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" - name: "regenerate FFI header file" run: | cargo +$NIGHTLY_TOOLCHAIN build --profile ci -p vortex-ffi diff --git a/.github/workflows/claude-write.yml b/.github/workflows/claude-write.yml index acf5add785c..2b1f73d6d51 100644 --- a/.github/workflows/claude-write.yml +++ b/.github/workflows/claude-write.yml @@ -187,8 +187,6 @@ jobs: - name: Setup Rust toolchain uses: ./.github/actions/setup-rust - with: - enable-sccache: "false" - name: Install uv uses: spiraldb/actions/.github/actions/setup-uv@a746510eafaa926484c354541cfc49b2ec06cc63 # 0.18.6 diff --git a/.github/workflows/close-fixed-fuzzer-issues.yml b/.github/workflows/close-fixed-fuzzer-issues.yml index ee203553ac7..9030809f5cd 100644 --- a/.github/workflows/close-fixed-fuzzer-issues.yml +++ b/.github/workflows/close-fixed-fuzzer-issues.yml @@ -27,7 +27,7 @@ jobs: target: [file_io, array_ops, compress_roundtrip] runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=arm64-medium/disk=large/tag=fuzzer-cleanup-{1}', github.run_id, matrix.target) + && format('runs-on={0}/runner=arm64-medium/disk=large/extras=s3-cache/tag=fuzzer-cleanup-{1}', github.run_id, matrix.target) || 'ubuntu-latest' }} timeout-minutes: 30 steps: @@ -42,6 +42,7 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} toolchain: ${{ env.NIGHTLY_TOOLCHAIN }} + enable-sccache: ${{ github.repository == 'vortex-data/vortex' && 'true' || 'false' }} - name: Install llvm uses: aminya/setup-cpp@v1 diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 2d04d8d35f3..8c55eb71f7c 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -58,7 +58,7 @@ jobs: timeout-minutes: 30 runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/tag=bench-codspeed-{1}', github.run_id, matrix.shard) + && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=bench-codspeed-{1}', github.run_id, matrix.shard) || 'ubuntu-latest' }} steps: - uses: runs-on/action@v2 @@ -69,6 +69,8 @@ jobs: - name: Setup benchmark environment run: sudo bash scripts/setup-benchmark.sh - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: ${{ github.repository == 'vortex-data/vortex' && 'true' || 'false' }} - uses: ./.github/actions/system-info - run: | sudo apt-get update @@ -98,7 +100,7 @@ jobs: name: "Build Codspeed CUDA benchmarks" timeout-minutes: 30 runs-on: >- - runs-on=${{ github.run_id }}/family=g5/cpu=8/image=ubuntu24-gpu-x64/tag=bench-codspeed-cuda-build + runs-on=${{ github.run_id }}/family=g5/cpu=8/image=ubuntu24-gpu-x64/extras=s3-cache/tag=bench-codspeed-cuda-build steps: - uses: runs-on/action@v2 with: @@ -107,6 +109,7 @@ jobs: - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} + enable-sccache: "true" - name: Install Codspeed uses: taiki-e/cache-cargo-install-action@66c9585ef5ca780ee69399975a5e911f47905995 with: @@ -142,7 +145,7 @@ jobs: name: "Benchmark with Codspeed (CUDA Shard #${{ matrix.shard }} - ${{ matrix.name }})" timeout-minutes: 30 runs-on: >- - runs-on=${{ github.run_id }}/family=g5/cpu=8/image=ubuntu24-gpu-x64/tag=bench-codspeed-cuda-${{ matrix.shard }} + runs-on=${{ github.run_id }}/family=g5/cpu=8/image=ubuntu24-gpu-x64/extras=s3-cache/tag=bench-codspeed-cuda-${{ matrix.shard }} steps: - uses: runs-on/action@v2 with: @@ -151,6 +154,7 @@ jobs: - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} + enable-sccache: "true" - name: Display NVIDIA SMI details run: | nvidia-smi diff --git a/.github/workflows/compat-gen-upload.yml b/.github/workflows/compat-gen-upload.yml index 8dd4cd1999e..bfc3c580307 100644 --- a/.github/workflows/compat-gen-upload.yml +++ b/.github/workflows/compat-gen-upload.yml @@ -25,7 +25,7 @@ jobs: dry-run: runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/tag=compat-gen-dry-run', github.run_id) + && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=compat-gen-dry-run', github.run_id) || 'ubuntu-latest' }} timeout-minutes: 30 permissions: @@ -42,6 +42,8 @@ jobs: with: fetch-depth: 0 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: ${{ github.repository == 'vortex-data/vortex' && 'true' || 'false' }} - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 @@ -76,7 +78,7 @@ jobs: if: inputs.confirm_upload == 'yes' runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/tag=compat-gen-upload', github.run_id) + && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=compat-gen-upload', github.run_id) || 'ubuntu-latest' }} timeout-minutes: 30 environment: compat-upload @@ -92,6 +94,8 @@ jobs: with: fetch-depth: 0 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: ${{ github.repository == 'vortex-data/vortex' && 'true' || 'false' }} - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 diff --git a/.github/workflows/compat-validation.yml b/.github/workflows/compat-validation.yml index 8732bdfbf6d..60741d50620 100644 --- a/.github/workflows/compat-validation.yml +++ b/.github/workflows/compat-validation.yml @@ -25,7 +25,7 @@ jobs: compat-test: runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/tag=compat-validation', github.run_id) + && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=compat-validation', github.run_id) || 'ubuntu-latest' }} timeout-minutes: 30 steps: @@ -35,6 +35,8 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: ${{ github.repository == 'vortex-data/vortex' && 'true' || 'false' }} - name: Run compat tests run: | MODE="${{ inputs.mode || 'last' }}" diff --git a/.github/workflows/cuda.yaml b/.github/workflows/cuda.yaml index 2bd9afbbdf1..004ca7d3a49 100644 --- a/.github/workflows/cuda.yaml +++ b/.github/workflows/cuda.yaml @@ -54,7 +54,7 @@ jobs: needs.changes.outputs.run-cuda-san == 'true' name: "CUDA build & lint" timeout-minutes: 30 - runs-on: runs-on=${{ github.run_id }}/runner=gpu/tag=cuda-build + runs-on: runs-on=${{ github.run_id }}/runner=gpu/extras=s3-cache/tag=cuda-build steps: - uses: runs-on/action@v2 with: @@ -63,6 +63,7 @@ jobs: - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} + enable-sccache: "true" - uses: ./.github/actions/check-rebuild with: command: >- @@ -88,7 +89,7 @@ jobs: needs.changes.outputs.run-cuda-san == 'true' name: "CUDA tests" timeout-minutes: 30 - runs-on: runs-on=${{ github.run_id }}/runner=gpu/tag=cuda-tests + runs-on: runs-on=${{ github.run_id }}/runner=gpu/extras=s3-cache/tag=cuda-tests steps: - uses: runs-on/action@v2 with: @@ -102,6 +103,7 @@ jobs: - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} + enable-sccache: "true" - name: Install uv uses: spiraldb/actions/.github/actions/setup-uv@a746510eafaa926484c354541cfc49b2ec06cc63 # 0.18.6 with: @@ -144,7 +146,7 @@ jobs: needs.changes.outputs.run-cuda-san == 'true' name: "CUDA tests (${{ matrix.sanitizer }})" timeout-minutes: 30 - runs-on: runs-on=${{ github.run_id }}/runner=gpu/tag=cuda-test-sanitizer + runs-on: runs-on=${{ github.run_id }}/runner=gpu/extras=s3-cache/tag=cuda-test-sanitizer strategy: fail-fast: false matrix: @@ -171,6 +173,7 @@ jobs: - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} + enable-sccache: "true" - name: Build tests run: cargo test --profile ci --locked -p vortex-cuda --all-features --target x86_64-unknown-linux-gnu --no-run - name: "CUDA - ${{ matrix.sanitizer }}" @@ -185,7 +188,7 @@ jobs: needs.changes.outputs.run-cuda-san == 'true' name: "CUDA tests (cudf)" timeout-minutes: 30 - runs-on: runs-on=${{ github.run_id }}/runner=gpu/tag=cuda-test-cudf + runs-on: runs-on=${{ github.run_id }}/runner=gpu/extras=s3-cache/tag=cuda-test-cudf steps: - uses: runs-on/action@v2 with: @@ -199,6 +202,7 @@ jobs: - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} + enable-sccache: "true" - name: Build cudf test library run: cargo build --profile ci --locked -p vortex-test-e2e-cuda --target x86_64-unknown-linux-gnu - name: Download and run cudf-test-harness diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1a0d376f319..cee26f919d6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -23,7 +23,6 @@ jobs: - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} - enable-sccache: "false" - name: Set up JDK 17 uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 with: diff --git a/.github/workflows/fuzz-coverage.yml b/.github/workflows/fuzz-coverage.yml index fd6ca8aed1b..88bfb7d01b7 100644 --- a/.github/workflows/fuzz-coverage.yml +++ b/.github/workflows/fuzz-coverage.yml @@ -18,7 +18,7 @@ jobs: timeout-minutes: 120 runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=arm64-medium/disk=large', github.run_id) + && format('runs-on={0}/runner=arm64-medium/disk=large/extras=s3-cache', github.run_id) || 'ubuntu-latest' }} steps: - uses: runs-on/action@v2 @@ -33,6 +33,7 @@ jobs: repo-token: ${{ secrets.GITHUB_TOKEN }} toolchain: ${{ env.NIGHTLY_TOOLCHAIN }} components: clippy, rustfmt, llvm-tools + enable-sccache: ${{ github.repository == 'vortex-data/vortex' && 'true' || 'false' }} - name: Ensure llvm-tools are installed run: | diff --git a/.github/workflows/fuzzer-fix-automation.yml b/.github/workflows/fuzzer-fix-automation.yml index 7b8e7012511..0db67516346 100644 --- a/.github/workflows/fuzzer-fix-automation.yml +++ b/.github/workflows/fuzzer-fix-automation.yml @@ -86,7 +86,6 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} toolchain: ${{ env.NIGHTLY_TOOLCHAIN }} - enable-sccache: "false" - name: Install llvm uses: aminya/setup-cpp@v1 diff --git a/.github/workflows/minimize_fuzz_corpus_workflow.yml b/.github/workflows/minimize_fuzz_corpus_workflow.yml index 00f9ba73b84..b0c2fc58d50 100644 --- a/.github/workflows/minimize_fuzz_corpus_workflow.yml +++ b/.github/workflows/minimize_fuzz_corpus_workflow.yml @@ -36,7 +36,7 @@ jobs: name: "Minimize ${{ inputs.fuzz_target }}" runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=arm64-medium/disk=large/tag={1}-minimize', github.run_id, inputs.fuzz_target) + && format('runs-on={0}/runner=arm64-medium/disk=large/extras=s3-cache/tag={1}-minimize', github.run_id, inputs.fuzz_target) || 'ubuntu-latest' }} timeout-minutes: 240 steps: @@ -57,6 +57,7 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} toolchain: ${{ env.NIGHTLY_TOOLCHAIN }} + enable-sccache: ${{ github.repository == 'vortex-data/vortex' && 'true' || 'false' }} - name: Install cargo-fuzz uses: taiki-e/cache-cargo-install-action@66c9585ef5ca780ee69399975a5e911f47905995 diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 3de9ac5c81d..bded979fdf8 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -39,7 +39,6 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} targets: ${{ matrix.target.target }} - enable-sccache: "false" - name: Install cargo-edit uses: taiki-e/cache-cargo-install-action@66c9585ef5ca780ee69399975a5e911f47905995 @@ -124,7 +123,6 @@ jobs: - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} - enable-sccache: "false" - run: cargo build --release --package vortex-jni - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -140,9 +138,9 @@ jobs: matrix: include: - target: x86_64-unknown-linux-gnu - runner: runs-on=${{ github.run_id }}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/tag=prepare-java-linux-amd64 + runner: runs-on=${{ github.run_id }}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=prepare-java-linux-amd64 - target: aarch64-unknown-linux-gnu - runner: runs-on=${{ github.run_id }}/runner=arm64-medium/image=ubuntu24-full-arm64-pre-v2/tag=prepare-java-linux-arm64 + runner: runs-on=${{ github.run_id }}/runner=arm64-medium/image=ubuntu24-full-arm64-pre-v2/extras=s3-cache/tag=prepare-java-linux-arm64 runs-on: ${{ matrix.runner }} steps: - uses: runs-on/action@v2 @@ -157,6 +155,8 @@ jobs: distribution: "corretto" java-version: "17" - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: ${{ github.repository == 'vortex-data/vortex' && 'true' || 'false' }} - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 - name: Install cargo-zigbuild uses: taiki-e/cache-cargo-install-action@66c9585ef5ca780ee69399975a5e911f47905995 diff --git a/.github/workflows/publish-dry-runs.yml b/.github/workflows/publish-dry-runs.yml index 49f539a19e4..77ed1a15526 100644 --- a/.github/workflows/publish-dry-runs.yml +++ b/.github/workflows/publish-dry-runs.yml @@ -35,7 +35,6 @@ jobs: - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} - enable-sccache: "false" - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 - name: Install uv uses: spiraldb/actions/.github/actions/setup-uv@a746510eafaa926484c354541cfc49b2ec06cc63 # 0.18.6 @@ -70,9 +69,9 @@ jobs: matrix: include: - target: x86_64-unknown-linux-gnu - runner: runs-on=${{ github.run_id }}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/tag=check-java-publish-build-amd64 + runner: runs-on=${{ github.run_id }}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=check-java-publish-build-amd64 - target: aarch64-unknown-linux-gnu - runner: runs-on=${{ github.run_id }}/runner=arm64-medium/image=ubuntu24-full-arm64-pre-v2/tag=check-java-publish-build-arm64 + runner: runs-on=${{ github.run_id }}/runner=arm64-medium/image=ubuntu24-full-arm64-pre-v2/extras=s3-cache/tag=check-java-publish-build-arm64 runs-on: ${{ matrix.runner }} steps: - uses: runs-on/action@v2 @@ -87,6 +86,8 @@ jobs: distribution: "corretto" java-version: "17" - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: ${{ github.repository == 'vortex-data/vortex' && 'true' || 'false' }} - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 - name: Install cargo-zigbuild uses: taiki-e/cache-cargo-install-action@66c9585ef5ca780ee69399975a5e911f47905995 @@ -105,7 +106,7 @@ jobs: timeout-minutes: 30 runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-xsmall/image=ubuntu24-full-x64-pre-v2/tag=rust-publish-dry-run', github.run_id) + && format('runs-on={0}/runner=amd64-xsmall/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=rust-publish-dry-run', github.run_id) || 'ubuntu-latest' }} steps: - uses: runs-on/action@v2 @@ -114,6 +115,8 @@ jobs: sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: ${{ github.repository == 'vortex-data/vortex' && 'true' || 'false' }} - name: Install cargo-edit uses: taiki-e/cache-cargo-install-action@66c9585ef5ca780ee69399975a5e911f47905995 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aba77190690..fbc76722d1c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,7 +29,6 @@ jobs: - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} - enable-sccache: "false" - name: Install cargo-edit uses: taiki-e/cache-cargo-install-action@66c9585ef5ca780ee69399975a5e911f47905995 with: diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index dcedec293c3..eb933f8a0c8 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -31,11 +31,11 @@ jobs: # cargo-zigbuild with glibc 2.31 pinning, matching the Python wheel # and JNI builds. - target: aarch64-unknown-linux-gnu - runner: runs-on=${{ github.run_id }}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/tag=release-vx-aarch64-linux + runner: runs-on=${{ github.run_id }}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=release-vx-aarch64-linux fallback_runner: ubuntu-24.04 archive: tgz - target: x86_64-unknown-linux-gnu - runner: runs-on=${{ github.run_id }}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/tag=release-vx-amd64-linux + runner: runs-on=${{ github.run_id }}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=release-vx-amd64-linux fallback_runner: ubuntu-24.04 archive: tgz steps: @@ -52,7 +52,7 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} targets: ${{ matrix.target }} - enable-sccache: ${{ contains(matrix.target, 'linux') && 'true' || 'false' }} + enable-sccache: ${{ github.repository == 'vortex-data/vortex' && contains(matrix.target, 'linux') && 'true' || 'false' }} - name: Setup Zig if: contains(matrix.target, 'linux') diff --git a/.github/workflows/run-fuzzer.yml b/.github/workflows/run-fuzzer.yml index e563dbefbee..35effdc6e59 100644 --- a/.github/workflows/run-fuzzer.yml +++ b/.github/workflows/run-fuzzer.yml @@ -64,7 +64,7 @@ jobs: timeout-minutes: 370 # 6 hours 10 minutes runs-on: >- ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner={1}/disk=large/tag={2}-fuzz', github.run_id, inputs.runner, inputs.fuzz_name || inputs.fuzz_target) + && format('runs-on={0}/runner={1}/disk=large/extras=s3-cache/tag={2}-fuzz', github.run_id, inputs.runner, inputs.fuzz_name || inputs.fuzz_target) || 'ubuntu-latest' }} outputs: crashes_found: ${{ steps.check.outputs.crashes_found }} @@ -82,6 +82,7 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} toolchain: ${{ env.NIGHTLY_TOOLCHAIN }} + enable-sccache: ${{ github.repository == 'vortex-data/vortex' && 'true' || 'false' }} - name: Install llvm uses: aminya/setup-cpp@v1 diff --git a/.github/workflows/sql-benchmarks.yml b/.github/workflows/sql-benchmarks.yml index b8c2d08f64f..0f9fdcb9eb1 100644 --- a/.github/workflows/sql-benchmarks.yml +++ b/.github/workflows/sql-benchmarks.yml @@ -541,6 +541,7 @@ jobs: - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} + enable-sccache: ${{ (inputs.mode != 'pr' || github.event.pull_request.head.repo.fork == false) && 'true' || 'false' }} - name: Install uv uses: spiraldb/actions/.github/actions/setup-uv@a746510eafaa926484c354541cfc49b2ec06cc63 # 0.18.6 with: diff --git a/.github/workflows/wasm-fuzz.yml b/.github/workflows/wasm-fuzz.yml index d61b3b60598..30a688447f9 100644 --- a/.github/workflows/wasm-fuzz.yml +++ b/.github/workflows/wasm-fuzz.yml @@ -33,7 +33,6 @@ jobs: toolchain: ${{ env.NIGHTLY_TOOLCHAIN }} targets: "wasm32-wasip1" components: "rust-src" - enable-sccache: "false" - name: Build WASM fuzz target run: | diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index d03a2b45d14..7ba67e2b245 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -48,7 +48,6 @@ jobs: - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} - enable-sccache: "false" - name: Install wasm-pack run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh working-directory: . @@ -75,7 +74,6 @@ jobs: - uses: ./.github/actions/setup-rust with: repo-token: ${{ secrets.GITHUB_TOKEN }} - enable-sccache: "false" - name: Install wasm-pack run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh working-directory: . From 0d503423cae449fe2b816294157e8e77947007d8 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Tue, 7 Jul 2026 14:12:42 +0100 Subject: [PATCH 027/104] Replace ToCanonical trait usages with execute (#8609) This also removes ArrayBuilder::extend_from_array and moves that logic in to ArrayRef::append_to_builder Plumb ExecutionCtx through more methods instead of creating fresh one. Fixes: #3235 --- clippy.toml | 9 + encodings/alp/src/alp/compute/cast.rs | 2 +- encodings/alp/src/alp/compute/filter.rs | 2 +- encodings/alp/src/alp/compute/mask.rs | 4 +- encodings/alp/src/alp/compute/take.rs | 2 +- encodings/alp/src/alp_rd/compute/cast.rs | 7 +- encodings/alp/src/alp_rd/compute/filter.rs | 4 + encodings/alp/src/alp_rd/compute/mask.rs | 15 + encodings/alp/src/alp_rd/compute/take.rs | 4 + encodings/bytebool/src/compute.rs | 16 +- encodings/datetime-parts/src/compute/cast.rs | 5 +- .../datetime-parts/src/compute/filter.rs | 4 +- encodings/datetime-parts/src/compute/take.rs | 5 +- .../src/decimal_byte_parts/compute/cast.rs | 5 +- .../src/decimal_byte_parts/compute/filter.rs | 12 +- encodings/experimental/onpair/src/array.rs | 14 +- .../src/bitpacking/array/bitpack_compress.rs | 2 +- .../fastlanes/src/bitpacking/compute/cast.rs | 2 +- .../src/bitpacking/compute/filter.rs | 6 +- .../fastlanes/src/bitpacking/compute/take.rs | 4 +- encodings/fastlanes/src/delta/compute/cast.rs | 5 +- encodings/fastlanes/src/for/compute/cast.rs | 2 +- encodings/fastlanes/src/for/compute/mod.rs | 24 +- encodings/fastlanes/src/rle/compute/cast.rs | 2 +- encodings/fsst/src/array.rs | 14 +- encodings/fsst/src/canonical.rs | 2 +- encodings/fsst/src/compute/cast.rs | 2 +- encodings/fsst/src/compute/mod.rs | 2 +- encodings/pco/src/compute/cast.rs | 2 +- encodings/runend/src/arbitrary.rs | 118 ++++--- encodings/runend/src/compute/cast.rs | 2 +- encodings/runend/src/compute/take.rs | 4 +- encodings/sequence/src/compute/cast.rs | 2 +- encodings/sequence/src/compute/filter.rs | 7 +- encodings/sequence/src/compute/take.rs | 9 +- encodings/sparse/src/compute/cast.rs | 2 +- encodings/sparse/src/compute/filter.rs | 2 + encodings/sparse/src/compute/mod.rs | 2 + encodings/sparse/src/compute/take.rs | 4 +- encodings/sparse/src/lib.rs | 2 +- encodings/zigzag/src/compute/cast.rs | 2 +- encodings/zigzag/src/compute/mod.rs | 12 +- encodings/zstd/src/compute/cast.rs | 2 +- encodings/zstd/src/zstd_buffers.rs | 14 +- fuzz/src/array/mod.rs | 55 +-- fuzz/src/compress.rs | 9 +- .../benches/dict_unreferenced_mask.rs | 19 +- .../benches/listview_builder_extend.rs | 14 +- vortex-array/benches/take_patches.rs | 32 +- vortex-array/benches/varbinview_compact.rs | 49 ++- .../fns/uncompressed_size_in_bytes/mod.rs | 10 +- vortex-array/src/array/mod.rs | 2 +- vortex-array/src/array/vtable/mod.rs | 3 +- vortex-array/src/arrays/arbitrary.rs | 6 +- vortex-array/src/arrays/bool/compute/cast.rs | 2 +- .../src/arrays/bool/compute/fill_null.rs | 9 +- .../src/arrays/bool/compute/filter.rs | 5 +- vortex-array/src/arrays/bool/compute/mask.rs | 7 +- vortex-array/src/arrays/bool/compute/take.rs | 11 +- vortex-array/src/arrays/bool/vtable/mod.rs | 10 +- .../src/arrays/bool/vtable/operations.rs | 19 +- vortex-array/src/arrays/chunked/array.rs | 50 ++- .../src/arrays/chunked/compute/cast.rs | 2 +- .../src/arrays/chunked/compute/filter.rs | 7 +- .../src/arrays/chunked/compute/mask.rs | 7 +- .../src/arrays/chunked/compute/take.rs | 21 +- .../src/arrays/chunked/compute/zip.rs | 7 +- .../src/arrays/chunked/paired_chunks.rs | 20 +- vortex-array/src/arrays/chunked/tests.rs | 49 +-- .../src/arrays/chunked/vtable/canonical.rs | 2 +- vortex-array/src/arrays/chunked/vtable/mod.rs | 13 +- vortex-array/src/arrays/constant/arbitrary.rs | 6 +- .../src/arrays/constant/compute/cast.rs | 6 +- .../src/arrays/constant/compute/mod.rs | 32 +- .../src/arrays/constant/compute/take.rs | 17 +- .../src/arrays/constant/vtable/mod.rs | 2 +- vortex-array/src/arrays/datetime/test.rs | 9 +- .../src/arrays/decimal/compute/cast.rs | 74 +++-- .../src/arrays/decimal/compute/fill_null.rs | 18 +- .../src/arrays/decimal/compute/take.rs | 5 +- vortex-array/src/arrays/decimal/vtable/mod.rs | 13 + vortex-array/src/arrays/dict/arbitrary.rs | 1 - vortex-array/src/arrays/dict/array.rs | 42 +-- vortex-array/src/arrays/dict/compute/cast.rs | 2 +- .../src/arrays/dict/compute/is_constant.rs | 2 +- .../src/arrays/dict/compute/min_max.rs | 2 +- vortex-array/src/arrays/dict/compute/mod.rs | 18 +- vortex-array/src/arrays/dict/compute/rules.rs | 5 +- .../src/arrays/extension/compute/cast.rs | 2 +- .../src/arrays/extension/compute/mod.rs | 17 +- .../src/arrays/extension/compute/rules.rs | 17 +- .../src/arrays/extension/vtable/mod.rs | 13 + .../src/arrays/filter/execute/bool.rs | 17 +- .../src/arrays/filter/execute/decimal.rs | 12 +- .../arrays/filter/execute/fixed_size_list.rs | 10 +- .../src/arrays/filter/execute/listview.rs | 10 +- vortex-array/src/arrays/filter/execute/mod.rs | 10 +- .../src/arrays/filter/execute/primitive.rs | 17 +- .../src/arrays/filter/execute/struct_.rs | 12 +- .../src/arrays/filter/execute/varbinview.rs | 42 ++- vortex-array/src/arrays/filter/vtable.rs | 2 +- .../src/arrays/fixed_size_list/array.rs | 10 - .../arrays/fixed_size_list/tests/filter.rs | 2 +- .../arrays/fixed_size_list/tests/nested.rs | 57 ++-- .../src/arrays/fixed_size_list/tests/take.rs | 10 +- .../src/arrays/fixed_size_list/vtable/mod.rs | 13 + vortex-array/src/arrays/list/compute/cast.rs | 2 +- vortex-array/src/arrays/list/compute/mod.rs | 10 +- vortex-array/src/arrays/list/compute/take.rs | 24 +- vortex-array/src/arrays/list/test_harness.rs | 9 +- vortex-array/src/arrays/list/vtable/mod.rs | 9 + vortex-array/src/arrays/listview/array.rs | 34 +- vortex-array/src/arrays/listview/mod.rs | 3 +- vortex-array/src/arrays/listview/rebuild.rs | 6 +- .../src/arrays/listview/tests/basic.rs | 21 +- .../src/arrays/listview/tests/filter.rs | 2 +- .../src/arrays/listview/tests/operations.rs | 47 +-- .../src/arrays/listview/tests/take.rs | 2 +- .../src/arrays/listview/vtable/mod.rs | 9 + .../src/arrays/masked/compute/filter.rs | 7 +- .../src/arrays/masked/compute/mask.rs | 7 +- .../src/arrays/masked/compute/take.rs | 7 +- vortex-array/src/arrays/masked/tests.rs | 10 +- vortex-array/src/arrays/null/compute/cast.rs | 5 +- vortex-array/src/arrays/null/compute/mod.rs | 48 ++- vortex-array/src/arrays/null/compute/take.rs | 9 +- vortex-array/src/arrays/null/mod.rs | 15 + vortex-array/src/arrays/patched/vtable/mod.rs | 3 +- .../src/arrays/primitive/array/patch.rs | 5 +- .../src/arrays/primitive/array/top_value.rs | 13 +- .../src/arrays/primitive/compute/cast.rs | 54 +-- .../src/arrays/primitive/compute/fill_null.rs | 21 +- .../src/arrays/primitive/compute/mask.rs | 7 +- .../src/arrays/primitive/compute/take/mod.rs | 18 +- vortex-array/src/arrays/primitive/tests.rs | 12 +- .../src/arrays/primitive/vtable/mod.rs | 3 +- .../src/arrays/scalar_fn/vtable/validity.rs | 28 +- .../src/arrays/struct_/compute/cast.rs | 2 +- .../src/arrays/struct_/compute/mod.rs | 8 + vortex-array/src/arrays/struct_/vtable/mod.rs | 13 + .../src/arrays/varbin/compute/cast.rs | 2 +- .../src/arrays/varbin/compute/compare.rs | 23 +- .../src/arrays/varbin/compute/filter.rs | 10 +- .../src/arrays/varbin/compute/mask.rs | 12 +- .../src/arrays/varbin/compute/take.rs | 5 +- .../src/arrays/varbin/vtable/canonical.rs | 21 +- vortex-array/src/arrays/varbinview/compact.rs | 73 ++-- .../src/arrays/varbinview/compute/cast.rs | 2 +- .../src/arrays/varbinview/compute/mask.rs | 4 + .../src/arrays/varbinview/compute/mod.rs | 5 +- .../src/arrays/varbinview/compute/take.rs | 5 +- .../src/arrays/varbinview/compute/zip.rs | 6 +- .../src/arrays/varbinview/vtable/mod.rs | 10 +- vortex-array/src/arrow/executor/byte.rs | 34 +- vortex-array/src/arrow/executor/byte_view.rs | 2 +- vortex-array/src/builders/bool.rs | 23 +- vortex-array/src/builders/decimal.rs | 57 ++-- vortex-array/src/builders/extension.rs | 23 +- vortex-array/src/builders/fixed_size_list.rs | 140 ++++---- vortex-array/src/builders/list.rs | 312 ++++++++++++------ vortex-array/src/builders/listview.rs | 201 +++++++---- vortex-array/src/builders/mod.rs | 65 ++-- vortex-array/src/builders/null.rs | 7 +- vortex-array/src/builders/primitive.rs | 15 +- vortex-array/src/builders/struct_.rs | 47 ++- vortex-array/src/builders/tests.rs | 65 ++-- vortex-array/src/builders/varbinview.rs | 27 +- vortex-array/src/canonical.rs | 2 +- vortex-array/src/compute/conformance/cast.rs | 169 +++++----- .../src/compute/conformance/filter.rs | 51 ++- vortex-array/src/compute/conformance/mask.rs | 83 +++-- vortex-array/src/compute/conformance/take.rs | 112 +++---- vortex-array/src/display/mod.rs | 6 +- vortex-array/src/executor.rs | 6 +- vortex-array/src/expr/exprs.rs | 48 ++- vortex-array/src/patches.rs | 37 ++- .../src/scalar_fn/fns/binary/boolean.rs | 11 +- .../src/scalar_fn/fns/list_contains/mod.rs | 32 +- vortex-array/src/scalar_fn/fns/merge.rs | 41 ++- vortex-array/src/scalar_fn/fns/not/mod.rs | 13 +- vortex-array/src/scalar_fn/fns/pack.rs | 50 ++- vortex-array/src/scalar_fn/fns/select.rs | 22 +- .../src/scalar_fn/fns/variant_get/mod.rs | 2 +- vortex-btrblocks/src/schemes/binary/zstd.rs | 5 +- vortex-btrblocks/src/schemes/string/zstd.rs | 5 +- vortex-cxx/src/read.rs | 7 +- vortex-file/src/tests.rs | 6 +- vortex-layout/src/layouts/file_stats.rs | 22 +- vortex-layout/src/layouts/zoned/builder.rs | 10 +- vortex-layout/src/layouts/zoned/writer.rs | 5 +- vortex-python/src/iter/mod.rs | 3 +- 191 files changed, 2237 insertions(+), 1510 deletions(-) diff --git a/clippy.toml b/clippy.toml index 08c0b720379..73f74ca35f5 100644 --- a/clippy.toml +++ b/clippy.toml @@ -18,4 +18,13 @@ disallowed-methods = [ { path = "vortex_session::registry::Id::new", reason = "Interning a static id on every call grabs the interner lock (#8380). Use a `CachedId` static for static ids; for a dynamic string, annotate the call with `#[expect(clippy::disallowed_methods)]`.", allow-invalid = true }, { path = "vortex_session::registry::Id::new_static", reason = "Interning a static id on every call grabs the interner lock; use a `CachedId` static instead (#8380).", allow-invalid = true }, { path = "vortex_array::legacy_session", reason = "Relies on the hidden global session; thread an explicit `VortexSession`/`ExecutionCtx` through instead" }, + { path = "vortex_array::ToCanonical::to_null", reason = "This function doesn't take the context, use `array.execute::(ctx)` instead", allow-invalid = true }, + { path = "vortex_array::ToCanonical::to_bool", reason = "This function doesn't take the context, use `array.execute::(ctx)` instead", allow-invalid = true }, + { path = "vortex_array::ToCanonical::to_primitive", reason = "This function doesn't take the context, use `array.execute::(ctx)` instead", allow-invalid = true }, + { path = "vortex_array::ToCanonical::to_decimal", reason = "This function doesn't take the context, use `array.execute::(ctx)` instead", allow-invalid = true }, + { path = "vortex_array::ToCanonical::to_struct", reason = "This function doesn't take the context, use `array.execute::(ctx)` instead", allow-invalid = true }, + { path = "vortex_array::ToCanonical::to_listview", reason = "This function doesn't take the context, use `array.execute::(ctx)` instead", allow-invalid = true }, + { path = "vortex_array::ToCanonical::to_fixed_size_list", reason = "This function doesn't take the context, use `array.execute::(ctx)` instead", allow-invalid = true }, + { path = "vortex_array::ToCanonical::to_varbinview", reason = "This function doesn't take the context, use `array.execute::(ctx)` instead", allow-invalid = true }, + { path = "vortex_array::ToCanonical::to_extension", reason = "This function doesn't take the context, use `array.execute::(ctx)` instead", allow-invalid = true }, ] diff --git a/encodings/alp/src/alp/compute/cast.rs b/encodings/alp/src/alp/compute/cast.rs index d77a7257544..324500889ee 100644 --- a/encodings/alp/src/alp/compute/cast.rs +++ b/encodings/alp/src/alp/compute/cast.rs @@ -154,7 +154,7 @@ mod tests { let array_primitive = array.execute::(&mut ctx)?; let alp = alp_encode(array_primitive.as_view(), None, &mut ctx).vortex_expect("cannot fail"); - test_cast_conformance(&alp.into_array()); + test_cast_conformance(&alp.into_array(), &mut ctx); Ok(()) } diff --git a/encodings/alp/src/alp/compute/filter.rs b/encodings/alp/src/alp/compute/filter.rs index f38a87f19b0..8c7212cdeab 100644 --- a/encodings/alp/src/alp/compute/filter.rs +++ b/encodings/alp/src/alp/compute/filter.rs @@ -65,6 +65,6 @@ mod test { let mut ctx = array_session().create_execution_ctx(); let array_primitive = array.execute::(&mut ctx).unwrap(); let alp = alp_encode(array_primitive.as_view(), None, &mut ctx).unwrap(); - test_filter_conformance(&alp.into_array()); + test_filter_conformance(&alp.into_array(), &mut ctx); } } diff --git a/encodings/alp/src/alp/compute/mask.rs b/encodings/alp/src/alp/compute/mask.rs index 2a528987143..37f912e5eca 100644 --- a/encodings/alp/src/alp/compute/mask.rs +++ b/encodings/alp/src/alp/compute/mask.rs @@ -76,7 +76,7 @@ mod test { let mut ctx = array_session().create_execution_ctx(); let array_primitive = array.execute::(&mut ctx).unwrap(); let alp = alp_encode(array_primitive.as_view(), None, &mut ctx).unwrap(); - test_mask_conformance(&alp.into_array()); + test_mask_conformance(&alp.into_array(), &mut ctx); } #[test] @@ -90,7 +90,7 @@ mod test { let array = PrimitiveArray::from_iter(values); let alp = alp_encode(array.as_view(), None, &mut ctx).unwrap(); assert!(alp.patches().is_some(), "expected patches"); - test_mask_conformance(&alp.into_array()); + test_mask_conformance(&alp.into_array(), &mut ctx); } #[test] diff --git a/encodings/alp/src/alp/compute/take.rs b/encodings/alp/src/alp/compute/take.rs index 2e071eaa2aa..2c34b73c6e4 100644 --- a/encodings/alp/src/alp/compute/take.rs +++ b/encodings/alp/src/alp/compute/take.rs @@ -51,6 +51,6 @@ mod test { let mut ctx = array_session().create_execution_ctx(); let array_primitive = array.execute::(&mut ctx).unwrap(); let alp = alp_encode(array_primitive.as_view(), None, &mut ctx).unwrap(); - test_take_conformance(&alp.into_array()); + test_take_conformance(&alp.into_array(), &mut ctx); } } diff --git a/encodings/alp/src/alp_rd/compute/cast.rs b/encodings/alp/src/alp_rd/compute/cast.rs index 68ed39dd390..9a08f00e298 100644 --- a/encodings/alp/src/alp_rd/compute/cast.rs +++ b/encodings/alp/src/alp_rd/compute/cast.rs @@ -28,8 +28,6 @@ impl CastReduce for ALPRD { .with_nullability(dtype.nullability()), )?; - // NOTE: `CastReduce::cast` has a fixed trait signature without `ExecutionCtx`, so we - // construct a legacy ctx locally at this trait boundary. Ok(Some( unsafe { ALPRD::new_unchecked( @@ -149,6 +147,9 @@ mod tests { encoder.encode(arr.as_view()) })] fn test_cast_alprd_conformance(#[case] alprd: crate::alp_rd::ALPRDArray) { - test_cast_conformance(&alprd.into_array()); + test_cast_conformance( + &alprd.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/encodings/alp/src/alp_rd/compute/filter.rs b/encodings/alp/src/alp_rd/compute/filter.rs index 7aa8c247f2a..69c26c53e3b 100644 --- a/encodings/alp/src/alp_rd/compute/filter.rs +++ b/encodings/alp/src/alp_rd/compute/filter.rs @@ -86,10 +86,12 @@ mod test { #[case(0.1f32, 0.2f32, 3e25f32)] #[case(0.1f64, 0.2f64, 3e100f64)] fn test_filter_simple(#[case] a: T, #[case] b: T, #[case] outlier: T) { + let mut ctx = SESSION.create_execution_ctx(); test_filter_conformance( &RDEncoder::new(&[a, b]) .encode(PrimitiveArray::from_iter([a, b, outlier, b, outlier]).as_view()) .into_array(), + &mut ctx, ); } @@ -97,6 +99,7 @@ mod test { #[case(0.1f32, 3e25f32)] #[case(0.5f64, 1e100f64)] fn test_filter_with_nulls(#[case] a: T, #[case] outlier: T) { + let mut ctx = SESSION.create_execution_ctx(); test_filter_conformance( &RDEncoder::new(&[a]) .encode( @@ -104,6 +107,7 @@ mod test { .as_view(), ) .into_array(), + &mut ctx, ); } } diff --git a/encodings/alp/src/alp_rd/compute/mask.rs b/encodings/alp/src/alp_rd/compute/mask.rs index 3a631d6417f..25bdc8f9ab4 100644 --- a/encodings/alp/src/alp_rd/compute/mask.rs +++ b/encodings/alp/src/alp_rd/compute/mask.rs @@ -37,22 +37,35 @@ impl MaskReduce for ALPRD { #[cfg(test)] mod tests { + use std::sync::LazyLock; + use rstest::rstest; use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::array_session; use vortex_array::arrays::PrimitiveArray; use vortex_array::compute::conformance::mask::test_mask_conformance; + use vortex_session::VortexSession; use crate::ALPRDFloat; use crate::RDEncoder; + static SESSION: LazyLock = LazyLock::new(|| { + let session = array_session(); + crate::initialize(&session); + session + }); + #[rstest] #[case(0.1f32, 0.2f32, 3e25f32)] #[case(0.1f64, 0.2f64, 3e100f64)] fn test_mask_simple(#[case] a: T, #[case] b: T, #[case] outlier: T) { + let mut ctx = SESSION.create_execution_ctx(); test_mask_conformance( &RDEncoder::new(&[a, b]) .encode(PrimitiveArray::from_iter([a, b, outlier, b, outlier]).as_view()) .into_array(), + &mut ctx, ); } @@ -60,6 +73,7 @@ mod tests { #[case(0.1f32, 3e25f32)] #[case(0.5f64, 1e100f64)] fn test_mask_with_nulls(#[case] a: T, #[case] outlier: T) { + let mut ctx = SESSION.create_execution_ctx(); test_mask_conformance( &RDEncoder::new(&[a]) .encode( @@ -67,6 +81,7 @@ mod tests { .as_view(), ) .into_array(), + &mut ctx, ); } } diff --git a/encodings/alp/src/alp_rd/compute/take.rs b/encodings/alp/src/alp_rd/compute/take.rs index 8d12bfbb316..18d5e2b5ad4 100644 --- a/encodings/alp/src/alp_rd/compute/take.rs +++ b/encodings/alp/src/alp_rd/compute/take.rs @@ -142,10 +142,12 @@ mod test { #[case(0.1f32, 0.2f32, 3e25f32)] #[case(0.1f64, 0.2f64, 3e100f64)] fn test_take_conformance_alprd(#[case] a: T, #[case] b: T, #[case] outlier: T) { + let mut ctx = SESSION.create_execution_ctx(); test_take_conformance( &RDEncoder::new(&[a, b]) .encode(PrimitiveArray::from_iter([a, b, outlier, b, outlier]).as_view()) .into_array(), + &mut ctx, ); } @@ -153,6 +155,7 @@ mod test { #[case(0.1f32, 3e25f32)] #[case(0.5f64, 1e100f64)] fn test_take_with_nulls_conformance(#[case] a: T, #[case] outlier: T) { + let mut ctx = SESSION.create_execution_ctx(); test_take_conformance( &RDEncoder::new(&[a]) .encode( @@ -160,6 +163,7 @@ mod test { .as_view(), ) .into_array(), + &mut ctx, ); } } diff --git a/encodings/bytebool/src/compute.rs b/encodings/bytebool/src/compute.rs index 112abc921a4..d68154b6f2b 100644 --- a/encodings/bytebool/src/compute.rs +++ b/encodings/bytebool/src/compute.rs @@ -297,17 +297,25 @@ mod tests { #[test] fn test_mask_byte_bool() { - test_mask_conformance(&bb(vec![true, false, true, true, false]).into_array()); + test_mask_conformance( + &bb(vec![true, false, true, true, false]).into_array(), + &mut SESSION.create_execution_ctx(), + ); test_mask_conformance( &bb_opt(vec![Some(true), Some(true), None, Some(false), None]).into_array(), + &mut SESSION.create_execution_ctx(), ); } #[test] fn test_filter_byte_bool() { - test_filter_conformance(&bb(vec![true, false, true, true, false]).into_array()); + test_filter_conformance( + &bb(vec![true, false, true, true, false]).into_array(), + &mut SESSION.create_execution_ctx(), + ); test_filter_conformance( &bb_opt(vec![Some(true), Some(true), None, Some(false), None]).into_array(), + &mut SESSION.create_execution_ctx(), ); } @@ -317,7 +325,7 @@ mod tests { #[case(bb(vec![true, false]))] #[case(bb(vec![true]))] fn test_take_byte_bool_conformance(#[case] array: ByteBoolArray) { - test_take_conformance(&array.into_array()); + test_take_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] @@ -338,7 +346,7 @@ mod tests { #[case(bb(vec![true]))] #[case(bb_opt(vec![Some(true), None]))] fn test_cast_bytebool_conformance(#[case] array: ByteBoolArray) { - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[rstest] diff --git a/encodings/datetime-parts/src/compute/cast.rs b/encodings/datetime-parts/src/compute/cast.rs index 90aba343ae3..fbfb6f87e39 100644 --- a/encodings/datetime-parts/src/compute/cast.rs +++ b/encodings/datetime-parts/src/compute/cast.rs @@ -140,6 +140,9 @@ mod tests { ), &mut array_session().create_execution_ctx()).unwrap())] fn test_cast_datetime_parts_conformance(#[case] array: DateTimePartsArray) { use vortex_array::compute::conformance::cast::test_cast_conformance; - test_cast_conformance(&array.into_array()); + test_cast_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/encodings/datetime-parts/src/compute/filter.rs b/encodings/datetime-parts/src/compute/filter.rs index 696660510d5..6578467eddd 100644 --- a/encodings/datetime-parts/src/compute/filter.rs +++ b/encodings/datetime-parts/src/compute/filter.rs @@ -54,7 +54,7 @@ mod test { TemporalArray::new_timestamp(timestamps, TimeUnit::Milliseconds, Some("UTC".into())); let array = DateTimeParts::try_from_temporal(temporal, &mut ctx).unwrap(); - test_filter_conformance(&array.into_array()); + test_filter_conformance(&array.into_array(), &mut ctx); // Test with nullable values let timestamps = PrimitiveArray::from_option_iter([ @@ -70,6 +70,6 @@ mod test { TemporalArray::new_timestamp(timestamps, TimeUnit::Milliseconds, Some("UTC".into())); let array = DateTimeParts::try_from_temporal(temporal, &mut ctx).unwrap(); - test_filter_conformance(&array.into_array()); + test_filter_conformance(&array.into_array(), &mut ctx); } } diff --git a/encodings/datetime-parts/src/compute/take.rs b/encodings/datetime-parts/src/compute/take.rs index 9f5a50d3a9b..2b49cfaa15d 100644 --- a/encodings/datetime-parts/src/compute/take.rs +++ b/encodings/datetime-parts/src/compute/take.rs @@ -136,6 +136,9 @@ mod tests { Some("UTC".into()) ), &mut array_session().create_execution_ctx()).unwrap())] fn test_take_datetime_parts_conformance(#[case] array: DateTimePartsArray) { - test_take_conformance(&array.into_array()); + test_take_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/encodings/decimal-byte-parts/src/decimal_byte_parts/compute/cast.rs b/encodings/decimal-byte-parts/src/decimal_byte_parts/compute/cast.rs index 376ad12f564..303e9d3247b 100644 --- a/encodings/decimal-byte-parts/src/decimal_byte_parts/compute/cast.rs +++ b/encodings/decimal-byte-parts/src/decimal_byte_parts/compute/cast.rs @@ -118,6 +118,9 @@ mod tests { DecimalDType::new(10, 2), ).unwrap())] fn test_cast_decimal_byte_parts_conformance(#[case] array: DecimalBytePartsArray) { - test_cast_conformance(&array.into_array()); + test_cast_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/encodings/decimal-byte-parts/src/decimal_byte_parts/compute/filter.rs b/encodings/decimal-byte-parts/src/decimal_byte_parts/compute/filter.rs index c82171b0e13..a1b50ad788a 100644 --- a/encodings/decimal-byte-parts/src/decimal_byte_parts/compute/filter.rs +++ b/encodings/decimal-byte-parts/src/decimal_byte_parts/compute/filter.rs @@ -27,6 +27,8 @@ impl FilterReduce for DecimalByteParts { #[cfg(test)] mod test { use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::array_session; use vortex_array::arrays::PrimitiveArray; use vortex_array::compute::conformance::filter::test_filter_conformance; use vortex_array::dtype::DecimalDType; @@ -41,7 +43,10 @@ mod test { let decimal_dtype = DecimalDType::new(8, 2); let array = DecimalByteParts::try_new(msp, decimal_dtype).unwrap(); - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); // Test with nullable values let msp = PrimitiveArray::from_option_iter([Some(10i64), None, Some(30), Some(40), None]) @@ -49,6 +54,9 @@ mod test { let decimal_dtype = DecimalDType::new(18, 4); let array = DecimalByteParts::try_new(msp, decimal_dtype).unwrap(); - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/encodings/experimental/onpair/src/array.rs b/encodings/experimental/onpair/src/array.rs index 58fd0332ac3..519243b100e 100644 --- a/encodings/experimental/onpair/src/array.rs +++ b/encodings/experimental/onpair/src/array.rs @@ -493,14 +493,12 @@ impl VTable for OnPair { ctx: &mut ExecutionCtx, ) -> VortexResult<()> { let Some(builder) = builder.as_any_mut().downcast_mut::() else { - builder.extend_from_array( - &array - .array() - .clone() - .execute::(ctx)? - .into_array(), - ); - return Ok(()); + return array + .array() + .clone() + .execute::(ctx)? + .into_array() + .append_to_builder(builder, ctx); }; let next_buffer_index = builder.completed_block_count() + u32::from(builder.in_progress()); diff --git a/encodings/fastlanes/src/bitpacking/array/bitpack_compress.rs b/encodings/fastlanes/src/bitpacking/array/bitpack_compress.rs index f7a3485c113..a393db6ecc8 100644 --- a/encodings/fastlanes/src/bitpacking/array/bitpack_compress.rs +++ b/encodings/fastlanes/src/bitpacking/array/bitpack_compress.rs @@ -521,7 +521,7 @@ mod test { let mut primitive_builder = PrimitiveBuilder::::with_capacity(chunked.dtype().nullability(), 10 * 100); - primitive_builder.extend_from_array(&chunked); + chunked.append_to_builder(&mut primitive_builder, &mut ctx)?; let ca_into = primitive_builder.finish(); assert_arrays_eq!(into_ca, ca_into, &mut ctx); diff --git a/encodings/fastlanes/src/bitpacking/compute/cast.rs b/encodings/fastlanes/src/bitpacking/compute/cast.rs index 3b917aa36af..f7cfc56acf0 100644 --- a/encodings/fastlanes/src/bitpacking/compute/cast.rs +++ b/encodings/fastlanes/src/bitpacking/compute/cast.rs @@ -266,6 +266,6 @@ mod tests { #[case(bp(&buffer![0u32, 1000, 2000, 3000, 4000].into_array(), 12))] #[case(bp(&PrimitiveArray::from_option_iter([Some(1u32), None, Some(7), Some(15), None]).into_array(), 4))] fn test_cast_bitpacked_conformance(#[case] array: BitPackedArray) { - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } } diff --git a/encodings/fastlanes/src/bitpacking/compute/filter.rs b/encodings/fastlanes/src/bitpacking/compute/filter.rs index 21184d785a5..1530c33874d 100644 --- a/encodings/fastlanes/src/bitpacking/compute/filter.rs +++ b/encodings/fastlanes/src/bitpacking/compute/filter.rs @@ -273,17 +273,17 @@ mod test { // Test with u8 values let unpacked = buffer![1u8, 2, 3, 4, 5].into_array(); let bitpacked = BitPackedData::encode(&unpacked, 3, &mut ctx).unwrap(); - test_filter_conformance(&bitpacked.into_array()); + test_filter_conformance(&bitpacked.into_array(), &mut ctx); // Test with u32 values let unpacked = buffer![100u32, 200, 300, 400, 500].into_array(); let bitpacked = BitPackedData::encode(&unpacked, 9, &mut ctx).unwrap(); - test_filter_conformance(&bitpacked.into_array()); + test_filter_conformance(&bitpacked.into_array(), &mut ctx); // Test with nullable values let unpacked = PrimitiveArray::from_option_iter([Some(1u16), None, Some(3), Some(4), None]); let bitpacked = BitPackedData::encode(&unpacked.into_array(), 3, &mut ctx).unwrap(); - test_filter_conformance(&bitpacked.into_array()); + test_filter_conformance(&bitpacked.into_array(), &mut ctx); } /// Regression test for signed integers with patches. diff --git a/encodings/fastlanes/src/bitpacking/compute/take.rs b/encodings/fastlanes/src/bitpacking/compute/take.rs index 34aa5a0c3f5..77f6b9d3127 100644 --- a/encodings/fastlanes/src/bitpacking/compute/take.rs +++ b/encodings/fastlanes/src/bitpacking/compute/take.rs @@ -169,6 +169,7 @@ mod test { use vortex_array::VortexSessionExecute; use vortex_array::arrays::PrimitiveArray; use vortex_array::assert_arrays_eq; + use vortex_array::compute::conformance::take::test_take_conformance; use vortex_array::validity::Validity; use vortex_buffer::Buffer; use vortex_buffer::buffer; @@ -327,7 +328,6 @@ mod test { #[case(bp(buffer![42u32].into_array(), 6))] #[case(bp(PrimitiveArray::from_iter((0..1024).map(|i| i as u32)).into_array(), 8))] fn test_take_bitpacked_conformance(#[case] bitpacked: BitPackedArray) { - use vortex_array::compute::conformance::take::test_take_conformance; - test_take_conformance(&bitpacked.into_array()); + test_take_conformance(&bitpacked.into_array(), &mut SESSION.create_execution_ctx()); } } diff --git a/encodings/fastlanes/src/delta/compute/cast.rs b/encodings/fastlanes/src/delta/compute/cast.rs index 2ee10e7619a..c6de602bb0b 100644 --- a/encodings/fastlanes/src/delta/compute/cast.rs +++ b/encodings/fastlanes/src/delta/compute/cast.rs @@ -244,6 +244,9 @@ mod tests { let delta_array = Delta::try_from_primitive_array(&primitive, &mut SESSION.create_execution_ctx()) .unwrap(); - test_cast_conformance(&delta_array.into_array()); + test_cast_conformance( + &delta_array.into_array(), + &mut SESSION.create_execution_ctx(), + ); } } diff --git a/encodings/fastlanes/src/for/compute/cast.rs b/encodings/fastlanes/src/for/compute/cast.rs index b865af43d12..e56188d9e00 100644 --- a/encodings/fastlanes/src/for/compute/cast.rs +++ b/encodings/fastlanes/src/for/compute/cast.rs @@ -118,6 +118,6 @@ mod tests { Scalar::from(-100i32) ))] fn test_cast_for_conformance(#[case] array: FoRArray) { - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } } diff --git a/encodings/fastlanes/src/for/compute/mod.rs b/encodings/fastlanes/src/for/compute/mod.rs index 84e8812b598..168fcbc4192 100644 --- a/encodings/fastlanes/src/for/compute/mod.rs +++ b/encodings/fastlanes/src/for/compute/mod.rs @@ -49,8 +49,11 @@ mod test { use rstest::rstest; use vortex_array::ArrayRef; use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::array_session; use vortex_array::arrays::PrimitiveArray; use vortex_array::compute::conformance::filter::test_filter_conformance; + use vortex_array::compute::conformance::take::test_take_conformance; use vortex_array::scalar::Scalar; use vortex_buffer::buffer; use vortex_error::VortexExpect; @@ -68,18 +71,27 @@ mod test { buffer![100i32, 101, 102, 103, 104].into_array(), Scalar::from(100i32), ); - test_filter_conformance(&for_array.into_array()); + test_filter_conformance( + &for_array.into_array(), + &mut array_session().create_execution_ctx(), + ); let for_array = fa( buffer![1000u64, 1001, 1002, 1003, 1004].into_array(), Scalar::from(1000u64), ); - test_filter_conformance(&for_array.into_array()); + test_filter_conformance( + &for_array.into_array(), + &mut array_session().create_execution_ctx(), + ); let values = PrimitiveArray::from_option_iter([Some(50i16), None, Some(52), Some(53), None]); let for_array = fa(values.into_array(), Scalar::from(50i16)); - test_filter_conformance(&for_array.into_array()); + test_filter_conformance( + &for_array.into_array(), + &mut array_session().create_execution_ctx(), + ); } #[rstest] @@ -92,8 +104,10 @@ mod test { #[case(fa(buffer![-100i32, -99, -98, -97, -96].into_array(), Scalar::from(-100i32)))] #[case(fa(buffer![42i64].into_array(), Scalar::from(40i64)))] fn test_take_for_conformance(#[case] for_array: FoRArray) { - use vortex_array::compute::conformance::take::test_take_conformance; - test_take_conformance(&for_array.into_array()); + test_take_conformance( + &for_array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/encodings/fastlanes/src/rle/compute/cast.rs b/encodings/fastlanes/src/rle/compute/cast.rs index 92947d927bc..38e7927f8e6 100644 --- a/encodings/fastlanes/src/rle/compute/cast.rs +++ b/encodings/fastlanes/src/rle/compute/cast.rs @@ -162,6 +162,6 @@ mod tests { fn test_cast_rle_conformance(#[case] primitive: PrimitiveArray) { let mut ctx = SESSION.create_execution_ctx(); let rle_array = rle(&primitive, &mut ctx); - test_cast_conformance(&rle_array.into_array()); + test_cast_conformance(&rle_array.into_array(), &mut ctx); } } diff --git a/encodings/fsst/src/array.rs b/encodings/fsst/src/array.rs index 362d3e6d967..de0cf3157ba 100644 --- a/encodings/fsst/src/array.rs +++ b/encodings/fsst/src/array.rs @@ -305,14 +305,12 @@ impl VTable for FSST { ctx: &mut ExecutionCtx, ) -> VortexResult<()> { let Some(builder) = builder.as_any_mut().downcast_mut::() else { - builder.extend_from_array( - &array - .array() - .clone() - .execute::(ctx)? - .into_array(), - ); - return Ok(()); + return array + .array() + .clone() + .execute::(ctx)? + .into_array() + .append_to_builder(builder, ctx); }; // Decompress the whole block of data into a new buffer, and create some views diff --git a/encodings/fsst/src/canonical.rs b/encodings/fsst/src/canonical.rs index 43712aad7dd..5c58dbbd501 100644 --- a/encodings/fsst/src/canonical.rs +++ b/encodings/fsst/src/canonical.rs @@ -180,7 +180,7 @@ mod tests { .append_to_builder(&mut builder, &mut ctx)?; { - let arr = builder.finish_into_canonical().into_varbinview(); + let arr = builder.finish_into_canonical(&mut ctx).into_varbinview(); let mask = arr.validity()?.execute_mask(arr.len(), &mut ctx)?; let res1 = (0..arr.len()) .map(|i| mask.value(i).then(|| arr.bytes_at(i).to_vec())) diff --git a/encodings/fsst/src/compute/cast.rs b/encodings/fsst/src/compute/cast.rs index bdca68e841b..a1469681590 100644 --- a/encodings/fsst/src/compute/cast.rs +++ b/encodings/fsst/src/compute/cast.rs @@ -147,7 +147,7 @@ mod tests { let array = array.into_array(); let compressor = fsst_train_compressor(&array, &mut ctx)?; let fsst = fsst_compress(&array, &compressor, &mut ctx)?; - test_cast_conformance(&fsst.into_array()); + test_cast_conformance(&fsst.into_array(), &mut ctx); Ok(()) } } diff --git a/encodings/fsst/src/compute/mod.rs b/encodings/fsst/src/compute/mod.rs index 0065e0f499a..d447b4b4958 100644 --- a/encodings/fsst/src/compute/mod.rs +++ b/encodings/fsst/src/compute/mod.rs @@ -117,7 +117,7 @@ mod tests { let varbin = varbin.into_array(); let compressor = fsst_train_compressor(&varbin, &mut ctx)?; let array = fsst_compress(&varbin, &compressor, &mut ctx)?; - test_take_conformance(&array.into_array()); + test_take_conformance(&array.into_array(), &mut ctx); Ok(()) } diff --git a/encodings/pco/src/compute/cast.rs b/encodings/pco/src/compute/cast.rs index 5f562fafca6..c59e205e2e1 100644 --- a/encodings/pco/src/compute/cast.rs +++ b/encodings/pco/src/compute/cast.rs @@ -188,6 +188,6 @@ mod tests { fn test_cast_pco_conformance(#[case] values: PrimitiveArray) { let mut ctx = SESSION.create_execution_ctx(); let pco = Pco::from_primitive(values.as_view(), 0, 128, &mut ctx).unwrap(); - test_cast_conformance(&pco.into_array()); + test_cast_conformance(&pco.into_array(), &mut ctx); } } diff --git a/encodings/runend/src/arbitrary.rs b/encodings/runend/src/arbitrary.rs index e0e34df984f..d913de378be 100644 --- a/encodings/runend/src/arbitrary.rs +++ b/encodings/runend/src/arbitrary.rs @@ -4,8 +4,9 @@ use arbitrary::Arbitrary; use arbitrary::Result; use arbitrary::Unstructured; +use arbitrary::unstructured::Int; +use vortex_array::ArrayRef; use vortex_array::IntoArray; -use vortex_array::VortexSessionExecute; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::arbitrary::ArbitraryArray; use vortex_array::arrays::arbitrary::ArbitraryArrayConfig; @@ -13,7 +14,7 @@ use vortex_array::arrays::arbitrary::ArbitraryWith; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; -use vortex_array::legacy_session; +use vortex_array::dtype::UnsignedPType; use vortex_array::validity::Validity; use vortex_buffer::Buffer; use vortex_error::VortexExpect; @@ -31,7 +32,7 @@ impl<'a> Arbitrary<'a> for ArbitraryRunEndArray { let ptype: PType = u.arbitrary()?; let nullability: Nullability = u.arbitrary()?; let dtype = DType::Primitive(ptype, nullability); - Self::with_dtype(u, &dtype, None) + Self::with_dtype(u, &dtype) } } @@ -39,16 +40,16 @@ impl ArbitraryRunEndArray { /// Generate an arbitrary RunEndArray with the given dtype for values. /// /// The dtype must be a primitive or boolean type. - #[allow(clippy::disallowed_methods)] - pub fn with_dtype(u: &mut Unstructured, dtype: &DType, len: Option) -> Result { + pub fn with_dtype(u: &mut Unstructured, dtype: &DType) -> Result { // Number of runs (values/ends pairs) let num_runs = u.int_in_range(0..=20)?; - // TODO(ctx): trait fixes - Arbitrary::arbitrary has a fixed signature. - let mut ctx = legacy_session().create_execution_ctx(); if num_runs == 0 { // Empty RunEndArray - let ends = PrimitiveArray::from_iter(Vec::::new()).into_array(); + let ends = unsafe { + PrimitiveArray::new_unchecked(Buffer::::empty(), Validity::NonNullable) + .into_array() + }; let values = ArbitraryArray::arbitrary_with_config( u, &ArbitraryArrayConfig { @@ -57,8 +58,7 @@ impl ArbitraryRunEndArray { }, )? .0; - let runend_array = RunEnd::try_new(ends, values, &mut ctx) - .vortex_expect("Empty RunEndArray creation should succeed"); + let runend_array = unsafe { RunEnd::new_unchecked(ends, values, 0, 0) }; return Ok(ArbitraryRunEndArray(runend_array)); } @@ -72,12 +72,12 @@ impl ArbitraryRunEndArray { )? .0; + let len = u.int_in_range(0..=2048)?; // Generate strictly increasing ends // Each end must be > previous end, and first end must be >= 1 let ends = random_strictly_sorted_ends(u, num_runs, len)?; - let runend_array = RunEnd::try_new(ends, values, &mut ctx) - .vortex_expect("RunEndArray creation should succeed in arbitrary impl"); + let runend_array = unsafe { RunEnd::new_unchecked(ends, values, 0, len) }; Ok(ArbitraryRunEndArray(runend_array)) } @@ -90,67 +90,75 @@ impl ArbitraryRunEndArray { fn random_strictly_sorted_ends( u: &mut Unstructured, num_runs: usize, - target_len: Option, -) -> Result { + target_len: usize, +) -> Result { // Choose a random unsigned PType for ends - let ends_ptype = *u.choose(&[PType::U8, PType::U16, PType::U32, PType::U64])?; + let mut ends_ptypes = vec![PType::U8, PType::U16, PType::U32, PType::U64]; + if target_len >= u8::MAX as usize { + ends_ptypes.remove(0); + } + if target_len >= u16::MAX as usize { + ends_ptypes.remove(0); + } + if target_len >= u32::MAX as usize { + ends_ptypes.remove(0); + } + let ends_ptype = *u.choose(&ends_ptypes)?; + match ends_ptype { + PType::U8 => random_strictly_sorted( + u, + num_runs, + u8::try_from(target_len).vortex_expect("must fit in u8"), + ), + PType::U16 => random_strictly_sorted( + u, + num_runs, + u16::try_from(target_len).vortex_expect("must fit in u16"), + ), + PType::U32 => random_strictly_sorted( + u, + num_runs, + u32::try_from(target_len).vortex_expect("must fit in u32"), + ), + PType::U64 => random_strictly_sorted( + u, + num_runs, + u64::try_from(target_len).vortex_expect("must fit in u64"), + ), + _ => unreachable!("Only unsigned integer types are valid for ends"), + } +} + +fn random_strictly_sorted( + u: &mut Unstructured, + num_runs: usize, + target: T, +) -> Result { // Generate strictly increasing values // Start from 0, increment by at least 1 each time - let mut ends: Vec = Vec::with_capacity(num_runs); - let mut current: u64 = 0; + let mut ends: Vec = Vec::with_capacity(num_runs); + let mut current = T::zero(); for i in 0..num_runs { // Each run must have at least length 1, so increment by at least 1 - let increment = match (i == num_runs - 1, target_len) { - (true, Some(target)) => { + let increment = match i == num_runs - 1 { + true => { // Last element should reach target_len - let target = target as u64; if target > current { target - current } else { - 1 + T::one() } } - _ => { + false => { // Random increment between 1 and 10 - u.int_in_range(1..=10)? + u.int_in_range(T::one()..=T::from(10).vortex_expect("10 will fit in all T"))? } }; current += increment; ends.push(current); } - // Convert to the chosen PType - // The values are bounded: max is num_runs (20) * max_increment (10) = 200 - // This fits in all unsigned types - let ends_array = match ends_ptype { - PType::U8 => { - let ends_typed: Vec = ends - .iter() - .map(|&e| u8::try_from(e).vortex_expect("end value fits in u8")) - .collect(); - PrimitiveArray::new(Buffer::copy_from(ends_typed), Validity::NonNullable).into_array() - } - PType::U16 => { - let ends_typed: Vec = ends - .iter() - .map(|&e| u16::try_from(e).vortex_expect("end value fits in u16")) - .collect(); - PrimitiveArray::new(Buffer::copy_from(ends_typed), Validity::NonNullable).into_array() - } - PType::U32 => { - let ends_typed: Vec = ends - .iter() - .map(|&e| u32::try_from(e).vortex_expect("end value fits in u32")) - .collect(); - PrimitiveArray::new(Buffer::copy_from(ends_typed), Validity::NonNullable).into_array() - } - PType::U64 => { - PrimitiveArray::new(Buffer::copy_from(ends), Validity::NonNullable).into_array() - } - _ => unreachable!("Only unsigned integer types are valid for ends"), - }; - - Ok(ends_array) + Ok(PrimitiveArray::new(Buffer::copy_from(ends), Validity::NonNullable).into_array()) } diff --git a/encodings/runend/src/compute/cast.rs b/encodings/runend/src/compute/cast.rs index fe740739ca9..4722f9e5549 100644 --- a/encodings/runend/src/compute/cast.rs +++ b/encodings/runend/src/compute/cast.rs @@ -184,6 +184,6 @@ mod tests { fn test_cast_runend_conformance(#[case] build: RunEndBuilder) { let mut ctx = SESSION.create_execution_ctx(); let array = build(&mut ctx); - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut ctx); } } diff --git a/encodings/runend/src/compute/take.rs b/encodings/runend/src/compute/take.rs index c61854635e3..4ec601e71a6 100644 --- a/encodings/runend/src/compute/take.rs +++ b/encodings/runend/src/compute/take.rs @@ -751,7 +751,7 @@ mod tests { .unwrap() })] fn test_take_runend_conformance(#[case] array: RunEndArray) { - test_take_conformance(&array.into_array()); + test_take_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[rstest] @@ -765,6 +765,6 @@ mod tests { array.slice(2..8).unwrap() })] fn test_take_sliced_runend_conformance(#[case] sliced: ArrayRef) { - test_take_conformance(&sliced); + test_take_conformance(&sliced, &mut SESSION.create_execution_ctx()); } } diff --git a/encodings/sequence/src/compute/cast.rs b/encodings/sequence/src/compute/cast.rs index e0a63d0c922..fb18cf11181 100644 --- a/encodings/sequence/src/compute/cast.rs +++ b/encodings/sequence/src/compute/cast.rs @@ -213,6 +213,6 @@ mod tests { 5, ).unwrap())] fn test_cast_sequence_conformance(#[case] sequence: SequenceArray) { - test_cast_conformance(&sequence.into_array()); + test_cast_conformance(&sequence.into_array(), &mut SESSION.create_execution_ctx()); } } diff --git a/encodings/sequence/src/compute/filter.rs b/encodings/sequence/src/compute/filter.rs index 3ef24188fbf..aae06610d82 100644 --- a/encodings/sequence/src/compute/filter.rs +++ b/encodings/sequence/src/compute/filter.rs @@ -48,6 +48,8 @@ fn filter_impl(mul: T, base: T, mask: &Mask, validity: Validity) mod tests { use rstest::rstest; use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::array_session; use vortex_array::compute::conformance::filter::LARGE_SIZE; use vortex_array::compute::conformance::filter::MEDIUM_SIZE; use vortex_array::compute::conformance::filter::test_filter_conformance; @@ -70,6 +72,9 @@ mod tests { #[case(Sequence::try_new_typed(0u32, 5, Nullability::NonNullable, MEDIUM_SIZE).unwrap())] #[case(Sequence::try_new_typed(0u64, 1, Nullability::NonNullable, LARGE_SIZE).unwrap())] fn test_filter_sequence_conformance(#[case] array: SequenceArray) { - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/encodings/sequence/src/compute/take.rs b/encodings/sequence/src/compute/take.rs index 4b056d0ae7f..c59842224e2 100644 --- a/encodings/sequence/src/compute/take.rs +++ b/encodings/sequence/src/compute/take.rs @@ -107,6 +107,7 @@ mod test { use vortex_array::VortexSessionExecute; use vortex_array::array_session; use vortex_array::arrays::PrimitiveArray; + use vortex_array::compute::conformance::take::test_take_conformance; use vortex_array::dtype::Nullability; use crate::Sequence; @@ -161,9 +162,11 @@ mod test { Nullability::Nullable, 1000 ).unwrap())] - fn test_take_conformance(#[case] sequence: SequenceArray) { - use vortex_array::compute::conformance::take::test_take_conformance; - test_take_conformance(&sequence.into_array()); + fn sequence_take_conformance(#[case] sequence: SequenceArray) { + test_take_conformance( + &sequence.into_array(), + &mut array_session().create_execution_ctx(), + ); } #[test] diff --git a/encodings/sparse/src/compute/cast.rs b/encodings/sparse/src/compute/cast.rs index 21af0bc212a..f9596aa9976 100644 --- a/encodings/sparse/src/compute/cast.rs +++ b/encodings/sparse/src/compute/cast.rs @@ -131,7 +131,7 @@ mod tests { Scalar::from(0u8) ).unwrap())] fn test_cast_sparse_conformance(#[case] array: SparseArray) { - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] diff --git a/encodings/sparse/src/compute/filter.rs b/encodings/sparse/src/compute/filter.rs index 91e67297fd2..c1818262b9b 100644 --- a/encodings/sparse/src/compute/filter.rs +++ b/encodings/sparse/src/compute/filter.rs @@ -147,6 +147,7 @@ mod tests { ) .unwrap() .into_array(), + &mut SESSION.create_execution_ctx(), ); let ten_fill_value = Scalar::from(10i32); @@ -159,6 +160,7 @@ mod tests { ) .unwrap() .into_array(), + &mut SESSION.create_execution_ctx(), ) } } diff --git a/encodings/sparse/src/compute/mod.rs b/encodings/sparse/src/compute/mod.rs index bdae87e560d..81dfa89080a 100644 --- a/encodings/sparse/src/compute/mod.rs +++ b/encodings/sparse/src/compute/mod.rs @@ -138,6 +138,7 @@ mod tests { ) .unwrap() .into_array(), + &mut SESSION.create_execution_ctx(), ); let ten_fill_value = Scalar::from(10i32); @@ -150,6 +151,7 @@ mod tests { ) .unwrap() .into_array(), + &mut SESSION.create_execution_ctx(), ) } diff --git a/encodings/sparse/src/compute/take.rs b/encodings/sparse/src/compute/take.rs index e260eb26e9e..3babc19e57b 100644 --- a/encodings/sparse/src/compute/take.rs +++ b/encodings/sparse/src/compute/take.rs @@ -64,6 +64,7 @@ mod test { use vortex_array::arrays::ConstantArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::assert_arrays_eq; + use vortex_array::compute::conformance::take::test_take_conformance; use vortex_array::dtype::Nullability; use vortex_array::scalar::Scalar; use vortex_array::validity::Validity; @@ -227,7 +228,6 @@ mod test { Scalar::from(-1i32), ).unwrap())] fn test_take_sparse_conformance(#[case] sparse: SparseArray) { - use vortex_array::compute::conformance::take::test_take_conformance; - test_take_conformance(&sparse.into_array()); + test_take_conformance(&sparse.into_array(), &mut SESSION.create_execution_ctx()); } } diff --git a/encodings/sparse/src/lib.rs b/encodings/sparse/src/lib.rs index 93c0bbe0363..4d4ec2623de 100644 --- a/encodings/sparse/src/lib.rs +++ b/encodings/sparse/src/lib.rs @@ -590,7 +590,7 @@ impl SparseData { // TODO(robert): Support other dtypes, only thing missing is getting most common value out of the array let primitive = array.clone().execute::(ctx)?; let (top_pvalue, _) = primitive - .top_value()? + .top_value(ctx)? .vortex_expect("Non empty or all null array"); Scalar::primitive_value(top_pvalue, top_pvalue.ptype(), array.dtype().nullability()) diff --git a/encodings/zigzag/src/compute/cast.rs b/encodings/zigzag/src/compute/cast.rs index c3fae84e346..94123e4498d 100644 --- a/encodings/zigzag/src/compute/cast.rs +++ b/encodings/zigzag/src/compute/cast.rs @@ -143,6 +143,6 @@ mod tests { #[case(zigzag_encode(PrimitiveArray::from_option_iter([Some(-5i16), None, Some(0), Some(5), None]).as_view()).unwrap())] #[case(zigzag_encode(PrimitiveArray::from_iter([i32::MIN, -1, 0, 1, i32::MAX]).as_view()).unwrap())] fn test_cast_zigzag_conformance(#[case] array: ZigZagArray) { - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } } diff --git a/encodings/zigzag/src/compute/mod.rs b/encodings/zigzag/src/compute/mod.rs index 1da341989c5..e1c2f27f5d7 100644 --- a/encodings/zigzag/src/compute/mod.rs +++ b/encodings/zigzag/src/compute/mod.rs @@ -147,7 +147,7 @@ mod tests { let zigzag = zigzag_encode( PrimitiveArray::new(buffer![-189i32, -160, 1, 42, -73], Validity::AllValid).as_view(), )?; - test_filter_conformance(&zigzag.into_array()); + test_filter_conformance(&zigzag.into_array(), &mut SESSION.create_execution_ctx()); // Test with i64 values let zigzag = zigzag_encode( @@ -157,13 +157,13 @@ mod tests { ) .as_view(), )?; - test_filter_conformance(&zigzag.into_array()); + test_filter_conformance(&zigzag.into_array(), &mut SESSION.create_execution_ctx()); // Test with nullable values let array = PrimitiveArray::from_option_iter([Some(-10i16), None, Some(20), Some(-30), None]); let zigzag = zigzag_encode(array.as_view())?; - test_filter_conformance(&zigzag.into_array()); + test_filter_conformance(&zigzag.into_array(), &mut SESSION.create_execution_ctx()); Ok(()) } @@ -176,13 +176,13 @@ mod tests { PrimitiveArray::new(buffer![-100i32, 200, -300, 400, -500], Validity::AllValid) .as_view(), )?; - test_mask_conformance(&zigzag.into_array()); + test_mask_conformance(&zigzag.into_array(), &mut SESSION.create_execution_ctx()); // Test with i8 values let zigzag = zigzag_encode( PrimitiveArray::new(buffer![-127i8, 0, 127, -1, 1], Validity::AllValid).as_view(), )?; - test_mask_conformance(&zigzag.into_array()); + test_mask_conformance(&zigzag.into_array(), &mut SESSION.create_execution_ctx()); Ok(()) } @@ -198,7 +198,7 @@ mod tests { let mut ctx = SESSION.create_execution_ctx(); let array_primitive = array.execute::(&mut ctx)?; let zigzag = zigzag_encode(array_primitive.as_view())?; - test_take_conformance(&zigzag.into_array()); + test_take_conformance(&zigzag.into_array(), &mut ctx); Ok(()) } diff --git a/encodings/zstd/src/compute/cast.rs b/encodings/zstd/src/compute/cast.rs index c5f15eba284..dba59198c88 100644 --- a/encodings/zstd/src/compute/cast.rs +++ b/encodings/zstd/src/compute/cast.rs @@ -200,6 +200,6 @@ mod tests { fn test_cast_zstd_conformance(#[case] values: PrimitiveArray) { let zstd = Zstd::from_primitive(&values, 0, 0, &mut SESSION.create_execution_ctx()).unwrap(); - test_cast_conformance(&zstd.into_array()); + test_cast_conformance(&zstd.into_array(), &mut SESSION.create_execution_ctx()); } } diff --git a/encodings/zstd/src/zstd_buffers.rs b/encodings/zstd/src/zstd_buffers.rs index ad55ba654a4..73361a593a1 100644 --- a/encodings/zstd/src/zstd_buffers.rs +++ b/encodings/zstd/src/zstd_buffers.rs @@ -25,6 +25,7 @@ use vortex_array::dtype::DType; use vortex_array::scalar::Scalar; use vortex_array::serde::ArrayChildren; use vortex_array::session::ArraySessionExt; +use vortex_array::validity::Validity; use vortex_array::vtable::OperationsVTable; use vortex_array::vtable::VTable; use vortex_array::vtable::ValidityVTable; @@ -504,7 +505,6 @@ impl VTable for ZstdBuffers { } impl OperationsVTable for ZstdBuffers { - #[allow(clippy::disallowed_methods)] fn scalar_at( array: ArrayView<'_, ZstdBuffers>, index: usize, @@ -513,21 +513,17 @@ impl OperationsVTable for ZstdBuffers { // TODO(os): maybe we should not support scalar_at, it is really slow, and adding a cache // layer here is weird. Valid use of zstd buffers array would be by executing it first into // canonical - let inner_array = ZstdBuffers::decompress_and_build_inner( - &array.into_owned(), - vortex_array::legacy_session(), - )?; + let inner_array = + ZstdBuffers::decompress_and_build_inner(&array.into_owned(), ctx.session())?; inner_array.execute_scalar(index, ctx) } } impl ValidityVTable for ZstdBuffers { #[allow(clippy::disallowed_methods)] - fn validity( - array: ArrayView<'_, ZstdBuffers>, - ) -> VortexResult { + fn validity(array: ArrayView<'_, ZstdBuffers>) -> VortexResult { if !array.dtype().is_nullable() { - return Ok(vortex_array::validity::Validity::NonNullable); + return Ok(Validity::NonNullable); } let inner_array = ZstdBuffers::decompress_and_build_inner( diff --git a/fuzz/src/array/mod.rs b/fuzz/src/array/mod.rs index 7c1bbaad091..b6402cb1e1d 100644 --- a/fuzz/src/array/mod.rs +++ b/fuzz/src/array/mod.rs @@ -41,6 +41,7 @@ use strum::IntoEnumIterator; use tracing::debug; use vortex_array::ArrayRef; use vortex_array::Canonical; +use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; use vortex_array::aggregate_fn::NumericalAggregateOpts; @@ -553,25 +554,32 @@ fn random_action_from_list( /// Compress an array using the given strategy. #[cfg(feature = "zstd")] -pub fn compress_array(array: &ArrayRef, strategy: CompressorStrategy) -> ArrayRef { - let mut ctx = SESSION.create_execution_ctx(); +pub fn compress_array( + array: &ArrayRef, + strategy: CompressorStrategy, + ctx: &mut ExecutionCtx, +) -> ArrayRef { match strategy { CompressorStrategy::Default => BtrBlocksCompressor::default() - .compress(array, &mut ctx) + .compress(array, ctx) .vortex_expect("BtrBlocksCompressor compress should succeed in fuzz test"), CompressorStrategy::Compact => BtrBlocksCompressorBuilder::default() .with_compact() .build() - .compress(array, &mut ctx) + .compress(array, ctx) .vortex_expect("Compact compress should succeed in fuzz test"), } } /// Compress an array using the given strategy (only Default). #[cfg(not(feature = "zstd"))] -pub fn compress_array(array: &ArrayRef, _strategy: CompressorStrategy) -> ArrayRef { +pub fn compress_array( + array: &ArrayRef, + _strategy: CompressorStrategy, + ctx: &mut ExecutionCtx, +) -> ArrayRef { BtrBlocksCompressor::default() - .compress(array, &mut SESSION.create_execution_ctx()) + .compress(array, ctx) .vortex_expect("BtrBlocksCompressor compress should succeed in fuzz test") } @@ -602,14 +610,14 @@ pub fn run_fuzz_action(fuzz_action: FuzzArrayAction) -> VortexFuzzResult { .clone() .execute::(&mut ctx) .vortex_expect("execute canonical should succeed in fuzz test"); - current_array = compress_array(&canonical.into_array(), strategy); - assert_array_eq(&expected.array(), ¤t_array, i)?; + current_array = compress_array(&canonical.into_array(), strategy, &mut ctx); + assert_array_eq(&expected.array(), ¤t_array, i, &mut ctx)?; } Action::Slice(range) => { current_array = current_array .slice(range) .vortex_expect("slice operation should succeed in fuzz test"); - assert_array_eq(&expected.array(), ¤t_array, i)?; + assert_array_eq(&expected.array(), ¤t_array, i, &mut ctx)?; } Action::Take(indices) => { if indices.is_empty() { @@ -618,14 +626,14 @@ pub fn run_fuzz_action(fuzz_action: FuzzArrayAction) -> VortexFuzzResult { current_array = current_array .take(indices) .vortex_expect("take operation should succeed in fuzz test"); - assert_array_eq(&expected.array(), ¤t_array, i)?; + assert_array_eq(&expected.array(), ¤t_array, i, &mut ctx)?; } Action::SearchSorted(s, side) => { let mut sorted = sort_canonical_array(¤t_array, &mut ctx) .vortex_expect("sort_canonical_array should succeed in fuzz test"); if !current_array.is_canonical() { - sorted = compress_array(&sorted, CompressorStrategy::Default); + sorted = compress_array(&sorted, CompressorStrategy::Default, &mut ctx); } assert_search_sorted(sorted, s, side, expected.search(), i)?; } @@ -633,7 +641,7 @@ pub fn run_fuzz_action(fuzz_action: FuzzArrayAction) -> VortexFuzzResult { current_array = current_array .filter(mask_val) .vortex_expect("filter operation should succeed in fuzz test"); - assert_array_eq(&expected.array(), ¤t_array, i)?; + assert_array_eq(&expected.array(), ¤t_array, i, &mut ctx)?; } Action::Compare(v, op) => { let compare_result = current_array @@ -642,7 +650,7 @@ pub fn run_fuzz_action(fuzz_action: FuzzArrayAction) -> VortexFuzzResult { Operator::from(op), ) .vortex_expect("compare operation should succeed in fuzz test"); - if let Err(e) = assert_array_eq(&expected.array(), &compare_result, i) { + if let Err(e) = assert_array_eq(&expected.array(), &compare_result, i, &mut ctx) { vortex_panic!( "Failed to compare {}with {op} {v}\nError: {e}", current_array.display_tree() @@ -654,7 +662,7 @@ pub fn run_fuzz_action(fuzz_action: FuzzArrayAction) -> VortexFuzzResult { let cast_result = current_array .cast(to.clone()) .vortex_expect("cast operation should succeed in fuzz test"); - if let Err(e) = assert_array_eq(&expected.array(), &cast_result, i) { + if let Err(e) = assert_array_eq(&expected.array(), &cast_result, i, &mut ctx) { vortex_panic!( "Failed to cast {} to dtype {to}\nError: {e}", current_array.display_tree() @@ -677,13 +685,13 @@ pub fn run_fuzz_action(fuzz_action: FuzzArrayAction) -> VortexFuzzResult { current_array = current_array .fill_null(fill_value.clone()) .vortex_expect("fill_null operation should succeed in fuzz test"); - assert_array_eq(&expected.array(), ¤t_array, i)?; + assert_array_eq(&expected.array(), ¤t_array, i, &mut ctx)?; } Action::Mask(mask_val) => { current_array = current_array .mask(mask_val.into_array()) .vortex_expect("mask operation should succeed in fuzz test"); - assert_array_eq(&expected.array(), ¤t_array, i)?; + assert_array_eq(&expected.array(), ¤t_array, i, &mut ctx)?; } Action::ScalarAt(indices) => { let expected_scalars = expected.scalar_vec(); @@ -730,7 +738,12 @@ fn assert_search_sorted( /// Uses `all_non_distinct` for an efficient buffer-level comparison on the happy path. /// Falls back to element-wise scalar comparison only on mismatch to produce a detailed error. #[expect(clippy::result_large_err)] -pub fn assert_array_eq(lhs: &ArrayRef, rhs: &ArrayRef, step: usize) -> VortexFuzzResult<()> { +pub fn assert_array_eq( + lhs: &ArrayRef, + rhs: &ArrayRef, + step: usize, + ctx: &mut ExecutionCtx, +) -> VortexFuzzResult<()> { if lhs.dtype() != rhs.dtype() { return Err(VortexFuzzError::DTypeMismatch( lhs.clone(), @@ -751,10 +764,8 @@ pub fn assert_array_eq(lhs: &ArrayRef, rhs: &ArrayRef, step: usize) -> VortexFuz )); } - let mut ctx = SESSION.create_execution_ctx(); - // Fast path: buffer-level comparison. - let identical = all_non_distinct(lhs, rhs, &mut ctx) + let identical = all_non_distinct(lhs, rhs, ctx) .map_err(|e| VortexFuzzError::VortexError(e, Backtrace::capture()))?; if identical { return Ok(()); @@ -762,8 +773,8 @@ pub fn assert_array_eq(lhs: &ArrayRef, rhs: &ArrayRef, step: usize) -> VortexFuz // Slow path: find the first differing element for a detailed error message. for idx in 0..lhs.len() { - let l = lhs.execute_scalar(idx, &mut ctx).vortex_expect("scalar_at"); - let r = rhs.execute_scalar(idx, &mut ctx).vortex_expect("scalar_at"); + let l = lhs.execute_scalar(idx, ctx).vortex_expect("scalar_at"); + let r = rhs.execute_scalar(idx, ctx).vortex_expect("scalar_at"); if l != r { return Err(VortexFuzzError::ArrayNotEqual( diff --git a/fuzz/src/compress.rs b/fuzz/src/compress.rs index 1284a7be45e..9953de79b0a 100644 --- a/fuzz/src/compress.rs +++ b/fuzz/src/compress.rs @@ -6,6 +6,8 @@ //! This module generates arbitrary instances of compressed encodings (DictArray, etc.), //! then verifies that `to_canonical()` works and produces correct `len` and `dtype`. +use std::backtrace::Backtrace; + use arbitrary::Arbitrary; use arbitrary::Unstructured; use vortex_array::ArrayRef; @@ -17,6 +19,8 @@ use vortex_array::arrays::dict::ArbitraryDictArray; use vortex_runend::ArbitraryRunEndArray; use crate::SESSION; +use crate::error::VortexFuzzError; +use crate::error::VortexFuzzResult; /// Which compressed encoding to generate. #[derive(Debug, Clone, Copy)] @@ -64,10 +68,7 @@ impl<'a> Arbitrary<'a> for FuzzCompressRoundtrip { /// - `Ok(false)` - reject from corpus /// - `Err(_)` - a bug was found #[expect(clippy::result_large_err)] -pub fn run_compress_roundtrip(fuzz: FuzzCompressRoundtrip) -> crate::error::VortexFuzzResult { - use crate::error::Backtrace; - use crate::error::VortexFuzzError; - +pub fn run_compress_roundtrip(fuzz: FuzzCompressRoundtrip) -> VortexFuzzResult { let FuzzCompressRoundtrip { array } = fuzz; // Store original properties diff --git a/vortex-array/benches/dict_unreferenced_mask.rs b/vortex-array/benches/dict_unreferenced_mask.rs index 1ec143682af..ec8653d6688 100644 --- a/vortex-array/benches/dict_unreferenced_mask.rs +++ b/vortex-array/benches/dict_unreferenced_mask.rs @@ -3,14 +3,21 @@ #![expect(clippy::unwrap_used)] +use std::sync::LazyLock; + use divan::Bencher; use rand::RngExt; use rand::SeedableRng; use rand::rngs::StdRng; use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::DictArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::dict::DictArrayExt; +use vortex_session::VortexSession; + +static SESSION: LazyLock = LazyLock::new(array_session); fn main() { divan::main(); @@ -41,8 +48,8 @@ fn bench_many_codes_few_values(bencher: Bencher, num_values: i32) { let array = DictArray::try_new(codes, values).unwrap(); bencher - .with_inputs(|| &array) - .bench_refs(|array| array.compute_referenced_values_mask(false).unwrap()); + .with_inputs(|| (&array, SESSION.create_execution_ctx())) + .bench_refs(|(array, ctx)| array.compute_referenced_values_mask(false, ctx).unwrap()); } /// Benchmark with many nulls in the codes array. @@ -73,8 +80,8 @@ fn bench_many_nulls(bencher: Bencher, fraction_valid: f64) { let array = DictArray::try_new(codes, values).unwrap(); bencher - .with_inputs(|| &array) - .bench_refs(|array| array.compute_referenced_values_mask(false).unwrap()); + .with_inputs(|| (&array, SESSION.create_execution_ctx())) + .bench_refs(|(array, ctx)| array.compute_referenced_values_mask(false, ctx).unwrap()); } /// Benchmark with sparse code coverage (many unreferenced values). @@ -107,6 +114,6 @@ fn bench_sparse_coverage(bencher: Bencher, fraction_coverage: f64) { let array = DictArray::try_new(codes, values).unwrap(); bencher - .with_inputs(|| &array) - .bench_refs(|array| array.compute_referenced_values_mask(false).unwrap()); + .with_inputs(|| (&array, SESSION.create_execution_ctx())) + .bench_refs(|(array, ctx)| array.compute_referenced_values_mask(false, ctx).unwrap()); } diff --git a/vortex-array/benches/listview_builder_extend.rs b/vortex-array/benches/listview_builder_extend.rs index 75487564ea8..99f35e9fe82 100644 --- a/vortex-array/benches/listview_builder_extend.rs +++ b/vortex-array/benches/listview_builder_extend.rs @@ -3,14 +3,16 @@ #![expect(clippy::cast_possible_truncation)] #![expect(clippy::cast_possible_wrap)] +#![expect(clippy::unwrap_used)] use std::sync::Arc; use divan::Bencher; use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::ListViewArray; use vortex_array::arrays::PrimitiveArray; -use vortex_array::builders::ArrayBuilder; use vortex_array::builders::ListViewBuilder; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability::NonNullable; @@ -63,13 +65,14 @@ fn extend_from_array_zctl(bencher: Bencher, (num_lists, list_size): (usize, usiz let source = source.into_array(); bencher.with_inputs(|| &source).bench_refs(|source| { - let mut builder = ListViewBuilder::::with_capacity( + let mut ctx = array_session().create_execution_ctx(); + let mut builder = ListViewBuilder::::with_capacity( Arc::new(DType::Primitive(I32, NonNullable)), NonNullable, num_lists * list_size, num_lists, ); - builder.extend_from_array(source); + source.append_to_builder(&mut builder, &mut ctx).unwrap(); divan::black_box(builder.finish_into_listview()) }); } @@ -85,13 +88,14 @@ fn extend_from_array_non_zctl_overlapping( let source = source.into_array(); bencher.with_inputs(|| &source).bench_refs(|source| { - let mut builder = ListViewBuilder::::with_capacity( + let mut ctx = array_session().create_execution_ctx(); + let mut builder = ListViewBuilder::::with_capacity( Arc::new(DType::Primitive(I32, NonNullable)), Nullable, num_lists * list_size, num_lists, ); - builder.extend_from_array(source); + source.append_to_builder(&mut builder, &mut ctx).unwrap(); divan::black_box(builder.finish_into_listview()) }); } diff --git a/vortex-array/benches/take_patches.rs b/vortex-array/benches/take_patches.rs index b935f97b54b..d3b99e8ea1b 100644 --- a/vortex-array/benches/take_patches.rs +++ b/vortex-array/benches/take_patches.rs @@ -12,12 +12,12 @@ use rand::SeedableRng; use rand::rngs::StdRng; use vortex_array::ArrayRef; use vortex_array::IntoArray; -#[expect(deprecated)] -use vortex_array::ToCanonical as _; use vortex_array::VortexSessionExecute; use vortex_array::array_session; +use vortex_array::arrays::PrimitiveArray; use vortex_array::patches::Patches; use vortex_buffer::Buffer; +use vortex_error::VortexExpect; use vortex_session::VortexSession; fn main() { @@ -56,8 +56,10 @@ fn take_search(bencher: Bencher, (patches_sparsity, index_multiple): (f64, f64)) bencher .with_inputs(|| (&patches, &indices, SESSION.create_execution_ctx())) .bench_refs(|(patches, indices, ctx)| { - #[expect(deprecated)] - let prim = indices.to_primitive(); + let prim = indices + .clone() + .execute::(ctx) + .vortex_expect("operation should succeed in benchmark"); patches.take_search(prim, false, ctx) }); } @@ -75,8 +77,10 @@ fn take_search_chunked(bencher: Bencher, (patches_sparsity, index_multiple): (f6 bencher .with_inputs(|| (&patches, &indices, SESSION.create_execution_ctx())) .bench_refs(|(patches, indices, ctx)| { - #[expect(deprecated)] - let prim = indices.to_primitive(); + let prim = indices + .clone() + .execute::(ctx) + .vortex_expect("operation should succeed in benchmark"); patches.take_search(prim, false, ctx) }); } @@ -94,8 +98,10 @@ fn take_map(bencher: Bencher, (patches_sparsity, index_multiple): (f64, f64)) { bencher .with_inputs(|| (&patches, &indices, SESSION.create_execution_ctx())) .bench_refs(|(patches, indices, ctx)| { - #[expect(deprecated)] - let prim = indices.to_primitive(); + let prim = indices + .clone() + .execute::(ctx) + .vortex_expect("operation should succeed in benchmark"); patches.take_map(prim, false, ctx) }); } @@ -107,15 +113,7 @@ fn fixture(len: usize, sparsity: f64, rng: &mut StdRng) -> Patches { .collect::>(); let sparse_len = indices.len(); let values = Buffer::from_iter((0..sparse_len).map(|x| x as u64)).into_array(); - Patches::new( - len, - 0, - indices.into_array(), - values, - // TODO(0ax1): handle chunk offsets - None, - ) - .unwrap() + Patches::new(len, 0, indices.into_array(), values, None).unwrap() } fn fixture_with_chunk_offsets(len: usize, sparsity: f64, rng: &mut StdRng) -> Patches { diff --git a/vortex-array/benches/varbinview_compact.rs b/vortex-array/benches/varbinview_compact.rs index 99243ce3606..d68420a264f 100644 --- a/vortex-array/benches/varbinview_compact.rs +++ b/vortex-array/benches/varbinview_compact.rs @@ -1,20 +1,23 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +use std::sync::LazyLock; + use divan::Bencher; use rand::RngExt; use rand::SeedableRng; use rand::rngs::StdRng; use vortex_array::ArrayRef; use vortex_array::IntoArray; -#[expect(deprecated)] -use vortex_array::ToCanonical as _; +use vortex_array::VortexSessionExecute; +use vortex_array::array_session; use vortex_array::arrays::VarBinViewArray; use vortex_array::builders::VarBinViewBuilder; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; use vortex_buffer::Buffer; use vortex_error::VortexExpect; +use vortex_session::VortexSession; fn main() { divan::main(); @@ -28,6 +31,8 @@ const ARGS: &[(usize, usize)] = &[ (1 << 14, 90), ]; +static SESSION: LazyLock = LazyLock::new(array_session); + #[divan::bench(args = ARGS)] fn compact(bencher: Bencher, args: (usize, usize)) { compact_impl(bencher, args); @@ -46,31 +51,39 @@ fn compact_impl(bencher: Bencher, (output_size, utilization_pct): (usize, usize) .into_array() .take(indices) .vortex_expect("operation should succeed in benchmark"); - #[expect(deprecated)] - let array = taken.to_varbinview(); - - bencher.with_inputs(|| &array).bench_refs(|array| { - array - .compact_buffers() - .vortex_expect("operation should succeed in benchmark") - }) + let mut ctx = SESSION.create_execution_ctx(); + let array = taken + .execute::(&mut ctx) + .vortex_expect("operation should succeed in benchmark"); + + bencher + .with_inputs(|| (&array, SESSION.create_execution_ctx())) + .bench_refs(|(array, ctx)| { + array + .compact_buffers(ctx) + .vortex_expect("operation should succeed in benchmark") + }) } fn compact_sliced_impl(bencher: Bencher, (output_size, utilization_pct): (usize, usize)) { + let mut ctx = SESSION.create_execution_ctx(); let base_size = (output_size * 100) / utilization_pct; let base_array = build_varbinview_fixture(base_size); let sliced = base_array .into_array() .slice(0..output_size) .vortex_expect("slice should succeed"); - #[expect(deprecated)] - let array = sliced.to_varbinview(); - - bencher.with_inputs(|| &array).bench_refs(|array| { - array - .compact_buffers() - .vortex_expect("operation should succeed in benchmark") - }) + let array = sliced + .execute::(&mut ctx) + .vortex_expect("operation should succeed in benchmark"); + + bencher + .with_inputs(|| (&array, SESSION.create_execution_ctx())) + .bench_refs(|(array, ctx)| { + array + .compact_buffers(ctx) + .vortex_expect("operation should succeed in benchmark") + }) } /// Creates a base VarBinViewArray with mix of inlined and outlined strings. diff --git a/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs b/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs index d73e670f50f..eac5b3c33ce 100644 --- a/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs +++ b/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs @@ -313,6 +313,7 @@ pub(crate) fn packed_bit_buffer_size_in_bytes(len: usize) -> VortexResult { #[cfg(test)] mod tests { use vortex_buffer::buffer; + use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_error::vortex_err; @@ -355,9 +356,12 @@ mod tests { fn materialized_uncompressed_size_in_bytes(array: &ArrayRef) -> u64 { let mut builder = builder_with_capacity(array.dtype(), array.len()); - unsafe { - builder.extend_from_array_unchecked(array); - } + array + .append_to_builder( + builder.as_mut(), + &mut array_session().create_execution_ctx(), + ) + .vortex_expect("appended"); builder.finish().nbytes() } diff --git a/vortex-array/src/array/mod.rs b/vortex-array/src/array/mod.rs index 0d3bbe9cae3..3eea66c6a68 100644 --- a/vortex-array/src/array/mod.rs +++ b/vortex-array/src/array/mod.rs @@ -158,7 +158,7 @@ pub(crate) trait DynArrayData: 'static + private::Sealed + Send + Sync + Debug { /// Execute the array by taking a single encoding-specific execution step. /// /// This is the checked entry point. If the encoding reports - /// [`ExecutionStep::Done`](crate::ExecutionStep::Done), implementations must validate that the + /// [`ExecutionStep::Done`](ExecutionStep::Done), implementations must validate that the /// returned array preserves this array's logical `len` and `dtype`, and must transfer this /// array's statistics to the returned array. fn execute(&self, this: ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult; diff --git a/vortex-array/src/array/vtable/mod.rs b/vortex-array/src/array/vtable/mod.rs index 617b211a4c2..344aac64a88 100644 --- a/vortex-array/src/array/vtable/mod.rs +++ b/vortex-array/src/array/vtable/mod.rs @@ -193,8 +193,7 @@ pub trait VTable: 'static + Clone + Sized + Send + Sync + Debug { .clone() .execute::(ctx)? .into_array(); - builder.extend_from_array(&canonical); - Ok(()) + canonical.append_to_builder(builder, ctx) } /// Returns the name of the slot at the given index. diff --git a/vortex-array/src/arrays/arbitrary.rs b/vortex-array/src/arrays/arbitrary.rs index 035c23fd4db..5d5d9f60bf9 100644 --- a/vortex-array/src/arrays/arbitrary.rs +++ b/vortex-array/src/arrays/arbitrary.rs @@ -15,11 +15,10 @@ use vortex_error::VortexExpect; use crate::ArrayRef; use crate::IntoArray; -#[expect(deprecated)] -use crate::ToCanonical as _; use crate::arrays::BoolArray; use crate::arrays::ChunkedArray; use crate::arrays::NullArray; +use crate::arrays::Primitive; use crate::arrays::PrimitiveArray; use crate::arrays::StructArray; use crate::arrays::VarBinArray; @@ -130,9 +129,8 @@ fn random_array_chunk( PType::I32 => random_primitive::(u, *n, chunk_len), PType::I64 => random_primitive::(u, *n, chunk_len), PType::F16 => { - #[expect(deprecated)] let prim = random_primitive::(u, *n, chunk_len)? - .to_primitive() + .as_::() .reinterpret_cast(PType::F16) .into_array(); Ok(prim) diff --git a/vortex-array/src/arrays/bool/compute/cast.rs b/vortex-array/src/arrays/bool/compute/cast.rs index 3b47ce62c8f..36849f55a18 100644 --- a/vortex-array/src/arrays/bool/compute/cast.rs +++ b/vortex-array/src/arrays/bool/compute/cast.rs @@ -123,7 +123,7 @@ mod tests { #[case(BoolArray::from_iter(vec![true]))] #[case(BoolArray::from_iter(vec![false, false]))] fn test_cast_bool_conformance(#[case] array: BoolArray) { - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[rstest] diff --git a/vortex-array/src/arrays/bool/compute/fill_null.rs b/vortex-array/src/arrays/bool/compute/fill_null.rs index 59b9ca0b717..5fa18299106 100644 --- a/vortex-array/src/arrays/bool/compute/fill_null.rs +++ b/vortex-array/src/arrays/bool/compute/fill_null.rs @@ -48,11 +48,11 @@ mod tests { use vortex_buffer::bitbuffer; use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::BoolArray; use crate::arrays::bool::BoolArrayExt; use crate::builtins::ArrayBuiltins; - #[expect(deprecated)] - use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::Nullability; use crate::scalar::Scalar; @@ -62,16 +62,17 @@ mod tests { #[case(true, bitbuffer![true, true, false, true])] #[case(false, bitbuffer![true, false, false, false])] fn bool_fill_null(#[case] fill_value: bool, #[case] expected: BitBuffer) { + let mut ctx = array_session().create_execution_ctx(); let bool_array = BoolArray::new( BitBuffer::from_iter([true, true, false, false]), Validity::from_iter([true, false, true, false]), ); - #[expect(deprecated)] let non_null_array = bool_array .into_array() .fill_null(Scalar::from(fill_value)) .unwrap() - .to_bool(); + .execute::(&mut ctx) + .unwrap(); assert_eq!(non_null_array.to_bit_buffer(), expected); assert_eq!( non_null_array.dtype(), diff --git a/vortex-array/src/arrays/bool/compute/filter.rs b/vortex-array/src/arrays/bool/compute/filter.rs index f463f27f437..2759c6e3077 100644 --- a/vortex-array/src/arrays/bool/compute/filter.rs +++ b/vortex-array/src/arrays/bool/compute/filter.rs @@ -366,7 +366,10 @@ mod tests { #[case(BoolArray::from_iter((0..100).map(|i| i % 2 == 0)))] #[case(BoolArray::from_iter((0..1024).map(|i| i % 3 != 0)))] fn test_filter_bool_conformance(#[case] array: BoolArray) { - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } #[cfg(target_arch = "x86_64")] diff --git a/vortex-array/src/arrays/bool/compute/mask.rs b/vortex-array/src/arrays/bool/compute/mask.rs index 6fd278f2d3d..783c933fe5a 100644 --- a/vortex-array/src/arrays/bool/compute/mask.rs +++ b/vortex-array/src/arrays/bool/compute/mask.rs @@ -29,6 +29,8 @@ mod test { use rstest::rstest; use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::BoolArray; use crate::compute::conformance::mask::test_mask_conformance; @@ -39,6 +41,9 @@ mod test { #[case(BoolArray::from_iter([false, false]))] #[case(BoolArray::from_iter((0..100).map(|i| i % 2 == 0)))] fn test_mask_bool_conformance(#[case] array: BoolArray) { - test_mask_conformance(&array.into_array()); + test_mask_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/bool/compute/take.rs b/vortex-array/src/arrays/bool/compute/take.rs index abff3527bfe..8469c7e25c2 100644 --- a/vortex-array/src/arrays/bool/compute/take.rs +++ b/vortex-array/src/arrays/bool/compute/take.rs @@ -88,8 +88,6 @@ mod test { use vortex_buffer::buffer; use crate::IntoArray as _; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::BoolArray; @@ -110,11 +108,11 @@ mod test { Some(false), ]); - #[expect(deprecated)] let b = reference .take(buffer![0, 3, 4].into_array()) .unwrap() - .to_bool(); + .execute::(&mut ctx) + .unwrap(); assert_eq!( b.to_bit_buffer(), BoolArray::from_iter([Some(false), None, Some(false)]).to_bit_buffer() @@ -190,6 +188,9 @@ mod test { #[case(BoolArray::from_iter([true, false]))] #[case(BoolArray::from_iter([true]))] fn test_take_bool_conformance(#[case] array: BoolArray) { - test_take_conformance(&array.into_array()); + test_take_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/bool/vtable/mod.rs b/vortex-array/src/arrays/bool/vtable/mod.rs index 2420642d972..3d01b3a44dc 100644 --- a/vortex-array/src/arrays/bool/vtable/mod.rs +++ b/vortex-array/src/arrays/bool/vtable/mod.rs @@ -198,12 +198,10 @@ impl VTable for Bool { builder: &mut dyn ArrayBuilder, ctx: &mut ExecutionCtx, ) -> VortexResult<()> { - if let Some(builder) = builder.as_any_mut().downcast_mut::() { - return builder.append_bool_array(&array.into_owned(), ctx); - } - - builder.extend_from_array(array.as_ref()); - Ok(()) + let Some(builder) = builder.as_any_mut().downcast_mut::() else { + vortex_bail!("append_to_builder for Bool requires a BoolBuilder"); + }; + builder.append_bool_array(&array.into_owned(), ctx) } fn execute(array: Array, _ctx: &mut ExecutionCtx) -> VortexResult { diff --git a/vortex-array/src/arrays/bool/vtable/operations.rs b/vortex-array/src/arrays/bool/vtable/operations.rs index 9bae2b9e19c..c29ab20331b 100644 --- a/vortex-array/src/arrays/bool/vtable/operations.rs +++ b/vortex-array/src/arrays/bool/vtable/operations.rs @@ -28,8 +28,6 @@ mod tests { use std::iter; use crate::IntoArray; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::BoolArray; @@ -38,9 +36,14 @@ mod tests { #[test] fn test_slice_hundred_elements() { + let mut ctx = array_session().create_execution_ctx(); let arr = BoolArray::from_iter(iter::repeat_n(Some(true), 100)); - #[expect(deprecated)] - let sliced_arr = arr.into_array().slice(8..16).unwrap().to_bool(); + let sliced_arr = arr + .into_array() + .slice(8..16) + .unwrap() + .execute::(&mut ctx) + .unwrap(); assert_eq!(sliced_arr.len(), 8); assert_eq!(sliced_arr.to_bit_buffer().len(), 8); assert_eq!(sliced_arr.to_bit_buffer().offset(), 0); @@ -50,8 +53,12 @@ mod tests { fn test_slice() { let mut ctx = array_session().create_execution_ctx(); let arr = BoolArray::from_iter([Some(true), Some(true), None, Some(false), None]); - #[expect(deprecated)] - let sliced_arr = arr.into_array().slice(1..4).unwrap().to_bool(); + let sliced_arr = arr + .into_array() + .slice(1..4) + .unwrap() + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!( sliced_arr, diff --git a/vortex-array/src/arrays/chunked/array.rs b/vortex-array/src/arrays/chunked/array.rs index 54544ba1b4d..3963d6ba950 100644 --- a/vortex-array/src/arrays/chunked/array.rs +++ b/vortex-array/src/arrays/chunked/array.rs @@ -18,6 +18,8 @@ use vortex_error::vortex_bail; use crate::ArrayRef; use crate::ArraySlots; +use crate::Canonical; +use crate::ExecutionCtx; use crate::IntoArray; use crate::array::Array; use crate::array::ArrayParts; @@ -194,7 +196,12 @@ impl Array { Ok(unsafe { Self::new_unchecked(chunks, dtype) }) } - pub fn rechunk(&self, target_bytesize: u64, target_rowsize: usize) -> VortexResult { + pub fn rechunk( + &self, + target_bytesize: u64, + target_rowsize: usize, + ctx: &mut ExecutionCtx, + ) -> VortexResult { let mut new_chunks = Vec::new(); let mut chunks_to_combine = Vec::new(); let mut new_chunk_n_bytes = 0; @@ -207,12 +214,11 @@ impl Array { || new_chunk_n_elements + n_elements > target_rowsize) && !chunks_to_combine.is_empty() { - #[expect(deprecated)] let canonical = unsafe { Array::::new_unchecked(chunks_to_combine, self.dtype().clone()) } .into_array() - .to_canonical()? + .execute::(ctx)? .into_array(); new_chunks.push(canonical); @@ -231,11 +237,10 @@ impl Array { } if !chunks_to_combine.is_empty() { - #[expect(deprecated)] let canonical = unsafe { Array::::new_unchecked(chunks_to_combine, self.dtype().clone()) } .into_array() - .to_canonical()? + .execute::(ctx)? .into_array(); new_chunks.push(canonical); } @@ -298,7 +303,7 @@ mod test { ) .unwrap(); - let rechunked = chunked.rechunk(1 << 16, 1 << 16).unwrap(); + let rechunked = chunked.rechunk(1 << 16, 1 << 16, &mut ctx).unwrap(); assert_arrays_eq!(chunked, rechunked, &mut ctx); } @@ -312,7 +317,7 @@ mod test { ) .unwrap(); - let rechunked = chunked.rechunk(1 << 16, 1 << 16).unwrap(); + let rechunked = chunked.rechunk(1 << 16, 1 << 16, &mut ctx).unwrap(); assert_eq!(rechunked.nchunks(), 1); assert_arrays_eq!(chunked, rechunked, &mut ctx); @@ -330,7 +335,7 @@ mod test { ) .unwrap(); - let rechunked = chunked.rechunk(1 << 16, 5).unwrap(); + let rechunked = chunked.rechunk(1 << 16, 5, &mut ctx).unwrap(); assert_eq!(rechunked.nchunks(), 2); assert!(rechunked.iter_chunks().all(|c| c.len() < 5)); @@ -352,7 +357,7 @@ mod test { ) .unwrap(); - let rechunked = chunked.rechunk(1 << 16, 5).unwrap(); + let rechunked = chunked.rechunk(1 << 16, 5, &mut ctx).unwrap(); // greedy so should be: [0, 1, 2] [42, 42, 42, 42, 42, 42] [4, 5, 6, 7] [8, 9] assert_eq!(rechunked.nchunks(), 4); @@ -361,6 +366,7 @@ mod test { #[test] fn test_empty_chunks_all_valid() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); // Create chunks where some are empty but all non-empty chunks have all valid values let chunks = vec![ PrimitiveArray::new(buffer![1u64, 2, 3], Validity::AllValid).into_array(), @@ -373,18 +379,15 @@ mod test { ChunkedArray::try_new(chunks, DType::Primitive(PType::U64, Nullability::Nullable))?; // Should be all_valid since all non-empty chunks are all_valid - assert!(chunked.all_valid(&mut array_session().create_execution_ctx())?); - assert!( - !chunked - .into_array() - .all_invalid(&mut array_session().create_execution_ctx())? - ); + assert!(chunked.all_valid(&mut ctx)?); + assert!(!chunked.into_array().all_invalid(&mut ctx)?); Ok(()) } #[test] fn test_empty_chunks_all_invalid() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); // Create chunks where some are empty but all non-empty chunks have all invalid values let chunks = vec![ PrimitiveArray::new(buffer![1u64, 2], Validity::AllInvalid).into_array(), @@ -397,18 +400,15 @@ mod test { ChunkedArray::try_new(chunks, DType::Primitive(PType::U64, Nullability::Nullable))?; // Should be all_invalid since all non-empty chunks are all_invalid - assert!(!chunked.all_valid(&mut array_session().create_execution_ctx())?); - assert!( - chunked - .into_array() - .all_invalid(&mut array_session().create_execution_ctx())? - ); + assert!(!chunked.all_valid(&mut ctx)?); + assert!(chunked.into_array().all_invalid(&mut ctx)?); Ok(()) } #[test] fn test_empty_chunks_mixed_validity() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); // Create chunks with mixed validity including empty chunks let chunks = vec![ PrimitiveArray::new(buffer![1u64, 2], Validity::AllValid).into_array(), @@ -421,12 +421,8 @@ mod test { ChunkedArray::try_new(chunks, DType::Primitive(PType::U64, Nullability::Nullable))?; // Should be neither all_valid nor all_invalid - assert!(!chunked.all_valid(&mut array_session().create_execution_ctx())?); - assert!( - !chunked - .into_array() - .all_invalid(&mut array_session().create_execution_ctx())? - ); + assert!(!chunked.all_valid(&mut ctx)?); + assert!(!chunked.into_array().all_invalid(&mut ctx)?); Ok(()) } diff --git a/vortex-array/src/arrays/chunked/compute/cast.rs b/vortex-array/src/arrays/chunked/compute/cast.rs index 80c6848b973..25e312de748 100644 --- a/vortex-array/src/arrays/chunked/compute/cast.rs +++ b/vortex-array/src/arrays/chunked/compute/cast.rs @@ -97,6 +97,6 @@ mod test { DType::Primitive(PType::U8, Nullability::NonNullable) ).unwrap().into_array())] fn test_cast_chunked_conformance(#[case] array: crate::ArrayRef) { - test_cast_conformance(&array); + test_cast_conformance(&array, &mut array_session().create_execution_ctx()); } } diff --git a/vortex-array/src/arrays/chunked/compute/filter.rs b/vortex-array/src/arrays/chunked/compute/filter.rs index 0e32543761d..5f50ce18387 100644 --- a/vortex-array/src/arrays/chunked/compute/filter.rs +++ b/vortex-array/src/arrays/chunked/compute/filter.rs @@ -203,6 +203,8 @@ mod test { use vortex_mask::Mask; use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::ChunkedArray; use crate::arrays::PrimitiveArray; use crate::compute::conformance::filter::test_filter_conformance; @@ -275,6 +277,9 @@ mod test { DType::Primitive(PType::I64, Nullability::NonNullable), ).unwrap())] fn test_filter_chunked_conformance(#[case] chunked: ChunkedArray) { - test_filter_conformance(&chunked.into_array()); + test_filter_conformance( + &chunked.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/chunked/compute/mask.rs b/vortex-array/src/arrays/chunked/compute/mask.rs index bec7b5e83d0..01170a232bb 100644 --- a/vortex-array/src/arrays/chunked/compute/mask.rs +++ b/vortex-array/src/arrays/chunked/compute/mask.rs @@ -45,6 +45,8 @@ mod test { use vortex_buffer::buffer; use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::ChunkedArray; use crate::arrays::PrimitiveArray; use crate::compute::conformance::mask::test_mask_conformance; @@ -80,6 +82,9 @@ mod test { DType::Primitive(PType::F32, Nullability::NonNullable), ).unwrap())] fn test_mask_chunked_conformance(#[case] chunked: ChunkedArray) { - test_mask_conformance(&chunked.into_array()); + test_mask_conformance( + &chunked.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/chunked/compute/take.rs b/vortex-array/src/arrays/chunked/compute/take.rs index 34f443f1ce1..1b867981236 100644 --- a/vortex-array/src/arrays/chunked/compute/take.rs +++ b/vortex-array/src/arrays/chunked/compute/take.rs @@ -126,8 +126,6 @@ mod test { use vortex_error::VortexResult; use crate::IntoArray; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::BoolArray; @@ -268,6 +266,7 @@ mod test { #[test] fn test_take_shuffled_large() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); let nchunks: i32 = 100; let chunk_len: i32 = 1_000; let total = nchunks * chunk_len; @@ -297,8 +296,7 @@ mod test { let result = arr.take(indices_arr.into_array())?; // Verify every element. - #[expect(deprecated)] - let result = result.to_primitive(); + let result = result.execute::(&mut ctx)?; let result_vals = result.as_slice::(); for (pos, &idx) in indices.iter().enumerate() { assert_eq!( @@ -353,14 +351,20 @@ mod test { .clone(), ) .unwrap(); - test_take_conformance(&arr.into_array()); + test_take_conformance( + &arr.into_array(), + &mut array_session().create_execution_ctx(), + ); // Test with nullable chunked array let a = PrimitiveArray::from_option_iter([Some(1i32), None, Some(3)]); let b = PrimitiveArray::from_option_iter([Some(4i32), Some(5)]); let dtype = a.dtype().clone(); let arr = ChunkedArray::try_new(vec![a.into_array(), b.into_array()], dtype).unwrap(); - test_take_conformance(&arr.into_array()); + test_take_conformance( + &arr.into_array(), + &mut array_session().create_execution_ctx(), + ); // Test with multiple identical chunks let chunk = buffer![10i32, 20, 30, 40, 50].into_array(); @@ -369,6 +373,9 @@ mod test { chunk.dtype().clone(), ) .unwrap(); - test_take_conformance(&arr.into_array()); + test_take_conformance( + &arr.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/chunked/compute/zip.rs b/vortex-array/src/arrays/chunked/compute/zip.rs index 4037fd1dc49..dc95c194173 100644 --- a/vortex-array/src/arrays/chunked/compute/zip.rs +++ b/vortex-array/src/arrays/chunked/compute/zip.rs @@ -51,12 +51,11 @@ mod tests { use crate::ArrayRef; use crate::IntoArray; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::Chunked; use crate::arrays::ChunkedArray; + use crate::arrays::PrimitiveArray; use crate::arrays::chunked::ChunkedArrayExt; use crate::builtins::ArrayBuiltins; use crate::dtype::DType; @@ -65,6 +64,7 @@ mod tests { #[test] fn test_chunked_zip_aligns_across_boundaries() { + let mut ctx = array_session().create_execution_ctx(); let if_true = ChunkedArray::try_new( vec![ buffer![1i32, 2].into_array(), @@ -103,8 +103,7 @@ mod tests { assert_eq!(zipped.nchunks(), 4); let mut values: Vec = Vec::new(); for chunk in zipped.chunks() { - #[expect(deprecated)] - let primitive = chunk.to_primitive(); + let primitive = chunk.execute::(&mut ctx).unwrap(); values.extend_from_slice(primitive.as_slice::()); } assert_eq!(values, vec![1, 11, 3, 13, 5]); diff --git a/vortex-array/src/arrays/chunked/paired_chunks.rs b/vortex-array/src/arrays/chunked/paired_chunks.rs index 2145c88dbbd..c4a73a09518 100644 --- a/vortex-array/src/arrays/chunked/paired_chunks.rs +++ b/vortex-array/src/arrays/chunked/paired_chunks.rs @@ -121,9 +121,10 @@ mod tests { use vortex_error::VortexResult; use crate::IntoArray; - #[expect(deprecated)] - use crate::ToCanonical as _; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::ChunkedArray; + use crate::arrays::PrimitiveArray; use crate::arrays::chunked::paired_chunks::PairedChunksExt; use crate::dtype::DType; use crate::dtype::Nullability; @@ -138,13 +139,20 @@ mod tests { left: &ChunkedArray, right: &ChunkedArray, ) -> VortexResult, Vec, std::ops::Range)>> { + let mut ctx = array_session().create_execution_ctx(); let mut result = Vec::new(); for pair in left.paired_chunks(right) { let pair = pair?; - #[expect(deprecated)] - let l: Vec = pair.left.to_primitive().as_slice::().to_vec(); - #[expect(deprecated)] - let r: Vec = pair.right.to_primitive().as_slice::().to_vec(); + let l: Vec = pair + .left + .execute::(&mut ctx)? + .as_slice::() + .to_vec(); + let r: Vec = pair + .right + .execute::(&mut ctx)? + .as_slice::() + .to_vec(); result.push((l, r, pair.pos)); } Ok(result) diff --git a/vortex-array/src/arrays/chunked/tests.rs b/vortex-array/src/arrays/chunked/tests.rs index 578b0a7c068..a32d03e207b 100644 --- a/vortex-array/src/arrays/chunked/tests.rs +++ b/vortex-array/src/arrays/chunked/tests.rs @@ -12,9 +12,11 @@ use vortex_session::VortexSession; use crate::Canonical; use crate::IntoArray; use crate::VortexSessionExecute; +use crate::array_session; use crate::arrays::Chunked; use crate::arrays::ChunkedArray; use crate::arrays::ListArray; +use crate::arrays::ListViewArray; use crate::arrays::PrimitiveArray; use crate::arrays::StructArray; use crate::arrays::VarBinViewArray; @@ -23,8 +25,6 @@ use crate::arrays::dict_test::gen_dict_primitive_chunks; use crate::arrays::struct_::StructArrayExt; use crate::assert_arrays_eq; use crate::builders::builder_with_capacity; -#[expect(deprecated)] -use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::Nullability; use crate::dtype::PType; @@ -32,7 +32,7 @@ use crate::dtype::PType::I32; use crate::executor::execute_into_builder; use crate::validity::Validity; -static SESSION: LazyLock = LazyLock::new(crate::array_session); +static SESSION: LazyLock = LazyLock::new(array_session); fn chunked_array() -> ChunkedArray { ChunkedArray::try_new( @@ -375,33 +375,14 @@ pub fn pack_nested_structs() -> VortexResult<()> { dtype, )? .into_array(); - #[expect(deprecated)] - let canonical_struct = chunked.to_struct(); - #[expect(deprecated)] - let canonical_varbin = canonical_struct.unmasked_fields()[0].to_varbinview(); - #[expect(deprecated)] - let original_varbin = struct_array.unmasked_fields()[0].to_varbinview(); - let orig_mask = original_varbin - .validity()? - .execute_mask(original_varbin.len(), &mut ctx)?; - let orig_values = (0..original_varbin.len()) - .map(|i| { - orig_mask - .value(i) - .then(|| original_varbin.bytes_at(i).to_vec()) - }) - .collect::>(); - let canon_mask = canonical_varbin - .validity()? - .execute_mask(canonical_varbin.len(), &mut ctx)?; - let canon_values = (0..canonical_varbin.len()) - .map(|i| { - canon_mask - .value(i) - .then(|| canonical_varbin.bytes_at(i).to_vec()) - }) - .collect::>(); - assert_eq!(orig_values, canon_values); + let canonical_struct = chunked.execute::(&mut ctx)?; + let canonical_varbin = canonical_struct.unmasked_fields()[0] + .clone() + .execute::(&mut ctx)?; + let original_varbin = struct_array.unmasked_fields()[0] + .clone() + .execute::(&mut ctx)?; + assert_arrays_eq!(original_varbin, canonical_varbin, &mut ctx); Ok(()) } @@ -430,8 +411,12 @@ pub fn pack_nested_lists() { ), ); - #[expect(deprecated)] - let canon_values = chunked_list.unwrap().as_array().to_listview(); + let canon_values = chunked_list + .unwrap() + .as_array() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!( l1.execute_scalar(0, &mut ctx).unwrap(), diff --git a/vortex-array/src/arrays/chunked/vtable/canonical.rs b/vortex-array/src/arrays/chunked/vtable/canonical.rs index 995d90bc0aa..12c3bbcd9e4 100644 --- a/vortex-array/src/arrays/chunked/vtable/canonical.rs +++ b/vortex-array/src/arrays/chunked/vtable/canonical.rs @@ -73,7 +73,7 @@ pub(super) fn _canonicalize( _ => { let mut builder = builder_with_capacity_in(ctx.allocator(), array.dtype(), array.len()); array.array().append_to_builder(builder.as_mut(), ctx)?; - builder.finish_into_canonical() + builder.finish_into_canonical(ctx) } }) } diff --git a/vortex-array/src/arrays/chunked/vtable/mod.rs b/vortex-array/src/arrays/chunked/vtable/mod.rs index 673855cfc0a..2eabd883742 100644 --- a/vortex-array/src/arrays/chunked/vtable/mod.rs +++ b/vortex-array/src/arrays/chunked/vtable/mod.rs @@ -22,14 +22,14 @@ use crate::EqMode; use crate::ExecutionCtx; use crate::ExecutionResult; use crate::IntoArray; -#[expect(deprecated)] -use crate::ToCanonical as _; +use crate::VortexSessionExecute; use crate::array::Array; use crate::array::ArrayId; use crate::array::ArrayParts; use crate::array::ArrayView; use crate::array::VTable; use crate::array::with_empty_buffers; +use crate::arrays::PrimitiveArray; use crate::arrays::chunked::ChunkedArrayExt; use crate::arrays::chunked::ChunkedData; use crate::arrays::chunked::array::CHUNK_OFFSETS_SLOT; @@ -179,7 +179,7 @@ impl VTable for Chunked { metadata: &[u8], _buffers: &[BufferHandle], children: &dyn ArrayChildren, - _session: &VortexSession, + session: &VortexSession, ) -> VortexResult> { if !metadata.is_empty() { vortex_bail!( @@ -197,8 +197,11 @@ impl VTable for Chunked { &DType::Primitive(PType::U64, Nullability::NonNullable), nchunks + 1, )?; - #[expect(deprecated)] - let chunk_offsets_buf = chunk_offsets.to_primitive().to_buffer::(); + let mut ctx = session.create_execution_ctx(); + let chunk_offsets_buf = chunk_offsets + .clone() + .execute::(&mut ctx)? + .to_buffer::(); let chunk_offsets_usize = chunk_offsets_buf .iter() .copied() diff --git a/vortex-array/src/arrays/constant/arbitrary.rs b/vortex-array/src/arrays/constant/arbitrary.rs index c40bb5e82b7..37bde6414dd 100644 --- a/vortex-array/src/arrays/constant/arbitrary.rs +++ b/vortex-array/src/arrays/constant/arbitrary.rs @@ -16,15 +16,15 @@ pub struct ArbitraryConstantArray(pub ConstantArray); impl<'a> Arbitrary<'a> for ArbitraryConstantArray { fn arbitrary(u: &mut Unstructured<'a>) -> Result { let dtype: DType = u.arbitrary()?; - Self::with_dtype(u, &dtype, None) + Self::with_dtype(u, &dtype) } } impl ArbitraryConstantArray { /// Generate an arbitrary ConstantArray with the given dtype. - pub fn with_dtype(u: &mut Unstructured, dtype: &DType, len: Option) -> Result { + pub fn with_dtype(u: &mut Unstructured, dtype: &DType) -> Result { let scalar = random_scalar(u, dtype)?; - let len = len.unwrap_or(u.int_in_range(0..=100)?); + let len = u.int_in_range(0..=2048)?; Ok(ArbitraryConstantArray(ConstantArray::new(scalar, len))) } } diff --git a/vortex-array/src/arrays/constant/compute/cast.rs b/vortex-array/src/arrays/constant/compute/cast.rs index 00a6e15295a..439bf8367b8 100644 --- a/vortex-array/src/arrays/constant/compute/cast.rs +++ b/vortex-array/src/arrays/constant/compute/cast.rs @@ -26,13 +26,13 @@ mod tests { use crate::IntoArray; use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::ConstantArray; use crate::builtins::ArrayBuiltins; use crate::compute::conformance::cast::test_cast_conformance; use crate::dtype::DType; use crate::dtype::DecimalDType; use crate::dtype::Nullability; - use crate::legacy_session; use crate::scalar::DecimalValue; use crate::scalar::Scalar; @@ -44,7 +44,7 @@ mod tests { #[case(ConstantArray::new(Scalar::null_native::(), 4).into_array())] #[case(ConstantArray::new(Scalar::from(255u8), 1).into_array())] fn test_cast_constant_conformance(#[case] array: crate::ArrayRef) { - test_cast_conformance(&array); + test_cast_conformance(&array, &mut array_session().create_execution_ctx()); } #[test] @@ -58,7 +58,7 @@ mod tests { assert_eq!(casted.dtype(), &target_dtype); let scalar = casted - .execute_scalar(0, &mut legacy_session().create_execution_ctx()) + .execute_scalar(0, &mut array_session().create_execution_ctx()) .unwrap(); assert_eq!( scalar.as_decimal().decimal_value(), diff --git a/vortex-array/src/arrays/constant/compute/mod.rs b/vortex-array/src/arrays/constant/compute/mod.rs index 80fab02dc88..a996bf79a0f 100644 --- a/vortex-array/src/arrays/constant/compute/mod.rs +++ b/vortex-array/src/arrays/constant/compute/mod.rs @@ -29,21 +29,41 @@ mod test { #[test] fn test_mask_constant() { - test_mask_conformance(&ConstantArray::new(Scalar::null_native::(), 5).into_array()); - test_mask_conformance(&ConstantArray::new(Scalar::from(3u16), 5).into_array()); - test_mask_conformance(&ConstantArray::new(Scalar::from(1.0f32 / 0.0f32), 5).into_array()); + test_mask_conformance( + &ConstantArray::new(Scalar::null_native::(), 5).into_array(), + &mut array_session().create_execution_ctx(), + ); + test_mask_conformance( + &ConstantArray::new(Scalar::from(3u16), 5).into_array(), + &mut array_session().create_execution_ctx(), + ); + test_mask_conformance( + &ConstantArray::new(Scalar::from(1.0f32 / 0.0f32), 5).into_array(), + &mut array_session().create_execution_ctx(), + ); test_mask_conformance( &ConstantArray::new(Scalar::from(f16::from_f32(3.0f32)), 5).into_array(), + &mut array_session().create_execution_ctx(), ); } #[test] fn test_filter_constant() { - test_filter_conformance(&ConstantArray::new(Scalar::null_native::(), 5).into_array()); - test_filter_conformance(&ConstantArray::new(Scalar::from(3u16), 5).into_array()); - test_filter_conformance(&ConstantArray::new(Scalar::from(1.0f32 / 0.0f32), 5).into_array()); + test_filter_conformance( + &ConstantArray::new(Scalar::null_native::(), 5).into_array(), + &mut array_session().create_execution_ctx(), + ); + test_filter_conformance( + &ConstantArray::new(Scalar::from(3u16), 5).into_array(), + &mut array_session().create_execution_ctx(), + ); + test_filter_conformance( + &ConstantArray::new(Scalar::from(1.0f32 / 0.0f32), 5).into_array(), + &mut array_session().create_execution_ctx(), + ); test_filter_conformance( &ConstantArray::new(Scalar::from(f16::from_f32(3.0f32)), 5).into_array(), + &mut array_session().create_execution_ctx(), ); } diff --git a/vortex-array/src/arrays/constant/compute/take.rs b/vortex-array/src/arrays/constant/compute/take.rs index 834d4338bfb..ecd9db452ce 100644 --- a/vortex-array/src/arrays/constant/compute/take.rs +++ b/vortex-array/src/arrays/constant/compute/take.rs @@ -74,8 +74,6 @@ mod tests { use vortex_mask::AllOr; use crate::IntoArray; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::ConstantArray; @@ -105,8 +103,7 @@ mod tests { taken.dtype() ); assert_arrays_eq!( - #[expect(deprecated)] - taken.to_primitive(), + taken.clone().execute::(&mut ctx).unwrap(), PrimitiveArray::new( buffer![42i32, 42, 42], Validity::from_iter([false, true, false]) @@ -117,7 +114,7 @@ mod tests { taken .validity() .unwrap() - .execute_mask(taken.len(), &mut array_session().create_execution_ctx()) + .execute_mask(taken.len(), &mut ctx) .unwrap() .indices(), AllOr::Some(valid_indices) @@ -136,8 +133,7 @@ mod tests { taken.dtype() ); assert_arrays_eq!( - #[expect(deprecated)] - taken.to_primitive(), + taken.clone().execute::(&mut ctx).unwrap(), PrimitiveArray::new(buffer![42i32, 42, 42], Validity::AllValid), &mut ctx ); @@ -145,7 +141,7 @@ mod tests { taken .validity() .unwrap() - .execute_mask(taken.len(), &mut array_session().create_execution_ctx()) + .execute_mask(taken.len(), &mut ctx) .unwrap() .indices(), AllOr::All @@ -159,6 +155,9 @@ mod tests { #[case(ConstantArray::new(Scalar::null_native::(), 5))] #[case(ConstantArray::new(true, 1))] fn test_take_constant_conformance(#[case] array: ConstantArray) { - test_take_conformance(&array.into_array()); + test_take_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/constant/vtable/mod.rs b/vortex-array/src/arrays/constant/vtable/mod.rs index d3df3a14b36..a2057077335 100644 --- a/vortex-array/src/arrays/constant/vtable/mod.rs +++ b/vortex-array/src/arrays/constant/vtable/mod.rs @@ -246,7 +246,7 @@ impl VTable for Constant { .clone() .execute::(ctx)? .into_array(); - builder.extend_from_array(&canonical); + canonical.append_to_builder(builder, ctx)?; } } diff --git a/vortex-array/src/arrays/datetime/test.rs b/vortex-array/src/arrays/datetime/test.rs index 1ae114874d9..60081650f9f 100644 --- a/vortex-array/src/arrays/datetime/test.rs +++ b/vortex-array/src/arrays/datetime/test.rs @@ -8,8 +8,6 @@ use vortex_error::VortexResult; use crate::EqMode; use crate::IntoArray; -#[expect(deprecated)] -use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::PrimitiveArray; @@ -202,8 +200,11 @@ fn test_validity_preservation(#[case] validity: Validity) { let temporal_array = TemporalData::new_timestamp(milliseconds, TimeUnit::Milliseconds, Some("UTC".into())); - #[expect(deprecated)] - let prim = temporal_array.temporal_values().to_primitive(); + let prim = temporal_array + .temporal_values() + .clone() + .execute::(&mut array_session().create_execution_ctx()) + .unwrap(); assert!( prim.validity() .vortex_expect("temporal validity should be derivable") diff --git a/vortex-array/src/arrays/decimal/compute/cast.rs b/vortex-array/src/arrays/decimal/compute/cast.rs index af636ca0f00..20cac1d5106 100644 --- a/vortex-array/src/arrays/decimal/compute/cast.rs +++ b/vortex-array/src/arrays/decimal/compute/cast.rs @@ -451,8 +451,6 @@ mod tests { use crate::array_session; use crate::arrays::DecimalArray; use crate::builtins::ArrayBuiltins; - #[expect(deprecated)] - use crate::canonical::ToCanonical as _; use crate::compute::conformance::cast::test_cast_conformance; use crate::dtype::DType; use crate::dtype::DecimalDType; @@ -464,6 +462,7 @@ mod tests { #[test] fn cast_decimal_to_nullable() { + let mut ctx = array_session().create_execution_ctx(); let decimal_dtype = DecimalDType::new(10, 2); let array = DecimalArray::new( buffer![100i32, 200, 300], @@ -473,12 +472,12 @@ mod tests { // Cast to nullable let nullable_dtype = DType::Decimal(decimal_dtype, Nullability::Nullable); - #[expect(deprecated)] let casted = array .into_array() .cast(nullable_dtype.clone()) .unwrap() - .to_decimal(); + .execute::(&mut ctx) + .unwrap(); assert_eq!(casted.dtype(), &nullable_dtype); assert!(matches!(casted.validity(), Ok(Validity::AllValid))); @@ -487,6 +486,7 @@ mod tests { #[test] fn cast_nullable_to_non_nullable() { + let mut ctx = array_session().create_execution_ctx(); let decimal_dtype = DecimalDType::new(10, 2); // Create nullable array with no nulls @@ -494,12 +494,12 @@ mod tests { // Cast to non-nullable let non_nullable_dtype = DType::Decimal(decimal_dtype, Nullability::NonNullable); - #[expect(deprecated)] let casted = array .into_array() .cast(non_nullable_dtype.clone()) .unwrap() - .to_decimal(); + .execute::(&mut ctx) + .unwrap(); assert_eq!(casted.dtype(), &non_nullable_dtype); assert!(matches!(casted.validity(), Ok(Validity::NonNullable))); @@ -508,6 +508,7 @@ mod tests { #[test] #[should_panic(expected = "Cannot cast array with invalid values to non-nullable type")] fn cast_nullable_with_nulls_to_non_nullable_fails() { + let mut ctx = array_session().create_execution_ctx(); let decimal_dtype = DecimalDType::new(10, 2); // Create nullable array with nulls @@ -515,16 +516,16 @@ mod tests { // Attempt to cast to non-nullable should fail let non_nullable_dtype = DType::Decimal(decimal_dtype, Nullability::NonNullable); - #[expect(deprecated)] - let result = array + array .into_array() .cast(non_nullable_dtype) - .and_then(|a| a.to_canonical().map(|c| c.into_array())); - result.unwrap(); + .and_then(|a| a.execute::(&mut ctx).map(|c| c.into_array())) + .unwrap(); } #[test] fn cast_different_scale_rescales() { + let mut ctx = array_session().create_execution_ctx(); let array = DecimalArray::new( buffer![100i32], DecimalDType::new(10, 2), @@ -533,12 +534,12 @@ mod tests { // Cast 1.00 to scale 3, where it is stored as 1000. let different_dtype = DType::Decimal(DecimalDType::new(15, 3), Nullability::NonNullable); - #[expect(deprecated)] let casted = array .into_array() .cast(different_dtype) .unwrap() - .to_decimal(); + .execute::(&mut ctx) + .unwrap(); assert_eq!(casted.precision(), 15); assert_eq!(casted.scale(), 3); @@ -548,6 +549,7 @@ mod tests { #[test] fn cast_downcast_precision_succeeds_when_values_fit() { + let mut ctx = array_session().create_execution_ctx(); let array = DecimalArray::new( buffer![100i64], DecimalDType::new(18, 2), @@ -556,8 +558,12 @@ mod tests { // Downcasting precision is allowed when every value fits. let smaller_dtype = DType::Decimal(DecimalDType::new(10, 2), Nullability::NonNullable); - #[expect(deprecated)] - let casted = array.into_array().cast(smaller_dtype).unwrap().to_decimal(); + let casted = array + .into_array() + .cast(smaller_dtype) + .unwrap() + .execute::(&mut ctx) + .unwrap(); assert_eq!(casted.precision(), 10); assert_eq!(casted.scale(), 2); @@ -566,6 +572,7 @@ mod tests { #[test] fn cast_downcast_precision_checks_values() { + let mut ctx = array_session().create_execution_ctx(); let array = DecimalArray::new( buffer![1000i64], DecimalDType::new(18, 0), @@ -573,11 +580,10 @@ mod tests { ); let smaller_dtype = DType::Decimal(DecimalDType::new(3, 0), Nullability::NonNullable); - #[expect(deprecated)] let result = array .into_array() .cast(smaller_dtype) - .and_then(|a| a.to_canonical().map(|c| c.into_array())); + .and_then(|a| a.execute::(&mut ctx).map(|c| c.into_array())); assert!(result.is_err()); assert!( @@ -590,6 +596,7 @@ mod tests { #[test] fn cast_lower_scale_requires_exact_rescale() { + let mut ctx = array_session().create_execution_ctx(); let array = DecimalArray::new( buffer![123456i64], DecimalDType::new(10, 4), @@ -597,11 +604,10 @@ mod tests { ); let lower_scale_dtype = DType::Decimal(DecimalDType::new(10, 2), Nullability::NonNullable); - #[expect(deprecated)] let result = array .into_array() .cast(lower_scale_dtype) - .and_then(|a| a.to_canonical().map(|c| c.into_array())); + .and_then(|a| a.execute::(&mut ctx).map(|c| c.into_array())); assert!(result.is_err()); assert!( @@ -614,6 +620,7 @@ mod tests { #[test] fn cast_lower_scale_ignores_null_lane_failures() { + let mut ctx = array_session().create_execution_ctx(); let array = DecimalArray::new( buffer![100i64, 123456], DecimalDType::new(10, 4), @@ -621,12 +628,12 @@ mod tests { ); let lower_scale_dtype = DType::Decimal(DecimalDType::new(3, 2), Nullability::Nullable); - #[expect(deprecated)] let casted = array .into_array() .cast(lower_scale_dtype) .unwrap() - .to_decimal(); + .execute::(&mut ctx) + .unwrap(); let mask = casted .as_ref() @@ -644,6 +651,7 @@ mod tests { #[test] fn cast_upcast_precision_succeeds() { + let mut ctx = array_session().create_execution_ctx(); let array = DecimalArray::new( buffer![100i32, 200, 300], DecimalDType::new(10, 2), @@ -652,8 +660,12 @@ mod tests { // Cast to higher precision with same scale - should succeed let wider_dtype = DType::Decimal(DecimalDType::new(38, 2), Nullability::NonNullable); - #[expect(deprecated)] - let casted = array.into_array().cast(wider_dtype).unwrap().to_decimal(); + let casted = array + .into_array() + .cast(wider_dtype) + .unwrap() + .execute::(&mut ctx) + .unwrap(); assert_eq!(casted.precision(), 38); assert_eq!(casted.scale(), 2); @@ -664,6 +676,7 @@ mod tests { #[test] fn cast_widening_same_physical_type_is_zero_copy() { + let mut ctx = array_session().create_execution_ctx(); // Decimal(10,2) and Decimal(18,2) are both physically i64 with the same scale, so widening // the precision must reuse the values buffer rather than allocate and re-scan it. let array = DecimalArray::new( @@ -674,8 +687,12 @@ mod tests { let src_ptr = array.buffer::().as_ptr(); let wider_dtype = DType::Decimal(DecimalDType::new(18, 2), Nullability::NonNullable); - #[expect(deprecated)] - let casted = array.into_array().cast(wider_dtype).unwrap().to_decimal(); + let casted = array + .into_array() + .cast(wider_dtype) + .unwrap() + .execute::(&mut ctx) + .unwrap(); assert_eq!(casted.precision(), 18); assert_eq!(casted.scale(), 2); @@ -691,6 +708,7 @@ mod tests { #[test] fn cast_to_non_decimal_returns_err() { + let mut ctx = array_session().create_execution_ctx(); let array = DecimalArray::new( buffer![100i32], DecimalDType::new(10, 2), @@ -698,11 +716,10 @@ mod tests { ); // Try to cast to non-decimal type - should fail since no kernel can handle it - #[expect(deprecated)] let result = array .into_array() .cast(DType::Utf8(Nullability::NonNullable)) - .and_then(|a| a.to_canonical().map(|c| c.into_array())); + .and_then(|a| a.execute::(&mut ctx).map(|c| c.into_array())); assert!(result.is_err()); assert!( @@ -719,7 +736,10 @@ mod tests { #[case(DecimalArray::from_option_iter([Some(100i32), None, Some(300)], DecimalDType::new(10, 2)))] #[case(DecimalArray::new(buffer![42i32], DecimalDType::new(5, 1), Validity::NonNullable))] fn test_cast_decimal_conformance(#[case] array: DecimalArray) { - test_cast_conformance(&array.into_array()); + test_cast_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } #[test] diff --git a/vortex-array/src/arrays/decimal/compute/fill_null.rs b/vortex-array/src/arrays/decimal/compute/fill_null.rs index 1c2904b7b6b..c4de15de5cc 100644 --- a/vortex-array/src/arrays/decimal/compute/fill_null.rs +++ b/vortex-array/src/arrays/decimal/compute/fill_null.rs @@ -94,8 +94,6 @@ mod tests { use crate::arrays::DecimalArray; use crate::assert_arrays_eq; use crate::builtins::ArrayBuiltins; - #[expect(deprecated)] - use crate::canonical::ToCanonical as _; use crate::dtype::DecimalDType; use crate::dtype::Nullability; use crate::scalar::DecimalValue; @@ -110,7 +108,6 @@ mod tests { [None, Some(800i128), None, Some(1000i128), None], decimal_dtype, ); - #[expect(deprecated)] let p = arr .into_array() .fill_null(Scalar::decimal( @@ -119,7 +116,8 @@ mod tests { Nullability::NonNullable, )) .unwrap() - .to_decimal(); + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!( p, DecimalArray::from_iter([4200, 800, 4200, 1000, 4200], decimal_dtype), @@ -152,7 +150,6 @@ mod tests { decimal_dtype, ); - #[expect(deprecated)] let p = arr .into_array() .fill_null(Scalar::decimal( @@ -161,7 +158,8 @@ mod tests { Nullability::NonNullable, )) .unwrap() - .to_decimal(); + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!( p, DecimalArray::from_iter([25500, 25500, 25500, 25500, 25500], decimal_dtype), @@ -176,7 +174,6 @@ mod tests { let decimal_dtype = DecimalDType::new(3, 0); let arr = DecimalArray::from_option_iter([None, Some(10i8), None], decimal_dtype); // i8 max is 127, so 200 doesn't fit — the array should be widened to i16. - #[expect(deprecated)] let result = arr .into_array() .fill_null(Scalar::decimal( @@ -185,7 +182,8 @@ mod tests { Nullability::NonNullable, )) .unwrap() - .to_decimal(); + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!( result, DecimalArray::from_iter([200i16, 10, 200], decimal_dtype), @@ -203,7 +201,6 @@ mod tests { decimal_dtype, Validity::NonNullable, ); - #[expect(deprecated)] let p = arr .into_array() .fill_null(Scalar::decimal( @@ -212,7 +209,8 @@ mod tests { Nullability::NonNullable, )) .unwrap() - .to_decimal(); + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!( p, DecimalArray::from_iter([800i128, 1000, 1200, 1400, 1600], decimal_dtype), diff --git a/vortex-array/src/arrays/decimal/compute/take.rs b/vortex-array/src/arrays/decimal/compute/take.rs index 762ac3d7365..ce786f8e4ab 100644 --- a/vortex-array/src/arrays/decimal/compute/take.rs +++ b/vortex-array/src/arrays/decimal/compute/take.rs @@ -131,6 +131,9 @@ mod tests { ) })] fn test_take_decimal_conformance(#[case] array: DecimalArray) { - test_take_conformance(&array.into_array()); + test_take_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/decimal/vtable/mod.rs b/vortex-array/src/arrays/decimal/vtable/mod.rs index 674f8598256..1ed839b813e 100644 --- a/vortex-array/src/arrays/decimal/vtable/mod.rs +++ b/vortex-array/src/arrays/decimal/vtable/mod.rs @@ -20,6 +20,8 @@ use crate::array::ArrayView; use crate::array::VTable; use crate::arrays::decimal::DecimalData; use crate::buffer::BufferHandle; +use crate::builders::ArrayBuilder; +use crate::builders::DecimalBuilder; use crate::dtype::DType; use crate::dtype::DecimalType; use crate::dtype::NativeDecimalType; @@ -207,6 +209,17 @@ impl VTable for Decimal { Ok(ExecutionResult::done(array)) } + fn append_to_builder( + array: ArrayView<'_, Self>, + builder: &mut dyn ArrayBuilder, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + let Some(builder) = builder.as_any_mut().downcast_mut::() else { + vortex_bail!("append_to_builder for Decimal requires a DecimalBuilder"); + }; + builder.append_decimal_array(&array.into_owned(), ctx) + } + fn reduce_parent( array: ArrayView<'_, Self>, parent: &ArrayRef, diff --git a/vortex-array/src/arrays/dict/arbitrary.rs b/vortex-array/src/arrays/dict/arbitrary.rs index 4eb938181b9..2a70dd2586c 100644 --- a/vortex-array/src/arrays/dict/arbitrary.rs +++ b/vortex-array/src/arrays/dict/arbitrary.rs @@ -37,7 +37,6 @@ impl ArbitraryDictArray { pub fn with_dtype(u: &mut Unstructured, dtype: &DType, len: Option) -> Result { // Generate the number of unique values (dictionary size) let values_len = u.int_in_range(1..=20)?; - // Generate values array with the given dtype let values = ArbitraryArray::arbitrary_with_config( u, &ArbitraryArrayConfig { diff --git a/vortex-array/src/arrays/dict/array.rs b/vortex-array/src/arrays/dict/array.rs index 13539684fa4..cefc5a381fd 100644 --- a/vortex-array/src/arrays/dict/array.rs +++ b/vortex-array/src/arrays/dict/array.rs @@ -15,17 +15,15 @@ use vortex_mask::AllOr; use crate::ArrayRef; use crate::ArraySlots; -#[expect(deprecated)] -use crate::ToCanonical as _; -use crate::VortexSessionExecute; +use crate::ExecutionCtx; use crate::array::Array; use crate::array::ArrayParts; use crate::array::TypedArrayRef; use crate::array_slots; use crate::arrays::Dict; +use crate::arrays::PrimitiveArray; use crate::dtype::DType; use crate::dtype::PType; -use crate::legacy_session; use crate::match_each_integer_ptype; #[derive(Clone, prost::Message)] @@ -129,13 +127,13 @@ pub trait DictArrayExt: TypedArrayRef + DictArraySlotsExt { self.all_values_referenced } - fn validate_all_values_referenced(&self) -> VortexResult<()> { + fn validate_all_values_referenced(&self, ctx: &mut ExecutionCtx) -> VortexResult<()> { if self.has_all_values_referenced() { if !self.codes().is_host() { return Ok(()); } - let referenced_mask = self.compute_referenced_values_mask(true)?; + let referenced_mask = self.compute_referenced_values_mask(true, ctx)?; let all_referenced = referenced_mask.true_count() == referenced_mask.len(); vortex_ensure!(all_referenced, "value in dict not referenced"); @@ -144,14 +142,14 @@ pub trait DictArrayExt: TypedArrayRef + DictArraySlotsExt { Ok(()) } - #[allow(clippy::disallowed_methods)] - fn compute_referenced_values_mask(&self, referenced: bool) -> VortexResult { + fn compute_referenced_values_mask( + &self, + referenced: bool, + ctx: &mut ExecutionCtx, + ) -> VortexResult { let codes = self.codes(); - let codes_validity = codes - .validity()? - .execute_mask(codes.len(), &mut legacy_session().create_execution_ctx())?; - #[expect(deprecated)] - let codes_primitive = self.codes().to_primitive(); + let codes_validity = codes.validity()?.execute_mask(codes.len(), ctx)?; + let codes_primitive = codes.clone().execute::(ctx)?; let values_len = self.values().len(); let init_value = !referenced; @@ -269,18 +267,9 @@ impl Array { self.into_data() .set_all_values_referenced(all_values_referenced) }; - let array = unsafe { + unsafe { Array::from_parts_unchecked(ArrayParts::new(Dict, dtype, len, data).with_slots(slots)) - }; - - #[cfg(debug_assertions)] - if all_values_referenced { - array - .validate_all_values_referenced() - .vortex_expect("validation should succeed when all values are referenced"); } - - array } } @@ -300,8 +289,6 @@ mod test { use crate::ArrayRef; use crate::IntoArray; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::ChunkedArray; @@ -469,9 +456,8 @@ mod test { &mut array_session().create_execution_ctx(), )?; - #[expect(deprecated)] - let into_prim = array.to_primitive(); - let prim_into = builder.finish_into_canonical().into_primitive(); + let into_prim = array.execute::(&mut ctx)?; + let prim_into = builder.finish_into_canonical(&mut ctx).into_primitive(); assert_arrays_eq!(into_prim, prim_into, &mut ctx); Ok(()) diff --git a/vortex-array/src/arrays/dict/compute/cast.rs b/vortex-array/src/arrays/dict/compute/cast.rs index 56a433627a2..2597436f98f 100644 --- a/vortex-array/src/arrays/dict/compute/cast.rs +++ b/vortex-array/src/arrays/dict/compute/cast.rs @@ -184,7 +184,7 @@ mod tests { #[case(dict_encode(&PrimitiveArray::from_option_iter([Some(1i32), None, Some(2), Some(1), None]).into_array(), &mut SESSION.create_execution_ctx()).unwrap().into_array())] #[case(dict_encode(&buffer![1.5f32, 2.5, 1.5, 3.5].into_array(), &mut SESSION.create_execution_ctx()).unwrap().into_array())] fn test_cast_dict_conformance(#[case] array: ArrayRef) { - test_cast_conformance(&array); + test_cast_conformance(&array, &mut SESSION.create_execution_ctx()); } #[test] diff --git a/vortex-array/src/arrays/dict/compute/is_constant.rs b/vortex-array/src/arrays/dict/compute/is_constant.rs index 82518963106..85438683442 100644 --- a/vortex-array/src/arrays/dict/compute/is_constant.rs +++ b/vortex-array/src/arrays/dict/compute/is_constant.rs @@ -47,7 +47,7 @@ impl DynAggregateKernel for DictIsConstantKernel { let result = if dict.has_all_values_referenced() { is_constant(dict.values(), ctx)? } else { - let referenced_mask = dict.compute_referenced_values_mask(true)?; + let referenced_mask = dict.compute_referenced_values_mask(true, ctx)?; let mask = Mask::from(referenced_mask); let filtered_values = dict.values().filter(mask)?; is_constant(&filtered_values, ctx)? diff --git a/vortex-array/src/arrays/dict/compute/min_max.rs b/vortex-array/src/arrays/dict/compute/min_max.rs index ee38c0392d4..0122761c7bf 100644 --- a/vortex-array/src/arrays/dict/compute/min_max.rs +++ b/vortex-array/src/arrays/dict/compute/min_max.rs @@ -45,7 +45,7 @@ impl DynAggregateKernel for DictMinMaxKernel { min_max(dict.values(), ctx, *options)? } else { // Filter to only referenced values, then compute min/max. - let referenced_mask = dict.compute_referenced_values_mask(true)?; + let referenced_mask = dict.compute_referenced_values_mask(true, ctx)?; let mask = Mask::from(referenced_mask); let filtered_values = dict.values().filter(mask)?; min_max(&filtered_values, ctx, *options)? diff --git a/vortex-array/src/arrays/dict/compute/mod.rs b/vortex-array/src/arrays/dict/compute/mod.rs index e2b0d07b97d..5c013b2d321 100644 --- a/vortex-array/src/arrays/dict/compute/mod.rs +++ b/vortex-array/src/arrays/dict/compute/mod.rs @@ -215,7 +215,7 @@ mod test { &mut SESSION.create_execution_ctx(), ) .unwrap(); - test_mask_conformance(&array.into_array()); + test_mask_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); let array = dict_encode( &PrimitiveArray::from_option_iter([Some(2), None, Some(2), Some(0), Some(10)]) @@ -223,7 +223,7 @@ mod test { &mut SESSION.create_execution_ctx(), ) .unwrap(); - test_mask_conformance(&array.into_array()); + test_mask_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); let array = dict_encode( &VarBinArray::from_iter( @@ -240,7 +240,7 @@ mod test { &mut SESSION.create_execution_ctx(), ) .unwrap(); - test_mask_conformance(&array.into_array()); + test_mask_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] @@ -250,7 +250,7 @@ mod test { &mut SESSION.create_execution_ctx(), ) .unwrap(); - test_filter_conformance(&array.into_array()); + test_filter_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); let array = dict_encode( &PrimitiveArray::from_option_iter([Some(2), None, Some(2), Some(0), Some(10)]) @@ -258,7 +258,7 @@ mod test { &mut SESSION.create_execution_ctx(), ) .unwrap(); - test_filter_conformance(&array.into_array()); + test_filter_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); let array = dict_encode( &VarBinArray::from_iter( @@ -275,7 +275,7 @@ mod test { &mut SESSION.create_execution_ctx(), ) .unwrap(); - test_filter_conformance(&array.into_array()); + test_filter_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] @@ -302,7 +302,7 @@ mod test { &mut SESSION.create_execution_ctx(), ) .unwrap(); - test_take_conformance(&array.into_array()); + test_take_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); let array = dict_encode( &PrimitiveArray::from_option_iter([Some(2), None, Some(2), Some(0), Some(10)]) @@ -310,7 +310,7 @@ mod test { &mut SESSION.create_execution_ctx(), ) .unwrap(); - test_take_conformance(&array.into_array()); + test_take_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); let array = dict_encode( &VarBinArray::from_iter( @@ -327,7 +327,7 @@ mod test { &mut SESSION.create_execution_ctx(), ) .unwrap(); - test_take_conformance(&array.into_array()); + test_take_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } } diff --git a/vortex-array/src/arrays/dict/compute/rules.rs b/vortex-array/src/arrays/dict/compute/rules.rs index 94e3af66df2..a15f2641eab 100644 --- a/vortex-array/src/arrays/dict/compute/rules.rs +++ b/vortex-array/src/arrays/dict/compute/rules.rs @@ -267,6 +267,7 @@ mod tests { use crate::ArrayRef; use crate::IntoArray; + use crate::array_session; use crate::arrays::BoolArray; use crate::arrays::Chunked; use crate::arrays::ChunkedArray; @@ -299,7 +300,7 @@ mod tests { assert!(ArrayRef::ptr_eq(dict.values(), &values)); assert_eq!(codes.nchunks(), 2); - let mut ctx = crate::legacy_session().create_execution_ctx(); + let mut ctx = array_session().create_execution_ctx(); assert_arrays_eq!( optimized, PrimitiveArray::from_iter([10u32, 20, 30, 10, 20]), @@ -323,7 +324,7 @@ mod tests { let optimized = array.optimize()?; assert!(optimized.is::()); - let mut ctx = crate::legacy_session().create_execution_ctx(); + let mut ctx = array_session().create_execution_ctx(); assert_arrays_eq!( optimized, PrimitiveArray::from_iter([10u32, 20, 30, 10, 20]), diff --git a/vortex-array/src/arrays/extension/compute/cast.rs b/vortex-array/src/arrays/extension/compute/cast.rs index dfce610f43a..e080eb7f04c 100644 --- a/vortex-array/src/arrays/extension/compute/cast.rs +++ b/vortex-array/src/arrays/extension/compute/cast.rs @@ -146,7 +146,7 @@ mod tests { #[case(create_timestamp_array(TimeUnit::Nanoseconds, false))] #[case(create_timestamp_array(TimeUnit::Seconds, true))] fn test_cast_extension_conformance(#[case] array: ExtensionArray) { - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } fn create_timestamp_array(time_unit: TimeUnit, nullable: bool) -> ExtensionArray { diff --git a/vortex-array/src/arrays/extension/compute/mod.rs b/vortex-array/src/arrays/extension/compute/mod.rs index 8309ae43be2..256f31ac49b 100644 --- a/vortex-array/src/arrays/extension/compute/mod.rs +++ b/vortex-array/src/arrays/extension/compute/mod.rs @@ -15,6 +15,8 @@ mod test { use vortex_buffer::buffer; use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::ExtensionArray; use crate::arrays::PrimitiveArray; use crate::compute::conformance::filter::test_filter_conformance; @@ -31,14 +33,20 @@ mod test { // Create storage array let storage = buffer![1i32, 2, 3, 4, 5].into_array(); let array = ExtensionArray::new(ext_dtype.clone(), storage); - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); // Test with nullable extension type let ext_dtype_nullable = ext_dtype.with_nullability(Nullability::Nullable); let storage = PrimitiveArray::from_option_iter([Some(1i32), None, Some(3), Some(4), None]) .into_array(); let array = ExtensionArray::new(ext_dtype_nullable, storage); - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } #[rstest] @@ -77,7 +85,10 @@ mod test { ExtensionArray::new(ext_dtype_large, storage) })] fn test_take_extension_array_conformance(#[case] array: ExtensionArray) { - test_take_conformance(&array.into_array()); + test_take_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/extension/compute/rules.rs b/vortex-array/src/arrays/extension/compute/rules.rs index 396729b6347..2d02d1ae7a7 100644 --- a/vortex-array/src/arrays/extension/compute/rules.rs +++ b/vortex-array/src/arrays/extension/compute/rules.rs @@ -87,8 +87,7 @@ mod tests { use crate::EmptyMetadata; use crate::IntoArray; - #[expect(deprecated)] - use crate::ToCanonical as _; + use crate::VortexSessionExecute; use crate::arrays::Constant; use crate::arrays::ConstantArray; use crate::arrays::Extension; @@ -179,8 +178,11 @@ mod tests { assert_eq!(ext_result.ext_dtype(), &ext_dtype); // Check the storage values - #[expect(deprecated)] - let storage_prim = ext_result.storage_array().to_primitive(); + let storage_prim = ext_result + .storage_array() + .clone() + .execute::(&mut SESSION.create_execution_ctx()) + .unwrap(); let storage_result: &[i64] = &storage_prim.to_buffer::(); assert_eq!(storage_result, &[1, 3, 5]); } @@ -207,8 +209,11 @@ mod tests { assert_eq!(ext_result.len(), 3); // Check values: should be [Some(1), None, None] - #[expect(deprecated)] - let canonical = ext_result.storage_array().to_primitive(); + let canonical = ext_result + .storage_array() + .clone() + .execute::(&mut SESSION.create_execution_ctx()) + .unwrap(); assert_eq!(canonical.len(), 3); } diff --git a/vortex-array/src/arrays/extension/vtable/mod.rs b/vortex-array/src/arrays/extension/vtable/mod.rs index c27e70476dc..ef371e46afc 100644 --- a/vortex-array/src/arrays/extension/vtable/mod.rs +++ b/vortex-array/src/arrays/extension/vtable/mod.rs @@ -27,6 +27,8 @@ use crate::arrays::extension::array::STORAGE_SLOT; use crate::arrays::extension::compute::rules::PARENT_RULES; use crate::arrays::extension::compute::rules::RULES; use crate::buffer::BufferHandle; +use crate::builders::ArrayBuilder; +use crate::builders::ExtensionBuilder; use crate::dtype::DType; use crate::serde::ArrayChildren; @@ -188,6 +190,17 @@ impl VTable for Extension { Ok(ExecutionResult::done(array)) } + fn append_to_builder( + array: ArrayView<'_, Self>, + builder: &mut dyn ArrayBuilder, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + let Some(builder) = builder.as_any_mut().downcast_mut::() else { + vortex_bail!("append_to_builder for Extension requires an ExtensionBuilder"); + }; + builder.append_extension_array(&array.into_owned(), ctx) + } + fn reduce(array: ArrayView<'_, Self>) -> VortexResult> { RULES.evaluate(array) } diff --git a/vortex-array/src/arrays/filter/execute/bool.rs b/vortex-array/src/arrays/filter/execute/bool.rs index a22e55366fc..08364e3cac5 100644 --- a/vortex-array/src/arrays/filter/execute/bool.rs +++ b/vortex-array/src/arrays/filter/execute/bool.rs @@ -30,18 +30,22 @@ mod test { use vortex_mask::Mask; use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::filter::execute::bool::BoolArray; - #[expect(deprecated)] - use crate::canonical::ToCanonical as _; use crate::compute::conformance::filter::test_filter_conformance; #[test] fn filter_bool_test() { + let mut ctx = array_session().create_execution_ctx(); let arr = BoolArray::from_iter([true, true, false]); let mask = Mask::from_iter([true, false, true]); - #[expect(deprecated)] - let filtered = arr.filter(mask).unwrap().to_bool(); + let filtered = arr + .filter(mask) + .unwrap() + .execute::(&mut ctx) + .unwrap(); assert_eq!(2, filtered.len()); assert_eq!( @@ -58,6 +62,9 @@ mod test { #[case(BoolArray::from_iter((0..100).map(|i| i % 2 == 0)))] #[case(BoolArray::from_iter((0..1024).map(|i| i % 3 != 0)))] fn test_filter_bool_conformance(#[case] array: BoolArray) { - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/filter/execute/decimal.rs b/vortex-array/src/arrays/filter/execute/decimal.rs index 869e1fabe0c..5ffb4a963b2 100644 --- a/vortex-array/src/arrays/filter/execute/decimal.rs +++ b/vortex-array/src/arrays/filter/execute/decimal.rs @@ -34,6 +34,8 @@ pub fn filter_decimal(array: &DecimalArray, mask: &Arc) -> DecimalAr #[cfg(test)] mod test { use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::filter::execute::decimal::DecimalArray; use crate::compute::conformance::filter::test_filter_conformance; use crate::dtype::DecimalDType; @@ -49,7 +51,10 @@ mod test { Some(99999), ]; let array = DecimalArray::from_option_iter(values, decimal_dtype); - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } #[test] @@ -57,6 +62,9 @@ mod test { let decimal_dtype = DecimalDType::new(38, 4); let values = vec![Some(12345i128), None, Some(-12345), Some(0), None]; let array = DecimalArray::from_option_iter(values, decimal_dtype); - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/filter/execute/fixed_size_list.rs b/vortex-array/src/arrays/filter/execute/fixed_size_list.rs index 392a6373f76..6facbe3e38d 100644 --- a/vortex-array/src/arrays/filter/execute/fixed_size_list.rs +++ b/vortex-array/src/arrays/filter/execute/fixed_size_list.rs @@ -137,7 +137,10 @@ mod test { fn test_filter_fixed_size_list_conformance() { let elements = PrimitiveArray::from_iter([1i32, 2, 3, 4, 5, 6, 7, 8, 9]); let array = FixedSizeListArray::new(elements.into_array(), 3, Validity::NonNullable, 3); - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } #[test] @@ -146,7 +149,10 @@ mod test { PrimitiveArray::from_option_iter([Some(1i32), None, Some(3), Some(4), Some(5), None]); let validity = Validity::from_iter([true, false, true]); let array = FixedSizeListArray::new(elements.into_array(), 2, validity, 3); - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } #[test] diff --git a/vortex-array/src/arrays/filter/execute/listview.rs b/vortex-array/src/arrays/filter/execute/listview.rs index 0507452c89d..6d1521541bc 100644 --- a/vortex-array/src/arrays/filter/execute/listview.rs +++ b/vortex-array/src/arrays/filter/execute/listview.rs @@ -85,7 +85,7 @@ mod test { let sizes = buffer![2u32, 2, 2].into_array(); let array = ListViewArray::new(elements.into_array(), offsets, sizes, Validity::NonNullable); - test_filter_conformance(&array.into_array()); + test_filter_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] @@ -96,7 +96,7 @@ mod test { let sizes = buffer![2u32, 2, 2].into_array(); let validity = Validity::from_iter([true, false, true]); let array = ListViewArray::new(elements.into_array(), offsets, sizes, validity); - test_filter_conformance(&array.into_array()); + test_filter_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] @@ -109,7 +109,7 @@ mod test { ListViewArray::new_unchecked(elements, offsets, sizes, Validity::NonNullable) .with_zero_copy_to_list(true) }; - test_filter_conformance(&array.into_array()); + test_filter_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] @@ -120,7 +120,7 @@ mod test { let offsets = buffer![5u32, 2, 8, 0, 1].into_array(); let sizes = buffer![3u32, 2, 2, 2, 4].into_array(); let array = ListViewArray::new(elements, offsets, sizes, Validity::NonNullable); - test_filter_conformance(&array.into_array()); + test_filter_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] @@ -130,7 +130,7 @@ mod test { let offsets = buffer![0u32, 100, 200, 300, 400, 500, 600, 700, 800, 900].into_array(); let sizes = buffer![50u32, 50, 50, 50, 50, 50, 50, 50, 50, 50].into_array(); let array = ListViewArray::new(elements, offsets, sizes, Validity::NonNullable); - test_filter_conformance(&array.into_array()); + test_filter_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] diff --git a/vortex-array/src/arrays/filter/execute/mod.rs b/vortex-array/src/arrays/filter/execute/mod.rs index 52b0373ac49..f35f628c332 100644 --- a/vortex-array/src/arrays/filter/execute/mod.rs +++ b/vortex-array/src/arrays/filter/execute/mod.rs @@ -83,13 +83,19 @@ pub(super) fn execute_filter_fast_paths( } /// Filter a canonical array by a mask, returning a new canonical array. -pub(super) fn execute_filter(canonical: Canonical, mask: &Arc) -> Canonical { +pub(super) fn execute_filter( + canonical: Canonical, + mask: &Arc, + ctx: &mut ExecutionCtx, +) -> Canonical { match canonical { Canonical::Null(_) => Canonical::Null(NullArray::new(mask.true_count())), Canonical::Bool(a) => Canonical::Bool(bool::filter_bool(&a, mask)), Canonical::Primitive(a) => Canonical::Primitive(primitive::filter_primitive(&a, mask)), Canonical::Decimal(a) => Canonical::Decimal(decimal::filter_decimal(&a, mask)), - Canonical::VarBinView(a) => Canonical::VarBinView(varbinview::filter_varbinview(&a, mask)), + Canonical::VarBinView(a) => { + Canonical::VarBinView(varbinview::filter_varbinview(&a, mask, ctx)) + } Canonical::List(a) => Canonical::List(listview::filter_listview(&a, mask)), Canonical::FixedSizeList(a) => { Canonical::FixedSizeList(fixed_size_list::filter_fixed_size_list(&a, mask)) diff --git a/vortex-array/src/arrays/filter/execute/primitive.rs b/vortex-array/src/arrays/filter/execute/primitive.rs index 276e08b5526..b7abd8efa4b 100644 --- a/vortex-array/src/arrays/filter/execute/primitive.rs +++ b/vortex-array/src/arrays/filter/execute/primitive.rs @@ -59,20 +59,24 @@ mod test { use vortex_mask::Mask; use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::PrimitiveArray; - #[expect(deprecated)] - use crate::canonical::ToCanonical as _; use crate::compute::conformance::filter::LARGE_SIZE; use crate::compute::conformance::filter::MEDIUM_SIZE; use crate::compute::conformance::filter::test_filter_conformance; #[test] fn filter_run_variant_mixed_test() { + let mut ctx = array_session().create_execution_ctx(); let mask = [true, true, false, true, true, true, false, true]; let arr = PrimitiveArray::from_iter([1u32, 24, 54, 2, 3, 2, 3, 2]); - #[expect(deprecated)] - let filtered = arr.filter(Mask::from_iter(mask)).unwrap().to_primitive(); + let filtered = arr + .filter(Mask::from_iter(mask)) + .unwrap() + .execute::(&mut ctx) + .unwrap(); assert_eq!( filtered.len(), mask.iter().filter(|x| **x).collect_vec().len() @@ -104,6 +108,9 @@ mod test { #[case(PrimitiveArray::from_iter([0.1f32, 0.2, 0.3, 0.4, 0.5]))] #[case(PrimitiveArray::from_option_iter([Some(1.1f64), None, Some(2.2), Some(3.3), None]))] fn test_filter_primitive_conformance(#[case] array: PrimitiveArray) { - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/filter/execute/struct_.rs b/vortex-array/src/arrays/filter/execute/struct_.rs index 8980c422ef3..e4a8157d2e2 100644 --- a/vortex-array/src/arrays/filter/execute/struct_.rs +++ b/vortex-array/src/arrays/filter/execute/struct_.rs @@ -70,7 +70,10 @@ mod test { ]; let array = StructArray::try_new(["a", "b"].into(), fields, 5, Validity::NonNullable).unwrap(); - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } #[test] @@ -83,7 +86,10 @@ mod test { ]; let array = StructArray::try_new(["a", "b"].into(), fields, 5, Validity::NonNullable).unwrap(); - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } #[test] @@ -150,6 +156,7 @@ mod test { &StructArray::try_new(FieldNames::empty(), vec![], 5, Validity::NonNullable) .unwrap() .into_array(), + &mut array_session().create_execution_ctx(), ); } @@ -184,6 +191,7 @@ mod test { ) .unwrap() .into_array(), + &mut array_session().create_execution_ctx(), ); } } diff --git a/vortex-array/src/arrays/filter/execute/varbinview.rs b/vortex-array/src/arrays/filter/execute/varbinview.rs index 95a3c216a28..1f9df8f99cd 100644 --- a/vortex-array/src/arrays/filter/execute/varbinview.rs +++ b/vortex-array/src/arrays/filter/execute/varbinview.rs @@ -9,34 +9,44 @@ use vortex_mask::Mask; use vortex_mask::MaskValues; use crate::ArrayRef; +use crate::ExecutionCtx; use crate::IntoArray; -use crate::VortexSessionExecute; use crate::arrays::VarBinView; use crate::arrays::VarBinViewArray; use crate::arrow::ArrowSessionExt; use crate::arrow::FromArrowArray; -use crate::legacy_session; -pub fn filter_varbinview(array: &VarBinViewArray, mask: &Arc) -> VarBinViewArray { +pub fn filter_varbinview( + array: &VarBinViewArray, + mask: &Arc, + ctx: &mut ExecutionCtx, +) -> VarBinViewArray { // Delegate to the Arrow implementation of filter over `VarBinView`. - arrow_filter_fn(&array.clone().into_array(), &Mask::Values(Arc::clone(mask))) - .vortex_expect("VarBinViewArray is Arrow-compatible and supports arrow_filter_fn") - .as_::() - .into_owned() + arrow_filter_fn( + &array.clone().into_array(), + &Mask::Values(Arc::clone(mask)), + ctx, + ) + .vortex_expect("VarBinViewArray is Arrow-compatible and supports arrow_filter_fn") + .as_::() + .into_owned() } -#[allow(clippy::disallowed_methods)] -fn arrow_filter_fn(array: &ArrayRef, mask: &Mask) -> vortex_error::VortexResult { +fn arrow_filter_fn( + array: &ArrayRef, + mask: &Mask, + ctx: &mut ExecutionCtx, +) -> vortex_error::VortexResult { let values = match &mask { Mask::Values(values) => values, Mask::AllTrue(_) | Mask::AllFalse(_) => unreachable!("check in filter invoke"), }; - let array_ref = legacy_session().arrow().execute_arrow( - array.clone(), - None, - &mut legacy_session().create_execution_ctx(), - )?; + let array_ref = ctx + .session() + .arrow() + .clone() + .execute_arrow(array.clone(), None, ctx)?; let mask_array = BooleanArray::new(values.bit_buffer().clone().into(), None); let filtered = arrow_select::filter::filter(array_ref.as_ref(), &mask_array)?; @@ -46,6 +56,8 @@ fn arrow_filter_fn(array: &ArrayRef, mask: &Mask) -> vortex_error::VortexResult< #[cfg(test)] mod test { use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::VarBinViewArray; use crate::compute::conformance::filter::test_filter_conformance; @@ -53,6 +65,7 @@ mod test { fn test_filter_varbinview_conformance() { test_filter_conformance( &VarBinViewArray::from_iter_str(["one", "two", "three", "four", "five"]).into_array(), + &mut array_session().create_execution_ctx(), ); test_filter_conformance( @@ -64,6 +77,7 @@ mod test { Some("five"), ]) .into_array(), + &mut array_session().create_execution_ctx(), ); } } diff --git a/vortex-array/src/arrays/filter/vtable.rs b/vortex-array/src/arrays/filter/vtable.rs index 0f54a97c574..4f556eff61b 100644 --- a/vortex-array/src/arrays/filter/vtable.rs +++ b/vortex-array/src/arrays/filter/vtable.rs @@ -168,7 +168,7 @@ impl VTable for Filter { // TODO(joe): fix the ownership of AnyCanonical let child = Canonical::from(array.child().as_::()); Ok(ExecutionResult::done( - execute_filter(child, &mask_values).into_array(), + execute_filter(child, &mask_values, ctx).into_array(), )) } diff --git a/vortex-array/src/arrays/fixed_size_list/array.rs b/vortex-array/src/arrays/fixed_size_list/array.rs index 1c8954ed1e4..b6328163727 100644 --- a/vortex-array/src/arrays/fixed_size_list/array.rs +++ b/vortex-array/src/arrays/fixed_size_list/array.rs @@ -12,7 +12,6 @@ use vortex_error::vortex_ensure; use crate::ArrayRef; use crate::ArraySlots; -use crate::VortexSessionExecute; use crate::array::Array; use crate::array::ArrayParts; use crate::array::TypedArrayRef; @@ -20,7 +19,6 @@ use crate::array::child_to_validity; use crate::array::validity_to_child; use crate::arrays::FixedSizeList; use crate::dtype::DType; -use crate::legacy_session; use crate::validity::Validity; /// The `elements` data array, where each fixed-size list scalar is a _slice_ of the `elements` @@ -239,14 +237,6 @@ pub trait FixedSizeListArrayExt: TypedArrayRef { index, self.as_ref().len(), ); - #[expect(clippy::debug_assert_with_mut_call)] - { - debug_assert!( - self.fixed_size_list_validity() - .execute_is_valid(index, &mut legacy_session().create_execution_ctx()) - .unwrap_or(false) - ); - } let start = self.list_size() as usize * index; let end = self.list_size() as usize * (index + 1); diff --git a/vortex-array/src/arrays/fixed_size_list/tests/filter.rs b/vortex-array/src/arrays/fixed_size_list/tests/filter.rs index 6f609120901..69677400692 100644 --- a/vortex-array/src/arrays/fixed_size_list/tests/filter.rs +++ b/vortex-array/src/arrays/fixed_size_list/tests/filter.rs @@ -182,7 +182,7 @@ fn test_filter_nested_fixed_size_lists() { #[case(create_fsl_single_element())] #[case(create_fsl_empty())] fn test_filter_fsl_conformance(#[case] array: ArrayRef) { - test_filter_conformance(&array); + test_filter_conformance(&array, &mut array_session().create_execution_ctx()); } // Helper functions for creating test arrays. diff --git a/vortex-array/src/arrays/fixed_size_list/tests/nested.rs b/vortex-array/src/arrays/fixed_size_list/tests/nested.rs index 44420d22cb3..40fd2bc066a 100644 --- a/vortex-array/src/arrays/fixed_size_list/tests/nested.rs +++ b/vortex-array/src/arrays/fixed_size_list/tests/nested.rs @@ -6,8 +6,6 @@ use std::sync::Arc; use vortex_buffer::buffer; use crate::IntoArray; -#[expect(deprecated)] -use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::FixedSizeListArray; @@ -85,9 +83,10 @@ fn test_fsl_of_fsl_basic() { let first_outer_list = outer_fsl.fixed_size_list_elements_at(0).unwrap(); // Check first inner list [1,2]. - #[expect(deprecated)] let inner_list_0 = first_outer_list - .to_fixed_size_list() + .clone() + .execute::(&mut array_session().create_execution_ctx()) + .unwrap() .fixed_size_list_elements_at(0) .unwrap(); assert_eq!( @@ -104,9 +103,10 @@ fn test_fsl_of_fsl_basic() { ); // Check second inner list [3,4]. - #[expect(deprecated)] let inner_list_1 = first_outer_list - .to_fixed_size_list() + .clone() + .execute::(&mut array_session().create_execution_ctx()) + .unwrap() .fixed_size_list_elements_at(1) .unwrap(); assert_eq!( @@ -123,9 +123,9 @@ fn test_fsl_of_fsl_basic() { ); // Check third inner list [5,6]. - #[expect(deprecated)] let inner_list_2 = first_outer_list - .to_fixed_size_list() + .execute::(&mut array_session().create_execution_ctx()) + .unwrap() .fixed_size_list_elements_at(2) .unwrap(); assert_eq!( @@ -145,9 +145,10 @@ fn test_fsl_of_fsl_basic() { let second_outer_list = outer_fsl.fixed_size_list_elements_at(1).unwrap(); // Check first inner list [7,8]. - #[expect(deprecated)] let inner_list_0 = second_outer_list - .to_fixed_size_list() + .clone() + .execute::(&mut array_session().create_execution_ctx()) + .unwrap() .fixed_size_list_elements_at(0) .unwrap(); assert_eq!( @@ -164,9 +165,10 @@ fn test_fsl_of_fsl_basic() { ); // Check second inner list [9,10]. - #[expect(deprecated)] let inner_list_1 = second_outer_list - .to_fixed_size_list() + .clone() + .execute::(&mut array_session().create_execution_ctx()) + .unwrap() .fixed_size_list_elements_at(1) .unwrap(); assert_eq!( @@ -183,9 +185,9 @@ fn test_fsl_of_fsl_basic() { ); // Check third inner list [11,12]. - #[expect(deprecated)] let inner_list_2 = second_outer_list - .to_fixed_size_list() + .execute::(&mut array_session().create_execution_ctx()) + .unwrap() .fixed_size_list_elements_at(2) .unwrap(); assert_eq!( @@ -307,21 +309,23 @@ fn test_deeply_nested_fsl() { // Check the actual deeply nested values. // Structure: [[[1,2],[3,4]],[[5,6],[7,8]]]. let top_level = level3.fixed_size_list_elements_at(0).unwrap(); - #[expect(deprecated)] let level2_0 = top_level - .to_fixed_size_list() + .clone() + .execute::(&mut array_session().create_execution_ctx()) + .unwrap() .fixed_size_list_elements_at(0) .unwrap(); - #[expect(deprecated)] let level2_1 = top_level - .to_fixed_size_list() + .execute::(&mut array_session().create_execution_ctx()) + .unwrap() .fixed_size_list_elements_at(1) .unwrap(); // First level-2 list: [[1,2],[3,4]]. - #[expect(deprecated)] let level1_0_0 = level2_0 - .to_fixed_size_list() + .clone() + .execute::(&mut array_session().create_execution_ctx()) + .unwrap() .fixed_size_list_elements_at(0) .unwrap(); assert_eq!( @@ -337,9 +341,9 @@ fn test_deeply_nested_fsl() { 2i32.into() ); - #[expect(deprecated)] let level1_0_1 = level2_0 - .to_fixed_size_list() + .execute::(&mut array_session().create_execution_ctx()) + .unwrap() .fixed_size_list_elements_at(1) .unwrap(); assert_eq!( @@ -356,9 +360,10 @@ fn test_deeply_nested_fsl() { ); // Second level-2 list: [[5,6],[7,8]]. - #[expect(deprecated)] let level1_1_0 = level2_1 - .to_fixed_size_list() + .clone() + .execute::(&mut array_session().create_execution_ctx()) + .unwrap() .fixed_size_list_elements_at(0) .unwrap(); assert_eq!( @@ -374,9 +379,9 @@ fn test_deeply_nested_fsl() { 6i32.into() ); - #[expect(deprecated)] let level1_1_1 = level2_1 - .to_fixed_size_list() + .execute::(&mut array_session().create_execution_ctx()) + .unwrap() .fixed_size_list_elements_at(1) .unwrap(); assert_eq!( diff --git a/vortex-array/src/arrays/fixed_size_list/tests/take.rs b/vortex-array/src/arrays/fixed_size_list/tests/take.rs index e75139e5fc8..af8f94f2d5f 100644 --- a/vortex-array/src/arrays/fixed_size_list/tests/take.rs +++ b/vortex-array/src/arrays/fixed_size_list/tests/take.rs @@ -33,7 +33,10 @@ use crate::validity::Validity; #[case::single_element(create_single_element_fsl())] #[case::empty(create_empty_fsl())] fn test_take_fsl_conformance(#[case] fsl: FixedSizeListArray) { - test_take_conformance(&fsl.into_array()); + test_take_conformance( + &fsl.into_array(), + &mut array_session().create_execution_ctx(), + ); } // FSL-specific edge case tests that aren't covered by conformance. @@ -88,7 +91,10 @@ fn test_take_degenerate_lists( let elements = PrimitiveArray::empty::(Nullability::NonNullable); let fsl = FixedSizeListArray::new(elements.into_array(), 0, validity, 5); - test_take_conformance(&fsl.clone().into_array()); + test_take_conformance( + &fsl.clone().into_array(), + &mut array_session().create_execution_ctx(), + ); // Also test the specific behavior. let indices_array = PrimitiveArray::from_option_iter(indices); diff --git a/vortex-array/src/arrays/fixed_size_list/vtable/mod.rs b/vortex-array/src/arrays/fixed_size_list/vtable/mod.rs index 8f6fef6a909..9393b8563d4 100644 --- a/vortex-array/src/arrays/fixed_size_list/vtable/mod.rs +++ b/vortex-array/src/arrays/fixed_size_list/vtable/mod.rs @@ -31,6 +31,8 @@ use crate::arrays::fixed_size_list::array::NUM_SLOTS; use crate::arrays::fixed_size_list::array::SLOT_NAMES; use crate::arrays::fixed_size_list::compute::rules::PARENT_RULES; use crate::buffer::BufferHandle; +use crate::builders::ArrayBuilder; +use crate::builders::FixedSizeListBuilder; use crate::dtype::DType; use crate::serde::ArrayChildren; use crate::validity::Validity; @@ -201,4 +203,15 @@ impl VTable for FixedSizeList { fn execute(array: Array, _ctx: &mut ExecutionCtx) -> VortexResult { Ok(ExecutionResult::done(array)) } + + fn append_to_builder( + array: ArrayView<'_, Self>, + builder: &mut dyn ArrayBuilder, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + let Some(builder) = builder.as_any_mut().downcast_mut::() else { + vortex_bail!("append_to_builder for FixedSizeList requires a FixedSizeListBuilder"); + }; + builder.append_fixed_size_list_array(&array.into_owned(), ctx) + } } diff --git a/vortex-array/src/arrays/list/compute/cast.rs b/vortex-array/src/arrays/list/compute/cast.rs index 084d9613e75..9fd27f731f1 100644 --- a/vortex-array/src/arrays/list/compute/cast.rs +++ b/vortex-array/src/arrays/list/compute/cast.rs @@ -181,7 +181,7 @@ mod tests { #[case(create_nested_list())] #[case(create_empty_lists())] fn test_cast_list_conformance(#[case] array: ListArray) { - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } fn create_simple_list() -> ListArray { diff --git a/vortex-array/src/arrays/list/compute/mod.rs b/vortex-array/src/arrays/list/compute/mod.rs index d8ddbd37583..ce6cd960f74 100644 --- a/vortex-array/src/arrays/list/compute/mod.rs +++ b/vortex-array/src/arrays/list/compute/mod.rs @@ -37,7 +37,10 @@ mod tests { let array = ListArray::try_new(elements.into_array(), offsets.into_array(), validity).unwrap(); - test_mask_conformance(&array.into_array()); + test_mask_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } #[test] @@ -48,7 +51,10 @@ mod tests { let array = ListArray::try_new(elements.into_array(), offsets.into_array(), validity).unwrap(); - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } #[rstest] diff --git a/vortex-array/src/arrays/list/compute/take.rs b/vortex-array/src/arrays/list/compute/take.rs index 2c6089e0c58..14e89d4c238 100644 --- a/vortex-array/src/arrays/list/compute/take.rs +++ b/vortex-array/src/arrays/list/compute/take.rs @@ -212,12 +212,11 @@ mod test { use vortex_buffer::buffer; use crate::IntoArray as _; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::BoolArray; use crate::arrays::ListArray; + use crate::arrays::ListViewArray; use crate::arrays::PrimitiveArray; use crate::compute::conformance::take::test_take_conformance; use crate::dtype::DType; @@ -228,6 +227,7 @@ mod test { #[test] fn nullable_take() { + let mut ctx = array_session().create_execution_ctx(); let list = ListArray::try_new( buffer![0i32, 5, 3, 4].into_array(), buffer![0, 2, 3, 4, 4].into_array(), @@ -249,8 +249,7 @@ mod test { ) ); - #[expect(deprecated)] - let result = result.to_listview(); + let result = result.execute::(&mut ctx).unwrap(); assert_eq!(result.len(), 4); @@ -332,6 +331,7 @@ mod test { #[test] fn non_nullable_take() { + let mut ctx = array_session().create_execution_ctx(); let list = ListArray::try_new( buffer![0i32, 5, 3, 4].into_array(), buffer![0, 2, 3, 3, 4].into_array(), @@ -352,8 +352,7 @@ mod test { ) ); - #[expect(deprecated)] - let result = result.to_listview(); + let result = result.execute::(&mut ctx).unwrap(); assert_eq!(result.len(), 3); @@ -466,11 +465,15 @@ mod test { Validity::NonNullable, ).unwrap())] fn test_take_list_conformance(#[case] list: ListArray) { - test_take_conformance(&list.into_array()); + test_take_conformance( + &list.into_array(), + &mut array_session().create_execution_ctx(), + ); } #[test] fn test_u64_offset_accumulation_non_nullable() { + let mut ctx = array_session().create_execution_ctx(); let elements = buffer![0i32; 200].into_array(); let offsets = buffer![0u8, 200].into_array(); let list = ListArray::try_new(elements, offsets, Validity::NonNullable) @@ -483,8 +486,7 @@ mod test { assert_eq!(result.len(), 2); - #[expect(deprecated)] - let result_view = result.to_listview(); + let result_view = result.execute::(&mut ctx).unwrap(); assert_eq!(result_view.len(), 2); assert!( result_view @@ -500,6 +502,7 @@ mod test { #[test] fn test_u64_offset_accumulation_nullable() { + let mut ctx = array_session().create_execution_ctx(); let elements = buffer![0i32; 150].into_array(); let offsets = buffer![0u8, 150, 150].into_array(); let validity = BoolArray::from_iter(vec![true, false]).into_array(); @@ -513,8 +516,7 @@ mod test { assert_eq!(result.len(), 3); - #[expect(deprecated)] - let result_view = result.to_listview(); + let result_view = result.execute::(&mut ctx).unwrap(); assert_eq!(result_view.len(), 3); assert!( result_view diff --git a/vortex-array/src/arrays/list/test_harness.rs b/vortex-array/src/arrays/list/test_harness.rs index 42351484c19..ba2c9d335f1 100644 --- a/vortex-array/src/arrays/list/test_harness.rs +++ b/vortex-array/src/arrays/list/test_harness.rs @@ -6,7 +6,6 @@ use std::sync::Arc; use itertools::Itertools; use vortex_error::VortexResult; -use crate::ArrayRef; use crate::arrays::ListArray; use crate::builders::ArrayBuilder; use crate::builders::ListBuilder; @@ -21,7 +20,7 @@ impl ListArray { pub fn from_iter_slow( iter: I, dtype: Arc, - ) -> VortexResult + ) -> VortexResult where I::Item: IntoIterator, ::Item: Into, @@ -42,13 +41,13 @@ impl ListArray { ); builder.append_value(elem.as_list())? } - Ok(builder.finish()) + Ok(builder.finish_into_list()) } pub fn from_iter_opt_slow>, T>( iter: I, dtype: Arc, - ) -> VortexResult + ) -> VortexResult where T: IntoIterator, T::Item: Into, @@ -73,6 +72,6 @@ impl ListArray { builder.append_null() } } - Ok(builder.finish()) + Ok(builder.finish_into_list()) } } diff --git a/vortex-array/src/arrays/list/vtable/mod.rs b/vortex-array/src/arrays/list/vtable/mod.rs index 79316e76be7..55f7753c995 100644 --- a/vortex-array/src/arrays/list/vtable/mod.rs +++ b/vortex-array/src/arrays/list/vtable/mod.rs @@ -35,6 +35,7 @@ use crate::arrays::list::array::SLOT_NAMES; use crate::arrays::list::compute::rules::PARENT_RULES; use crate::arrays::listview::list_view_from_list; use crate::buffer::BufferHandle; +use crate::builders::ArrayBuilder; use crate::dtype::DType; use crate::dtype::Nullability; use crate::dtype::PType; @@ -199,6 +200,14 @@ impl VTable for List { list_view_from_list(array, ctx)?.into_array(), )) } + + fn append_to_builder( + array: ArrayView<'_, Self>, + builder: &mut dyn ArrayBuilder, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + builder.append_list_array(array, ctx) + } } #[derive(Clone, Debug)] diff --git a/vortex-array/src/arrays/listview/array.rs b/vortex-array/src/arrays/listview/array.rs index 68c15286aa0..daf7100dc59 100644 --- a/vortex-array/src/arrays/listview/array.rs +++ b/vortex-array/src/arrays/listview/array.rs @@ -18,8 +18,6 @@ use vortex_mask::Mask; use crate::ArrayRef; use crate::ArraySlots; use crate::ExecutionCtx; -#[expect(deprecated)] -use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::aggregate_fn::NumericalAggregateOpts; use crate::aggregate_fn::fns::min_max::min_max; @@ -259,10 +257,10 @@ impl ListViewData { // Skip host-only validation when offsets/sizes are not host-resident. if offsets.is_host() && sizes.is_host() { - #[expect(deprecated)] - let offsets_primitive = offsets.to_primitive(); - #[expect(deprecated)] - let sizes_primitive = sizes.to_primitive(); + #[allow(clippy::disallowed_methods)] + let mut ctx = legacy_session().create_execution_ctx(); + let offsets_primitive = offsets.clone().execute::(&mut ctx)?; + let sizes_primitive = sizes.clone().execute::(&mut ctx)?; // Offsets and sizes are non-negative; reinterpret to unsigned to dispatch over 4 widths // each (4x4 instead of 8x8). This is a read-only validation, so result types are moot. let offsets_primitive = @@ -429,14 +427,6 @@ pub trait ListViewArrayExt: TypedArrayRef { self.elements().slice(offset..offset + size) } - fn verify_is_zero_copy_to_list(&self) -> bool { - #[expect(deprecated)] - let offsets_primitive = self.offsets().to_primitive(); - #[expect(deprecated)] - let sizes_primitive = self.sizes().to_primitive(); - validate_zctl(self.elements(), offsets_primitive, sizes_primitive).is_ok() - } - /// Returns a [`Mask`] of length `elements.len()` where each bit is set iff that /// position in `elements` is referenced by at least one view. Caller must ensure `elements` /// is non-empty. @@ -650,10 +640,18 @@ impl Array { /// See [`ListViewData::with_zero_copy_to_list`]. pub unsafe fn with_zero_copy_to_list(self, is_zctl: bool) -> Self { if cfg!(debug_assertions) && is_zctl { - #[expect(deprecated)] - let offsets_primitive = self.offsets().to_primitive(); - #[expect(deprecated)] - let sizes_primitive = self.sizes().to_primitive(); + #[allow(clippy::disallowed_methods)] + let mut ctx = legacy_session().create_execution_ctx(); + let offsets_primitive = self + .offsets() + .clone() + .execute::(&mut ctx) + .vortex_expect("offsets must canonicalize to primitive"); + let sizes_primitive = self + .sizes() + .clone() + .execute::(&mut ctx) + .vortex_expect("sizes must canonicalize to primitive"); validate_zctl(self.elements(), offsets_primitive, sizes_primitive) .vortex_expect("Failed to validate zero-copy to list flag"); } diff --git a/vortex-array/src/arrays/listview/mod.rs b/vortex-array/src/arrays/listview/mod.rs index 80b2540a897..b240854c6cf 100644 --- a/vortex-array/src/arrays/listview/mod.rs +++ b/vortex-array/src/arrays/listview/mod.rs @@ -12,7 +12,7 @@ pub(crate) mod compute; mod vtable; pub use vtable::ListView; -pub(crate) fn initialize(session: &vortex_session::VortexSession) { +pub(crate) fn initialize(session: &VortexSession) { vtable::initialize(session); } @@ -25,6 +25,7 @@ mod rebuild; pub use rebuild::DEFAULT_REBUILD_DENSITY_THRESHOLD; pub use rebuild::DEFAULT_TRIM_ELEMENTS_THRESHOLD; pub use rebuild::ListViewRebuildMode; +use vortex_session::VortexSession; #[cfg(test)] mod tests; diff --git a/vortex-array/src/arrays/listview/rebuild.rs b/vortex-array/src/arrays/listview/rebuild.rs index bae2c4f3fb9..342c9eccf19 100644 --- a/vortex-array/src/arrays/listview/rebuild.rs +++ b/vortex-array/src/arrays/listview/rebuild.rs @@ -253,7 +253,7 @@ impl ListViewArray { } /// Rebuilds elements list-by-list: canonicalize elements upfront, then for each list `slice` - /// the relevant range and `extend_from_array` into a typed builder. + /// the relevant range and `append_to_builder` into a typed builder. fn rebuild_list_by_list( &self, ctx: &mut ExecutionCtx, @@ -317,7 +317,9 @@ impl ListViewArray { new_offsets.push(n_elements); new_sizes.push(size); - new_elements_builder.extend_from_array(&elements_canonical.slice(start..stop)?); + elements_canonical + .slice(start..stop)? + .append_to_builder(new_elements_builder.as_mut(), ctx)?; n_elements += num_traits::cast(size).vortex_expect("Cast failed"); } diff --git a/vortex-array/src/arrays/listview/tests/basic.rs b/vortex-array/src/arrays/listview/tests/basic.rs index 3d4d6db284d..f3082757ca0 100644 --- a/vortex-array/src/arrays/listview/tests/basic.rs +++ b/vortex-array/src/arrays/listview/tests/basic.rs @@ -401,9 +401,15 @@ fn test_verify_is_zero_copy_to_list() { let listview = ListViewArray::new(elements, offsets, sizes, Validity::NonNullable); - // Should return true since offsets are sorted and no overlaps exist. - assert!(listview.verify_is_zero_copy_to_list()); + // Marking as zero-copy-to-list validates the components and should succeed since offsets + // are sorted and no overlaps exist. + let zctl = unsafe { listview.with_zero_copy_to_list(true) }; + assert!(zctl.is_zero_copy_to_list()); +} +#[test] +#[should_panic(expected = "Zero-copy-to-list requires views to be non-overlapping and ordered")] +fn test_verify_is_zero_copy_to_list_overlapping() { // Create a ListView that is NOT zero-copyable to List due to overlapping views. // Logical lists: [[1,2], [2,3,4], [3,4]] let elements = buffer![1i32, 2, 3, 4, 5].into_array(); @@ -412,8 +418,10 @@ fn test_verify_is_zero_copy_to_list() { let listview = ListViewArray::new(elements, offsets, sizes, Validity::NonNullable); - // Should return false due to overlapping list views. - assert!(!listview.verify_is_zero_copy_to_list()); + // Validation should reject overlapping list views. + unsafe { + let _zctl = listview.with_zero_copy_to_list(true); + } } #[test] @@ -460,9 +468,6 @@ fn test_validate_monotonic_ends_correct_nulls() { let listview = ListViewArray::new(elements, offsets, sizes, validity); // Should be valid as zero-copy-to-list - this should NOT panic - let zctl_listview = unsafe { listview.clone().with_zero_copy_to_list(true) }; + let zctl_listview = unsafe { listview.with_zero_copy_to_list(true) }; assert!(zctl_listview.is_zero_copy_to_list()); - - // verify_is_zero_copy_to_list should also return true - assert!(listview.verify_is_zero_copy_to_list()); } diff --git a/vortex-array/src/arrays/listview/tests/filter.rs b/vortex-array/src/arrays/listview/tests/filter.rs index 25cc0de4aa8..e8cc7636b30 100644 --- a/vortex-array/src/arrays/listview/tests/filter.rs +++ b/vortex-array/src/arrays/listview/tests/filter.rs @@ -33,7 +33,7 @@ static SESSION: LazyLock = LazyLock::new(crate::array_session); #[case::overlapping(create_overlapping_listview())] #[case::large(create_large_listview())] fn test_filter_listview_conformance(#[case] listview: ListViewArray) { - test_filter_conformance(&listview.into_array()); + test_filter_conformance(&listview.into_array(), &mut SESSION.create_execution_ctx()); } #[test] diff --git a/vortex-array/src/arrays/listview/tests/operations.rs b/vortex-array/src/arrays/listview/tests/operations.rs index f49680a854e..51f03b1abeb 100644 --- a/vortex-array/src/arrays/listview/tests/operations.rs +++ b/vortex-array/src/arrays/listview/tests/operations.rs @@ -13,8 +13,6 @@ use super::common::create_large_listview; use super::common::create_nullable_listview; use crate::ArrayRef; use crate::IntoArray; -#[expect(deprecated)] -use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::aggregate_fn::fns::is_constant::is_constant; use crate::array_session; @@ -258,8 +256,9 @@ fn test_cast_numeric_types(#[case] from_ptype: PType, #[case] to_ptype: PType) { let result = listview.cast(target_dtype.clone()).unwrap(); assert_eq!(result.dtype(), &target_dtype); - #[expect(deprecated)] - let result_list = result.to_listview(); + let result_list = result + .execute::(&mut array_session().create_execution_ctx()) + .unwrap(); assert!( result_list.len() == 3 || result_list.len() == 2, "Expected 2 or 3 lists" @@ -295,8 +294,9 @@ fn test_cast_with_nulls() { let result = listview.cast(target_dtype.clone()).unwrap(); assert_eq!(result.dtype(), &target_dtype); - #[expect(deprecated)] - let result_list = result.to_listview(); + let result_list = result + .execute::(&mut array_session().create_execution_ctx()) + .unwrap(); assert!( result_list .is_valid(0, &mut array_session().create_execution_ctx()) @@ -346,8 +346,9 @@ fn test_cast_special_patterns(#[case] expected_sizes: Vec, #[case] list_c }; let result = listview.cast(target_dtype).unwrap(); - #[expect(deprecated)] - let result_list = result.to_listview(); + let result_list = result + .execute::(&mut array_session().create_execution_ctx()) + .unwrap(); assert_eq!(result_list.len(), list_count); @@ -379,8 +380,9 @@ fn test_cast_large_dataset() { ); let result = listview.cast(target_dtype).unwrap(); - #[expect(deprecated)] - let result_list = result.to_listview(); + let result_list = result + .execute::(&mut array_session().create_execution_ctx()) + .unwrap(); assert_eq!(result_list.len(), 20); for i in 0..20 { @@ -622,7 +624,10 @@ fn test_constant_repeated_same_lists() { #[case::nullable(create_nullable_listview())] #[case::large(create_large_listview())] fn test_mask_listview_conformance(#[case] listview: ListViewArray) { - test_mask_conformance(&listview.into_array()); + test_mask_conformance( + &listview.into_array(), + &mut array_session().create_execution_ctx(), + ); } #[test] @@ -644,8 +649,9 @@ fn test_mask_preserves_structure() { let result = listview.mask((!&selection).into_array()).unwrap(); assert_eq!(result.len(), 4); // Length is preserved. - #[expect(deprecated)] - let result_list = result.to_listview(); + let result_list = result + .execute::(&mut array_session().create_execution_ctx()) + .unwrap(); // Check validity: true in selection means null. assert!( @@ -698,8 +704,9 @@ fn test_mask_with_existing_nulls() { // Mask additional elements. let selection = Mask::from_iter([false, true, true]); let result = listview.mask((!&selection).into_array()).unwrap(); - #[expect(deprecated)] - let result_list = result.to_listview(); + let result_list = result + .execute::(&mut array_session().create_execution_ctx()) + .unwrap(); // Check combined validity: assert!( @@ -731,8 +738,9 @@ fn test_mask_with_gaps() { let selection = Mask::from_iter([true, false, false]); let result = listview.mask((!&selection).into_array()).unwrap(); - #[expect(deprecated)] - let result_list = result.to_listview(); + let result_list = result + .execute::(&mut array_session().create_execution_ctx()) + .unwrap(); assert_eq!(result_list.len(), 3); assert!( @@ -776,8 +784,9 @@ fn test_mask_constant_arrays() { let selection = Mask::from_iter([false, true, false]); let result = const_list.mask((!&selection).into_array()).unwrap(); - #[expect(deprecated)] - let result_list = result.to_listview(); + let result_list = result + .execute::(&mut array_session().create_execution_ctx()) + .unwrap(); assert_eq!(result_list.len(), 3); assert!( diff --git a/vortex-array/src/arrays/listview/tests/take.rs b/vortex-array/src/arrays/listview/tests/take.rs index 2a68f711d30..2af397e262f 100644 --- a/vortex-array/src/arrays/listview/tests/take.rs +++ b/vortex-array/src/arrays/listview/tests/take.rs @@ -32,7 +32,7 @@ static SESSION: LazyLock = LazyLock::new(crate::array_session); #[case::overlapping(create_overlapping_listview())] #[case::large(create_large_listview())] fn test_take_listview_conformance(#[case] listview: ListViewArray) { - test_take_conformance(&listview.into_array()); + test_take_conformance(&listview.into_array(), &mut SESSION.create_execution_ctx()); } // ListView-specific tests that aren't covered by conformance. diff --git a/vortex-array/src/arrays/listview/vtable/mod.rs b/vortex-array/src/arrays/listview/vtable/mod.rs index f7a7a5486b6..546e77898f8 100644 --- a/vortex-array/src/arrays/listview/vtable/mod.rs +++ b/vortex-array/src/arrays/listview/vtable/mod.rs @@ -35,6 +35,7 @@ use crate::arrays::listview::array::SIZES_SLOT; use crate::arrays::listview::array::SLOT_NAMES; use crate::arrays::listview::compute::rules::PARENT_RULES; use crate::buffer::BufferHandle; +use crate::builders::ArrayBuilder; use crate::dtype::DType; use crate::dtype::Nullability; use crate::dtype::PType; @@ -225,6 +226,14 @@ impl VTable for ListView { Ok(ExecutionResult::done(array)) } + fn append_to_builder( + array: ArrayView<'_, Self>, + builder: &mut dyn ArrayBuilder, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + builder.append_listview_array(array, ctx) + } + fn reduce_parent( array: ArrayView<'_, Self>, parent: &ArrayRef, diff --git a/vortex-array/src/arrays/masked/compute/filter.rs b/vortex-array/src/arrays/masked/compute/filter.rs index 9ea6b168105..287a996a12b 100644 --- a/vortex-array/src/arrays/masked/compute/filter.rs +++ b/vortex-array/src/arrays/masked/compute/filter.rs @@ -33,6 +33,8 @@ mod tests { use rstest::rstest; use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::MaskedArray; use crate::arrays::PrimitiveArray; use crate::compute::conformance::filter::test_filter_conformance; @@ -58,6 +60,9 @@ mod tests { ).unwrap() )] fn test_filter_masked_conformance(#[case] array: MaskedArray) { - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/masked/compute/mask.rs b/vortex-array/src/arrays/masked/compute/mask.rs index 514d0aeaafc..e830f15bfa0 100644 --- a/vortex-array/src/arrays/masked/compute/mask.rs +++ b/vortex-array/src/arrays/masked/compute/mask.rs @@ -34,6 +34,8 @@ mod tests { use rstest::rstest; use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::MaskedArray; use crate::arrays::PrimitiveArray; use crate::compute::conformance::mask::test_mask_conformance; @@ -59,6 +61,9 @@ mod tests { ).unwrap() )] fn test_mask_masked_conformance(#[case] array: MaskedArray) { - test_mask_conformance(&array.into_array()); + test_mask_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/masked/compute/take.rs b/vortex-array/src/arrays/masked/compute/take.rs index 1eff686b4d9..49ad0fc9fe6 100644 --- a/vortex-array/src/arrays/masked/compute/take.rs +++ b/vortex-array/src/arrays/masked/compute/take.rs @@ -44,6 +44,8 @@ mod tests { use rstest::rstest; use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::MaskedArray; use crate::arrays::PrimitiveArray; use crate::compute::conformance::take::test_take_conformance; @@ -69,6 +71,9 @@ mod tests { ).unwrap() )] fn test_take_masked_conformance(#[case] array: MaskedArray) { - test_take_conformance(&array.into_array()); + test_take_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/masked/tests.rs b/vortex-array/src/arrays/masked/tests.rs index 60b045d521d..842599bfa52 100644 --- a/vortex-array/src/arrays/masked/tests.rs +++ b/vortex-array/src/arrays/masked/tests.rs @@ -8,8 +8,6 @@ use vortex_error::VortexResult; use super::*; use crate::Canonical; use crate::IntoArray; -#[expect(deprecated)] -use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::PrimitiveArray; @@ -63,11 +61,13 @@ fn test_masked_child_with_validity() { let array = MaskedArray::try_new(child, Validity::from_iter([true, false, true, false, true])).unwrap(); - #[expect(deprecated)] - let prim = array.as_array().to_primitive(); - // Positions where validity is false should be null in masked_child. let mut ctx = array_session().create_execution_ctx(); + let prim = array + .as_array() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!(prim.valid_count(&mut ctx).unwrap(), 3); assert!( prim.is_valid(0, &mut array_session().create_execution_ctx()) diff --git a/vortex-array/src/arrays/null/compute/cast.rs b/vortex-array/src/arrays/null/compute/cast.rs index a2527e5fb73..c1058156127 100644 --- a/vortex-array/src/arrays/null/compute/cast.rs +++ b/vortex-array/src/arrays/null/compute/cast.rs @@ -90,6 +90,9 @@ mod tests { #[case(NullArray::new(100))] #[case(NullArray::new(0))] fn test_cast_null_conformance(#[case] array: NullArray) { - test_cast_conformance(&array.into_array()); + test_cast_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/null/compute/mod.rs b/vortex-array/src/arrays/null/compute/mod.rs index 5e756da27d0..73a23fe7a8d 100644 --- a/vortex-array/src/arrays/null/compute/mod.rs +++ b/vortex-array/src/arrays/null/compute/mod.rs @@ -15,8 +15,6 @@ mod test { use vortex_mask::Mask; use crate::IntoArray; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::NullArray; @@ -29,8 +27,11 @@ mod test { #[test] fn test_slice_nulls() { let nulls = NullArray::new(10); - #[expect(deprecated)] - let sliced = nulls.slice(0..4).unwrap().to_null(); + let sliced = nulls + .slice(0..4) + .unwrap() + .execute::(&mut array_session().create_execution_ctx()) + .unwrap(); assert_eq!(sliced.len(), 4); let sliced_arr = sliced.as_array(); @@ -50,11 +51,11 @@ mod test { #[test] fn test_take_nulls() { let nulls = NullArray::new(10); - #[expect(deprecated)] let taken = nulls .take(buffer![0u64, 2, 4, 6, 8].into_array()) .unwrap() - .to_null(); + .execute::(&mut array_session().create_execution_ctx()) + .unwrap(); assert_eq!(taken.len(), 5); let taken_arr = taken.as_array(); @@ -81,21 +82,42 @@ mod test { #[test] fn test_filter_null_array() { - test_filter_conformance(&NullArray::new(5).into_array()); - test_filter_conformance(&NullArray::new(1).into_array()); - test_filter_conformance(&NullArray::new(10).into_array()); + test_filter_conformance( + &NullArray::new(5).into_array(), + &mut array_session().create_execution_ctx(), + ); + test_filter_conformance( + &NullArray::new(1).into_array(), + &mut array_session().create_execution_ctx(), + ); + test_filter_conformance( + &NullArray::new(10).into_array(), + &mut array_session().create_execution_ctx(), + ); } #[test] fn test_mask_null_array() { - test_mask_conformance(&NullArray::new(5).into_array()); + test_mask_conformance( + &NullArray::new(5).into_array(), + &mut array_session().create_execution_ctx(), + ); } #[test] fn test_take_null_array_conformance() { - test_take_conformance(&NullArray::new(5).into_array()); - test_take_conformance(&NullArray::new(1).into_array()); - test_take_conformance(&NullArray::new(10).into_array()); + test_take_conformance( + &NullArray::new(5).into_array(), + &mut array_session().create_execution_ctx(), + ); + test_take_conformance( + &NullArray::new(1).into_array(), + &mut array_session().create_execution_ctx(), + ); + test_take_conformance( + &NullArray::new(10).into_array(), + &mut array_session().create_execution_ctx(), + ); } #[rstest] diff --git a/vortex-array/src/arrays/null/compute/take.rs b/vortex-array/src/arrays/null/compute/take.rs index 5352ae67653..dc328614743 100644 --- a/vortex-array/src/arrays/null/compute/take.rs +++ b/vortex-array/src/arrays/null/compute/take.rs @@ -6,11 +6,11 @@ use vortex_error::vortex_bail; use crate::ArrayRef; use crate::IntoArray; -#[expect(deprecated)] -use crate::ToCanonical as _; +use crate::VortexSessionExecute; use crate::array::ArrayView; use crate::arrays::Null; use crate::arrays::NullArray; +use crate::arrays::PrimitiveArray; use crate::arrays::dict::TakeReduce; use crate::arrays::dict::TakeReduceAdaptor; use crate::match_each_integer_ptype; @@ -19,8 +19,9 @@ use crate::optimizer::rules::ParentRuleSet; impl TakeReduce for Null { #[expect(clippy::cast_possible_truncation)] fn take(array: ArrayView<'_, Null>, indices: &ArrayRef) -> VortexResult> { - #[expect(deprecated)] - let indices = indices.to_primitive(); + #[allow(clippy::disallowed_methods)] + let mut ctx = crate::legacy_session().create_execution_ctx(); + let indices = indices.clone().execute::(&mut ctx)?; // Enforce all indices are valid match_each_integer_ptype!(indices.ptype(), |T| { diff --git a/vortex-array/src/arrays/null/mod.rs b/vortex-array/src/arrays/null/mod.rs index 9c8c8e41892..8479b7137cc 100644 --- a/vortex-array/src/arrays/null/mod.rs +++ b/vortex-array/src/arrays/null/mod.rs @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors use vortex_error::VortexResult; +use vortex_error::vortex_bail; use vortex_error::vortex_ensure; use vortex_error::vortex_panic; use vortex_session::VortexSession; @@ -21,6 +22,8 @@ use crate::array::ValidityVTable; use crate::array::with_empty_buffers; use crate::arrays::null::compute::rules::PARENT_RULES; use crate::buffer::BufferHandle; +use crate::builders::ArrayBuilder; +use crate::builders::NullBuilder; use crate::dtype::DType; use crate::scalar::Scalar; use crate::serde::ArrayChildren; @@ -118,6 +121,18 @@ impl VTable for Null { fn execute(array: Array, _ctx: &mut ExecutionCtx) -> VortexResult { Ok(ExecutionResult::done(array)) } + + fn append_to_builder( + array: ArrayView<'_, Self>, + builder: &mut dyn ArrayBuilder, + _ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + let Some(builder) = builder.as_any_mut().downcast_mut::() else { + vortex_bail!("append_to_builder for Null requires a NullBuilder"); + }; + builder.append_nulls(array.len()); + Ok(()) + } } /// A array where all values are null. diff --git a/vortex-array/src/arrays/patched/vtable/mod.rs b/vortex-array/src/arrays/patched/vtable/mod.rs index bbe592785ec..e36d426c0be 100644 --- a/vortex-array/src/arrays/patched/vtable/mod.rs +++ b/vortex-array/src/arrays/patched/vtable/mod.rs @@ -210,8 +210,7 @@ impl VTable for Patched { .clone() .execute::(ctx)? .into_array(); - builder.extend_from_array(&canonical); - return Ok(()); + return canonical.append_to_builder(builder, ctx); } let ptype = dtype.as_ptype(); diff --git a/vortex-array/src/arrays/primitive/array/patch.rs b/vortex-array/src/arrays/primitive/array/patch.rs index d183552b66f..d4b4899077d 100644 --- a/vortex-array/src/arrays/primitive/array/patch.rs +++ b/vortex-array/src/arrays/primitive/array/patch.rs @@ -135,8 +135,6 @@ mod tests { use vortex_buffer::buffer; use super::*; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::assert_arrays_eq; @@ -178,8 +176,7 @@ mod tests { let mut ctx = array_session().create_execution_ctx(); let input = PrimitiveArray::new(buffer![2u32; 10], Validity::AllValid); let sliced = input.slice(2..8).unwrap(); - #[expect(deprecated)] - let sliced_primitive = sliced.to_primitive(); + let sliced_primitive = sliced.execute::(&mut ctx).unwrap(); assert_arrays_eq!( sliced_primitive, PrimitiveArray::new(buffer![2u32; 6], Validity::AllValid), diff --git a/vortex-array/src/arrays/primitive/array/top_value.rs b/vortex-array/src/arrays/primitive/array/top_value.rs index a9dbd6a2f37..a6233eb89ee 100644 --- a/vortex-array/src/arrays/primitive/array/top_value.rs +++ b/vortex-array/src/arrays/primitive/array/top_value.rs @@ -10,18 +10,16 @@ use vortex_mask::AllOr; use vortex_mask::Mask; use vortex_utils::aliases::hash_map::HashMap; -use crate::VortexSessionExecute; +use crate::ExecutionCtx; use crate::arrays::PrimitiveArray; use crate::arrays::primitive::NativeValue; use crate::dtype::NativePType; -use crate::legacy_session; use crate::match_each_native_ptype; use crate::scalar::PValue; impl PrimitiveArray { /// Compute most common present value of this array - #[allow(clippy::disallowed_methods)] - pub fn top_value(&self) -> VortexResult> { + pub fn top_value(&self, ctx: &mut ExecutionCtx) -> VortexResult> { if self.is_empty() { return Ok(None); } @@ -33,10 +31,9 @@ impl PrimitiveArray { match_each_native_ptype!(self.ptype(), |P| { let (top, count) = typed_top_value( self.as_slice::

(), - self.as_ref().validity()?.execute_mask( - self.as_ref().len(), - &mut legacy_session().create_execution_ctx(), - )?, + self.as_ref() + .validity()? + .execute_mask(self.as_ref().len(), ctx)?, ); Ok(Some((top.into(), count))) }) diff --git a/vortex-array/src/arrays/primitive/compute/cast.rs b/vortex-array/src/arrays/primitive/compute/cast.rs index 36b95b4c554..09defa442b8 100644 --- a/vortex-array/src/arrays/primitive/compute/cast.rs +++ b/vortex-array/src/arrays/primitive/compute/cast.rs @@ -277,8 +277,6 @@ mod test { use crate::arrays::PrimitiveArray; use crate::assert_arrays_eq; use crate::builtins::ArrayBuiltins; - #[expect(deprecated)] - use crate::canonical::ToCanonical as _; use crate::compute::conformance::cast::test_cast_conformance; use crate::dtype::DType; use crate::dtype::Nullability; @@ -291,18 +289,21 @@ mod test { let arr = buffer![0u32, 10, 200].into_array(); // cast from u32 to u8 - #[expect(deprecated)] - let p = arr.cast(PType::U8.into()).unwrap().to_primitive(); + let p = arr + .cast(PType::U8.into()) + .unwrap() + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!(p, PrimitiveArray::from_iter([0u8, 10, 200]), &mut ctx); assert!(matches!(p.validity(), Ok(Validity::NonNullable))); // to nullable - #[expect(deprecated)] let p = p .into_array() .cast(DType::Primitive(PType::U8, Nullability::Nullable)) .unwrap() - .to_primitive(); + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!( p, PrimitiveArray::new(buffer![0u8, 10, 200], Validity::AllValid), @@ -311,22 +312,22 @@ mod test { assert!(matches!(p.validity(), Ok(Validity::AllValid))); // back to non-nullable - #[expect(deprecated)] let p = p .into_array() .cast(DType::Primitive(PType::U8, Nullability::NonNullable)) .unwrap() - .to_primitive(); + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!(p, PrimitiveArray::from_iter([0u8, 10, 200]), &mut ctx); assert!(matches!(p.validity(), Ok(Validity::NonNullable))); // to nullable u32 - #[expect(deprecated)] let p = p .into_array() .cast(DType::Primitive(PType::U32, Nullability::Nullable)) .unwrap() - .to_primitive(); + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!( p, PrimitiveArray::new(buffer![0u32, 10, 200], Validity::AllValid), @@ -335,12 +336,12 @@ mod test { assert!(matches!(p.validity(), Ok(Validity::AllValid))); // to non-nullable u8 - #[expect(deprecated)] let p = p .into_array() .cast(DType::Primitive(PType::U8, Nullability::NonNullable)) .unwrap() - .to_primitive(); + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!(p, PrimitiveArray::from_iter([0u8, 10, 200]), &mut ctx); assert!(matches!(p.validity(), Ok(Validity::NonNullable))); } @@ -349,8 +350,11 @@ mod test { fn cast_u32_f32() { let mut ctx = array_session().create_execution_ctx(); let arr = buffer![0u32, 10, 200].into_array(); - #[expect(deprecated)] - let u8arr = arr.cast(PType::F32.into()).unwrap().to_primitive(); + let u8arr = arr + .cast(PType::F32.into()) + .unwrap() + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!( u8arr, PrimitiveArray::from_iter([0.0f32, 10., 200.]), @@ -394,12 +398,12 @@ mod test { buffer![-1i32, 0, 10], Validity::from_iter([false, true, true]), ); - #[expect(deprecated)] let p = arr .into_array() .cast(DType::Primitive(PType::U32, Nullability::Nullable)) .unwrap() - .to_primitive(); + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!( p, PrimitiveArray::from_option_iter([None, Some(0u32), Some(10)]), @@ -426,8 +430,10 @@ mod test { let src = PrimitiveArray::from_iter([0u32, 10, 100]); let src_ptr = src.as_slice::().as_ptr(); - #[expect(deprecated)] - let dst = src.into_array().cast(PType::I32.into())?.to_primitive(); + let dst = src + .into_array() + .cast(PType::I32.into())? + .execute::(&mut ctx)?; let dst_ptr = dst.as_slice::().as_ptr(); // Zero-copy: the data pointer should be identical. @@ -453,12 +459,12 @@ mod test { /// touching the buffer contents. #[test] fn cast_same_width_all_null() -> vortex_error::VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); let arr = PrimitiveArray::new(buffer![0xFFu8, 0xFF], Validity::AllInvalid); - #[expect(deprecated)] let casted = arr .into_array() .cast(DType::Primitive(PType::I8, Nullability::Nullable))? - .to_primitive(); + .execute::(&mut ctx)?; assert_eq!(casted.len(), 2); assert!(matches!(casted.validity(), Ok(Validity::AllInvalid))); Ok(()) @@ -475,11 +481,10 @@ mod test { buffer![u32::MAX, 0u32, 42u32], Validity::from_iter([false, true, true]), ); - #[expect(deprecated)] let casted = arr .into_array() .cast(DType::Primitive(PType::I32, Nullability::Nullable))? - .to_primitive(); + .execute::(&mut ctx)?; assert_arrays_eq!( casted, PrimitiveArray::from_option_iter([None, Some(0i32), Some(42)]), @@ -495,11 +500,10 @@ mod test { buffer![1000u32, 10u32, 42u32], Validity::from_iter([false, true, true]), ); - #[expect(deprecated)] let casted = arr .into_array() .cast(DType::Primitive(PType::U8, Nullability::Nullable))? - .to_primitive(); + .execute::(&mut ctx)?; assert_arrays_eq!( casted, PrimitiveArray::from_option_iter([None, Some(10u8), Some(42)]), @@ -525,6 +529,6 @@ mod test { #[case(PrimitiveArray::from_option_iter([Some(1i32), None, Some(-100), Some(0), None]).into_array())] #[case(buffer![42u32].into_array())] fn test_cast_primitive_conformance(#[case] array: ArrayRef) { - test_cast_conformance(&array); + test_cast_conformance(&array, &mut array_session().create_execution_ctx()); } } diff --git a/vortex-array/src/arrays/primitive/compute/fill_null.rs b/vortex-array/src/arrays/primitive/compute/fill_null.rs index d73442a150e..e8b0d48b322 100644 --- a/vortex-array/src/arrays/primitive/compute/fill_null.rs +++ b/vortex-array/src/arrays/primitive/compute/fill_null.rs @@ -57,8 +57,6 @@ mod test { use crate::arrays::primitive::compute::fill_null::BoolArray; use crate::assert_arrays_eq; use crate::builtins::ArrayBuiltins; - #[expect(deprecated)] - use crate::canonical::ToCanonical as _; use crate::scalar::Scalar; use crate::validity::Validity; @@ -66,12 +64,12 @@ mod test { fn fill_null_leading_none() { let mut ctx = array_session().create_execution_ctx(); let arr = PrimitiveArray::from_option_iter([None, Some(8u8), None, Some(10), None]); - #[expect(deprecated)] let p = arr .into_array() .fill_null(Scalar::from(42u8)) .unwrap() - .to_primitive(); + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!( p, PrimitiveArray::from_iter([42u8, 8, 42, 10, 42]), @@ -95,12 +93,12 @@ mod test { let mut ctx = array_session().create_execution_ctx(); let arr = PrimitiveArray::from_option_iter([Option::::None, None, None, None, None]); - #[expect(deprecated)] let p = arr .into_array() .fill_null(Scalar::from(255u8)) .unwrap() - .to_primitive(); + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!( p, PrimitiveArray::from_iter([255u8, 255, 255, 255, 255]), @@ -126,12 +124,12 @@ mod test { buffer![8u8, 10, 12, 14, 16], Validity::Array(BoolArray::from_iter([true, true, true, true, true]).into_array()), ); - #[expect(deprecated)] let p = arr .into_array() .fill_null(Scalar::from(255u8)) .unwrap() - .to_primitive(); + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!( p, PrimitiveArray::from_iter([8u8, 10, 12, 14, 16]), @@ -154,8 +152,11 @@ mod test { fn fill_null_non_nullable() { let mut ctx = array_session().create_execution_ctx(); let arr = buffer![8u8, 10, 12, 14, 16].into_array(); - #[expect(deprecated)] - let p = arr.fill_null(Scalar::from(255u8)).unwrap().to_primitive(); + let p = arr + .fill_null(Scalar::from(255u8)) + .unwrap() + .execute::(&mut ctx) + .unwrap(); assert_arrays_eq!( p, PrimitiveArray::from_iter([8u8, 10, 12, 14, 16]), diff --git a/vortex-array/src/arrays/primitive/compute/mask.rs b/vortex-array/src/arrays/primitive/compute/mask.rs index 6768efd6fbf..023310b3807 100644 --- a/vortex-array/src/arrays/primitive/compute/mask.rs +++ b/vortex-array/src/arrays/primitive/compute/mask.rs @@ -30,6 +30,8 @@ mod test { use rstest::rstest; use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::PrimitiveArray; use crate::compute::conformance::mask::test_mask_conformance; @@ -42,6 +44,9 @@ mod test { #[case(PrimitiveArray::from_iter([0.1f32, 0.2, 0.3, 0.4, 0.5]))] #[case(PrimitiveArray::from_option_iter([Some(1.1f64), None, Some(2.2), Some(3.3), None]))] fn test_mask_primitive_conformance(#[case] array: PrimitiveArray) { - test_mask_conformance(&array.into_array()); + test_mask_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/primitive/compute/take/mod.rs b/vortex-array/src/arrays/primitive/compute/take/mod.rs index 9294659708e..dd562281608 100644 --- a/vortex-array/src/arrays/primitive/compute/take/mod.rs +++ b/vortex-array/src/arrays/primitive/compute/take/mod.rs @@ -170,6 +170,7 @@ mod test { #[test] fn test_take_with_null_indices() { + let mut ctx = array_session().create_execution_ctx(); let values = PrimitiveArray::new( buffer![1i32, 2, 3, 4, 5], Validity::Array(BoolArray::from_iter([true, true, false, false, true]).into_array()), @@ -180,23 +181,17 @@ mod test { ); let actual = values.take(indices.into_array()).unwrap(); assert_eq!( - actual - .execute_scalar(0, &mut array_session().create_execution_ctx()) - .vortex_expect("no fail"), + actual.execute_scalar(0, &mut ctx).vortex_expect("no fail"), Scalar::from(Some(1)) ); // position 3 is null assert_eq!( - actual - .execute_scalar(1, &mut array_session().create_execution_ctx()) - .vortex_expect("no fail"), + actual.execute_scalar(1, &mut ctx).vortex_expect("no fail"), Scalar::null_native::() ); // the third index is null assert_eq!( - actual - .execute_scalar(2, &mut array_session().create_execution_ctx()) - .vortex_expect("no fail"), + actual.execute_scalar(2, &mut ctx).vortex_expect("no fail"), Scalar::null_native::() ); } @@ -213,7 +208,10 @@ mod test { ))] #[case(PrimitiveArray::from_option_iter([Some(1), None, Some(3), Some(4), None]))] fn test_take_primitive_conformance(#[case] array: PrimitiveArray) { - test_take_conformance(&array.into_array()); + test_take_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/primitive/tests.rs b/vortex-array/src/arrays/primitive/tests.rs index 1f50c8edb1c..15569b2cebb 100644 --- a/vortex-array/src/arrays/primitive/tests.rs +++ b/vortex-array/src/arrays/primitive/tests.rs @@ -8,6 +8,7 @@ use vortex_session::VortexSession; use crate::ArrayRef; use crate::IntoArray; +use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::BoolArray; use crate::arrays::PrimitiveArray; @@ -16,7 +17,6 @@ use crate::compute::conformance::mask::test_mask_conformance; use crate::compute::conformance::search_sorted::rstest_reuse::apply; use crate::compute::conformance::search_sorted::search_sorted_conformance; use crate::compute::conformance::search_sorted::*; -use crate::executor::VortexSessionExecute; use crate::search_sorted::SearchResult; use crate::search_sorted::SearchSorted; use crate::search_sorted::SearchSortedPrimitiveArray; @@ -42,12 +42,15 @@ fn test_search_sorted_primitive( fn test_mask_primitive_array() { test_mask_conformance( &PrimitiveArray::new(buffer![0, 1, 2, 3, 4], Validity::NonNullable).into_array(), + &mut array_session().create_execution_ctx(), ); test_mask_conformance( &PrimitiveArray::new(buffer![0, 1, 2, 3, 4], Validity::AllValid).into_array(), + &mut array_session().create_execution_ctx(), ); test_mask_conformance( &PrimitiveArray::new(buffer![0, 1, 2, 3, 4], Validity::AllInvalid).into_array(), + &mut array_session().create_execution_ctx(), ); test_mask_conformance( &PrimitiveArray::new( @@ -55,6 +58,7 @@ fn test_mask_primitive_array() { Validity::Array(BoolArray::from_iter([true, false, true, false, true]).into_array()), ) .into_array(), + &mut array_session().create_execution_ctx(), ); } @@ -63,20 +67,25 @@ fn test_filter_primitive_array() { // Test various sizes test_filter_conformance( &PrimitiveArray::new(buffer![42i32], Validity::NonNullable).into_array(), + &mut array_session().create_execution_ctx(), ); test_filter_conformance( &PrimitiveArray::new(buffer![0, 1], Validity::NonNullable).into_array(), + &mut array_session().create_execution_ctx(), ); test_filter_conformance( &PrimitiveArray::new(buffer![0, 1, 2, 3, 4], Validity::NonNullable).into_array(), + &mut array_session().create_execution_ctx(), ); test_filter_conformance( &PrimitiveArray::new(buffer![0, 1, 2, 3, 4, 5, 6, 7], Validity::NonNullable).into_array(), + &mut array_session().create_execution_ctx(), ); // Test with validity test_filter_conformance( &PrimitiveArray::new(buffer![0, 1, 2, 3, 4], Validity::AllValid).into_array(), + &mut array_session().create_execution_ctx(), ); test_filter_conformance( &PrimitiveArray::new( @@ -86,5 +95,6 @@ fn test_filter_primitive_array() { ), ) .into_array(), + &mut array_session().create_execution_ctx(), ); } diff --git a/vortex-array/src/arrays/primitive/vtable/mod.rs b/vortex-array/src/arrays/primitive/vtable/mod.rs index a184253527e..50189bf0037 100644 --- a/vortex-array/src/arrays/primitive/vtable/mod.rs +++ b/vortex-array/src/arrays/primitive/vtable/mod.rs @@ -220,8 +220,7 @@ impl VTable for Primitive { } }); - builder.extend_from_array(array.as_ref()); - Ok(()) + vortex_bail!("append_to_builder for Primitive requires a matching PrimitiveBuilder"); } fn reduce_parent( diff --git a/vortex-array/src/arrays/scalar_fn/vtable/validity.rs b/vortex-array/src/arrays/scalar_fn/vtable/validity.rs index 056e8aa71dc..39ab8c2c466 100644 --- a/vortex-array/src/arrays/scalar_fn/vtable/validity.rs +++ b/vortex-array/src/arrays/scalar_fn/vtable/validity.rs @@ -2,12 +2,15 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors use vortex_error::VortexResult; +use vortex_error::vortex_bail; use crate::ArrayRef; +use crate::ExecutionCtx; use crate::IntoArray; use crate::VortexSessionExecute; use crate::array::ArrayView; use crate::array::ValidityVTable; +use crate::arrays::ConstantArray; use crate::arrays::scalar_fn::ScalarFnArrayExt; use crate::arrays::scalar_fn::vtable::ArrayExpr; use crate::arrays::scalar_fn::vtable::FakeEq; @@ -24,31 +27,32 @@ use crate::validity::Validity; /// Execute an expression tree recursively. /// /// This assumes all leaf expressions are either ArrayExpr (wrapping actual arrays) or Literals. -#[allow(clippy::disallowed_methods)] -fn execute_expr(expr: &Expression, row_count: usize) -> VortexResult { - let mut ctx = legacy_session().create_execution_ctx(); - +fn execute_expr( + expr: &Expression, + row_count: usize, + ctx: &mut ExecutionCtx, +) -> VortexResult { // Handle Root expression - this should not happen in validity expressions if expr.is::() { - vortex_error::vortex_bail!("Root expression cannot be executed in validity context"); + vortex_bail!("Root expression cannot be executed in validity context"); } // Handle Literal expression - create a constant array if expr.is::() { let scalar = expr.as_::(); - return Ok(crate::arrays::ConstantArray::new(scalar.clone(), row_count).into_array()); + return Ok(ConstantArray::new(scalar.clone(), row_count).into_array()); } // Recursively execute child expressions to get input arrays let inputs: Vec = expr .children() .iter() - .map(|child| execute_expr(child, row_count)) + .map(|child| execute_expr(child, row_count, ctx)) .collect::>()?; let args = VecExecutionArgs::new(inputs, row_count); - Ok(expr.scalar_fn().execute(&args, &mut ctx)?.into_array()) + Ok(expr.scalar_fn().execute(&args, ctx)?.into_array()) } impl ValidityVTable for ScalarFn { @@ -69,7 +73,13 @@ impl ValidityVTable for ScalarFn { let expr = Expression::try_new(array.scalar_fn().clone(), inputs)?; let validity_expr = array.scalar_fn().validity(&expr)?; + #[allow(clippy::disallowed_methods)] + let ctx = &mut legacy_session().create_execution_ctx(); // Execute the validity expression. All leaves are ArrayExpr nodes. - Ok(Validity::Array(execute_expr(&validity_expr, array.len())?)) + Ok(Validity::Array(execute_expr( + &validity_expr, + array.len(), + ctx, + )?)) } } diff --git a/vortex-array/src/arrays/struct_/compute/cast.rs b/vortex-array/src/arrays/struct_/compute/cast.rs index a7e1e760fd3..bfae815ceb5 100644 --- a/vortex-array/src/arrays/struct_/compute/cast.rs +++ b/vortex-array/src/arrays/struct_/compute/cast.rs @@ -188,7 +188,7 @@ mod tests { #[case(create_nested_struct())] #[case(create_simple_struct())] fn test_cast_struct_conformance(#[case] array: StructArray) { - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] diff --git a/vortex-array/src/arrays/struct_/compute/mod.rs b/vortex-array/src/arrays/struct_/compute/mod.rs index c1539a638bf..4b9a4e396fd 100644 --- a/vortex-array/src/arrays/struct_/compute/mod.rs +++ b/vortex-array/src/arrays/struct_/compute/mod.rs @@ -117,6 +117,7 @@ mod tests { &StructArray::try_new(FieldNames::empty(), vec![], 5, Validity::NonNullable) .unwrap() .into_array(), + &mut array_session().create_execution_ctx(), ); } @@ -151,6 +152,7 @@ mod tests { ) .unwrap() .into_array(), + &mut array_session().create_execution_ctx(), ); } @@ -270,6 +272,7 @@ mod tests { &StructArray::try_new(FieldNames::empty(), vec![], 5, Validity::NonNullable) .unwrap() .into_array(), + &mut array_session().create_execution_ctx(), ); } @@ -286,6 +289,7 @@ mod tests { &StructArray::try_new(["xs", "ys"].into(), vec![xs, ys], 5, Validity::NonNullable) .unwrap() .into_array(), + &mut array_session().create_execution_ctx(), ); } @@ -307,6 +311,7 @@ mod tests { ) .unwrap() .into_array(), + &mut array_session().create_execution_ctx(), ); } @@ -335,6 +340,7 @@ mod tests { ) .unwrap() .into_array(), + &mut array_session().create_execution_ctx(), ); } @@ -347,6 +353,7 @@ mod tests { &StructArray::try_new(["xs", "ys"].into(), vec![xs, ys], 1, Validity::NonNullable) .unwrap() .into_array(), + &mut array_session().create_execution_ctx(), ); } @@ -370,6 +377,7 @@ mod tests { ) .unwrap() .into_array(), + &mut array_session().create_execution_ctx(), ); } diff --git a/vortex-array/src/arrays/struct_/vtable/mod.rs b/vortex-array/src/arrays/struct_/vtable/mod.rs index 1b23ecf804c..0222a3c4712 100644 --- a/vortex-array/src/arrays/struct_/vtable/mod.rs +++ b/vortex-array/src/arrays/struct_/vtable/mod.rs @@ -23,6 +23,8 @@ use crate::arrays::struct_::array::VALIDITY_SLOT; use crate::arrays::struct_::array::make_struct_slots; use crate::arrays::struct_::compute::rules::PARENT_RULES; use crate::buffer::BufferHandle; +use crate::builders::ArrayBuilder; +use crate::builders::StructBuilder; use crate::dtype::DType; use crate::serde::ArrayChildren; use crate::validity::Validity; @@ -197,6 +199,17 @@ impl VTable for Struct { Ok(ExecutionResult::done(array)) } + fn append_to_builder( + array: ArrayView<'_, Self>, + builder: &mut dyn ArrayBuilder, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + let Some(builder) = builder.as_any_mut().downcast_mut::() else { + vortex_bail!("append_to_builder for Struct requires a StructBuilder"); + }; + builder.append_struct_array(&array.into_owned(), ctx) + } + fn reduce_parent( array: ArrayView<'_, Self>, parent: &ArrayRef, diff --git a/vortex-array/src/arrays/varbin/compute/cast.rs b/vortex-array/src/arrays/varbin/compute/cast.rs index 5983886ea30..b0c4325c774 100644 --- a/vortex-array/src/arrays/varbin/compute/cast.rs +++ b/vortex-array/src/arrays/varbin/compute/cast.rs @@ -130,6 +130,6 @@ mod tests { #[case(VarBinArray::from_iter(vec![Some(b"test".as_slice()), None], DType::Binary(Nullability::Nullable)))] #[case(VarBinArray::from_iter(vec![Some("single")], DType::Utf8(Nullability::NonNullable)))] fn test_cast_varbin_conformance(#[case] array: VarBinArray) { - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } } diff --git a/vortex-array/src/arrays/varbin/compute/compare.rs b/vortex-array/src/arrays/varbin/compute/compare.rs index ede3c409d94..42876a92aca 100644 --- a/vortex-array/src/arrays/varbin/compute/compare.rs +++ b/vortex-array/src/arrays/varbin/compute/compare.rs @@ -159,10 +159,9 @@ mod test { use vortex_buffer::ByteBuffer; use crate::IntoArray; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; + use crate::arrays::BoolArray; use crate::arrays::ConstantArray; use crate::arrays::VarBinArray; use crate::arrays::VarBinViewArray; @@ -175,11 +174,11 @@ mod test { #[test] fn test_binary_compare() { + let mut ctx = array_session().create_execution_ctx(); let array = VarBinArray::from_iter( [Some(b"abc".to_vec()), None, Some(b"def".to_vec())], DType::Binary(Nullability::Nullable), ); - #[expect(deprecated)] let result = array .into_array() .binary( @@ -191,17 +190,15 @@ mod test { Operator::Eq, ) .unwrap() - .to_bool(); + .execute::(&mut ctx) + .unwrap(); assert_eq!( &result .as_ref() .validity() .unwrap() - .execute_mask( - result.as_ref().len(), - &mut array_session().create_execution_ctx() - ) + .execute_mask(result.as_ref().len(), &mut ctx) .unwrap() .to_bit_buffer(), &BitBuffer::from_iter([true, false, true]) @@ -214,6 +211,7 @@ mod test { #[test] fn varbinview_compare() { + let mut ctx = array_session().create_execution_ctx(); let array = VarBinArray::from_iter( [Some(b"abc".to_vec()), None, Some(b"def".to_vec())], DType::Binary(Nullability::Nullable), @@ -222,22 +220,19 @@ mod test { [None, None, Some(b"def".to_vec())], DType::Binary(Nullability::Nullable), ); - #[expect(deprecated)] let result = array .into_array() .binary(vbv.into_array(), Operator::Eq) .unwrap() - .to_bool(); + .execute::(&mut ctx) + .unwrap(); assert_eq!( result .as_ref() .validity() .unwrap() - .execute_mask( - result.as_ref().len(), - &mut array_session().create_execution_ctx() - ) + .execute_mask(result.as_ref().len(), &mut ctx) .unwrap() .to_bit_buffer(), BitBuffer::from_iter([false, false, true]) diff --git a/vortex-array/src/arrays/varbin/compute/filter.rs b/vortex-array/src/arrays/varbin/compute/filter.rs index ab969cf3def..400beb9aa0a 100644 --- a/vortex-array/src/arrays/varbin/compute/filter.rs +++ b/vortex-array/src/arrays/varbin/compute/filter.rs @@ -360,12 +360,18 @@ mod test { vec!["hello", "world", "filter", "good", "bye"], DType::Utf8(NonNullable), ); - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); let array = VarBinArray::from_iter( vec![Some("hello"), None, Some("filter"), Some("good"), None], DType::Utf8(Nullable), ); - test_filter_conformance(&array.into_array()); + test_filter_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/varbin/compute/mask.rs b/vortex-array/src/arrays/varbin/compute/mask.rs index 1c5567adea7..19236cee47b 100644 --- a/vortex-array/src/arrays/varbin/compute/mask.rs +++ b/vortex-array/src/arrays/varbin/compute/mask.rs @@ -29,6 +29,8 @@ impl MaskReduce for VarBin { #[cfg(test)] mod test { use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::VarBinArray; use crate::compute::conformance::mask::test_mask_conformance; use crate::dtype::DType; @@ -40,12 +42,18 @@ mod test { vec!["hello", "world", "filter", "good", "bye"], DType::Utf8(Nullability::NonNullable), ); - test_mask_conformance(&array.into_array()); + test_mask_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); let array = VarBinArray::from_iter( vec![Some("hello"), None, Some("filter"), Some("good"), None], DType::Utf8(Nullability::Nullable), ); - test_mask_conformance(&array.into_array()); + test_mask_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/varbin/compute/take.rs b/vortex-array/src/arrays/varbin/compute/take.rs index 74e0bd6af9d..6a6454d0603 100644 --- a/vortex-array/src/arrays/varbin/compute/take.rs +++ b/vortex-array/src/arrays/varbin/compute/take.rs @@ -303,7 +303,10 @@ mod tests { ))] #[case(VarBinArray::from_iter(["single"].map(Some), DType::Utf8(Nullability::NonNullable)))] fn test_take_varbin_conformance(#[case] array: VarBinArray) { - test_take_conformance(&array.into_array()); + test_take_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } #[test] diff --git a/vortex-array/src/arrays/varbin/vtable/canonical.rs b/vortex-array/src/arrays/varbin/vtable/canonical.rs index 4865df5e1a3..fca9671e3b4 100644 --- a/vortex-array/src/arrays/varbin/vtable/canonical.rs +++ b/vortex-array/src/arrays/varbin/vtable/canonical.rs @@ -53,8 +53,6 @@ mod tests { use crate::arrays::VarBinViewArray; use crate::arrays::varbin::builder::VarBinBuilder; use crate::assert_arrays_eq; - #[expect(deprecated)] - use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::Nullability; @@ -73,8 +71,8 @@ mod tests { let varbin = varbin.slice(1..4).unwrap(); - #[expect(deprecated)] - let canonical = varbin.to_varbinview(); + let mut ctx = array_session().create_execution_ctx(); + let canonical = varbin.execute::(&mut ctx).unwrap(); assert_eq!(canonical.dtype(), &dtype); assert!( @@ -98,8 +96,11 @@ mod tests { fn test_canonical_varbin_unsliced(#[case] dtype: DType) { let mut ctx = array_session().create_execution_ctx(); let varbin = VarBinArray::from_iter_nonnull(["foo", "bar", "baz"], dtype.clone()); - #[expect(deprecated)] - let canonical = varbin.as_array().to_varbinview(); + let canonical = varbin + .as_array() + .clone() + .execute::(&mut ctx) + .unwrap(); let expected = match dtype { DType::Utf8(_) => VarBinViewArray::from_iter_str(["foo", "bar", "baz"]), _ => VarBinViewArray::from_iter_bin(["foo", "bar", "baz"]), @@ -112,8 +113,12 @@ mod tests { fn test_canonical_varbin_empty() { let varbin = VarBinArray::from_iter_nonnull([] as [&str; 0], DType::Utf8(Nullability::NonNullable)); - #[expect(deprecated)] - let canonical = varbin.as_array().to_varbinview(); + let mut ctx = array_session().create_execution_ctx(); + let canonical = varbin + .as_array() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!(canonical.len(), 0); } } diff --git a/vortex-array/src/arrays/varbinview/compact.rs b/vortex-array/src/arrays/varbinview/compact.rs index f608e1efff4..2aba21ae448 100644 --- a/vortex-array/src/arrays/varbinview/compact.rs +++ b/vortex-array/src/arrays/varbinview/compact.rs @@ -10,13 +10,10 @@ use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_mask::Mask; -use crate::IntoArray; -use crate::VortexSessionExecute; +use crate::ExecutionCtx; use crate::arrays::VarBinViewArray; use crate::arrays::varbinview::Ref; -use crate::builders::ArrayBuilder; use crate::builders::VarBinViewBuilder; -use crate::legacy_session; const DEFAULT_COMPACTION_THRESHOLD: f64 = 0.5; const MIN_RETAINED_BYTES_PER_ROW_TO_CHECK_COMPACTION: u64 = 128; @@ -30,16 +27,16 @@ impl VarBinViewArray { /// that are no longer visible. We detect when there is wasted space in any of the buffers, and if /// so, will aggressively compact all visible outlined string data into new buffers while keeping /// well-utilized buffers unchanged. - pub fn compact_buffers(&self) -> VortexResult { + pub fn compact_buffers(&self, ctx: &mut ExecutionCtx) -> VortexResult { // If there is nothing to be gained by compaction, return the original array untouched. - if !self.should_compact()? { + if !self.should_compact(ctx)? { return Ok(self.clone()); } - self.compact_with_threshold(DEFAULT_COMPACTION_THRESHOLD) + self.compact_with_threshold(DEFAULT_COMPACTION_THRESHOLD, ctx) } - fn should_compact(&self) -> VortexResult { + fn should_compact(&self, ctx: &mut ExecutionCtx) -> VortexResult { let nbuffers = self.data_buffers().len(); // If the array is entirely inlined strings, do not attempt to compact. @@ -62,22 +59,22 @@ impl VarBinViewArray { return Ok(false); } - let bytes_referenced: u64 = self.count_referenced_bytes()?; + let bytes_referenced: u64 = self.count_referenced_bytes(ctx)?; Ok((bytes_referenced as f64 / buffer_total_bytes as f64) < DEFAULT_COMPACTION_THRESHOLD) } /// Iterates over all valid, non-inlined views, calling the provided /// closure for each one. #[inline(always)] - #[allow(clippy::disallowed_methods)] - fn iter_valid_views(&self, mut f: F) -> VortexResult<()> + fn iter_valid_views(&self, ctx: &mut ExecutionCtx, mut f: F) -> VortexResult<()> where F: FnMut(&Ref), { - match self.as_ref().validity()?.execute_mask( - self.as_ref().len(), - &mut legacy_session().create_execution_ctx(), - )? { + match self + .as_ref() + .validity()? + .execute_mask(self.as_ref().len(), ctx)? + { Mask::AllTrue(_) => { for &view in self.views().iter() { if !view.is_inlined() { @@ -99,13 +96,16 @@ impl VarBinViewArray { /// Count the number of bytes addressed by the views, not including null /// values or any inlined strings. - fn count_referenced_bytes(&self) -> VortexResult { + fn count_referenced_bytes(&self, ctx: &mut ExecutionCtx) -> VortexResult { let mut total = 0u64; - self.iter_valid_views(|view| total += view.size as u64)?; + self.iter_valid_views(ctx, |view| total += view.size as u64)?; Ok(total) } - pub(crate) fn buffer_utilizations(&self) -> VortexResult> { + pub(crate) fn buffer_utilizations( + &self, + ctx: &mut ExecutionCtx, + ) -> VortexResult> { let mut utilizations: Vec = self .data_buffers() .iter() @@ -115,7 +115,7 @@ impl VarBinViewArray { }) .collect(); - self.iter_valid_views(|view| { + self.iter_valid_views(ctx, |view| { utilizations[view.buffer_index as usize].add(view.offset, view.size); })?; @@ -139,13 +139,14 @@ impl VarBinViewArray { pub fn compact_with_threshold( &self, buffer_utilization_threshold: f64, // [0, 1] + ctx: &mut ExecutionCtx, ) -> VortexResult { let mut builder = VarBinViewBuilder::with_compaction( self.dtype().clone(), self.len(), buffer_utilization_threshold, ); - builder.extend_from_array(&self.clone().into_array()); + builder.append_varbinview_array(self, ctx)?; Ok(builder.finish_into_varbinview()) } } @@ -228,14 +229,12 @@ mod tests { // Take only the first and last elements (indices 0 and 4) let indices = buffer![0u32, 4u32].into_array(); let taken = original.take(indices).unwrap(); - let taken = taken - .execute::(&mut array_session().create_execution_ctx()) - .unwrap(); + let taken = taken.execute::(&mut ctx).unwrap(); // The taken array should still have the same number of buffers assert_eq!(taken.data_buffers().len(), original_buffers); // Now optimize the taken array - let optimized_array = taken.compact_buffers().unwrap(); + let optimized_array = taken.compact_buffers(&mut ctx).unwrap(); // The optimized array should have compacted buffers // Since both remaining strings are short, they should be inlined @@ -273,7 +272,7 @@ mod tests { .execute::(&mut array_session().create_execution_ctx()) .unwrap(); - let optimized_array = taken_array.compact_with_threshold(1.0).unwrap(); + let optimized_array = taken_array.compact_with_threshold(1.0, &mut ctx).unwrap(); // The optimized array should have exactly 1 buffer (consolidated) assert_eq!(optimized_array.data_buffers().len(), 1); @@ -296,7 +295,7 @@ mod tests { assert_eq!(original.data_buffers().len(), 0); // Optimize should return the same array - let optimized_array = original.compact_buffers().unwrap(); + let optimized_array = original.compact_buffers(&mut ctx).unwrap(); assert_eq!(optimized_array.data_buffers().len(), 0); @@ -316,7 +315,7 @@ mod tests { assert_eq!(original.buffer(0).len(), str1.len() + str2.len()); // Optimize should return the same array (no change needed) - let optimized_array = original.compact_buffers().unwrap(); + let optimized_array = original.compact_buffers(&mut ctx).unwrap(); assert_eq!(optimized_array.data_buffers().len(), 1); @@ -342,7 +341,7 @@ mod tests { .execute::(&mut array_session().create_execution_ctx()) .unwrap(); // Compact with threshold=0 (should not compact) - let compacted = taken.compact_with_threshold(0.0).unwrap(); + let compacted = taken.compact_with_threshold(0.0, &mut ctx).unwrap(); // Should still have the same number of buffers as the taken array assert_eq!(compacted.data_buffers().len(), taken.data_buffers().len()); @@ -371,7 +370,7 @@ mod tests { let original_buffers = taken.data_buffers().len(); // Compact with threshold=1.0 (aggressive compaction) - let compacted = taken.compact_with_threshold(1.0).unwrap(); + let compacted = taken.compact_with_threshold(1.0, &mut ctx).unwrap(); // Should have compacted buffers assert!(compacted.data_buffers().len() <= original_buffers); @@ -394,7 +393,7 @@ mod tests { assert_eq!(original.data_buffers().len(), 1); // Compact with high threshold - let compacted = original.compact_with_threshold(0.8).unwrap(); + let compacted = original.compact_with_threshold(0.8, &mut ctx).unwrap(); // Well-utilized buffer should be preserved assert_eq!(compacted.data_buffers().len(), 1); @@ -426,7 +425,7 @@ mod tests { .unwrap(); // Compact with moderate threshold - let compacted = taken.compact_with_threshold(0.7).unwrap(); + let compacted = taken.compact_with_threshold(0.7, &mut ctx).unwrap(); let expected = VarBinViewArray::from_iter( [0, 2, 4, 6, 8].map(|i| Some(strings[i].as_str())), @@ -452,12 +451,12 @@ mod tests { .execute::(&mut array_session().create_execution_ctx()) .unwrap(); // Get buffer stats before compaction - let utils_before = taken.buffer_utilizations().unwrap(); + let utils_before = taken.buffer_utilizations(&mut ctx).unwrap(); let original_buffer_count = taken.data_buffers().len(); // Compact with a threshold that should trigger slicing // The range utilization should be high even if overall utilization is low - let compacted = taken.compact_with_threshold(0.8).unwrap(); + let compacted = taken.compact_with_threshold(0.8, &mut ctx).unwrap(); // After compaction, we should still have buffers (sliced, not rewritten) assert!( @@ -498,9 +497,13 @@ mod tests { #[case] expected_bytes: u64, #[case] expected_utils: &[f64], ) { - assert_eq!(arr.count_referenced_bytes().unwrap(), expected_bytes); + let mut ctx = array_session().create_execution_ctx(); + assert_eq!( + arr.count_referenced_bytes(&mut ctx).unwrap(), + expected_bytes + ); let utils: Vec = arr - .buffer_utilizations() + .buffer_utilizations(&mut ctx) .unwrap() .iter() .map(|u| u.overall_utilization()) diff --git a/vortex-array/src/arrays/varbinview/compute/cast.rs b/vortex-array/src/arrays/varbinview/compute/cast.rs index 476c74e7309..660a7842ed9 100644 --- a/vortex-array/src/arrays/varbinview/compute/cast.rs +++ b/vortex-array/src/arrays/varbinview/compute/cast.rs @@ -135,6 +135,6 @@ mod tests { #[case(VarBinViewArray::from_iter(vec![Some("single")], DType::Utf8(Nullability::NonNullable)))] #[case(VarBinViewArray::from_iter(vec![Some("very long string that exceeds the inline size to test view functionality with multiple buffers")], DType::Utf8(Nullability::NonNullable)))] fn test_cast_varbinview_conformance(#[case] array: VarBinViewArray) { - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } } diff --git a/vortex-array/src/arrays/varbinview/compute/mask.rs b/vortex-array/src/arrays/varbinview/compute/mask.rs index 6172c42ab7d..e470dceba32 100644 --- a/vortex-array/src/arrays/varbinview/compute/mask.rs +++ b/vortex-array/src/arrays/varbinview/compute/mask.rs @@ -33,6 +33,8 @@ impl MaskReduce for VarBinView { #[cfg(test)] mod tests { use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::VarBinViewArray; use crate::compute::conformance::mask::test_mask_conformance; @@ -40,6 +42,7 @@ mod tests { fn take_mask_var_bin_view_array() { test_mask_conformance( &VarBinViewArray::from_iter_str(["one", "two", "three", "four", "five"]).into_array(), + &mut array_session().create_execution_ctx(), ); test_mask_conformance( @@ -51,6 +54,7 @@ mod tests { Some("five"), ]) .into_array(), + &mut array_session().create_execution_ctx(), ); } } diff --git a/vortex-array/src/arrays/varbinview/compute/mod.rs b/vortex-array/src/arrays/varbinview/compute/mod.rs index 0358ad01460..e25b6eafb53 100644 --- a/vortex-array/src/arrays/varbinview/compute/mod.rs +++ b/vortex-array/src/arrays/varbinview/compute/mod.rs @@ -15,8 +15,6 @@ mod tests { use crate::IntoArray; use crate::arrays::VarBinViewArray; - #[expect(deprecated)] - use crate::canonical::ToCanonical as _; #[test] fn take_nullable() -> VortexResult<()> { let arr = VarBinViewArray::from_iter_nullable_str([ @@ -32,8 +30,7 @@ mod tests { assert!(taken.dtype().is_nullable()); let mut ctx = array_session().create_execution_ctx(); - #[expect(deprecated)] - let taken = taken.to_varbinview(); + let taken = taken.execute::(&mut ctx)?; let mask = taken.validity()?.execute_mask(taken.len(), &mut ctx)?; let result = (0..taken.len()) .map(|i| { diff --git a/vortex-array/src/arrays/varbinview/compute/take.rs b/vortex-array/src/arrays/varbinview/compute/take.rs index 1b7435729f1..ce10abda3d0 100644 --- a/vortex-array/src/arrays/varbinview/compute/take.rs +++ b/vortex-array/src/arrays/varbinview/compute/take.rs @@ -173,6 +173,9 @@ mod tests { ))] #[case(VarBinViewArray::from_iter(["single"].map(Some), DType::Utf8(NonNullable)))] fn test_take_varbinview_conformance(#[case] array: VarBinViewArray) { - test_take_conformance(&array.into_array()); + test_take_conformance( + &array.into_array(), + &mut array_session().create_execution_ctx(), + ); } } diff --git a/vortex-array/src/arrays/varbinview/compute/zip.rs b/vortex-array/src/arrays/varbinview/compute/zip.rs index 193dc523823..8966a327f9d 100644 --- a/vortex-array/src/arrays/varbinview/compute/zip.rs +++ b/vortex-array/src/arrays/varbinview/compute/zip.rs @@ -217,8 +217,6 @@ mod tests { use crate::array_session; use crate::arrays::VarBinViewArray; use crate::builtins::ArrayBuiltins; - #[expect(deprecated)] - use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::Nullability; @@ -250,12 +248,12 @@ mod tests { let mask = Mask::from_iter([true, false, true, false, false, true]); - #[expect(deprecated)] + let mut ctx = array_session().create_execution_ctx(); let zipped = mask .clone() .into_array() .zip(a.into_array(), b.into_array())? - .to_varbinview(); + .execute::(&mut ctx)?; let mut ctx = array_session().create_execution_ctx(); let validity_mask = zipped.validity()?.execute_mask(zipped.len(), &mut ctx)?; diff --git a/vortex-array/src/arrays/varbinview/vtable/mod.rs b/vortex-array/src/arrays/varbinview/vtable/mod.rs index 9335ed092ca..f730282ddb4 100644 --- a/vortex-array/src/arrays/varbinview/vtable/mod.rs +++ b/vortex-array/src/arrays/varbinview/vtable/mod.rs @@ -245,12 +245,10 @@ impl VTable for VarBinView { builder: &mut dyn ArrayBuilder, ctx: &mut ExecutionCtx, ) -> VortexResult<()> { - if let Some(builder) = builder.as_any_mut().downcast_mut::() { - return builder.append_varbinview_array(&array.into_owned(), ctx); - } - - builder.extend_from_array(array.as_ref()); - Ok(()) + let Some(builder) = builder.as_any_mut().downcast_mut::() else { + vortex_bail!("append_to_builder for VarBinView requires a VarBinViewBuilder"); + }; + builder.append_varbinview_array(&array.into_owned(), ctx) } fn execute(array: Array, _ctx: &mut ExecutionCtx) -> VortexResult { diff --git a/vortex-array/src/arrow/executor/byte.rs b/vortex-array/src/arrow/executor/byte.rs index 11e2e084e38..83c1c681284 100644 --- a/vortex-array/src/arrow/executor/byte.rs +++ b/vortex-array/src/arrow/executor/byte.rs @@ -79,6 +79,7 @@ mod tests { use arrow_array::Array; use arrow_array::cast::AsArray; use arrow_schema::DataType; + use arrow_schema::Field; use rstest::rstest; use vortex_error::VortexResult; use vortex_mask::Mask; @@ -86,11 +87,10 @@ mod tests { use crate::IntoArray; use crate::VortexSessionExecute; use crate::array_session; - use crate::arrow::ArrowArrayExecutor; + use crate::arrow::ArrowSessionExt; use crate::arrow::executor::byte::VarBinViewArray; use crate::dtype::DType; use crate::dtype::Nullability; - use crate::legacy_session; fn make_utf8_array() -> VarBinViewArray { VarBinViewArray::from_iter_str(["hello", "world", "this is a longer string for testing"]) @@ -122,10 +122,12 @@ mod tests { #[case] vortex_array: VarBinViewArray, #[case] target_dtype: DataType, ) { - let mut ctx = array_session().create_execution_ctx(); - let arrow = vortex_array - .into_array() - .execute_arrow(Some(&target_dtype), &mut ctx) + let session = array_session(); + let mut ctx = session.create_execution_ctx(); + let field = Field::new("test_field", target_dtype.clone(), true); + let arrow = session + .arrow() + .execute_arrow(vortex_array.into_array(), Some(&field), &mut ctx) .unwrap(); assert_eq!(arrow.data_type(), &target_dtype); @@ -169,10 +171,12 @@ mod tests { vortex_dtype, ); - let mut ctx = array_session().create_execution_ctx(); - let arrow = vortex_array - .into_array() - .execute_arrow(Some(&target_dtype), &mut ctx) + let session = array_session(); + let mut ctx = session.create_execution_ctx(); + let field = Field::new("test_field", target_dtype.clone(), true); + let arrow = session + .arrow() + .execute_arrow(vortex_array.into_array(), Some(&field), &mut ctx) .unwrap(); assert_eq!(arrow.data_type(), &target_dtype); @@ -184,7 +188,6 @@ mod tests { } #[test] - #[allow(clippy::disallowed_methods)] fn filtered_utf8_view_export_does_not_retain_unselected_buffers() -> VortexResult<()> { let unselected = "x".repeat(1 << 20); let array = @@ -193,10 +196,11 @@ mod tests { .into_array() .filter(Mask::from_iter([true, false, false]))?; - let arrow = filtered.execute_arrow( - Some(&DataType::Utf8View), - &mut legacy_session().create_execution_ctx(), - )?; + let session = array_session(); + let mut ctx = session.create_execution_ctx(); + let arrow = session + .arrow() + .execute_arrow(filtered.into_array(), None, &mut ctx)?; assert_eq!(arrow.as_string_view().value(0), "selected"); assert!( diff --git a/vortex-array/src/arrow/executor/byte_view.rs b/vortex-array/src/arrow/executor/byte_view.rs index 89f37681220..4e17b7f2376 100644 --- a/vortex-array/src/arrow/executor/byte_view.rs +++ b/vortex-array/src/arrow/executor/byte_view.rs @@ -47,7 +47,7 @@ pub fn execute_varbinview_to_arrow( array: &VarBinViewArray, ctx: &mut ExecutionCtx, ) -> VortexResult { - let compacted = array.compact_buffers()?; + let compacted = array.compact_buffers(ctx)?; canonical_varbinview_to_arrow::(&compacted, ctx) } diff --git a/vortex-array/src/builders/bool.rs b/vortex-array/src/builders/bool.rs index 0766854a722..73d2089e221 100644 --- a/vortex-array/src/builders/bool.rs +++ b/vortex-array/src/builders/bool.rs @@ -5,7 +5,6 @@ use std::any::Any; use std::mem; use vortex_buffer::BitBufferMut; -use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_error::vortex_ensure; use vortex_mask::Mask; @@ -13,18 +12,14 @@ use vortex_mask::Mask; use crate::ArrayRef; use crate::ExecutionCtx; use crate::IntoArray; -use crate::VortexSessionExecute; use crate::arrays::BoolArray; use crate::arrays::bool::BoolArrayExt; use crate::builders::ArrayBuilder; use crate::builders::DEFAULT_BUILDER_CAPACITY; use crate::builders::LazyBitBufferBuilder; use crate::canonical::Canonical; -#[expect(deprecated)] -use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::Nullability; -use crate::legacy_session; use crate::scalar::Scalar; pub struct BoolBuilder { @@ -127,14 +122,6 @@ impl ArrayBuilder for BoolBuilder { Ok(()) } - #[allow(clippy::disallowed_methods)] - unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { - #[expect(deprecated)] - let bool_array = array.to_bool(); - self.append_bool_array(&bool_array, &mut legacy_session().create_execution_ctx()) - .vortex_expect("Failed to append bool array"); - } - fn reserve_exact(&mut self, additional: usize) { self.inner.reserve(additional); self.nulls.reserve_exact(additional); @@ -148,7 +135,7 @@ impl ArrayBuilder for BoolBuilder { self.finish_into_bool().into_array() } - fn finish_into_canonical(&mut self) -> Canonical { + fn finish_into_canonical(&mut self, _ctx: &mut ExecutionCtx) -> Canonical { Canonical::Bool(self.finish_into_bool()) } } @@ -171,8 +158,6 @@ mod tests { use crate::builders::BoolBuilder; use crate::builders::bool::BoolArray; use crate::builders::builder_with_capacity; - #[expect(deprecated)] - use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::Nullability; use crate::scalar::Scalar; @@ -206,10 +191,8 @@ mod tests { .clone() .append_to_builder(builder.as_mut(), &mut ctx)?; - #[expect(deprecated)] - let canon_into = builder.finish().to_bool(); - #[expect(deprecated)] - let into_canon = chunk.to_bool(); + let canon_into = builder.finish().execute::(&mut ctx)?; + let into_canon = chunk.clone().execute::(&mut ctx)?; assert!(canon_into.validity()?.mask_eq( &into_canon.validity()?, diff --git a/vortex-array/src/builders/decimal.rs b/vortex-array/src/builders/decimal.rs index 7f3abbb9220..c08dfbc4d87 100644 --- a/vortex-array/src/builders/decimal.rs +++ b/vortex-array/src/builders/decimal.rs @@ -12,10 +12,8 @@ use vortex_error::vortex_panic; use vortex_mask::Mask; use crate::ArrayRef; +use crate::ExecutionCtx; use crate::IntoArray; -#[expect(deprecated)] -use crate::ToCanonical as _; -use crate::VortexSessionExecute; use crate::arrays::DecimalArray; use crate::builders::ArrayBuilder; use crate::builders::DEFAULT_BUILDER_CAPACITY; @@ -27,7 +25,6 @@ use crate::dtype::DecimalDType; use crate::dtype::NativeDecimalType; use crate::dtype::Nullability; use crate::dtype::i256; -use crate::legacy_session; use crate::match_each_decimal_value; use crate::match_each_decimal_value_type; use crate::scalar::DecimalValue; @@ -129,6 +126,28 @@ impl DecimalBuilder { self.nulls.append_n_non_nulls(n); } + /// Appends the values of a canonical [`DecimalArray`] to the builder, coercing the physical + /// storage type to the builder's type as needed. + pub(crate) fn append_decimal_array( + &mut self, + array: &DecimalArray, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + match_each_decimal_value_type!(array.values_type(), |D| { + // Extends the values buffer from another buffer of type D where D can be coerced to the + // builder type. + self.values.extend(array.buffer::().iter().copied()); + }); + + self.nulls.append_validity_mask( + &array + .as_ref() + .validity()? + .execute_mask(array.as_ref().len(), ctx)?, + ); + Ok(()) + } + /// Finishes the builder directly into a [`DecimalArray`]. pub fn finish_into_decimal(&mut self) -> DecimalArray { let validity = self.nulls.finish_with_nullability(self.dtype.nullability()); @@ -195,31 +214,6 @@ impl ArrayBuilder for DecimalBuilder { Ok(()) } - #[allow(clippy::disallowed_methods)] - unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { - #[expect(deprecated)] - let decimal_array = array.to_decimal(); - - match_each_decimal_value_type!(decimal_array.values_type(), |D| { - // Extends the values buffer from another buffer of type D where D can be coerced to the - // builder type. - self.values - .extend(decimal_array.buffer::().iter().copied()); - }); - - self.nulls.append_validity_mask( - &decimal_array - .as_ref() - .validity() - .vortex_expect("validity_mask") - .execute_mask( - decimal_array.as_ref().len(), - &mut legacy_session().create_execution_ctx(), - ) - .vortex_expect("Failed to compute validity mask"), - ); - } - fn reserve_exact(&mut self, additional: usize) { self.values.reserve(additional); self.nulls.reserve_exact(additional); @@ -233,7 +227,7 @@ impl ArrayBuilder for DecimalBuilder { self.finish_into_decimal().into_array() } - fn finish_into_canonical(&mut self) -> Canonical { + fn finish_into_canonical(&mut self, _ctx: &mut ExecutionCtx) -> Canonical { Canonical::Decimal(self.finish_into_decimal()) } } @@ -329,7 +323,8 @@ mod tests { let i8s = i8s.finish(); let mut i128s = DecimalBuilder::new::(DecimalDType::new(2, 1), false.into()); - i128s.extend_from_array(&i8s); + i8s.append_to_builder(&mut i128s, &mut array_session().create_execution_ctx()) + .unwrap(); let i128s = i128s.finish(); for i in 0..i8s.len() { diff --git a/vortex-array/src/builders/extension.rs b/vortex-array/src/builders/extension.rs index 61d5fe8d7b0..aa8e1b76aa2 100644 --- a/vortex-array/src/builders/extension.rs +++ b/vortex-array/src/builders/extension.rs @@ -8,6 +8,7 @@ use vortex_error::vortex_ensure; use vortex_mask::Mask; use crate::ArrayRef; +use crate::ExecutionCtx; use crate::IntoArray; use crate::arrays::ExtensionArray; use crate::arrays::extension::ExtensionArrayExt; @@ -15,8 +16,6 @@ use crate::builders::ArrayBuilder; use crate::builders::DEFAULT_BUILDER_CAPACITY; use crate::builders::builder_with_capacity; use crate::canonical::Canonical; -#[expect(deprecated)] -use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::extension::ExtDTypeRef; use crate::scalar::ExtScalar; @@ -47,6 +46,18 @@ impl ExtensionBuilder { self.storage.append_scalar(&value.to_storage_scalar()) } + /// Appends the values of a canonical [`ExtensionArray`] to the builder by appending its + /// storage array to the underlying storage builder. + pub(crate) fn append_extension_array( + &mut self, + array: &ExtensionArray, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + array + .storage_array() + .append_to_builder(self.storage.as_mut(), ctx) + } + /// Finishes the builder directly into a [`ExtensionArray`]. pub fn finish_into_extension(&mut self) -> ExtensionArray { let storage = self.storage.finish(); @@ -101,12 +112,6 @@ impl ArrayBuilder for ExtensionBuilder { self.append_value(scalar.as_extension()) } - unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { - #[expect(deprecated)] - let ext_array = array.to_extension(); - self.storage.extend_from_array(ext_array.storage_array()) - } - fn reserve_exact(&mut self, capacity: usize) { self.storage.reserve_exact(capacity) } @@ -119,7 +124,7 @@ impl ArrayBuilder for ExtensionBuilder { self.finish_into_extension().into_array() } - fn finish_into_canonical(&mut self) -> Canonical { + fn finish_into_canonical(&mut self, _ctx: &mut ExecutionCtx) -> Canonical { Canonical::Extension(self.finish_into_extension()) } } diff --git a/vortex-array/src/builders/fixed_size_list.rs b/vortex-array/src/builders/fixed_size_list.rs index b0d08daea90..9150d37b50b 100644 --- a/vortex-array/src/builders/fixed_size_list.rs +++ b/vortex-array/src/builders/fixed_size_list.rs @@ -14,7 +14,6 @@ use vortex_mask::Mask; use crate::ArrayRef; use crate::ExecutionCtx; use crate::IntoArray; -use crate::VortexSessionExecute; use crate::arrays::FixedSizeListArray; use crate::arrays::fixed_size_list::FixedSizeListArrayExt; use crate::builders::ArrayBuilder; @@ -22,11 +21,8 @@ use crate::builders::DEFAULT_BUILDER_CAPACITY; use crate::builders::LazyBitBufferBuilder; use crate::builders::builder_with_capacity; use crate::canonical::Canonical; -#[expect(deprecated)] -use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::Nullability; -use crate::legacy_session; use crate::scalar::ListScalar; use crate::scalar::Scalar; @@ -108,6 +104,25 @@ impl FixedSizeListBuilder { Ok(()) } + /// Appends the values of a canonical [`FixedSizeListArray`] to the builder, recursing into the + /// elements builder. + pub(crate) fn append_fixed_size_list_array( + &mut self, + array: &FixedSizeListArray, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + if array.is_empty() { + return Ok(()); + } + + array + .elements() + .append_to_builder(self.elements_builder.as_mut(), ctx)?; + self.nulls + .append_validity_mask(&array.validity()?.execute_mask(array.len(), ctx)?); + Ok(()) + } + /// Appends a fixed-size list `value` to the builder. /// /// Note that a [`ListScalar`] can represent both a [`ListArray`] scalar **and** a @@ -242,24 +257,6 @@ impl ArrayBuilder for FixedSizeListBuilder { /// This will increase the capacity if extending with this `array` would go past the original /// capacity. - #[allow(clippy::disallowed_methods)] - unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { - #[expect(deprecated)] - let fsl = array.to_fixed_size_list(); - if fsl.is_empty() { - return; - } - - self.elements_builder.extend_from_array(fsl.elements()); - self.nulls.append_validity_mask( - &array - .validity() - .vortex_expect("validity_mask in extend_from_array_unchecked") - .execute_mask(array.len(), &mut legacy_session().create_execution_ctx()) - .vortex_expect("Failed to compute validity mask"), - ); - } - fn reserve_exact(&mut self, additional: usize) { self.elements_builder .reserve_exact(additional * self.list_size() as usize); @@ -274,7 +271,7 @@ impl ArrayBuilder for FixedSizeListBuilder { self.finish_into_fixed_size_list().into_array() } - fn finish_into_canonical(&mut self) -> Canonical { + fn finish_into_canonical(&mut self, _ctx: &mut ExecutionCtx) -> Canonical { Canonical::FixedSizeList(self.finish_into_fixed_size_list()) } } @@ -288,8 +285,6 @@ mod tests { use super::FixedSizeListBuilder; use crate::IntoArray as _; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::PrimitiveArray; @@ -343,8 +338,8 @@ mod tests { let fsl = builder.finish(); assert_eq!(fsl.len(), 2); - #[expect(deprecated)] - let fsl_array = fsl.to_fixed_size_list(); + let mut ctx = array_session().create_execution_ctx(); + let fsl_array = fsl.execute::(&mut ctx).unwrap(); assert_eq!(fsl_array.elements().len(), 6); assert_eq!(fsl_array.list_size(), 3); } @@ -367,8 +362,8 @@ mod tests { let fsl = builder.finish(); assert_eq!(fsl.len(), 100); - #[expect(deprecated)] - let fsl_array = fsl.to_fixed_size_list(); + let mut ctx = array_session().create_execution_ctx(); + let fsl_array = fsl.execute::(&mut ctx).unwrap(); assert_eq!(fsl_array.list_size(), 0); // The elements array should be empty since list_size is 0. assert_eq!(fsl_array.elements().len(), 0); @@ -397,8 +392,8 @@ mod tests { let fsl = builder.finish(); assert_eq!(fsl.len(), 100); - #[expect(deprecated)] - let fsl_array = fsl.to_fixed_size_list(); + let mut ctx = array_session().create_execution_ctx(); + let fsl_array = fsl.execute::(&mut ctx).unwrap(); assert_eq!(fsl_array.list_size(), 0); assert_eq!(fsl_array.elements().len(), 0); } @@ -427,8 +422,8 @@ mod tests { let fsl = builder.finish(); assert_eq!(fsl.len(), 5); - #[expect(deprecated)] - let fsl_array = fsl.to_fixed_size_list(); + let mut ctx = array_session().create_execution_ctx(); + let fsl_array = fsl.execute::(&mut ctx).unwrap(); assert_eq!(fsl_array.elements().len(), 10); } @@ -441,8 +436,8 @@ mod tests { let fsl = builder.finish(); assert_eq!(fsl.len(), 0); - #[expect(deprecated)] - let fsl_array = fsl.to_fixed_size_list(); + let mut ctx = array_session().create_execution_ctx(); + let fsl_array = fsl.execute::(&mut ctx).unwrap(); assert_eq!(fsl_array.list_size(), 100000000); assert_eq!(fsl_array.elements().len(), 0); } @@ -475,8 +470,7 @@ mod tests { let fsl = builder.finish(); assert_eq!(fsl.len(), 3); - #[expect(deprecated)] - let fsl_array = fsl.to_fixed_size_list(); + let fsl_array = fsl.execute::(&mut ctx).unwrap(); assert!( fsl_array .validity() @@ -539,8 +533,8 @@ mod tests { let fsl = builder.finish(); assert_eq!(fsl.len(), 2); - #[expect(deprecated)] - let fsl_array = fsl.to_fixed_size_list(); + let mut ctx = array_session().create_execution_ctx(); + let fsl_array = fsl.execute::(&mut ctx).unwrap(); assert_eq!(fsl_array.elements().len(), 6); } @@ -554,14 +548,17 @@ mod tests { let fsl = builder.finish(); assert_eq!(fsl.len(), 5); - #[expect(deprecated)] - let fsl_array = fsl.to_fixed_size_list(); + let mut ctx = array_session().create_execution_ctx(); + let fsl_array = fsl.execute::(&mut ctx).unwrap(); assert_eq!(fsl_array.list_size(), 3); assert_eq!(fsl_array.elements().len(), 15); // Check that all elements are zeros. - #[expect(deprecated)] - let elements_array = fsl_array.elements().to_primitive(); + let elements_array = fsl_array + .elements() + .clone() + .execute::(&mut ctx) + .unwrap(); let elements = elements_array.as_slice::(); assert!(elements.iter().all(|&x| x == 0)); } @@ -580,8 +577,7 @@ mod tests { let fsl = builder.finish(); assert_eq!(fsl.len(), 3); - #[expect(deprecated)] - let fsl_array = fsl.to_fixed_size_list(); + let fsl_array = fsl.execute::(&mut ctx).unwrap(); assert_eq!(fsl_array.list_size(), 2); // Check that all lists are null. @@ -612,8 +608,7 @@ mod tests { let fsl = builder.finish(); assert_eq!(fsl.len(), 1); - #[expect(deprecated)] - let fsl_array = fsl.to_fixed_size_list(); + let fsl_array = fsl.execute::(&mut ctx).unwrap(); assert_eq!(fsl_array.list_size(), 2); // Check that all lists are null. @@ -638,8 +633,8 @@ mod tests { let fsl = builder.finish(); assert_eq!(fsl.len(), 1000); - #[expect(deprecated)] - let fsl_array = fsl.to_fixed_size_list(); + let mut ctx = array_session().create_execution_ctx(); + let fsl_array = fsl.execute::(&mut ctx).unwrap(); assert_eq!(fsl_array.list_size(), 0); assert_eq!(fsl_array.elements().len(), 0); } @@ -685,14 +680,17 @@ mod tests { let mut builder = FixedSizeListBuilder::with_capacity(dtype, 2, Nullable, 0); let source_array = source.into_array(); - builder.extend_from_array(&source_array); - builder.extend_from_array(&source_array); + source_array + .append_to_builder(&mut builder, &mut ctx) + .unwrap(); + source_array + .append_to_builder(&mut builder, &mut ctx) + .unwrap(); let fsl = builder.finish(); assert_eq!(fsl.len(), 6); - #[expect(deprecated)] - let fsl_array = fsl.to_fixed_size_list(); + let fsl_array = fsl.execute::(&mut ctx).unwrap(); assert_eq!(fsl_array.elements().len(), 12); // Check validity pattern is repeated. @@ -762,14 +760,19 @@ mod tests { let mut builder = FixedSizeListBuilder::with_capacity(dtype, 0, Nullable, 0); - builder.extend_from_array(&source1.into_array()); - builder.extend_from_array(&source2.into_array()); + source1 + .into_array() + .append_to_builder(&mut builder, &mut ctx) + .unwrap(); + source2 + .into_array() + .append_to_builder(&mut builder, &mut ctx) + .unwrap(); let fsl = builder.finish(); assert_eq!(fsl.len(), 5); - #[expect(deprecated)] - let fsl_array = fsl.to_fixed_size_list(); + let fsl_array = fsl.execute::(&mut ctx).unwrap(); assert_eq!(fsl_array.list_size(), 0); assert_eq!(fsl_array.elements().len(), 0); @@ -813,6 +816,7 @@ mod tests { #[test] fn test_extend_empty_array() { + let mut ctx = array_session().create_execution_ctx(); let dtype: Arc = Arc::new(I32.into()); // Create an empty source array. @@ -839,7 +843,10 @@ mod tests { .unwrap(); // Extend with empty array (should be no-op). - builder.extend_from_array(&source.into_array()); + source + .into_array() + .append_to_builder(&mut builder, &mut ctx) + .unwrap(); let fsl = builder.finish(); assert_eq!(fsl.len(), 1); @@ -877,13 +884,15 @@ mod tests { Validity::AllValid, 1, ); - builder.extend_from_array(&source.into_array()); + source + .into_array() + .append_to_builder(&mut builder, &mut ctx) + .unwrap(); let fsl = builder.finish(); assert_eq!(fsl.len(), 6); - #[expect(deprecated)] - let fsl_array = fsl.to_fixed_size_list(); + let fsl_array = fsl.execute::(&mut ctx).unwrap(); assert_eq!(fsl_array.elements().len(), 12); // Check validity. @@ -928,7 +937,7 @@ mod tests { .vortex_expect("fixed-size-list validity should be derivable") .execute_is_valid(5, &mut ctx) .unwrap() - ); // extend_from_array + ); } #[test] @@ -1044,8 +1053,11 @@ mod tests { assert_eq!(fsl.list_size(), 3); // Verify elements array: [1, 2, 3, 10, 11, 12, 4, 5, 6, 20, 21, 22]. - #[expect(deprecated)] - let elements = fsl.elements().to_primitive(); + let elements = fsl + .elements() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!( elements.as_slice::(), &[1, 2, 3, 10, 11, 12, 4, 5, 6, 20, 21, 22] diff --git a/vortex-array/src/builders/list.rs b/vortex-array/src/builders/list.rs index 8f2bba09192..5a97f790eeb 100644 --- a/vortex-array/src/builders/list.rs +++ b/vortex-array/src/builders/list.rs @@ -4,6 +4,7 @@ use std::any::Any; use std::sync::Arc; +use num_traits::AsPrimitive; use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_error::vortex_bail; @@ -15,21 +16,23 @@ use crate::ArrayRef; use crate::Canonical; use crate::ExecutionCtx; use crate::IntoArray; -use crate::VortexSessionExecute; +use crate::array::ArrayView; +use crate::arrays::List; use crate::arrays::ListArray; +use crate::arrays::ListView; +use crate::arrays::ListViewArray; +use crate::arrays::PrimitiveArray; +use crate::arrays::list::ListArrayExt; use crate::arrays::listview::ListViewArrayExt; use crate::builders::ArrayBuilder; use crate::builders::DEFAULT_BUILDER_CAPACITY; use crate::builders::LazyBitBufferBuilder; use crate::builders::PrimitiveBuilder; use crate::builders::builder_with_capacity; -#[expect(deprecated)] -use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::IntegerPType; use crate::dtype::Nullability; use crate::dtype::Nullability::NonNullable; -use crate::legacy_session; use crate::match_each_integer_ptype; use crate::scalar::ListScalar; use crate::scalar::Scalar; @@ -172,6 +175,51 @@ impl ListBuilder { } } +/// Appends `ListViewArray`-layout lists (`n` offsets and sizes) into a [`ListBuilder`], converting +/// into the `ListArray` (`n + 1` offsets) layout. +fn extend_from_listview( + builder: &mut ListBuilder, + new_elements: &ArrayRef, + new_offsets: &[OffsetType], + new_sizes: &[SizeType], + ctx: &mut ExecutionCtx, +) -> VortexResult<()> +where + O: IntegerPType, + OffsetType: IntegerPType, + SizeType: IntegerPType, +{ + let num_lists = new_offsets.len(); + debug_assert_eq!(num_lists, new_sizes.len()); + + let mut curr_offset = builder.elements_builder.len(); + let mut offsets_range = builder.offsets_builder.uninit_range(num_lists); + + // We need to append each list individually, converting from `ListViewArray` format to + // the `ListArray` format that `ListBuilder` expects. + for i in 0..new_offsets.len() { + let offset: usize = new_offsets[i].as_(); + let size: usize = new_sizes[i].as_(); + + if size > 0 { + let list_elements = new_elements + .slice(offset..offset + size) + .vortex_expect("list builder slice"); + list_elements.append_to_builder(builder.elements_builder.as_mut(), ctx)?; + curr_offset += size; + } + + let new_offset = O::from_usize(curr_offset).vortex_expect("Failed to convert offset"); + + offsets_range.set_value(i, new_offset); + } + + // SAFETY: We have initialized all `num_lists` values, and since the `offsets` array is + // non-nullable, we are done. + unsafe { offsets_range.finish() }; + Ok(()) +} + impl ArrayBuilder for ListBuilder { fn as_any(&self) -> &dyn Any { self @@ -222,101 +270,102 @@ impl ArrayBuilder for ListBuilder { self.append_value(scalar.as_list()) } - #[allow(clippy::disallowed_methods)] - unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { - #[expect(deprecated)] - let list = array.to_listview(); - if list.is_empty() { - return; - } + fn reserve_exact(&mut self, additional: usize) { + self.elements_builder.reserve_exact(additional); + self.offsets_builder.reserve_exact(additional); + self.nulls.reserve_exact(additional); + } - // Append validity information. - self.nulls.append_validity_mask( - &array - .validity() - .vortex_expect("validity_mask in extend_from_array_unchecked") - .execute_mask(array.len(), &mut legacy_session().create_execution_ctx()) - .vortex_expect("Failed to compute validity mask"), - ); + unsafe fn set_validity_unchecked(&mut self, validity: Mask) { + self.nulls = LazyBitBufferBuilder::from_validity_mask(validity); + } - // Note that `ListViewArray` has `n` offsets and sizes, not `n+1` offsets like `ListArray`. - let elements = list.elements(); - #[expect(deprecated)] - let offsets = list.offsets().to_primitive(); - #[expect(deprecated)] - let sizes = list.sizes().to_primitive(); - - fn extend_inner( - builder: &mut ListBuilder, - new_elements: &ArrayRef, - new_offsets: &[OffsetType], - new_sizes: &[SizeType], - ) where - O: IntegerPType, - OffsetType: IntegerPType, - SizeType: IntegerPType, - { - let num_lists = new_offsets.len(); - debug_assert_eq!(num_lists, new_sizes.len()); - - let mut curr_offset = builder.elements_builder.len(); - let mut offsets_range = builder.offsets_builder.uninit_range(num_lists); - - // We need to append each list individually, converting from `ListViewArray` format to - // the `ListArray` format that `ListBuilder` expects. - for i in 0..new_offsets.len() { - let offset: usize = new_offsets[i].as_(); - let size: usize = new_sizes[i].as_(); - - if size > 0 { - let list_elements = new_elements - .slice(offset..offset + size) - .vortex_expect("list builder slice"); - builder.elements_builder.extend_from_array(&list_elements); - curr_offset += size; - } + fn finish(&mut self) -> ArrayRef { + self.finish_into_list().into_array() + } + + fn finish_into_canonical(&mut self, ctx: &mut ExecutionCtx) -> Canonical { + let listview = self + .finish() + .execute::(ctx) + .vortex_expect("list builder should canonicalize to listview"); + Canonical::List(listview) + } + + fn append_list_array( + &mut self, + array: ArrayView<'_, List>, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + if array.is_empty() { + return Ok(()); + } - let new_offset = - O::from_usize(curr_offset).vortex_expect("Failed to convert offset"); + self.nulls + .append_validity_mask(&array.validity()?.execute_mask(array.len(), ctx)?); - offsets_range.set_value(i, new_offset); + let num_lists = array.len(); + let offsets = array.offsets().clone().execute::(ctx)?; + match_each_integer_ptype!(offsets.ptype(), |OffsetType| { + let offsets = offsets.as_slice::(); + let first: usize = offsets[0].as_(); + let last: usize = offsets[num_lists].as_(); + + // Lists in a `ListArray` are contiguous, so the referenced elements can be appended + // in bulk and the offsets rebased onto this builder's elements. + let elements_base = self.elements_builder.len(); + if last > first { + array + .elements() + .slice(first..last)? + .append_to_builder(self.elements_builder.as_mut(), ctx)?; } + let mut offsets_range = self.offsets_builder.uninit_range(num_lists); + for i in 0..num_lists { + let end: usize = offsets[i + 1].as_(); + offsets_range.set_value( + i, + O::from_usize(end - first + elements_base) + .vortex_expect("Failed to convert offset"), + ); + } // SAFETY: We have initialized all `num_lists` values, and since the `offsets` array is // non-nullable, we are done. unsafe { offsets_range.finish() }; + }); + Ok(()) + } + + fn append_listview_array( + &mut self, + array: ArrayView<'_, ListView>, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + if array.is_empty() { + return Ok(()); } + self.nulls + .append_validity_mask(&array.validity()?.execute_mask(array.len(), ctx)?); + + // Note that `ListViewArray` has `n` offsets and sizes, not `n+1` offsets like `ListArray`. + let elements = array.elements(); + let offsets = array.offsets().clone().execute::(ctx)?; + let sizes = array.sizes().clone().execute::(ctx)?; + match_each_integer_ptype!(offsets.ptype(), |OffsetType| { match_each_integer_ptype!(sizes.ptype(), |SizeType| { - extend_inner( + extend_from_listview( self, elements, offsets.as_slice::(), sizes.as_slice::(), - ) + ctx, + )? }) - }) - } - - fn reserve_exact(&mut self, additional: usize) { - self.elements_builder.reserve_exact(additional); - self.offsets_builder.reserve_exact(additional); - self.nulls.reserve_exact(additional); - } - - unsafe fn set_validity_unchecked(&mut self, validity: Mask) { - self.nulls = LazyBitBufferBuilder::from_validity_mask(validity); - } - - fn finish(&mut self) -> ArrayRef { - self.finish_into_list().into_array() - } - - fn finish_into_canonical(&mut self) -> Canonical { - #[expect(deprecated)] - let listview = self.finish_into_list().into_array().to_listview(); - Canonical::List(listview) + }); + Ok(()) } } @@ -328,17 +377,19 @@ mod tests { use Nullability::Nullable; use vortex_buffer::buffer; use vortex_error::VortexExpect; + use vortex_error::VortexResult; use crate::IntoArray; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::array_session; use crate::arrays::ChunkedArray; + use crate::arrays::ListViewArray; use crate::arrays::PrimitiveArray; use crate::arrays::list::ListArrayExt; use crate::arrays::listview::ListViewArrayExt; use crate::assert_arrays_eq; use crate::builders::ArrayBuilder; + use crate::builders::ListViewBuilder; + use crate::builders::builder_with_capacity; use crate::builders::list::ListArray; use crate::builders::list::ListBuilder; use crate::dtype::DType; @@ -388,8 +439,8 @@ mod tests { let list = builder.finish(); assert_eq!(list.len(), 2); - #[expect(deprecated)] - let list_array = list.to_listview(); + let mut ctx = array_session().create_execution_ctx(); + let list_array = list.execute::(&mut ctx).unwrap(); assert_eq!(list_array.list_elements_at(0).unwrap().len(), 3); assert_eq!(list_array.list_elements_at(1).unwrap().len(), 3); @@ -441,8 +492,8 @@ mod tests { let list = builder.finish(); assert_eq!(list.len(), 3); - #[expect(deprecated)] - let list_array = list.to_listview(); + let mut ctx = array_session().create_execution_ctx(); + let list_array = list.execute::(&mut ctx).unwrap(); assert_eq!(list_array.list_elements_at(0).unwrap().len(), 3); assert_eq!(list_array.list_elements_at(1).unwrap().len(), 0); @@ -454,18 +505,24 @@ mod tests { [Some(vec![0, 1, 2]), None, Some(vec![4, 5])], Arc::new(I32.into()), ) - .unwrap(); + .unwrap() + .into_array(); assert_eq!(list.len(), 3); let mut ctx = array_session().create_execution_ctx(); let mut builder = ListBuilder::::with_capacity(Arc::new(I32.into()), Nullable, 18, 9); - builder.extend_from_array(&list); - builder.extend_from_array(&list); - builder.extend_from_array(&list.slice(0..0).unwrap()); - builder.extend_from_array(&list.slice(1..3).unwrap()); + list.append_to_builder(&mut builder, &mut ctx).unwrap(); + list.append_to_builder(&mut builder, &mut ctx).unwrap(); + list.slice(0..0) + .unwrap() + .append_to_builder(&mut builder, &mut ctx) + .unwrap(); + list.slice(1..3) + .unwrap() + .append_to_builder(&mut builder, &mut ctx) + .unwrap(); - #[expect(deprecated)] let expected = ListArray::from_iter_opt_slow::( [ Some(vec![0, 1, 2]), @@ -480,9 +537,11 @@ mod tests { Arc::new(DType::Primitive(I32, NonNullable)), ) .unwrap() - .to_listview(); + .into_array() + .execute::(&mut ctx) + .unwrap(); - let actual = builder.finish_into_canonical().into_listview(); + let actual = builder.finish_into_canonical(&mut ctx).into_listview(); assert_arrays_eq!(actual.elements(), expected.elements(), &mut ctx); @@ -503,6 +562,60 @@ mod tests { ); } + /// `append_to_builder` must handle any list builder kind without assuming the offset/size + /// integer types produced by `builder_with_capacity`. It appends a `List`-encoded array and a + /// `ListView`-encoded array into `ListViewBuilder`s and `ListBuilder`s with assorted (and + /// non-`u64`) offset/size types. + #[test] + fn test_append_to_builder_any_list_builder() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + + let list = ListArray::from_iter_opt_slow::( + [Some(vec![0, 1, 2]), None, Some(vec![4, 5])], + Arc::new(I32.into()), + )? + .into_array(); + let listview = list + .clone() + .execute::(&mut ctx)? + .into_array(); + let elem_dtype = || Arc::new(I32.into()); + + // `builder_with_capacity` produces a `ListViewBuilder` for `DType::List`; appending the + // `List`-encoded array must dispatch into it instead of bailing. + let mut listview_builder = builder_with_capacity(list.dtype(), list.len()); + list.append_to_builder(listview_builder.as_mut(), &mut ctx)?; + assert_arrays_eq!(listview_builder.finish(), list, &mut ctx); + + // A `ListViewBuilder` with non-`u64` (including signed) offset and size types must work + // for both source encodings. + let mut lv_u32_u8 = ListViewBuilder::::with_capacity(elem_dtype(), Nullable, 8, 4); + list.append_to_builder(&mut lv_u32_u8, &mut ctx)?; + assert_arrays_eq!(lv_u32_u8.finish(), list, &mut ctx); + + let mut lv_i32_i16 = + ListViewBuilder::::with_capacity(elem_dtype(), Nullable, 8, 4); + list.append_to_builder(&mut lv_i32_i16, &mut ctx)?; + assert_arrays_eq!(lv_i32_i16.finish(), list, &mut ctx); + + let mut lv_u16_u16 = + ListViewBuilder::::with_capacity(elem_dtype(), Nullable, 8, 4); + listview.append_to_builder(&mut lv_u16_u16, &mut ctx)?; + assert_arrays_eq!(lv_u16_u16.finish(), list, &mut ctx); + + // Both source encodings appended into `ListBuilder`s with non-`u64` (including signed) + // offset types. + let mut list_builder = ListBuilder::::with_capacity(elem_dtype(), Nullable, 8, 4); + list.append_to_builder(&mut list_builder, &mut ctx)?; + assert_arrays_eq!(list_builder.finish(), list, &mut ctx); + + let mut list_builder_i16 = ListBuilder::::with_capacity(elem_dtype(), Nullable, 8, 4); + listview.append_to_builder(&mut list_builder_i16, &mut ctx)?; + assert_arrays_eq!(list_builder_i16.finish(), list, &mut ctx); + + Ok(()) + } + #[test] fn test_extend_builder() { test_extend_builder_gen::(); @@ -540,8 +653,13 @@ mod tests { DType::List(Arc::new(DType::Primitive(I32, NonNullable)), NonNullable), ); - #[expect(deprecated)] - let canon_values = chunked_list.unwrap().as_array().to_listview(); + let mut ctx = array_session().create_execution_ctx(); + let canon_values = chunked_list + .unwrap() + .as_array() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!( one_trailing_unused_element diff --git a/vortex-array/src/builders/listview.rs b/vortex-array/src/builders/listview.rs index 53855f2c1bf..59205a0f79d 100644 --- a/vortex-array/src/builders/listview.rs +++ b/vortex-array/src/builders/listview.rs @@ -21,10 +21,13 @@ use vortex_mask::Mask; use crate::ArrayRef; use crate::Canonical; use crate::ExecutionCtx; -use crate::VortexSessionExecute; +use crate::array::ArrayView; use crate::array::IntoArray; +use crate::arrays::List; +use crate::arrays::ListView; use crate::arrays::ListViewArray; use crate::arrays::PrimitiveArray; +use crate::arrays::list::ListArrayExt; use crate::arrays::listview::ListViewArrayExt; use crate::arrays::listview::ListViewRebuildMode; use crate::builders::ArrayBuilder; @@ -37,7 +40,6 @@ use crate::builtins::ArrayBuiltins; use crate::dtype::DType; use crate::dtype::IntegerPType; use crate::dtype::Nullability; -use crate::legacy_session; use crate::match_each_integer_ptype; use crate::scalar::ListScalar; use crate::scalar::Scalar; @@ -208,8 +210,9 @@ impl ListViewBuilder { // - The offsets, sizes, and validity have the same length since we always appended the same // amount. // - We checked on construction that the sizes type fits into the offsets. - // - In every method that adds values to this builder (`append_value`, `append_scalar`, and - // `extend_from_array_unchecked`), we checked that `offset + size` does not overflow. + // - In every method that adds values to this builder (`append_value`, `append_scalar`, + // `append_list_array`, and `append_listview_array`), we checked that `offset + size` + // does not overflow. // - We constructed everything in a way that builds the `ListViewArray` similar to the shape // of a `ListArray`, so we know the resulting array is zero-copyable to a `ListArray`. unsafe { @@ -296,42 +299,75 @@ impl ArrayBuilder for ListViewBuilder { self.append_value(list_scalar) } - #[allow(clippy::disallowed_methods)] - unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { - // TODO: The `ArrayBuilder` trait does not thread an `ExecutionCtx` through its extend - // methods, so we are forced to mint a fresh `legacy_session()` context here on every call - // (which for chunked input means once per chunk). Once the trait carries a `&mut - // ExecutionCtx`, the caller's session should be reused instead. - let mut ctx = legacy_session().create_execution_ctx(); + fn reserve_exact(&mut self, capacity: usize) { + self.elements_builder.reserve_exact(capacity * 2); + self.offsets_builder.reserve_exact(capacity); + self.sizes_builder.reserve_exact(capacity); + self.nulls.reserve_exact(capacity); + } - let listview = array - .clone() - .execute::(&mut ctx) - .vortex_expect("failed to execute array into ListViewArray in extend_from_array"); - if listview.is_empty() { - return; + unsafe fn set_validity_unchecked(&mut self, validity: Mask) { + self.nulls = LazyBitBufferBuilder::from_validity_mask(validity); + } + + fn finish(&mut self) -> ArrayRef { + self.finish_into_listview().into_array() + } + + fn finish_into_canonical(&mut self, _ctx: &mut ExecutionCtx) -> Canonical { + Canonical::List(self.finish_into_listview()) + } + + fn append_list_array( + &mut self, + array: ArrayView<'_, List>, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + if array.is_empty() { + return Ok(()); + } + + self.nulls + .append_validity_mask(&array.validity()?.execute_mask(array.len(), ctx)?); + + let offsets = array.offsets().clone().execute::(ctx)?; + match_each_integer_ptype!(offsets.ptype(), |OffsetType| { + extend_from_list( + self, + array.elements(), + offsets.as_slice::(), + ctx, + )? + }); + Ok(()) + } + + fn append_listview_array( + &mut self, + array: ArrayView<'_, ListView>, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + if array.is_empty() { + return Ok(()); } // Normalize to an exact zero-copy-to-list layout and then bulk append. This avoids the // very expensive scalar_at-per-list path for overlapping / out-of-order list views. - let listview = listview - .rebuild(ListViewRebuildMode::MakeExact, &mut ctx) - .vortex_expect("ListViewArray::rebuild(MakeExact) failed in extend_from_array"); + let listview = array + .into_owned() + .rebuild(ListViewRebuildMode::MakeExact, ctx)?; debug_assert!(listview.is_zero_copy_to_list()); - self.nulls.append_validity_mask( - &array - .validity() - .vortex_expect("validity_mask in extend_from_array_unchecked") - .execute_mask(array.len(), &mut ctx) - .vortex_expect("Failed to compute validity mask"), - ); + self.nulls + .append_validity_mask(&array.validity()?.execute_mask(array.len(), ctx)?); // Bulk append the new elements (which should have no gaps or overlaps). let old_elements_len = self.elements_builder.len(); self.elements_builder .reserve_exact(listview.elements().len()); - self.elements_builder.extend_from_array(listview.elements()); + listview + .elements() + .append_to_builder(self.elements_builder.as_mut(), ctx)?; let new_elements_len = self.elements_builder.len(); // Reserve enough space for the new views. @@ -343,23 +379,15 @@ impl ArrayBuilder for ListViewBuilder { let cast_sizes = listview .sizes() .clone() - .cast(self.sizes_builder.dtype().clone()) - .vortex_expect( - "was somehow unable to cast the new sizes to the type of the builder sizes", - ); - self.sizes_builder.extend_from_array(&cast_sizes); + .cast(self.sizes_builder.dtype().clone())?; + cast_sizes.append_to_builder(&mut self.sizes_builder, ctx)?; // Now we need to adjust all of the offsets by adding the current number of elements in the // builder. - let uninit_range = self.offsets_builder.uninit_range(extend_length); // This should be cheap because we didn't compress after rebuilding. - let new_offsets = listview - .offsets() - .clone() - .execute::(&mut ctx) - .vortex_expect("failed to execute list view offsets into a PrimitiveArray"); + let new_offsets = listview.offsets().clone().execute::(ctx)?; match_each_integer_ptype!(new_offsets.ptype(), |A| { adjust_and_extend_offsets::( @@ -368,33 +396,72 @@ impl ArrayBuilder for ListViewBuilder { old_elements_len, new_elements_len, ); - }) - } - - fn reserve_exact(&mut self, capacity: usize) { - self.elements_builder.reserve_exact(capacity * 2); - self.offsets_builder.reserve_exact(capacity); - self.sizes_builder.reserve_exact(capacity); - self.nulls.reserve_exact(capacity); - } - - unsafe fn set_validity_unchecked(&mut self, validity: Mask) { - self.nulls = LazyBitBufferBuilder::from_validity_mask(validity); + }); + Ok(()) } +} - fn finish(&mut self) -> ArrayRef { - self.finish_into_listview().into_array() +/// Appends `ListArray`-layout lists (`n + 1` cumulative offsets) into a [`ListViewBuilder`]. +/// +/// Lists in a `ListArray` are contiguous, so the referenced elements can be appended in bulk, +/// with the offsets rebased onto the builder's elements and the sizes taken from consecutive +/// offset differences. +fn extend_from_list( + builder: &mut ListViewBuilder, + elements: &ArrayRef, + offsets: &[OffsetType], + ctx: &mut ExecutionCtx, +) -> VortexResult<()> +where + O: IntegerPType, + S: IntegerPType, + OffsetType: IntegerPType, +{ + let num_lists = offsets.len() - 1; + let first: usize = offsets[0].as_(); + let last: usize = offsets[num_lists].as_(); + + let elements_base = builder.elements_builder.len(); + + // We must assert this even in release mode to ensure that the safety comment in + // `finish_into_listview` is correct. + assert!( + ((elements_base + (last - first)) as u64) < O::max_value_as_u64(), + "appending this list would cause an offset overflow" + ); + + if last > first { + elements + .slice(first..last)? + .append_to_builder(builder.elements_builder.as_mut(), ctx)?; } - fn finish_into_canonical(&mut self) -> Canonical { - Canonical::List(self.finish_into_listview()) + let mut offsets_range = builder.offsets_builder.uninit_range(num_lists); + let mut sizes_range = builder.sizes_builder.uninit_range(num_lists); + for i in 0..num_lists { + let start: usize = offsets[i].as_(); + let end: usize = offsets[i + 1].as_(); + offsets_range.set_value( + i, + O::from_usize(start - first + elements_base) + .vortex_expect("Failed to convert from usize to `O`"), + ); + sizes_range.set_value( + i, + S::from_usize(end - start).vortex_expect("Failed to convert from usize to `S`"), + ); } + // SAFETY: We have initialized all `num_lists` values in both ranges, and both the `offsets` + // and the `sizes` builders are non-nullable. + unsafe { offsets_range.finish() }; + unsafe { sizes_range.finish() }; + Ok(()) } /// Given new offsets, adds them to the `UninitRange` after adding the `old_elements_len` to each /// offset. -fn adjust_and_extend_offsets<'a, O: IntegerPType, A: IntegerPType>( - mut uninit_range: UninitRange<'a, O>, +fn adjust_and_extend_offsets( + mut uninit_range: UninitRange, new_offsets: PrimitiveArray, old_elements_len: usize, new_elements_len: usize, @@ -675,7 +742,13 @@ mod tests { .unwrap(); // Extend from the ListArray. - builder.extend_from_array(&source.into_array()); + let source = source + .into_array() + .execute::(&mut ctx) + .unwrap(); + builder + .append_listview_array(source.as_view(), &mut ctx) + .unwrap(); // Extend from empty array (should be no-op). let empty_source = ListArray::from_iter_opt_slow::>( @@ -683,7 +756,13 @@ mod tests { Arc::new(I32.into()), ) .unwrap(); - builder.extend_from_array(&empty_source.into_array()); + let empty_source = empty_source + .into_array() + .execute::(&mut ctx) + .unwrap(); + builder + .append_listview_array(empty_source.as_view(), &mut ctx) + .unwrap(); let listview = builder.finish_into_listview(); assert_eq!(listview.len(), 4); @@ -741,7 +820,9 @@ mod tests { let mut builder = ListViewBuilder::::with_capacity(Arc::clone(&dtype), Nullable, 0, 0); - builder.extend_from_array(&source.into_array()); + builder + .append_listview_array(source.as_view(), &mut ctx) + .unwrap(); let listview = builder.finish_into_listview(); assert_eq!(listview.len(), 3); diff --git a/vortex-array/src/builders/mod.rs b/vortex-array/src/builders/mod.rs index 48a347aa3d5..561efc2711e 100644 --- a/vortex-array/src/builders/mod.rs +++ b/vortex-array/src/builders/mod.rs @@ -34,10 +34,14 @@ use std::any::Any; use std::sync::Arc; use vortex_error::VortexResult; -use vortex_error::vortex_panic; +use vortex_error::vortex_bail; use vortex_mask::Mask; use crate::ArrayRef; +use crate::ExecutionCtx; +use crate::array::ArrayView; +use crate::arrays::List; +use crate::arrays::ListView; use crate::canonical::Canonical; use crate::dtype::DType; use crate::match_each_decimal_value_type; @@ -153,30 +157,6 @@ pub trait ArrayBuilder: Send { /// A generic function to append a scalar to the builder. fn append_scalar(&mut self, scalar: &Scalar) -> VortexResult<()>; - /// The inner part of `extend_from_array`. - /// - /// # Safety - /// - /// The array that must have an equal [`DType`] to the array builder's `DType` (with nullability - /// superset semantics). - unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef); - - /// Extends the array with the provided array, canonicalizing if necessary. - /// - /// Implementors must validate that the passed in [`ArrayRef`] has the correct [`DType`]. - fn extend_from_array(&mut self, array: &ArrayRef) { - if !self.dtype().eq_with_nullability_superset(array.dtype()) { - vortex_panic!( - "tried to extend a builder with `DType` {} with an array with `DType {}", - self.dtype(), - array.dtype() - ); - } - - // SAFETY: We checked that the array had a valid `DType` above. - unsafe { self.extend_from_array_unchecked(array) } - } - /// Allocate space for extra `additional` items fn reserve_exact(&mut self, additional: usize); @@ -214,7 +194,40 @@ pub trait ArrayBuilder: Send { /// This method provides a default implementation that creates an [`ArrayRef`] via `finish` and /// then converts it to canonical form. Specific builders can override this with optimized /// implementations that avoid the intermediate [`ArrayRef`] creation. - fn finish_into_canonical(&mut self) -> Canonical; + fn finish_into_canonical(&mut self, ctx: &mut ExecutionCtx) -> Canonical; + + /// Appends the values of a [`List`]-encoded `array` to this builder. + /// + /// Only list-typed builders support this; the default implementation returns an error. List + /// encodings dispatch through this method because the concrete list builders are generic over + /// their offset/size integer types, which cannot be named through a `dyn ArrayBuilder`. + fn append_list_array( + &mut self, + array: ArrayView<'_, List>, + _ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + vortex_bail!( + "cannot append a List array of dtype {} to a {} builder", + array.dtype(), + self.dtype() + ) + } + + /// Appends the values of a [`ListView`]-encoded `array` to this builder. + /// + /// See [`append_list_array`](Self::append_list_array); this is the same hook for the canonical + /// [`ListViewArray`](crate::arrays::ListViewArray) encoding. + fn append_listview_array( + &mut self, + array: ArrayView<'_, ListView>, + _ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + vortex_bail!( + "cannot append a ListView array of dtype {} to a {} builder", + array.dtype(), + self.dtype() + ) + } } /// Construct a new canonical builder for the given [`DType`]. diff --git a/vortex-array/src/builders/null.rs b/vortex-array/src/builders/null.rs index 45b6e7ed974..28eab14dd7a 100644 --- a/vortex-array/src/builders/null.rs +++ b/vortex-array/src/builders/null.rs @@ -8,6 +8,7 @@ use vortex_error::vortex_ensure; use vortex_mask::Mask; use crate::ArrayRef; +use crate::ExecutionCtx; use crate::IntoArray; use crate::arrays::NullArray; use crate::builders::ArrayBuilder; @@ -69,10 +70,6 @@ impl ArrayBuilder for NullBuilder { Ok(()) } - unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { - self.append_nulls(array.len()); - } - fn reserve_exact(&mut self, _additional: usize) {} unsafe fn set_validity_unchecked(&mut self, _validity: Mask) {} @@ -81,7 +78,7 @@ impl ArrayBuilder for NullBuilder { NullArray::new(self.length).into_array() } - fn finish_into_canonical(&mut self) -> Canonical { + fn finish_into_canonical(&mut self, _ctx: &mut ExecutionCtx) -> Canonical { Canonical::Null(NullArray::new(self.length)) } } diff --git a/vortex-array/src/builders/primitive.rs b/vortex-array/src/builders/primitive.rs index f0dfdbb3f40..4ce1aafe1f7 100644 --- a/vortex-array/src/builders/primitive.rs +++ b/vortex-array/src/builders/primitive.rs @@ -13,18 +13,14 @@ use vortex_mask::Mask; use crate::ArrayRef; use crate::ExecutionCtx; use crate::IntoArray; -use crate::VortexSessionExecute; use crate::arrays::PrimitiveArray; use crate::builders::ArrayBuilder; use crate::builders::DEFAULT_BUILDER_CAPACITY; use crate::builders::LazyBitBufferBuilder; use crate::canonical::Canonical; -#[expect(deprecated)] -use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::NativePType; use crate::dtype::Nullability; -use crate::legacy_session; use crate::scalar::Scalar; /// The builder for building a [`PrimitiveArray`], parametrized by the `PType`. @@ -200,15 +196,6 @@ impl ArrayBuilder for PrimitiveBuilder { Ok(()) } - #[allow(clippy::disallowed_methods)] - unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { - #[expect(deprecated)] - let array = array.to_primitive(); - - self.append_primitive_array(&array, &mut legacy_session().create_execution_ctx()) - .vortex_expect("Failed to append primitive array"); - } - fn reserve_exact(&mut self, additional: usize) { self.values.reserve(additional); self.nulls.reserve_exact(additional); @@ -222,7 +209,7 @@ impl ArrayBuilder for PrimitiveBuilder { self.finish_into_primitive().into_array() } - fn finish_into_canonical(&mut self) -> Canonical { + fn finish_into_canonical(&mut self, _ctx: &mut ExecutionCtx) -> Canonical { Canonical::Primitive(self.finish_into_primitive()) } } diff --git a/vortex-array/src/builders/struct_.rs b/vortex-array/src/builders/struct_.rs index 6cea7798463..da08e262760 100644 --- a/vortex-array/src/builders/struct_.rs +++ b/vortex-array/src/builders/struct_.rs @@ -12,8 +12,8 @@ use vortex_error::vortex_panic; use vortex_mask::Mask; use crate::ArrayRef; +use crate::ExecutionCtx; use crate::IntoArray; -use crate::VortexSessionExecute; use crate::arrays::StructArray; use crate::arrays::struct_::StructArrayExt; use crate::builders::ArrayBuilder; @@ -21,12 +21,9 @@ use crate::builders::DEFAULT_BUILDER_CAPACITY; use crate::builders::LazyBitBufferBuilder; use crate::builders::builder_with_capacity; use crate::canonical::Canonical; -#[expect(deprecated)] -use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::Nullability; use crate::dtype::StructFields; -use crate::legacy_session; use crate::scalar::Scalar; use crate::scalar::StructScalar; @@ -122,6 +119,25 @@ impl StructBuilder { struct_fields } + + /// Appends the values of a canonical [`StructArray`] to the builder, recursing into each + /// field's builder. + pub(crate) fn append_struct_array( + &mut self, + array: &StructArray, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + for (field, builder) in array + .iter_unmasked_fields() + .zip_eq(self.builders.iter_mut()) + { + field.append_to_builder(builder.as_mut(), ctx)?; + } + + self.nulls + .append_validity_mask(&array.validity()?.execute_mask(array.len(), ctx)?); + Ok(()) + } } impl ArrayBuilder for StructBuilder { @@ -168,27 +184,6 @@ impl ArrayBuilder for StructBuilder { self.append_value(scalar.as_struct()) } - #[allow(clippy::disallowed_methods)] - unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { - #[expect(deprecated)] - let array = array.to_struct(); - - for (a, builder) in array - .iter_unmasked_fields() - .zip_eq(self.builders.iter_mut()) - { - builder.extend_from_array(a); - } - - self.nulls.append_validity_mask( - &array - .validity() - .vortex_expect("validity_mask") - .execute_mask(array.len(), &mut legacy_session().create_execution_ctx()) - .vortex_expect("Failed to compute validity mask"), - ); - } - fn reserve_exact(&mut self, capacity: usize) { self.builders.iter_mut().for_each(|builder| { builder.reserve_exact(capacity); @@ -204,7 +199,7 @@ impl ArrayBuilder for StructBuilder { self.finish_into_struct().into_array() } - fn finish_into_canonical(&mut self) -> Canonical { + fn finish_into_canonical(&mut self, _ctx: &mut ExecutionCtx) -> Canonical { Canonical::Struct(self.finish_into_struct()) } } diff --git a/vortex-array/src/builders/tests.rs b/vortex-array/src/builders/tests.rs index 02babab4a7d..138e6941def 100644 --- a/vortex-array/src/builders/tests.rs +++ b/vortex-array/src/builders/tests.rs @@ -8,7 +8,10 @@ use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_mask::Mask; +use crate::Canonical; +use crate::ExecutionCtx; use crate::VortexSessionExecute; +use crate::array::IntoArray; use crate::array_session; use crate::builders::ArrayBuilder; use crate::builders::builder_with_capacity; @@ -223,12 +226,10 @@ fn test_append_defaults_behavior(#[case] dtype: DType, #[case] should_be_null: b /// Helper function that fills two builders with the same values and compares the results /// of `to_canonical()` vs `finish().to_canonical()`. -fn compare_to_canonical_methods(dtype: &DType, mut fill_builder: F) +fn compare_to_canonical_methods(dtype: &DType, ctx: &mut ExecutionCtx, mut fill_builder: F) where F: FnMut(&mut dyn ArrayBuilder), { - use crate::IntoArray; - // Create two identical builders. let mut builder1 = builder_with_capacity(dtype, 10); let mut builder2 = builder_with_capacity(dtype, 10); @@ -238,11 +239,10 @@ where fill_builder(builder2.as_mut()); // Get canonical arrays using both methods. - let canonical_direct = builder1.finish_into_canonical(); - #[expect(deprecated)] + let canonical_direct = builder1.finish_into_canonical(ctx); let canonical_indirect = builder2 .finish() - .to_canonical() + .execute::(ctx) .vortex_expect("to_canonical failed"); // Convert both to arrays for comparison. @@ -254,12 +254,8 @@ where // Compare each element. for i in 0..array_direct.len() { - let scalar_direct = array_direct - .execute_scalar(i, &mut array_session().create_execution_ctx()) - .unwrap(); - let scalar_indirect = array_indirect - .execute_scalar(i, &mut array_session().create_execution_ctx()) - .unwrap(); + let scalar_direct = array_direct.execute_scalar(i, ctx).unwrap(); + let scalar_indirect = array_indirect.execute_scalar(i, ctx).unwrap(); assert_eq!( scalar_direct, scalar_indirect, @@ -271,8 +267,9 @@ where #[test] fn test_to_canonical_bool() { + let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Bool(Nullability::NonNullable); - compare_to_canonical_methods(&dtype, |builder| { + compare_to_canonical_methods(&dtype, &mut ctx, |builder| { for i in 0..5 { let value = Scalar::bool(i % 2 == 0, Nullability::NonNullable); builder.append_scalar(&value).unwrap(); @@ -282,8 +279,9 @@ fn test_to_canonical_bool() { #[test] fn test_to_canonical_bool_nullable() { + let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Bool(Nullability::Nullable); - compare_to_canonical_methods(&dtype, |builder| { + compare_to_canonical_methods(&dtype, &mut ctx, |builder| { for i in 0..5 { let value = Scalar::bool(i % 2 == 0, Nullability::Nullable); builder.append_scalar(&value).unwrap(); @@ -294,8 +292,9 @@ fn test_to_canonical_bool_nullable() { #[test] fn test_to_canonical_i32() { + let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Primitive(PType::I32, Nullability::NonNullable); - compare_to_canonical_methods(&dtype, |builder| { + compare_to_canonical_methods(&dtype, &mut ctx, |builder| { for i in 0..5 { let value = Scalar::primitive(i, Nullability::NonNullable); builder.append_scalar(&value).unwrap(); @@ -305,8 +304,9 @@ fn test_to_canonical_i32() { #[test] fn test_to_canonical_i32_nullable() { + let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Primitive(PType::I32, Nullability::Nullable); - compare_to_canonical_methods(&dtype, |builder| { + compare_to_canonical_methods(&dtype, &mut ctx, |builder| { for i in 0..5 { let value = Scalar::primitive(i, Nullability::Nullable); builder.append_scalar(&value).unwrap(); @@ -317,8 +317,9 @@ fn test_to_canonical_i32_nullable() { #[test] fn test_to_canonical_f64() { + let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Primitive(PType::F64, Nullability::NonNullable); - compare_to_canonical_methods(&dtype, |builder| { + compare_to_canonical_methods(&dtype, &mut ctx, |builder| { for i in 0..5 { let value = Scalar::primitive(i as f64, Nullability::NonNullable); builder.append_scalar(&value).unwrap(); @@ -328,8 +329,9 @@ fn test_to_canonical_f64() { #[test] fn test_to_canonical_utf8() { + let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Utf8(Nullability::NonNullable); - compare_to_canonical_methods(&dtype, |builder| { + compare_to_canonical_methods(&dtype, &mut ctx, |builder| { let values = ["hello", "world", "test", "data", "vortex"]; for value in &values { let scalar = Scalar::utf8(*value, Nullability::NonNullable); @@ -340,8 +342,9 @@ fn test_to_canonical_utf8() { #[test] fn test_to_canonical_utf8_nullable() { + let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Utf8(Nullability::Nullable); - compare_to_canonical_methods(&dtype, |builder| { + compare_to_canonical_methods(&dtype, &mut ctx, |builder| { let values = ["hello", "world", "test"]; for value in &values { let scalar = Scalar::utf8(*value, Nullability::Nullable); @@ -353,8 +356,9 @@ fn test_to_canonical_utf8_nullable() { #[test] fn test_to_canonical_binary() { + let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Binary(Nullability::NonNullable); - compare_to_canonical_methods(&dtype, |builder| { + compare_to_canonical_methods(&dtype, &mut ctx, |builder| { let values = [b"hello", b"world", b"vortx", b"bytes", b"tests"]; for value in &values { let scalar = Scalar::binary(value.to_vec(), Nullability::NonNullable); @@ -365,6 +369,7 @@ fn test_to_canonical_binary() { #[test] fn test_to_canonical_struct() { + let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Struct( StructFields::from_iter([ ("a", DType::Primitive(PType::I32, Nullability::NonNullable)), @@ -372,7 +377,7 @@ fn test_to_canonical_struct() { ]), Nullability::NonNullable, ); - compare_to_canonical_methods(&dtype, |builder| { + compare_to_canonical_methods(&dtype, &mut ctx, |builder| { for _ in 0..3 { let value = Scalar::default_value(&dtype); builder.append_scalar(&value).unwrap(); @@ -382,9 +387,10 @@ fn test_to_canonical_struct() { #[test] fn test_to_canonical_extension() { + let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Extension(Timestamp::new(TimeUnit::Milliseconds, Nullability::NonNullable).erased()); - compare_to_canonical_methods(&dtype, |builder| { + compare_to_canonical_methods(&dtype, &mut ctx, |builder| { let ext_dtype = match &dtype { DType::Extension(ext) => ext.clone(), _ => unreachable!(), @@ -399,16 +405,18 @@ fn test_to_canonical_extension() { #[test] fn test_to_canonical_null() { + let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Null; - compare_to_canonical_methods(&dtype, |builder| { + compare_to_canonical_methods(&dtype, &mut ctx, |builder| { builder.append_nulls(5); }); } #[test] fn test_to_canonical_decimal() { + let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Decimal(DecimalDType::new(10, 2), Nullability::NonNullable); - compare_to_canonical_methods(&dtype, |builder| { + compare_to_canonical_methods(&dtype, &mut ctx, |builder| { for _ in 0..5 { let value = Scalar::default_value(&dtype); builder.append_scalar(&value).unwrap(); @@ -418,8 +426,9 @@ fn test_to_canonical_decimal() { #[test] fn test_to_canonical_i8() { + let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Primitive(PType::I8, Nullability::NonNullable); - compare_to_canonical_methods(&dtype, |builder| { + compare_to_canonical_methods(&dtype, &mut ctx, |builder| { for i in 0..5i8 { let value = Scalar::primitive(i, Nullability::NonNullable); builder.append_scalar(&value).unwrap(); @@ -429,8 +438,9 @@ fn test_to_canonical_i8() { #[test] fn test_to_canonical_u64() { + let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Primitive(PType::U64, Nullability::NonNullable); - compare_to_canonical_methods(&dtype, |builder| { + compare_to_canonical_methods(&dtype, &mut ctx, |builder| { for i in 0..5 { let value = Scalar::primitive(i as u64, Nullability::NonNullable); builder.append_scalar(&value).unwrap(); @@ -440,8 +450,9 @@ fn test_to_canonical_u64() { #[test] fn test_to_canonical_f32() { + let mut ctx = array_session().create_execution_ctx(); let dtype = DType::Primitive(PType::F32, Nullability::NonNullable); - compare_to_canonical_methods(&dtype, |builder| { + compare_to_canonical_methods(&dtype, &mut ctx, |builder| { for i in 0..5 { let value = Scalar::primitive(i as f32, Nullability::NonNullable); builder.append_scalar(&value).unwrap(); diff --git a/vortex-array/src/builders/varbinview.rs b/vortex-array/src/builders/varbinview.rs index 53bb728e7a7..1d530a384ef 100644 --- a/vortex-array/src/builders/varbinview.rs +++ b/vortex-array/src/builders/varbinview.rs @@ -22,7 +22,6 @@ use vortex_utils::aliases::hash_map::HashMap; use crate::ArrayRef; use crate::ExecutionCtx; use crate::IntoArray; -use crate::VortexSessionExecute; use crate::arrays::VarBinViewArray; use crate::arrays::varbinview::VarBinViewArrayExt; use crate::arrays::varbinview::build_views::BinaryView; @@ -30,10 +29,7 @@ use crate::arrays::varbinview::compact::BufferUtilization; use crate::builders::ArrayBuilder; use crate::builders::LazyBitBufferBuilder; use crate::canonical::Canonical; -#[expect(deprecated)] -use crate::canonical::ToCanonical as _; use crate::dtype::DType; -use crate::legacy_session; use crate::scalar::Scalar; /// The builder for building a [`VarBinViewArray`]. @@ -280,6 +276,7 @@ impl VarBinViewBuilder { .extend_from_compaction(BuffersWithOffsets::from_array( array, self.compaction_threshold, + ctx, )); match view_adjustment { @@ -372,14 +369,6 @@ impl ArrayBuilder for VarBinViewBuilder { Ok(()) } - #[allow(clippy::disallowed_methods)] - unsafe fn extend_from_array_unchecked(&mut self, array: &ArrayRef) { - #[expect(deprecated)] - let array = array.to_varbinview(); - self.append_varbinview_array(&array, &mut legacy_session().create_execution_ctx()) - .vortex_expect("Failed to append varbinview array"); - } - fn reserve_exact(&mut self, additional: usize) { self.views_builder.reserve(additional); self.nulls.reserve_exact(additional); @@ -393,7 +382,7 @@ impl ArrayBuilder for VarBinViewBuilder { self.finish_into_varbinview().into_array() } - fn finish_into_canonical(&mut self) -> Canonical { + fn finish_into_canonical(&mut self, _ctx: &mut ExecutionCtx) -> Canonical { Canonical::VarBinView(self.finish_into_varbinview()) } } @@ -637,7 +626,11 @@ enum BuffersWithOffsets { } impl BuffersWithOffsets { - pub fn from_array(array: &VarBinViewArray, compaction_threshold: f64) -> Self { + pub fn from_array( + array: &VarBinViewArray, + compaction_threshold: f64, + ctx: &mut ExecutionCtx, + ) -> Self { if compaction_threshold == 0.0 { return Self::AllKept { buffers: Arc::from( @@ -653,7 +646,7 @@ impl BuffersWithOffsets { } let buffer_utilizations = array - .buffer_utilizations() + .buffer_utilizations(ctx) .vortex_expect("buffer_utilizations in BuffersWithOffsets::from_array"); let mut has_rewrite = false; let mut has_nonzero_offset = false; @@ -924,11 +917,11 @@ mod tests { let mut builder = VarBinViewBuilder::with_capacity(DType::Utf8(Nullability::Nullable), 10); builder.append_value("Hello1"); - builder.extend_from_array(&array); + array.append_to_builder(&mut builder, &mut ctx).unwrap(); builder.append_nulls(2); builder.append_value("Hello3"); - let actual = builder.finish_into_canonical(); + let actual = builder.finish_into_canonical(&mut ctx); let expected = >::from_iter([ Some("Hello1"), None, diff --git a/vortex-array/src/canonical.rs b/vortex-array/src/canonical.rs index 02029fc8e3e..62749db4650 100644 --- a/vortex-array/src/canonical.rs +++ b/vortex-array/src/canonical.rs @@ -262,7 +262,7 @@ impl Canonical { /// and copy operations. pub fn compact(&self, ctx: &mut ExecutionCtx) -> VortexResult { match self { - Canonical::VarBinView(array) => Ok(Canonical::VarBinView(array.compact_buffers()?)), + Canonical::VarBinView(array) => Ok(Canonical::VarBinView(array.compact_buffers(ctx)?)), Canonical::List(array) => Ok(Canonical::List( array.rebuild(ListViewRebuildMode::TrimElements, ctx)?, )), diff --git a/vortex-array/src/compute/conformance/cast.rs b/vortex-array/src/compute/conformance/cast.rs index 922116f520a..abe23e6e2dc 100644 --- a/vortex-array/src/compute/conformance/cast.rs +++ b/vortex-array/src/compute/conformance/cast.rs @@ -6,13 +6,12 @@ use vortex_error::VortexResult; use vortex_error::vortex_panic; use crate::ArrayRef; +use crate::ExecutionCtx; use crate::IntoArray; use crate::RecursiveCanonical; -use crate::VortexSessionExecute; use crate::aggregate_fn::NumericalAggregateOpts; use crate::aggregate_fn::fns::min_max::MinMaxResult; use crate::aggregate_fn::fns::min_max::min_max; -use crate::array_session; use crate::builtins::ArrayBuiltins; use crate::dtype::DType; use crate::dtype::Nullability; @@ -20,10 +19,14 @@ use crate::dtype::PType; use crate::scalar::Scalar; /// Cast and force execution via `to_canonical`, returning the canonical array. -fn cast_and_execute(array: &ArrayRef, dtype: DType) -> VortexResult { +fn cast_and_execute( + array: &ArrayRef, + dtype: DType, + ctx: &mut ExecutionCtx, +) -> VortexResult { Ok(array .cast(dtype)? - .execute::(&mut array_session().create_execution_ctx())? + .execute::(ctx)? .0 .into_array()) } @@ -37,18 +40,18 @@ fn cast_and_execute(array: &ArrayRef, dtype: DType) -> VortexResult { /// - Casting with nullability changes /// - Casting between string types (Utf8/Binary) /// - Edge cases like overflow behavior -pub fn test_cast_conformance(array: &ArrayRef) { +pub fn test_cast_conformance(array: &ArrayRef, ctx: &mut ExecutionCtx) { let dtype = array.dtype(); // Always test identity cast and nullability changes - test_cast_identity(array); + test_cast_identity(array, ctx); - test_cast_to_non_nullable(array); - test_cast_to_nullable(array); + test_cast_to_non_nullable(array, ctx); + test_cast_to_nullable(array, ctx); // Test based on the specific DType match dtype { - DType::Null => test_cast_from_null(array), + DType::Null => test_cast_from_null(array, ctx), DType::Primitive(ptype, ..) => match ptype { PType::U8 | PType::U16 @@ -57,16 +60,16 @@ pub fn test_cast_conformance(array: &ArrayRef) { | PType::I8 | PType::I16 | PType::I32 - | PType::I64 => test_cast_to_integral_types(array), - PType::F16 | PType::F32 | PType::F64 => test_cast_from_floating_point_types(array), + | PType::I64 => test_cast_to_integral_types(array, ctx), + PType::F16 | PType::F32 | PType::F64 => test_cast_from_floating_point_types(array, ctx), }, _ => {} } } -fn test_cast_identity(array: &ArrayRef) { +fn test_cast_identity(array: &ArrayRef, ctx: &mut ExecutionCtx) { // Casting to the same type should be a no-op - let result = cast_and_execute(&array.clone(), array.dtype().clone()) + let result = cast_and_execute(&array.clone(), array.dtype().clone(), ctx) .vortex_expect("cast should succeed in conformance test"); assert_eq!(result.len(), array.len()); assert_eq!(result.dtype(), array.dtype()); @@ -75,18 +78,18 @@ fn test_cast_identity(array: &ArrayRef) { for i in 0..array.len().min(10) { assert_eq!( array - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"), result - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } } -fn test_cast_from_null(array: &ArrayRef) { +fn test_cast_from_null(array: &ArrayRef, ctx: &mut ExecutionCtx) { // Null can be cast to itself - let result = cast_and_execute(&array.clone(), DType::Null) + let result = cast_and_execute(&array.clone(), DType::Null, ctx) .vortex_expect("cast should succeed in conformance test"); assert_eq!(result.len(), array.len()); assert_eq!(result.dtype(), &DType::Null); @@ -101,7 +104,7 @@ fn test_cast_from_null(array: &ArrayRef) { ]; for dtype in nullable_types { - let result = cast_and_execute(&array.clone(), dtype.clone()) + let result = cast_and_execute(&array.clone(), dtype.clone(), ctx) .vortex_expect("cast should succeed in conformance test"); assert_eq!(result.len(), array.len()); assert_eq!(result.dtype(), &dtype); @@ -110,7 +113,7 @@ fn test_cast_from_null(array: &ArrayRef) { for i in 0..array.len().min(10) { assert!( result - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") .is_null() ); @@ -124,17 +127,17 @@ fn test_cast_from_null(array: &ArrayRef) { ]; for dtype in non_nullable_types { - assert!(cast_and_execute(&array.clone(), dtype.clone()).is_err()); + assert!(cast_and_execute(&array.clone(), dtype.clone(), ctx).is_err()); } } -fn test_cast_to_non_nullable(array: &ArrayRef) { +fn test_cast_to_non_nullable(array: &ArrayRef, ctx: &mut ExecutionCtx) { if array - .invalid_count(&mut array_session().create_execution_ctx()) + .invalid_count(ctx) .vortex_expect("invalid_count should succeed in conformance test") == 0 { - let non_nullable = cast_and_execute(&array.clone(), array.dtype().as_nonnullable()) + let non_nullable = cast_and_execute(&array.clone(), array.dtype().as_nonnullable(), ctx) .vortex_expect("arrays without nulls can cast to non-nullable"); assert_eq!(non_nullable.dtype(), &array.dtype().as_nonnullable()); assert_eq!(non_nullable.len(), array.len()); @@ -142,15 +145,15 @@ fn test_cast_to_non_nullable(array: &ArrayRef) { for i in 0..array.len().min(10) { assert_eq!( array - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"), non_nullable - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } - let back_to_nullable = cast_and_execute(&non_nullable, array.dtype().clone()) + let back_to_nullable = cast_and_execute(&non_nullable, array.dtype().clone(), ctx) .vortex_expect("non-nullable arrays can cast to nullable"); assert_eq!(back_to_nullable.dtype(), array.dtype()); assert_eq!(back_to_nullable.len(), array.len()); @@ -158,10 +161,10 @@ fn test_cast_to_non_nullable(array: &ArrayRef) { for i in 0..array.len().min(10) { assert_eq!( array - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"), back_to_nullable - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } @@ -171,7 +174,7 @@ fn test_cast_to_non_nullable(array: &ArrayRef) { // array can be casted to DType::Null. return; } - cast_and_execute(&array.clone(), array.dtype().as_nonnullable()) + cast_and_execute(&array.clone(), array.dtype().as_nonnullable(), ctx) .err() .unwrap_or_else(|| { vortex_panic!( @@ -182,8 +185,8 @@ fn test_cast_to_non_nullable(array: &ArrayRef) { } } -fn test_cast_to_nullable(array: &ArrayRef) { - let nullable = cast_and_execute(&array.clone(), array.dtype().as_nullable()) +fn test_cast_to_nullable(array: &ArrayRef, ctx: &mut ExecutionCtx) { + let nullable = cast_and_execute(&array.clone(), array.dtype().as_nullable(), ctx) .vortex_expect("arrays without nulls can cast to nullable"); assert_eq!(nullable.dtype(), &array.dtype().as_nullable()); assert_eq!(nullable.len(), array.len()); @@ -191,15 +194,15 @@ fn test_cast_to_nullable(array: &ArrayRef) { for i in 0..array.len().min(10) { assert_eq!( array - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"), nullable - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } - let back = cast_and_execute(&nullable, array.dtype().clone()) + let back = cast_and_execute(&nullable, array.dtype().clone(), ctx) .vortex_expect("casting to nullable and back should be a no-op"); assert_eq!(back.dtype(), array.dtype()); assert_eq!(back.len(), array.len()); @@ -207,38 +210,43 @@ fn test_cast_to_nullable(array: &ArrayRef) { for i in 0..array.len().min(10) { assert_eq!( array - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"), - back.execute_scalar(i, &mut array_session().create_execution_ctx()) + back.execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } } -fn test_cast_from_floating_point_types(array: &ArrayRef) { +fn test_cast_from_floating_point_types(array: &ArrayRef, ctx: &mut ExecutionCtx) { let ptype = array.dtype().as_ptype(); - test_cast_to_primitive(array, PType::I8, false); - test_cast_to_primitive(array, PType::U8, false); - test_cast_to_primitive(array, PType::I16, false); - test_cast_to_primitive(array, PType::U16, false); - test_cast_to_primitive(array, PType::I32, false); - test_cast_to_primitive(array, PType::U32, false); - test_cast_to_primitive(array, PType::I64, false); - test_cast_to_primitive(array, PType::U64, false); - test_cast_to_primitive(array, PType::F16, matches!(ptype, PType::F16)); - test_cast_to_primitive(array, PType::F32, matches!(ptype, PType::F16 | PType::F32)); - test_cast_to_primitive(array, PType::F64, true); + test_cast_to_primitive(array, PType::I8, false, ctx); + test_cast_to_primitive(array, PType::U8, false, ctx); + test_cast_to_primitive(array, PType::I16, false, ctx); + test_cast_to_primitive(array, PType::U16, false, ctx); + test_cast_to_primitive(array, PType::I32, false, ctx); + test_cast_to_primitive(array, PType::U32, false, ctx); + test_cast_to_primitive(array, PType::I64, false, ctx); + test_cast_to_primitive(array, PType::U64, false, ctx); + test_cast_to_primitive(array, PType::F16, matches!(ptype, PType::F16), ctx); + test_cast_to_primitive( + array, + PType::F32, + matches!(ptype, PType::F16 | PType::F32), + ctx, + ); + test_cast_to_primitive(array, PType::F64, true, ctx); } -fn test_cast_to_integral_types(array: &ArrayRef) { - test_cast_to_primitive(array, PType::I8, true); - test_cast_to_primitive(array, PType::U8, true); - test_cast_to_primitive(array, PType::I16, true); - test_cast_to_primitive(array, PType::U16, true); - test_cast_to_primitive(array, PType::I32, true); - test_cast_to_primitive(array, PType::U32, true); - test_cast_to_primitive(array, PType::I64, true); - test_cast_to_primitive(array, PType::U64, true); +fn test_cast_to_integral_types(array: &ArrayRef, ctx: &mut ExecutionCtx) { + test_cast_to_primitive(array, PType::I8, true, ctx); + test_cast_to_primitive(array, PType::U8, true, ctx); + test_cast_to_primitive(array, PType::I16, true, ctx); + test_cast_to_primitive(array, PType::U16, true, ctx); + test_cast_to_primitive(array, PType::I32, true, ctx); + test_cast_to_primitive(array, PType::U32, true, ctx); + test_cast_to_primitive(array, PType::I64, true, ctx); + test_cast_to_primitive(array, PType::U64, true, ctx); } /// Does this scalar fit in this type? @@ -247,9 +255,13 @@ fn fits(value: &Scalar, ptype: PType) -> bool { value.cast(&dtype).is_ok() } -fn test_cast_to_primitive(array: &ArrayRef, target_ptype: PType, test_round_trip: bool) { - let mut ctx = array_session().create_execution_ctx(); - let maybe_min_max = min_max(array, &mut ctx, NumericalAggregateOpts::default()) +fn test_cast_to_primitive( + array: &ArrayRef, + target_ptype: PType, + test_round_trip: bool, + ctx: &mut ExecutionCtx, +) { + let maybe_min_max = min_max(array, ctx, NumericalAggregateOpts::default()) .vortex_expect("cast should succeed in conformance test"); if let Some(MinMaxResult { min, max }) = maybe_min_max @@ -258,6 +270,7 @@ fn test_cast_to_primitive(array: &ArrayRef, target_ptype: PType, test_round_trip cast_and_execute( &array.clone(), DType::Primitive(target_ptype, array.dtype().nullability()), + ctx, ) .err() .unwrap_or_else(|| { @@ -277,6 +290,7 @@ fn test_cast_to_primitive(array: &ArrayRef, target_ptype: PType, test_round_trip let casted = cast_and_execute( &array.clone(), DType::Primitive(target_ptype, array.dtype().nullability()), + ctx, ) .unwrap_or_else(|e| { vortex_panic!( @@ -289,20 +303,20 @@ fn test_cast_to_primitive(array: &ArrayRef, target_ptype: PType, test_round_trip array .validity() .vortex_expect("validity_mask should succeed in conformance test") - .execute_mask(array.len(), &mut array_session().create_execution_ctx()) + .execute_mask(array.len(), ctx) .vortex_expect("Failed to compute validity mask"), casted .validity() .vortex_expect("validity_mask should succeed in conformance test") - .execute_mask(casted.len(), &mut array_session().create_execution_ctx()) + .execute_mask(casted.len(), ctx) .vortex_expect("Failed to compute validity mask") ); for i in 0..array.len().min(10) { let original = array - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"); let casted = casted - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"); assert_eq!( original @@ -325,10 +339,15 @@ fn test_cast_to_primitive(array: &ArrayRef, target_ptype: PType, test_round_trip #[cfg(test)] mod tests { + use std::sync::LazyLock; + use vortex_buffer::buffer; + use vortex_session::VortexSession; use super::*; use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::BoolArray; use crate::arrays::ListArray; use crate::arrays::NullArray; @@ -339,40 +358,42 @@ mod tests { use crate::dtype::FieldNames; use crate::dtype::Nullability; + static SESSION: LazyLock = LazyLock::new(array_session); + #[test] fn test_cast_conformance_u32() { let array = buffer![0u32, 100, 200, 65535, 1000000].into_array(); - test_cast_conformance(&array); + test_cast_conformance(&array, &mut SESSION.create_execution_ctx()); } #[test] fn test_cast_conformance_i32() { let array = buffer![-100i32, -1, 0, 1, 100].into_array(); - test_cast_conformance(&array); + test_cast_conformance(&array, &mut SESSION.create_execution_ctx()); } #[test] fn test_cast_conformance_f32() { let array = buffer![0.0f32, 1.5, -2.5, 100.0, 1e6].into_array(); - test_cast_conformance(&array); + test_cast_conformance(&array, &mut SESSION.create_execution_ctx()); } #[test] fn test_cast_conformance_nullable() { let array = PrimitiveArray::from_option_iter([Some(1u8), None, Some(255), Some(0), None]); - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] fn test_cast_conformance_bool() { let array = BoolArray::from_iter(vec![true, false, true, false]); - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] fn test_cast_conformance_null() { let array = NullArray::new(5); - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] @@ -381,7 +402,7 @@ mod tests { vec![Some("hello"), None, Some("world")], DType::Utf8(Nullability::Nullable), ); - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] @@ -390,7 +411,7 @@ mod tests { vec![Some(b"data".as_slice()), None, Some(b"bytes".as_slice())], DType::Binary(Nullability::Nullable), ); - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] @@ -407,7 +428,7 @@ mod tests { let array = StructArray::try_new(names, vec![a, b], 3, crate::validity::Validity::NonNullable) .unwrap(); - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } #[test] @@ -417,6 +438,6 @@ mod tests { let array = ListArray::try_new(data, offsets, crate::validity::Validity::NonNullable).unwrap(); - test_cast_conformance(&array.into_array()); + test_cast_conformance(&array.into_array(), &mut SESSION.create_execution_ctx()); } } diff --git a/vortex-array/src/compute/conformance/filter.rs b/vortex-array/src/compute/conformance/filter.rs index 01bfb3bb2fa..5c78e0d70ad 100644 --- a/vortex-array/src/compute/conformance/filter.rs +++ b/vortex-array/src/compute/conformance/filter.rs @@ -5,9 +5,9 @@ use vortex_error::VortexExpect; use vortex_mask::Mask; use crate::ArrayRef; +use crate::Canonical; +use crate::ExecutionCtx; use crate::IntoArray; -use crate::VortexSessionExecute; -use crate::array_session; use crate::assert_arrays_eq; use crate::dtype::DType; @@ -18,16 +18,16 @@ pub const LARGE_SIZE: usize = 1024; /// Test filter compute function with various array sizes and patterns. /// The input array can be of any length. -pub fn test_filter_conformance(array: &ArrayRef) { +pub fn test_filter_conformance(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); // Test with arrays of any size if len > 0 { - test_all_filter(array); + test_all_filter(array, ctx); test_none_filter(array); - test_selective_filter(array); - test_single_element_filter(array); - test_alternating_pattern_filter(array); + test_selective_filter(array, ctx); + test_single_element_filter(array, ctx); + test_alternating_pattern_filter(array, ctx); test_runs_pattern_filter(array); test_sparse_true_filter(array); test_sparse_false_filter(array); @@ -61,14 +61,13 @@ pub fn create_runs_pattern(len: usize, run_length: usize) -> Vec { } /// Tests that filtering with an all-true mask returns all elements unchanged -fn test_all_filter(array: &ArrayRef) { - let mut ctx = array_session().create_execution_ctx(); +fn test_all_filter(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); let mask = Mask::new_true(len); let filtered = array .filter(mask) .vortex_expect("filter should succeed in conformance test"); - assert_arrays_eq!(filtered, array, &mut ctx); + assert_arrays_eq!(filtered, array, ctx); } /// Tests that filtering with an all-false mask returns an empty array with the same dtype @@ -82,7 +81,7 @@ fn test_none_filter(array: &ArrayRef) { assert_eq!(filtered.dtype(), array.dtype()); } -fn test_selective_filter(array: &ArrayRef) { +fn test_selective_filter(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); if len < 2 { return; // Skip for very small arrays @@ -101,10 +100,10 @@ fn test_selective_filter(array: &ArrayRef) { for (filtered_idx, i) in (0..len).step_by(2).enumerate() { assert_eq!( filtered - .execute_scalar(filtered_idx, &mut array_session().create_execution_ctx()) + .execute_scalar(filtered_idx, ctx) .vortex_expect("scalar_at should succeed in conformance test"), array - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } @@ -121,24 +120,24 @@ fn test_selective_filter(array: &ArrayRef) { assert_eq!(filtered.len(), 2); assert_eq!( filtered - .execute_scalar(0, &mut array_session().create_execution_ctx()) + .execute_scalar(0, ctx) .vortex_expect("scalar_at should succeed in conformance test"), array - .execute_scalar(0, &mut array_session().create_execution_ctx()) + .execute_scalar(0, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); assert_eq!( filtered - .execute_scalar(1, &mut array_session().create_execution_ctx()) + .execute_scalar(1, ctx) .vortex_expect("scalar_at should succeed in conformance test"), array - .execute_scalar(len - 1, &mut array_session().create_execution_ctx()) + .execute_scalar(len - 1, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } } -fn test_single_element_filter(array: &ArrayRef) { +fn test_single_element_filter(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); if len == 0 { return; @@ -154,10 +153,10 @@ fn test_single_element_filter(array: &ArrayRef) { assert_eq!(filtered.len(), 1); assert_eq!( filtered - .execute_scalar(0, &mut array_session().create_execution_ctx()) + .execute_scalar(0, ctx) .vortex_expect("scalar_at should succeed in conformance test"), array - .execute_scalar(0, &mut array_session().create_execution_ctx()) + .execute_scalar(0, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); @@ -172,18 +171,16 @@ fn test_single_element_filter(array: &ArrayRef) { assert_eq!(filtered.len(), 1); assert_eq!( filtered - .execute_scalar(0, &mut array_session().create_execution_ctx()) + .execute_scalar(0, ctx) .vortex_expect("scalar_at should succeed in conformance test"), array - .execute_scalar(len - 1, &mut array_session().create_execution_ctx()) + .execute_scalar(len - 1, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } } fn test_empty_array_filter(dtype: &DType) { - use crate::Canonical; - let empty_array = Canonical::empty(dtype).into_array(); let empty_mask = Mask::new_false(0); let filtered = empty_array @@ -221,7 +218,7 @@ fn test_mismatched_lengths(array: &ArrayRef) { } /// Tests filtering with alternating true/false pattern -fn test_alternating_pattern_filter(array: &ArrayRef) { +fn test_alternating_pattern_filter(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); let pattern = create_alternating_pattern(len); let expected_count = pattern.iter().filter(|&&v| v).count(); @@ -238,10 +235,10 @@ fn test_alternating_pattern_filter(array: &ArrayRef) { if keep { assert_eq!( filtered - .execute_scalar(filtered_idx, &mut array_session().create_execution_ctx()) + .execute_scalar(filtered_idx, ctx) .vortex_expect("scalar_at should succeed in conformance test"), array - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); filtered_idx += 1; diff --git a/vortex-array/src/compute/conformance/mask.rs b/vortex-array/src/compute/conformance/mask.rs index 7a002662d38..50930ba2876 100644 --- a/vortex-array/src/compute/conformance/mask.rs +++ b/vortex-array/src/compute/conformance/mask.rs @@ -5,38 +5,38 @@ use vortex_error::VortexExpect; use vortex_mask::Mask; use crate::ArrayRef; +use crate::ExecutionCtx; use crate::IntoArray; -use crate::VortexSessionExecute; -use crate::array_session; use crate::arrays::BoolArray; use crate::arrays::bool::BoolArrayExt; use crate::builtins::ArrayBuiltins; +use crate::validity::Validity; /// Test mask compute function with various array sizes and patterns. /// The mask operation sets elements to null where the mask is true. -pub fn test_mask_conformance(array: &ArrayRef) { +pub fn test_mask_conformance(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); if len > 0 { - test_heterogenous_mask(array); - test_empty_mask(array); - test_full_mask(array); - test_alternating_mask(array); - test_sparse_mask(array); - test_single_element_mask(array); + test_heterogenous_mask(array, ctx); + test_empty_mask(array, ctx); + test_full_mask(array, ctx); + test_alternating_mask(array, ctx); + test_sparse_mask(array, ctx); + test_single_element_mask(array, ctx); } if len >= 5 { - test_double_mask(array); + test_double_mask(array, ctx); } if len > 0 { - test_nullable_mask_input(array); + test_nullable_mask_input(array, ctx); } } /// Tests masking with a heterogeneous pattern -fn test_heterogenous_mask(array: &ArrayRef) { +fn test_heterogenous_mask(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); // Create a pattern where roughly half the values are masked @@ -54,16 +54,16 @@ fn test_heterogenous_mask(array: &ArrayRef) { if masked_out { assert!( !masked - .is_valid(i, &mut array_session().create_execution_ctx()) + .is_valid(i, ctx) .vortex_expect("is_valid should succeed in conformance test") ); } else { assert_eq!( masked - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"), array - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") .into_nullable() ); @@ -72,7 +72,7 @@ fn test_heterogenous_mask(array: &ArrayRef) { } /// Tests that an empty mask (all false) preserves all elements -fn test_empty_mask(array: &ArrayRef) { +fn test_empty_mask(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); let all_unmasked = vec![false; len]; let mask_array = Mask::from_iter(all_unmasked); @@ -87,10 +87,10 @@ fn test_empty_mask(array: &ArrayRef) { for i in 0..len { assert_eq!( masked - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"), array - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") .into_nullable() ); @@ -98,7 +98,7 @@ fn test_empty_mask(array: &ArrayRef) { } /// Tests that a full mask (all true) makes all elements null -fn test_full_mask(array: &ArrayRef) { +fn test_full_mask(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); let all_masked = vec![true; len]; let mask_array = Mask::from_iter(all_masked); @@ -113,14 +113,14 @@ fn test_full_mask(array: &ArrayRef) { for i in 0..len { assert!( !masked - .is_valid(i, &mut array_session().create_execution_ctx()) + .is_valid(i, ctx) .vortex_expect("is_valid should succeed in conformance test") ); } } /// Tests alternating mask pattern -fn test_alternating_mask(array: &ArrayRef) { +fn test_alternating_mask(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); let pattern: Vec = (0..len).map(|i| i % 2 == 0).collect(); let mask_array = Mask::from_iter(pattern); @@ -135,16 +135,16 @@ fn test_alternating_mask(array: &ArrayRef) { if i % 2 == 0 { assert!( !masked - .is_valid(i, &mut array_session().create_execution_ctx()) + .is_valid(i, ctx) .vortex_expect("is_valid should succeed in conformance test") ); } else { assert_eq!( masked - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"), array - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") .into_nullable() ); @@ -153,7 +153,7 @@ fn test_alternating_mask(array: &ArrayRef) { } /// Tests sparse mask (only a few elements masked) -fn test_sparse_mask(array: &ArrayRef) { +fn test_sparse_mask(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); if len < 10 { return; // Skip for small arrays @@ -173,7 +173,7 @@ fn test_sparse_mask(array: &ArrayRef) { let valid_count = (0..len) .filter(|&i| { masked - .is_valid(i, &mut array_session().create_execution_ctx()) + .is_valid(i, ctx) .vortex_expect("is_valid should succeed in conformance test") }) .count(); @@ -185,7 +185,7 @@ fn test_sparse_mask(array: &ArrayRef) { .filter(|&i| { pattern[i] || !array - .is_valid(i, &mut array_session().create_execution_ctx()) + .is_valid(i, ctx) .vortex_expect("is_valid should succeed in conformance test") }) .count(); @@ -194,7 +194,7 @@ fn test_sparse_mask(array: &ArrayRef) { } /// Tests masking a single element -fn test_single_element_mask(array: &ArrayRef) { +fn test_single_element_mask(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); // Mask only the first element @@ -208,17 +208,17 @@ fn test_single_element_mask(array: &ArrayRef) { .vortex_expect("mask should succeed in conformance test"); assert!( !masked - .is_valid(0, &mut array_session().create_execution_ctx()) + .is_valid(0, ctx) .vortex_expect("is_valid should succeed in conformance test") ); for i in 1..len { assert_eq!( masked - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"), array - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") .into_nullable() ); @@ -226,7 +226,7 @@ fn test_single_element_mask(array: &ArrayRef) { } /// Tests double masking operations -fn test_double_mask(array: &ArrayRef) { +fn test_double_mask(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); // Create two different mask patterns @@ -249,16 +249,16 @@ fn test_double_mask(array: &ArrayRef) { if mask1_pattern[i] || mask2_pattern[i] { assert!( !double_masked - .is_valid(i, &mut array_session().create_execution_ctx()) + .is_valid(i, ctx) .vortex_expect("is_valid should succeed in conformance test") ); } else { assert_eq!( double_masked - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"), array - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") .into_nullable() ); @@ -267,7 +267,7 @@ fn test_double_mask(array: &ArrayRef) { } /// Tests masking with nullable mask (nulls treated as false) -fn test_nullable_mask_input(array: &ArrayRef) { +fn test_nullable_mask_input(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); if len < 3 { return; // Skip for very small arrays @@ -278,11 +278,10 @@ fn test_nullable_mask_input(array: &ArrayRef) { let validity_values: Vec = (0..len).map(|i| i % 3 != 0).collect(); let bool_array = BoolArray::from_iter(bool_values.clone()); - let validity = crate::validity::Validity::from_iter(validity_values.clone()); + let validity = Validity::from_iter(validity_values.clone()); let nullable_mask = BoolArray::new(bool_array.to_bit_buffer(), validity); - let mask_array = - nullable_mask.to_mask_fill_null_false(&mut array_session().create_execution_ctx()); + let mask_array = nullable_mask.to_mask_fill_null_false(ctx); let masked = array .clone() .mask((!&mask_array).into_array()) @@ -293,16 +292,16 @@ fn test_nullable_mask_input(array: &ArrayRef) { if bool_values[i] && validity_values[i] { assert!( !masked - .is_valid(i, &mut array_session().create_execution_ctx()) + .is_valid(i, ctx) .vortex_expect("is_valid should succeed in conformance test") ); } else { assert_eq!( masked - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"), array - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") .into_nullable() ); diff --git a/vortex-array/src/compute/conformance/take.rs b/vortex-array/src/compute/conformance/take.rs index babaf392eb9..47bc8f1bd82 100644 --- a/vortex-array/src/compute/conformance/take.rs +++ b/vortex-array/src/compute/conformance/take.rs @@ -6,9 +6,8 @@ use vortex_error::VortexExpect; use crate::ArrayRef; use crate::Canonical; +use crate::ExecutionCtx; use crate::IntoArray as _; -use crate::VortexSessionExecute; -use crate::array_session; use crate::arrays::PrimitiveArray; use crate::dtype::Nullability; @@ -21,39 +20,39 @@ use crate::dtype::Nullability; /// - Taking with out-of-bounds indices (should panic) /// - Taking with nullable indices /// - Edge cases like empty arrays -pub fn test_take_conformance(array: &ArrayRef) { +pub fn test_take_conformance(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); if len > 0 { - test_take_all(array); + test_take_all(array, ctx); test_take_none(array); - test_take_selective(array); - test_take_first_and_last(array); - test_take_with_nullable_indices(array); - test_take_repeated_indices(array); + test_take_selective(array, ctx); + test_take_first_and_last(array, ctx); + test_take_with_nullable_indices(array, ctx); + test_take_repeated_indices(array, ctx); } test_empty_indices(array); // Additional edge cases for non-empty arrays if len > 0 { - test_take_reverse(array); - test_take_single_middle(array); + test_take_reverse(array, ctx); + test_take_single_middle(array, ctx); } if len > 3 { - test_take_random_unsorted(array); - test_take_contiguous_range(array); - test_take_mixed_repeated(array); + test_take_random_unsorted(array, ctx); + test_take_contiguous_range(array, ctx); + test_take_mixed_repeated(array, ctx); } // Test for larger arrays if len >= 1024 { - test_take_large_indices(array); + test_take_large_indices(array, ctx); } } -fn test_take_all(array: &ArrayRef) { +fn test_take_all(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); let indices = PrimitiveArray::from_iter(0..len as u64); let result = array @@ -64,13 +63,13 @@ fn test_take_all(array: &ArrayRef) { assert_eq!(result.dtype(), array.dtype()); // Verify elements match - #[expect(deprecated)] let array_canonical = array - .to_canonical() + .clone() + .execute::(ctx) .vortex_expect("to_canonical failed on array"); - #[expect(deprecated)] let result_canonical = result - .to_canonical() + .clone() + .execute::(ctx) .vortex_expect("to_canonical failed on result"); match (array_canonical, result_canonical) { (Canonical::Primitive(orig_prim), Canonical::Primitive(result_prim)) => { @@ -84,10 +83,10 @@ fn test_take_all(array: &ArrayRef) { for i in 0..len { assert_eq!( array - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"), result - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } @@ -106,7 +105,7 @@ fn test_take_none(array: &ArrayRef) { } #[expect(clippy::cast_possible_truncation)] -fn test_take_selective(array: &ArrayRef) { +fn test_take_selective(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); // Take every other element @@ -123,19 +122,16 @@ fn test_take_selective(array: &ArrayRef) { for (result_idx, &original_idx) in indices.iter().enumerate() { assert_eq!( array - .execute_scalar( - original_idx as usize, - &mut array_session().create_execution_ctx() - ) + .execute_scalar(original_idx as usize, ctx) .vortex_expect("scalar_at should succeed in conformance test"), result - .execute_scalar(result_idx, &mut array_session().create_execution_ctx()) + .execute_scalar(result_idx, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } } -fn test_take_first_and_last(array: &ArrayRef) { +fn test_take_first_and_last(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); let indices = PrimitiveArray::from_iter([0u64, (len - 1) as u64]); let result = array @@ -145,24 +141,24 @@ fn test_take_first_and_last(array: &ArrayRef) { assert_eq!(result.len(), 2); assert_eq!( array - .execute_scalar(0, &mut array_session().create_execution_ctx()) + .execute_scalar(0, ctx) .vortex_expect("scalar_at should succeed in conformance test"), result - .execute_scalar(0, &mut array_session().create_execution_ctx()) + .execute_scalar(0, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); assert_eq!( array - .execute_scalar(len - 1, &mut array_session().create_execution_ctx()) + .execute_scalar(len - 1, ctx) .vortex_expect("scalar_at should succeed in conformance test"), result - .execute_scalar(1, &mut array_session().create_execution_ctx()) + .execute_scalar(1, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } #[expect(clippy::cast_possible_truncation)] -fn test_take_with_nullable_indices(array: &ArrayRef) { +fn test_take_with_nullable_indices(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); // Create indices with some null values @@ -190,17 +186,17 @@ fn test_take_with_nullable_indices(array: &ArrayRef) { match idx_opt { Some(idx) => { let expected = array - .execute_scalar(*idx as usize, &mut array_session().create_execution_ctx()) + .execute_scalar(*idx as usize, ctx) .vortex_expect("scalar_at should succeed in conformance test"); let actual = result - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"); assert_eq!(expected, actual); } None => { assert!( result - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") .is_null() ); @@ -209,7 +205,7 @@ fn test_take_with_nullable_indices(array: &ArrayRef) { } } -fn test_take_repeated_indices(array: &ArrayRef) { +fn test_take_repeated_indices(array: &ArrayRef, ctx: &mut ExecutionCtx) { if array.is_empty() { return; } @@ -222,12 +218,12 @@ fn test_take_repeated_indices(array: &ArrayRef) { assert_eq!(result.len(), 3); let first_elem = array - .execute_scalar(0, &mut array_session().create_execution_ctx()) + .execute_scalar(0, ctx) .vortex_expect("scalar_at should succeed in conformance test"); for i in 0..3 { assert_eq!( result - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test"), first_elem ); @@ -244,7 +240,7 @@ fn test_empty_indices(array: &ArrayRef) { assert_eq!(result.dtype(), array.dtype()); } -fn test_take_reverse(array: &ArrayRef) { +fn test_take_reverse(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); // Take elements in reverse order let indices = PrimitiveArray::from_iter((0..len as u64).rev()); @@ -258,16 +254,16 @@ fn test_take_reverse(array: &ArrayRef) { for i in 0..len { assert_eq!( array - .execute_scalar(len - 1 - i, &mut array_session().create_execution_ctx()) + .execute_scalar(len - 1 - i, ctx) .vortex_expect("scalar_at should succeed in conformance test"), result - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } } -fn test_take_single_middle(array: &ArrayRef) { +fn test_take_single_middle(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); let middle_idx = len / 2; @@ -279,20 +275,20 @@ fn test_take_single_middle(array: &ArrayRef) { assert_eq!(result.len(), 1); assert_eq!( array - .execute_scalar(middle_idx, &mut array_session().create_execution_ctx()) + .execute_scalar(middle_idx, ctx) .vortex_expect("scalar_at should succeed in conformance test"), result - .execute_scalar(0, &mut array_session().create_execution_ctx()) + .execute_scalar(0, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } #[expect(clippy::cast_possible_truncation)] -fn test_take_random_unsorted(array: &ArrayRef) { +fn test_take_random_unsorted(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); // Create a pseudo-random but deterministic pattern - let mut indices = Vec::new(); + let mut indices = Vec::with_capacity(len.min(10)); let mut idx = 1u64; for _ in 0..len.min(10) { indices.push((idx * 7 + 3) % len as u64); @@ -310,16 +306,16 @@ fn test_take_random_unsorted(array: &ArrayRef) { for (i, &idx) in indices.iter().enumerate() { assert_eq!( array - .execute_scalar(idx as usize, &mut array_session().create_execution_ctx()) + .execute_scalar(idx as usize, ctx) .vortex_expect("scalar_at should succeed in conformance test"), result - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } } -fn test_take_contiguous_range(array: &ArrayRef) { +fn test_take_contiguous_range(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); let start = len / 4; let end = len / 2; @@ -336,17 +332,17 @@ fn test_take_contiguous_range(array: &ArrayRef) { for i in 0..(end - start) { assert_eq!( array - .execute_scalar(start + i, &mut array_session().create_execution_ctx()) + .execute_scalar(start + i, ctx) .vortex_expect("scalar_at should succeed in conformance test"), result - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } } #[expect(clippy::cast_possible_truncation)] -fn test_take_mixed_repeated(array: &ArrayRef) { +fn test_take_mixed_repeated(array: &ArrayRef, ctx: &mut ExecutionCtx) { let len = array.len(); // Create pattern with some repeated indices @@ -372,17 +368,17 @@ fn test_take_mixed_repeated(array: &ArrayRef) { for (i, &idx) in indices.iter().enumerate() { assert_eq!( array - .execute_scalar(idx as usize, &mut array_session().create_execution_ctx()) + .execute_scalar(idx as usize, ctx) .vortex_expect("scalar_at should succeed in conformance test"), result - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } } #[expect(clippy::cast_possible_truncation)] -fn test_take_large_indices(array: &ArrayRef) { +fn test_take_large_indices(array: &ArrayRef, ctx: &mut ExecutionCtx) { // Test with a large number of indices to stress test performance let len = array.len(); let num_indices = 10000.min(len * 3); @@ -404,10 +400,10 @@ fn test_take_large_indices(array: &ArrayRef) { let expected_idx = indices[i] as usize; assert_eq!( array - .execute_scalar(expected_idx, &mut array_session().create_execution_ctx()) + .execute_scalar(expected_idx, ctx) .vortex_expect("scalar_at should succeed in conformance test"), result - .execute_scalar(i, &mut array_session().create_execution_ctx()) + .execute_scalar(i, ctx) .vortex_expect("scalar_at should succeed in conformance test") ); } diff --git a/vortex-array/src/display/mod.rs b/vortex-array/src/display/mod.rs index e13d6d5f894..1c12afcf8d2 100644 --- a/vortex-array/src/display/mod.rs +++ b/vortex-array/src/display/mod.rs @@ -6,6 +6,7 @@ mod extractors; mod tree_display; use std::fmt::Display; +use std::iter::repeat_n; pub use extractor::IndentedFormatter; pub use extractor::TreeContext; @@ -545,10 +546,7 @@ impl ArrayRef { "{opening_brace}{}{closing_brace}", (0..limit.saturating_sub(3)) .map(fmt_scalar) - .chain(std::iter::repeat_n( - "...".to_string(), - is_truncated as usize - )) + .chain(repeat_n("...".to_string(), is_truncated as usize)) .chain((self.len().saturating_sub(3)..self.len()).map(fmt_scalar)) .format(sep) ) diff --git a/vortex-array/src/executor.rs b/vortex-array/src/executor.rs index 4f41302c91f..a49f98b4d55 100644 --- a/vortex-array/src/executor.rs +++ b/vortex-array/src/executor.rs @@ -495,9 +495,9 @@ impl Executable for ArrayRef { /// Execute `array` into the given `builder`. /// /// This uses the encoding's [`crate::array::VTable::append_to_builder`] implementation. Most -/// encodings use the default path of `execute::` followed by `builder.extend_from_array`, -/// while encodings like `Chunked` can override that to append child-by-child without materializing -/// the entire parent. +/// encodings use the default path of `execute::` followed by re-dispatching +/// `append_to_builder` on the canonical array, while encodings like `Chunked` can override that to +/// append child-by-child without materializing the entire parent. /// /// The builder must have a [`DType`] that is a nullability-superset of `array.dtype()`. pub fn execute_into_builder( diff --git a/vortex-array/src/expr/exprs.rs b/vortex-array/src/expr/exprs.rs index ec12a321552..2bdd5346e09 100644 --- a/vortex-array/src/expr/exprs.rs +++ b/vortex-array/src/expr/exprs.rs @@ -201,15 +201,17 @@ pub fn nested_case_when( /// ``` /// # use vortex_array::arrays::{BoolArray, PrimitiveArray}; /// # use vortex_array::arrays::bool::BoolArrayExt; -/// # use vortex_array::{IntoArray, ToCanonical}; +/// # use vortex_array::IntoArray; +/// # use vortex_array::{VortexSessionExecute, array_session}; /// # use vortex_array::validity::Validity; /// # use vortex_buffer::buffer; /// # use vortex_array::expr::{eq, root, lit}; /// let xs = PrimitiveArray::new(buffer![1i32, 2i32, 3i32], Validity::NonNullable); /// let result = xs.into_array().apply(&eq(root(), lit(3))).unwrap(); +/// let mut ctx = array_session().create_execution_ctx(); /// /// assert_eq!( -/// result.to_bool().to_bit_buffer(), +/// result.execute::(&mut ctx).unwrap().to_bit_buffer(), /// BoolArray::from_iter(vec![false, false, true]).to_bit_buffer(), /// ); /// ``` @@ -226,15 +228,17 @@ pub fn eq(lhs: Expression, rhs: Expression) -> Expression { /// ``` /// # use vortex_array::arrays::{BoolArray, PrimitiveArray}; /// # use vortex_array::arrays::bool::BoolArrayExt; -/// # use vortex_array::{ IntoArray, ToCanonical}; +/// # use vortex_array::IntoArray; +/// # use vortex_array::{VortexSessionExecute, array_session}; /// # use vortex_array::validity::Validity; /// # use vortex_buffer::buffer; /// # use vortex_array::expr::{root, lit, not_eq}; /// let xs = PrimitiveArray::new(buffer![1i32, 2i32, 3i32], Validity::NonNullable); /// let result = xs.into_array().apply(¬_eq(root(), lit(3))).unwrap(); +/// let mut ctx = array_session().create_execution_ctx(); /// /// assert_eq!( -/// result.to_bool().to_bit_buffer(), +/// result.execute::(&mut ctx).unwrap().to_bit_buffer(), /// BoolArray::from_iter(vec![true, true, false]).to_bit_buffer(), /// ); /// ``` @@ -251,15 +255,17 @@ pub fn not_eq(lhs: Expression, rhs: Expression) -> Expression { /// ``` /// # use vortex_array::arrays::{BoolArray, PrimitiveArray }; /// # use vortex_array::arrays::bool::BoolArrayExt; -/// # use vortex_array::{IntoArray, ToCanonical}; +/// # use vortex_array::IntoArray; +/// # use vortex_array::{VortexSessionExecute, array_session}; /// # use vortex_array::validity::Validity; /// # use vortex_buffer::buffer; /// # use vortex_array::expr::{gt_eq, root, lit}; /// let xs = PrimitiveArray::new(buffer![1i32, 2i32, 3i32], Validity::NonNullable); /// let result = xs.into_array().apply(>_eq(root(), lit(3))).unwrap(); +/// let mut ctx = array_session().create_execution_ctx(); /// /// assert_eq!( -/// result.to_bool().to_bit_buffer(), +/// result.execute::(&mut ctx).unwrap().to_bit_buffer(), /// BoolArray::from_iter(vec![false, false, true]).to_bit_buffer(), /// ); /// ``` @@ -276,15 +282,17 @@ pub fn gt_eq(lhs: Expression, rhs: Expression) -> Expression { /// ``` /// # use vortex_array::arrays::{BoolArray, PrimitiveArray }; /// # use vortex_array::arrays::bool::BoolArrayExt; -/// # use vortex_array::{IntoArray, ToCanonical}; +/// # use vortex_array::IntoArray; +/// # use vortex_array::{VortexSessionExecute, array_session}; /// # use vortex_array::validity::Validity; /// # use vortex_buffer::buffer; /// # use vortex_array::expr::{gt, root, lit}; /// let xs = PrimitiveArray::new(buffer![1i32, 2i32, 3i32], Validity::NonNullable); /// let result = xs.into_array().apply(>(root(), lit(2))).unwrap(); +/// let mut ctx = array_session().create_execution_ctx(); /// /// assert_eq!( -/// result.to_bool().to_bit_buffer(), +/// result.execute::(&mut ctx).unwrap().to_bit_buffer(), /// BoolArray::from_iter(vec![false, false, true]).to_bit_buffer(), /// ); /// ``` @@ -301,15 +309,17 @@ pub fn gt(lhs: Expression, rhs: Expression) -> Expression { /// ``` /// # use vortex_array::arrays::{BoolArray, PrimitiveArray }; /// # use vortex_array::arrays::bool::BoolArrayExt; -/// # use vortex_array::{IntoArray, ToCanonical}; +/// # use vortex_array::IntoArray; +/// # use vortex_array::{VortexSessionExecute, array_session}; /// # use vortex_array::validity::Validity; /// # use vortex_buffer::buffer; /// # use vortex_array::expr::{root, lit, lt_eq}; /// let xs = PrimitiveArray::new(buffer![1i32, 2i32, 3i32], Validity::NonNullable); /// let result = xs.into_array().apply(<_eq(root(), lit(2))).unwrap(); +/// let mut ctx = array_session().create_execution_ctx(); /// /// assert_eq!( -/// result.to_bool().to_bit_buffer(), +/// result.execute::(&mut ctx).unwrap().to_bit_buffer(), /// BoolArray::from_iter(vec![true, true, false]).to_bit_buffer(), /// ); /// ``` @@ -326,15 +336,17 @@ pub fn lt_eq(lhs: Expression, rhs: Expression) -> Expression { /// ``` /// # use vortex_array::arrays::{BoolArray, PrimitiveArray }; /// # use vortex_array::arrays::bool::BoolArrayExt; -/// # use vortex_array::{IntoArray, ToCanonical}; +/// # use vortex_array::IntoArray; +/// # use vortex_array::{VortexSessionExecute, array_session}; /// # use vortex_array::validity::Validity; /// # use vortex_buffer::buffer; /// # use vortex_array::expr::{root, lit, lt}; /// let xs = PrimitiveArray::new(buffer![1i32, 2i32, 3i32], Validity::NonNullable); /// let result = xs.into_array().apply(<(root(), lit(3))).unwrap(); +/// let mut ctx = array_session().create_execution_ctx(); /// /// assert_eq!( -/// result.to_bool().to_bit_buffer(), +/// result.execute::(&mut ctx).unwrap().to_bit_buffer(), /// BoolArray::from_iter(vec![true, true, false]).to_bit_buffer(), /// ); /// ``` @@ -351,13 +363,15 @@ pub fn lt(lhs: Expression, rhs: Expression) -> Expression { /// ``` /// # use vortex_array::arrays::BoolArray; /// # use vortex_array::arrays::bool::BoolArrayExt; -/// # use vortex_array::{IntoArray, ToCanonical}; +/// # use vortex_array::IntoArray; +/// # use vortex_array::{VortexSessionExecute, array_session}; /// # use vortex_array::expr::{root, lit, or}; /// let xs = BoolArray::from_iter(vec![true, false, true]); /// let result = xs.into_array().apply(&or(root(), lit(false))).unwrap(); +/// let mut ctx = array_session().create_execution_ctx(); /// /// assert_eq!( -/// result.to_bool().to_bit_buffer(), +/// result.execute::(&mut ctx).unwrap().to_bit_buffer(), /// BoolArray::from_iter(vec![true, false, true]).to_bit_buffer(), /// ); /// ``` @@ -387,13 +401,15 @@ where /// ``` /// # use vortex_array::arrays::BoolArray; /// # use vortex_array::arrays::bool::BoolArrayExt; -/// # use vortex_array::{IntoArray, ToCanonical}; +/// # use vortex_array::IntoArray; +/// # use vortex_array::{VortexSessionExecute, array_session}; /// # use vortex_array::expr::{and, root, lit}; /// let xs = BoolArray::from_iter(vec![true, false, true]).into_array(); /// let result = xs.apply(&and(root(), lit(true))).unwrap(); +/// let mut ctx = array_session().create_execution_ctx(); /// /// assert_eq!( -/// result.to_bool().to_bit_buffer(), +/// result.execute::(&mut ctx).unwrap().to_bit_buffer(), /// BoolArray::from_iter(vec![true, false, true]).to_bit_buffer(), /// ); /// ``` diff --git a/vortex-array/src/patches.rs b/vortex-array/src/patches.rs index f5d7b0e2d2a..641efd61404 100644 --- a/vortex-array/src/patches.rs +++ b/vortex-array/src/patches.rs @@ -1228,8 +1228,6 @@ mod test { use vortex_mask::Mask; use crate::IntoArray; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::assert_arrays_eq; @@ -1287,10 +1285,16 @@ mod test { ) .unwrap() .unwrap(); - #[expect(deprecated)] - let primitive_values = taken.values().to_primitive(); - #[expect(deprecated)] - let primitive_indices = taken.indices().to_primitive(); + let primitive_values = taken + .values() + .clone() + .execute::(&mut ctx) + .unwrap(); + let primitive_indices = taken + .indices() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!(taken.array_len(), 2); assert_arrays_eq!( primitive_values, @@ -1334,8 +1338,11 @@ mod test { .unwrap() .unwrap(); - #[expect(deprecated)] - let primitive_values = taken.values().to_primitive(); + let primitive_values = taken + .values() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!(taken.array_len(), 2); assert_arrays_eq!( primitive_values, @@ -1648,8 +1655,11 @@ mod test { ); // Values should be the null and 300 - #[expect(deprecated)] - let masked_values = masked.values().to_primitive(); + let masked_values = masked + .values() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!(masked_values.len(), 2); assert!(!masked_values.is_valid(0, &mut ctx).unwrap()); // the null value at index 5 assert!(masked_values.is_valid(1, &mut ctx).unwrap()); // the 300 value at index 8 @@ -1844,8 +1854,11 @@ mod test { ) .unwrap(); - #[expect(deprecated)] - let values = patches.values().to_primitive(); + let values = patches + .values() + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!( i32::try_from(&values.execute_scalar(0, &mut ctx).unwrap()).unwrap(), 100i32 diff --git a/vortex-array/src/scalar_fn/fns/binary/boolean.rs b/vortex-array/src/scalar_fn/fns/binary/boolean.rs index 3fb91016e3e..550b6c2c701 100644 --- a/vortex-array/src/scalar_fn/fns/binary/boolean.rs +++ b/vortex-array/src/scalar_fn/fns/binary/boolean.rs @@ -725,8 +725,6 @@ mod tests { use crate::arrays::ConstantArray; use crate::assert_arrays_eq; use crate::builtins::ArrayBuiltins; - #[expect(deprecated)] - use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::Nullability; use crate::scalar::Scalar; @@ -826,9 +824,9 @@ mod tests { BoolArray::from_iter([Some(true), Some(true), Some(false), Some(false)]).into_array(), )] fn test_or(#[case] lhs: ArrayRef, #[case] rhs: ArrayRef) { + let mut ctx = array_session().create_execution_ctx(); let r = lhs.binary(rhs, Operator::Or).unwrap(); - #[expect(deprecated)] - let r = r.to_bool().into_array(); + let r = r.execute::(&mut ctx).unwrap().into_array(); let v0 = r .execute_scalar(0, &mut array_session().create_execution_ctx()) @@ -867,11 +865,12 @@ mod tests { BoolArray::from_iter([Some(true), Some(true), Some(false), Some(false)]).into_array(), )] fn test_and(#[case] lhs: ArrayRef, #[case] rhs: ArrayRef) { - #[expect(deprecated)] + let mut ctx = array_session().create_execution_ctx(); let r = lhs .binary(rhs, Operator::And) .unwrap() - .to_bool() + .execute::(&mut ctx) + .unwrap() .into_array(); let v0 = r diff --git a/vortex-array/src/scalar_fn/fns/list_contains/mod.rs b/vortex-array/src/scalar_fn/fns/list_contains/mod.rs index 5185e513e70..edf530cb75f 100644 --- a/vortex-array/src/scalar_fn/fns/list_contains/mod.rs +++ b/vortex-array/src/scalar_fn/fns/list_contains/mod.rs @@ -400,6 +400,7 @@ mod tests { use rstest::rstest; use vortex_buffer::BitBuffer; use vortex_buffer::Buffer; + use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_session::VortexSession; @@ -410,8 +411,6 @@ mod tests { use crate::arrays::ListArray; use crate::arrays::VarBinArray; use crate::assert_arrays_eq; - #[expect(deprecated)] - use crate::canonical::ToCanonical as _; use crate::dtype::DType; use crate::dtype::Nullability; use crate::dtype::PType::I32; @@ -666,15 +665,14 @@ mod tests { // -- Tests migrated from compute/list_contains.rs -- fn nonnull_strings(values: Vec>) -> ArrayRef { - #[expect(deprecated)] - let result = ListArray::from_iter_slow::( - values, - Arc::new(DType::Utf8(Nullability::NonNullable)), - ) - .unwrap() - .to_listview() - .into_array(); - result + let mut ctx = array_session().create_execution_ctx(); + + ListArray::from_iter_slow::(values, Arc::new(DType::Utf8(Nullability::NonNullable))) + .unwrap() + .into_array() + .execute::(&mut ctx) + .vortex_expect("failed to convert to listview") + .into_array() } fn null_strings(values: Vec>>) -> ArrayRef { @@ -693,13 +691,15 @@ mod tests { let elements = VarBinArray::from_iter(elements, DType::Utf8(Nullability::Nullable)).into_array(); - #[expect(deprecated)] - let result = ListArray::try_new(elements, offsets, Validity::NonNullable) + let mut ctx = array_session().create_execution_ctx(); + + ListArray::try_new(elements, offsets, Validity::NonNullable) .unwrap() .as_array() - .to_listview() - .into_array(); - result + .clone() + .execute::(&mut ctx) + .vortex_expect("failed to convert to listview") + .into_array() } fn bool_array(values: Vec, validity: Validity) -> BoolArray { diff --git a/vortex-array/src/scalar_fn/fns/merge.rs b/vortex-array/src/scalar_fn/fns/merge.rs index 23fedb5009b..7b9d57fcdca 100644 --- a/vortex-array/src/scalar_fn/fns/merge.rs +++ b/vortex-array/src/scalar_fn/fns/merge.rs @@ -277,8 +277,6 @@ mod tests { use crate::ArrayRef; use crate::IntoArray; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::PrimitiveArray; @@ -300,21 +298,27 @@ mod tests { use crate::scalar_fn::fns::pack::Pack; fn primitive_field(array: &ArrayRef, field_path: &[&str]) -> VortexResult { + let mut ctx = array_session().create_execution_ctx(); let mut field_path = field_path.iter(); let Some(field) = field_path.next() else { vortex_bail!("empty field path"); }; - #[expect(deprecated)] - let mut array = array.to_struct().unmasked_field_by_name(field)?.clone(); + let mut array = array + .clone() + .execute::(&mut ctx)? + .unmasked_field_by_name(field)? + .clone(); for field in field_path { - #[expect(deprecated)] - let next = array.to_struct().unmasked_field_by_name(field)?.clone(); + let next = array + .clone() + .execute::(&mut ctx)? + .unmasked_field_by_name(field)? + .clone(); array = next; } - #[expect(deprecated)] - let result = array.to_primitive(); + let result = array.execute::(&mut ctx)?; Ok(result) } @@ -491,14 +495,19 @@ mod tests { ]) .unwrap() .into_array(); - #[expect(deprecated)] - let actual_array = test_array.apply(&expr).unwrap().to_struct(); + let mut ctx = array_session().create_execution_ctx(); + let actual_array = test_array + .apply(&expr) + .unwrap() + .execute::(&mut ctx) + .unwrap(); - #[expect(deprecated)] let inner_struct = actual_array .unmasked_field_by_name("a") .unwrap() - .to_struct(); + .clone() + .execute::(&mut ctx) + .unwrap(); assert_eq!( inner_struct .names() @@ -511,6 +520,7 @@ mod tests { #[test] pub fn test_merge_order() { + let mut ctx = array_session().create_execution_ctx(); let expr = merge(vec![get_item("0", root()), get_item("1", root())]); let test_array = StructArray::from_fields(&[ @@ -535,8 +545,11 @@ mod tests { ]) .unwrap() .into_array(); - #[expect(deprecated)] - let actual_array = test_array.apply(&expr).unwrap().to_struct(); + let actual_array = test_array + .apply(&expr) + .unwrap() + .execute::(&mut ctx) + .unwrap(); assert_eq!(actual_array.names(), ["a", "c", "b", "d"]); } diff --git a/vortex-array/src/scalar_fn/fns/not/mod.rs b/vortex-array/src/scalar_fn/fns/not/mod.rs index 156a6568df7..d0f9e400bde 100644 --- a/vortex-array/src/scalar_fn/fns/not/mod.rs +++ b/vortex-array/src/scalar_fn/fns/not/mod.rs @@ -110,8 +110,8 @@ impl ScalarFnVTable for Not { #[cfg(test)] mod tests { use crate::IntoArray; - #[expect(deprecated)] - use crate::ToCanonical as _; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::bool::BoolArrayExt; use crate::dtype::DType; use crate::dtype::Nullability; @@ -124,10 +124,15 @@ mod tests { #[test] fn invert_booleans() { + let mut ctx = array_session().create_execution_ctx(); let not_expr = not(root()); let bools = BoolArray::from_iter([false, true, false, false, true, true]); - #[expect(deprecated)] - let result = bools.into_array().apply(¬_expr).unwrap().to_bool(); + let result = bools + .into_array() + .apply(¬_expr) + .unwrap() + .execute::(&mut ctx) + .unwrap(); assert_eq!( result.to_bit_buffer().iter().collect::>(), vec![true, false, true, true, false, false] diff --git a/vortex-array/src/scalar_fn/fns/pack.rs b/vortex-array/src/scalar_fn/fns/pack.rs index 81019e22d6f..45475623d32 100644 --- a/vortex-array/src/scalar_fn/fns/pack.rs +++ b/vortex-array/src/scalar_fn/fns/pack.rs @@ -170,8 +170,6 @@ mod tests { use super::PackOptions; use crate::ArrayRef; use crate::IntoArray; - #[expect(deprecated)] - use crate::ToCanonical as _; use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::PrimitiveArray; @@ -194,26 +192,33 @@ mod tests { } fn primitive_field(array: &ArrayRef, field_path: &[&str]) -> VortexResult { + let mut ctx = array_session().create_execution_ctx(); let mut field_path = field_path.iter(); let Some(field) = field_path.next() else { vortex_bail!("empty field path"); }; - #[expect(deprecated)] - let mut array = array.to_struct().unmasked_field_by_name(field)?.clone(); + let mut array = array + .clone() + .execute::(&mut ctx)? + .unmasked_field_by_name(field)? + .clone(); for field in field_path { - #[expect(deprecated)] - let next = array.to_struct().unmasked_field_by_name(field)?.clone(); + let next = array + .clone() + .execute::(&mut ctx)? + .unmasked_field_by_name(field)? + .clone(); array = next; } - #[expect(deprecated)] - let result = array.to_primitive(); + let result = array.execute::(&mut ctx)?; Ok(result) } #[test] pub fn test_empty_pack() { + let mut ctx = array_session().create_execution_ctx(); let expr = Pack.new_expr( PackOptions { names: Default::default(), @@ -225,8 +230,11 @@ mod tests { let test_array = test_array(); let actual_array = test_array.clone().apply(&expr).unwrap(); assert_eq!(actual_array.len(), test_array.len()); - #[expect(deprecated)] - let nfields = actual_array.to_struct().struct_fields().nfields(); + let nfields = actual_array + .execute::(&mut ctx) + .unwrap() + .struct_fields() + .nfields(); assert_eq!(nfields, 0); } @@ -241,8 +249,11 @@ mod tests { [col("a"), col("b"), col("a")], ); - #[expect(deprecated)] - let actual_array = test_array().apply(&expr).unwrap().to_struct(); + let actual_array = test_array() + .apply(&expr) + .unwrap() + .execute::(&mut ctx) + .unwrap(); assert_eq!(actual_array.names(), ["one", "two", "three"]); assert!(matches!(actual_array.validity(), Ok(Validity::NonNullable))); @@ -285,8 +296,11 @@ mod tests { ], ); - #[expect(deprecated)] - let actual_array = test_array().apply(&expr).unwrap().to_struct(); + let actual_array = test_array() + .apply(&expr) + .unwrap() + .execute::(&mut ctx) + .unwrap(); assert_eq!(actual_array.names(), ["one", "two", "three"]); @@ -314,6 +328,7 @@ mod tests { #[test] pub fn test_pack_nullable() { + let mut ctx = array_session().create_execution_ctx(); let expr = Pack.new_expr( PackOptions { names: ["one", "two", "three"].into(), @@ -322,8 +337,11 @@ mod tests { [col("a"), col("b"), col("a")], ); - #[expect(deprecated)] - let actual_array = test_array().apply(&expr).unwrap().to_struct(); + let actual_array = test_array() + .apply(&expr) + .unwrap() + .execute::(&mut ctx) + .unwrap(); assert_eq!(actual_array.names(), ["one", "two", "three"]); assert!(matches!(actual_array.validity(), Ok(Validity::AllValid))); diff --git a/vortex-array/src/scalar_fn/fns/select.rs b/vortex-array/src/scalar_fn/fns/select.rs index 54b63a00f89..2fbe4593503 100644 --- a/vortex-array/src/scalar_fn/fns/select.rs +++ b/vortex-array/src/scalar_fn/fns/select.rs @@ -309,8 +309,8 @@ mod tests { use vortex_buffer::buffer; use crate::IntoArray; - #[expect(deprecated)] - use crate::ToCanonical as _; + use crate::VortexSessionExecute; + use crate::array_session; use crate::arrays::struct_::StructArrayExt; use crate::dtype::DType; use crate::dtype::FieldName; @@ -336,20 +336,30 @@ mod tests { #[test] pub fn include_columns() { + let mut ctx = array_session().create_execution_ctx(); let st = test_array(); let select = select(vec![FieldName::from("a")], root()); - #[expect(deprecated)] - let selected = st.into_array().apply(&select).unwrap().to_struct(); + let selected = st + .into_array() + .apply(&select) + .unwrap() + .execute::(&mut ctx) + .unwrap(); let selected_names = selected.names().clone(); assert_eq!(selected_names.as_ref(), &["a"]); } #[test] pub fn exclude_columns() { + let mut ctx = array_session().create_execution_ctx(); let st = test_array(); let select = select_exclude(vec![FieldName::from("a")], root()); - #[expect(deprecated)] - let selected = st.into_array().apply(&select).unwrap().to_struct(); + let selected = st + .into_array() + .apply(&select) + .unwrap() + .execute::(&mut ctx) + .unwrap(); let selected_names = selected.names().clone(); assert_eq!(selected_names.as_ref(), &["b"]); } diff --git a/vortex-array/src/scalar_fn/fns/variant_get/mod.rs b/vortex-array/src/scalar_fn/fns/variant_get/mod.rs index 1a4854a6a4a..2f7b563202c 100644 --- a/vortex-array/src/scalar_fn/fns/variant_get/mod.rs +++ b/vortex-array/src/scalar_fn/fns/variant_get/mod.rs @@ -138,7 +138,7 @@ impl ScalarFnVTable for VariantGet { builder.append_scalar(&output)?; } - return Ok(builder.finish_into_canonical().into_array()); + return Ok(builder.finish()); } // TODO(variant): replace this with a Variant builder once one exists. diff --git a/vortex-btrblocks/src/schemes/binary/zstd.rs b/vortex-btrblocks/src/schemes/binary/zstd.rs index 56be76cdcb8..b63049f36e7 100644 --- a/vortex-btrblocks/src/schemes/binary/zstd.rs +++ b/vortex-btrblocks/src/schemes/binary/zstd.rs @@ -45,7 +45,10 @@ impl Scheme for ZstdScheme { _compress_ctx: CompressorContext, exec_ctx: &mut ExecutionCtx, ) -> VortexResult { - let compacted = data.array_as_varbinview().into_owned().compact_buffers()?; + let compacted = data + .array_as_varbinview() + .into_owned() + .compact_buffers(exec_ctx)?; Ok( vortex_zstd::Zstd::from_var_bin_view_without_dict(&compacted, 3, 8192, exec_ctx)? .into_array(), diff --git a/vortex-btrblocks/src/schemes/string/zstd.rs b/vortex-btrblocks/src/schemes/string/zstd.rs index 6e99cc27aa3..c799b64e359 100644 --- a/vortex-btrblocks/src/schemes/string/zstd.rs +++ b/vortex-btrblocks/src/schemes/string/zstd.rs @@ -45,7 +45,10 @@ impl Scheme for ZstdScheme { _compress_ctx: CompressorContext, exec_ctx: &mut ExecutionCtx, ) -> VortexResult { - let compacted = data.array_as_varbinview().into_owned().compact_buffers()?; + let compacted = data + .array_as_varbinview() + .into_owned() + .compact_buffers(exec_ctx)?; Ok( vortex_zstd::Zstd::from_var_bin_view_without_dict(&compacted, 3, 8192, exec_ctx)? .into_array(), diff --git a/vortex-cxx/src/read.rs b/vortex-cxx/src/read.rs index 480023084ef..110e42d70f0 100644 --- a/vortex-cxx/src/read.rs +++ b/vortex-cxx/src/read.rs @@ -17,7 +17,6 @@ use futures::stream::TryStreamExt; use vortex::array::ArrayRef; use vortex::array::VortexSessionExecute; use vortex::array::arrow::ArrowSessionExt; -use vortex::array::legacy_session; use vortex::buffer::Buffer; use vortex::file::OpenOptionsSessionExt; use vortex::io::runtime::BlockingRuntime; @@ -168,11 +167,7 @@ pub(crate) fn scan_builder_into_threadsafe_cloneable_reader( .map(move |b| { SESSION .arrow() - .execute_arrow( - b, - Some(&target), - &mut legacy_session().create_execution_ctx(), - ) + .execute_arrow(b, Some(&target), &mut SESSION.create_execution_ctx()) .map(|struct_array| RecordBatch::from(struct_array.as_struct())) }) .into_stream()? diff --git a/vortex-file/src/tests.rs b/vortex-file/src/tests.rs index 81cfb086337..7ceb7f21ba9 100644 --- a/vortex-file/src/tests.rs +++ b/vortex-file/src/tests.rs @@ -237,12 +237,14 @@ async fn test_read_simple_with_spawn() { vec![vec![11, 12], vec![21, 22], vec![31, 32], vec![41, 42]], Arc::new(I32.into()), ) - .unwrap(), + .unwrap() + .into_array(), ListArray::from_iter_slow::( vec![vec![51, 52], vec![61, 62], vec![71, 72], vec![81, 82]], Arc::new(I32.into()), ) - .unwrap(), + .unwrap() + .into_array(), ]) .into_array(); diff --git a/vortex-layout/src/layouts/file_stats.rs b/vortex-layout/src/layouts/file_stats.rs index 580489d0d68..c4cd032c686 100644 --- a/vortex-layout/src/layouts/file_stats.rs +++ b/vortex-layout/src/layouts/file_stats.rs @@ -25,7 +25,6 @@ use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; use vortex_array::expr::stats::Precision; use vortex_array::expr::stats::Stat; -use vortex_array::legacy_session; use vortex_array::scalar::Scalar; use vortex_array::scalar::ScalarTruncation; use vortex_array::scalar::lower_bound; @@ -115,7 +114,7 @@ impl StatsAccumulator { Ok(()) } - fn as_array(&mut self) -> VortexResult> { + fn as_array(&mut self, ctx: &mut ExecutionCtx) -> VortexResult> { let mut names = Vec::new(); let mut fields = Vec::new(); @@ -128,7 +127,7 @@ impl StatsAccumulator { let values = builder.finish(); // We drop any all-null stats columns. - if values.all_invalid()? { + if values.all_invalid(ctx)? { continue; } @@ -146,7 +145,7 @@ impl StatsAccumulator { /// Returns an aggregated stats set for the table. fn as_stats_set(&mut self, stats: &[Stat], ctx: &mut ExecutionCtx) -> VortexResult { let mut stats_set = StatsSet::default(); - let Some(array) = self.as_array()? else { + let Some(array) = self.as_array(ctx)? else { return Ok(stats_set); }; @@ -236,9 +235,8 @@ struct NamedArrays { } impl NamedArrays { - #[allow(clippy::disallowed_methods)] - fn all_invalid(&self) -> VortexResult { - self.arrays[0].all_invalid(&mut legacy_session().create_execution_ctx()) + fn all_invalid(&self, ctx: &mut ExecutionCtx) -> VortexResult { + self.arrays[0].all_invalid(ctx) } } @@ -525,7 +523,10 @@ mod tests { .vortex_expect("push_chunk should succeed for test data"); acc.push_chunk(&builder2.finish(), &mut ctx) .vortex_expect("push_chunk should succeed for test data"); - let stats_table = acc.as_array().unwrap().expect("Must have stats table"); + let stats_table = acc + .as_array(&mut ctx) + .unwrap() + .expect("Must have stats table"); assert_eq!( stats_table.names().as_ref(), &[ @@ -562,7 +563,10 @@ mod tests { let mut acc = StatsAccumulator::new(array.dtype(), &[Stat::Max, Stat::Min, Stat::Sum], 12); acc.push_chunk(&array, &mut ctx) .vortex_expect("push_chunk should succeed for test array"); - let stats_table = acc.as_array().unwrap().expect("Must have stats table"); + let stats_table = acc + .as_array(&mut ctx) + .unwrap() + .expect("Must have stats table"); assert_eq!( stats_table.names().as_ref(), &[ diff --git a/vortex-layout/src/layouts/zoned/builder.rs b/vortex-layout/src/layouts/zoned/builder.rs index 8c44113e3e1..3c86f371fb6 100644 --- a/vortex-layout/src/layouts/zoned/builder.rs +++ b/vortex-layout/src/layouts/zoned/builder.rs @@ -8,14 +8,12 @@ use std::sync::Arc; use itertools::Itertools; use vortex_array::ArrayRef; use vortex_array::ExecutionCtx; -use vortex_array::VortexSessionExecute; use vortex_array::aggregate_fn::AggregateFnRef; use vortex_array::arrays::StructArray; use vortex_array::builders::ArrayBuilder; use vortex_array::builders::builder_with_capacity; use vortex_array::dtype::DType; use vortex_array::dtype::FieldName; -use vortex_array::legacy_session; use vortex_array::scalar::Scalar; use vortex_array::validity::Validity; use vortex_error::VortexResult; @@ -74,6 +72,7 @@ impl AggregateStatsAccumulator { pub(crate) fn as_array( &mut self, + ctx: &mut ExecutionCtx, ) -> VortexResult)>> { let mut names = Vec::new(); let mut fields = Vec::new(); @@ -86,7 +85,7 @@ impl AggregateStatsAccumulator { { let values = builder.finish(); - if values.all_invalid()? { + if values.all_invalid(ctx)? { continue; } @@ -153,9 +152,8 @@ struct NamedArrays { } impl NamedArrays { - #[allow(clippy::disallowed_methods)] - fn all_invalid(&self) -> VortexResult { + fn all_invalid(&self, ctx: &mut ExecutionCtx) -> VortexResult { // By convention the first array is the logical validity signal for the stat column. - self.arrays[0].all_invalid(&mut legacy_session().create_execution_ctx()) + self.arrays[0].all_invalid(ctx) } } diff --git a/vortex-layout/src/layouts/zoned/writer.rs b/vortex-layout/src/layouts/zoned/writer.rs index 6ed7b541bcb..c1c12d7ddea 100644 --- a/vortex-layout/src/layouts/zoned/writer.rs +++ b/vortex-layout/src/layouts/zoned/writer.rs @@ -164,7 +164,10 @@ impl LayoutStrategy for ZonedStrategy { ) .await?; - let Some((stats_array, aggregate_fns)) = stats_accumulator.lock().as_array()? else { + let mut exec_ctx = session.create_execution_ctx(); + let Some((stats_array, aggregate_fns)) = + stats_accumulator.lock().as_array(&mut exec_ctx)? + else { // If we have no stats (e.g. the DType doesn't support them), then we just return the // child layout. return Ok(data_layout); diff --git a/vortex-python/src/iter/mod.rs b/vortex-python/src/iter/mod.rs index bb054a01edc..e03bd36d609 100644 --- a/vortex-python/src/iter/mod.rs +++ b/vortex-python/src/iter/mod.rs @@ -25,7 +25,6 @@ use vortex::array::arrow::ArrowSessionExt; use vortex::array::iter::ArrayIterator; use vortex::array::iter::ArrayIteratorAdapter; use vortex::array::iter::ArrayIteratorExt; -use vortex::array::legacy_session; use vortex::dtype::DType; use crate::arrays::PyArrayRef; @@ -133,7 +132,7 @@ impl PyArrayIterator { session().arrow().execute_arrow( chunk?, Some(&target), - &mut legacy_session().create_execution_ctx(), + &mut session().create_execution_ctx(), ) }) .map(|chunk| chunk.map_err(|e| ArrowError::ExternalError(Box::new(e)))) From 9a426152c281086b565d9120fdbdb3a24d4fe7c8 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Tue, 7 Jul 2026 17:28:37 +0100 Subject: [PATCH 028/104] Grow list builders as we append elements to it (#8673) Grow List/ListView builders when we append to them. Since they're variable length we can't preallocate them Signed-off-by: Robert Kruszewski --- .../bitpacking/array/bitpack_decompress.rs | 65 +++++++++++++++++++ vortex-array/src/builders/list.rs | 37 +++++++++++ vortex-array/src/builders/listview.rs | 34 ++++++++++ 3 files changed, 136 insertions(+) diff --git a/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs b/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs index 78aac1aa86e..692e7dcdd7f 100644 --- a/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs +++ b/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs @@ -203,13 +203,20 @@ pub fn count_exceptions(bit_width: u8, bit_width_freq: &[usize]) -> usize { #[cfg(test)] mod tests { + use std::sync::Arc; use std::sync::LazyLock; use vortex_array::Canonical; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; + use vortex_array::arrays::ListArray; + use vortex_array::arrays::ListViewArray; use vortex_array::assert_arrays_eq; + use vortex_array::builders::ListBuilder; + use vortex_array::builders::ListViewBuilder; + use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; use vortex_array::validity::Validity; use vortex_buffer::Buffer; use vortex_buffer::BufferMut; @@ -266,6 +273,64 @@ mod tests { compression_roundtrip(10_240); } + /// List builders bulk-append the source's elements into their internal elements builder. + /// Fixed-width builder appends assume the caller reserved capacity, so the list builders + /// must grow the elements builder themselves; encoded elements exercise that because + /// BitPacked's `append_to_builder` writes through `uninit_range`. + #[test] + fn test_list_append_grows_elements_builder() -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); + + let elements = encode(&PrimitiveArray::from_iter((0..3072u32).map(|i| i % 256)), 8); + let element_dtype: Arc = Arc::new(PType::U32.into()); + + // 48 lists of 64 elements each. + let offsets = Buffer::from_iter((0..=48u64).map(|i| i * 64)).into_array(); + let list = ListArray::try_new( + elements.clone().into_array(), + offsets, + Validity::NonNullable, + )?; + + let mut listview_builder = ListViewBuilder::::with_capacity( + Arc::clone(&element_dtype), + Nullability::NonNullable, + 0, + 0, + ); + list.clone() + .into_array() + .append_to_builder(&mut listview_builder, &mut ctx)?; + assert_arrays_eq!(listview_builder.finish(), list, &mut ctx); + + let mut list_builder = ListBuilder::::with_capacity( + Arc::clone(&element_dtype), + Nullability::NonNullable, + 0, + 0, + ); + list.clone() + .into_array() + .append_to_builder(&mut list_builder, &mut ctx)?; + assert_arrays_eq!(list_builder.finish(), list, &mut ctx); + + // A `ListViewArray` source appended into a `ListBuilder` appends elements list by list. + let listview = ListViewArray::try_new( + elements.into_array(), + Buffer::from_iter((0..48u64).map(|i| i * 64)).into_array(), + Buffer::from_iter(std::iter::repeat_n(64u32, 48)).into_array(), + Validity::NonNullable, + )?; + let mut list_builder = + ListBuilder::::with_capacity(element_dtype, Nullability::NonNullable, 0, 0); + listview + .into_array() + .append_to_builder(&mut list_builder, &mut ctx)?; + assert_arrays_eq!(list_builder.finish(), list, &mut ctx); + + Ok(()) + } + #[test] fn test_all_zeros() -> VortexResult<()> { let mut ctx = SESSION.create_execution_ctx(); diff --git a/vortex-array/src/builders/list.rs b/vortex-array/src/builders/list.rs index 5a97f790eeb..130f37534cf 100644 --- a/vortex-array/src/builders/list.rs +++ b/vortex-array/src/builders/list.rs @@ -111,6 +111,7 @@ impl ListBuilder { self.element_dtype() ); + self.elements_builder.reserve_exact(array.len()); array.append_to_builder(self.elements_builder.as_mut(), ctx)?; self.nulls.append_non_null(); self.offsets_builder.append_value( @@ -192,7 +193,11 @@ where let num_lists = new_offsets.len(); debug_assert_eq!(num_lists, new_sizes.len()); + let total_elements: usize = new_sizes.iter().map(|size| size.as_()).sum(); + builder.elements_builder.reserve_exact(total_elements); + let mut curr_offset = builder.elements_builder.len(); + builder.offsets_builder.reserve_exact(num_lists); let mut offsets_range = builder.offsets_builder.uninit_range(num_lists); // We need to append each list individually, converting from `ListViewArray` format to @@ -315,12 +320,14 @@ impl ArrayBuilder for ListBuilder { // in bulk and the offsets rebased onto this builder's elements. let elements_base = self.elements_builder.len(); if last > first { + self.elements_builder.reserve_exact(last - first); array .elements() .slice(first..last)? .append_to_builder(self.elements_builder.as_mut(), ctx)?; } + self.offsets_builder.reserve_exact(num_lists); let mut offsets_range = self.offsets_builder.uninit_range(num_lists); for i in 0..num_lists { let end: usize = offsets[i + 1].as_(); @@ -616,6 +623,36 @@ mod tests { Ok(()) } + #[test] + fn test_append_list_arrays_grow_builder() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let dtype: Arc = Arc::new(I32.into()); + + // Enough lists to exceed the offsets capacity of a zero-capacity builder, so appending + // must grow the builder rather than panic in `uninit_range`. + let lists: Vec>> = + (0..100).map(|i| (i % 10 != 0).then(|| vec![i])).collect(); + let source = ListArray::from_iter_opt_slow::(lists.clone(), Arc::clone(&dtype))?; + let expected = ListArray::from_iter_opt_slow::( + lists.iter().cloned().chain(lists.iter().cloned()), + Arc::clone(&dtype), + )?; + + // Appending twice checks growth from a non-empty builder and offset rebasing. + let mut builder = ListBuilder::::with_capacity(Arc::clone(&dtype), Nullable, 0, 0); + builder.append_list_array(source.as_view(), &mut ctx)?; + builder.append_list_array(source.as_view(), &mut ctx)?; + assert_arrays_eq!(builder.finish(), expected, &mut ctx); + + let source_listview = source.into_array().execute::(&mut ctx)?; + let mut builder = ListBuilder::::with_capacity(dtype, Nullable, 0, 0); + builder.append_listview_array(source_listview.as_view(), &mut ctx)?; + builder.append_listview_array(source_listview.as_view(), &mut ctx)?; + assert_arrays_eq!(builder.finish(), expected, &mut ctx); + + Ok(()) + } + #[test] fn test_extend_builder() { test_extend_builder_gen::(); diff --git a/vortex-array/src/builders/listview.rs b/vortex-array/src/builders/listview.rs index 59205a0f79d..4e6523bd7d3 100644 --- a/vortex-array/src/builders/listview.rs +++ b/vortex-array/src/builders/listview.rs @@ -142,6 +142,7 @@ impl ListViewBuilder { "appending this list would cause an offset overflow" ); + self.elements_builder.reserve_exact(num_elements); array.append_to_builder(self.elements_builder.as_mut(), ctx)?; self.nulls.append_non_null(); @@ -431,11 +432,14 @@ where ); if last > first { + builder.elements_builder.reserve_exact(last - first); elements .slice(first..last)? .append_to_builder(builder.elements_builder.as_mut(), ctx)?; } + builder.offsets_builder.reserve_exact(num_lists); + builder.sizes_builder.reserve_exact(num_lists); let mut offsets_range = builder.offsets_builder.uninit_range(num_lists); let mut sizes_range = builder.sizes_builder.uninit_range(num_lists); for i in 0..num_lists { @@ -504,6 +508,7 @@ mod tests { use vortex_buffer::buffer; use vortex_error::VortexExpect; + use vortex_error::VortexResult; use super::ListViewBuilder; use crate::IntoArray; @@ -799,6 +804,35 @@ mod tests { ); } + #[test] + fn test_append_list_array_grows_builder() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let dtype: Arc = Arc::new(I32.into()); + + // Enough lists to exceed the offsets/sizes capacity of a zero-capacity builder, so + // appending must grow the builder rather than panic in `uninit_range`. + let lists: Vec>> = + (0..100).map(|i| (i % 10 != 0).then(|| vec![i])).collect(); + let source = ListArray::from_iter_opt_slow::(lists.clone(), Arc::clone(&dtype))?; + + let mut builder = + ListViewBuilder::::with_capacity(Arc::clone(&dtype), Nullable, 0, 0); + builder.append_list_array(source.as_view(), &mut ctx)?; + // Append a second time to check growth from a non-empty builder and offset rebasing. + builder.append_list_array(source.as_view(), &mut ctx)?; + + let listview = builder.finish_into_listview(); + assert!(listview.is_zero_copy_to_list()); + + let expected = ListArray::from_iter_opt_slow::( + lists.iter().cloned().chain(lists.iter().cloned()), + dtype, + )?; + assert_arrays_eq!(listview, expected, &mut ctx); + + Ok(()) + } + #[test] fn test_extend_from_array_overlapping_listview() { let mut ctx = array_session().create_execution_ctx(); From 4abe3d04f097b1fbbfcae23795f347b7af5b0919 Mon Sep 17 00:00:00 2001 From: Adam Gutglick Date: Tue, 7 Jul 2026 17:29:51 +0100 Subject: [PATCH 029/104] fix: TableStrategy currently hides panic in spawned column writer tasks. (#8672) ## Rationale for this change `TableStrategy` currently hides panics that happen in the individual column streams. ## What changes are included in this PR? Instead of detaching the fanout work, we await it and make sure everything finishes successfully. ## What APIs are changed? Are there any user-facing changes? No API change, added test to verify panic propagation. Signed-off-by: Adam Gutglick --- vortex-layout/src/layouts/table.rs | 95 ++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 19 deletions(-) diff --git a/vortex-layout/src/layouts/table.rs b/vortex-layout/src/layouts/table.rs index 28b3af52c87..04c47a9f732 100644 --- a/vortex-layout/src/layouts/table.rs +++ b/vortex-layout/src/layouts/table.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use async_trait::async_trait; use futures::StreamExt; use futures::TryStreamExt; +use futures::future::try_join; use futures::future::try_join_all; use futures::pin_mut; use itertools::Itertools; @@ -266,30 +267,33 @@ impl LayoutStrategy for TableStrategy { let (column_streams_tx, column_streams_rx): (Vec<_>, Vec<_>) = (0..stream_count).map(|_| kanal::bounded_async(1)).unzip(); - // Spawn a task to fan out column chunks to their respective transposed streams + // Fan out column chunks to their respective transposed streams. Keep this future joined + // with the column writers so producer panics/errors cannot be hidden as channel EOF. let handle = session.handle(); - handle - .spawn(async move { - pin_mut!(columns_vec_stream); - while let Some(result) = columns_vec_stream.next().await { - match result { - Ok(columns) => { - for (tx, column) in column_streams_tx.iter().zip_eq(columns.into_iter()) - { - let _ = tx.send(Ok(column)).await; + let fanout_fut = async move { + pin_mut!(columns_vec_stream); + while let Some(result) = columns_vec_stream.next().await { + match result { + Ok(columns) => { + for (tx, column) in column_streams_tx.iter().zip_eq(columns.into_iter()) { + if tx.send(Ok(column)).await.is_err() { + vortex_bail!( + "table column writer finished before all chunks were sent" + ); } } - Err(e) => { - let e: Arc = Arc::new(e); - for tx in column_streams_tx.iter() { - let _ = tx.send(Err(VortexError::from(Arc::clone(&e)))).await; - } - break; + } + Err(e) => { + let e: Arc = Arc::new(e); + for tx in column_streams_tx.iter() { + let _ = tx.send(Err(VortexError::from(Arc::clone(&e)))).await; } + return Err(VortexError::from(e)); } } - }) - .detach(); + } + Ok(()) + }; // First child column is the validity, subsequence children are the individual struct fields let column_dtypes: Vec = if is_nullable { @@ -359,7 +363,7 @@ impl LayoutStrategy for TableStrategy { }) .collect(); - let column_layouts = try_join_all(layout_futures).await?; + let (_success, column_layouts) = try_join(fanout_fut, try_join_all(layout_futures)).await?; // TODO(os): transposed stream could count row counts as well, // This must hold though, all columns must have the same row count of the struct layout let row_count = column_layouts.first().map(|l| l.row_count()).unwrap_or(0); @@ -370,12 +374,28 @@ impl LayoutStrategy for TableStrategy { #[cfg(test)] mod tests { use std::sync::Arc; + use std::task::Poll; + use vortex_array::ArrayContext; + use vortex_array::ArrayRef; + use vortex_array::dtype::DType; use vortex_array::dtype::FieldPath; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::dtype::StructFields; use vortex_array::field_path; + use vortex_error::VortexResult; + use vortex_io::runtime::single::block_on; + use vortex_io::session::RuntimeSessionExt; + use crate::LayoutStrategy; use crate::layouts::flat::writer::FlatLayoutStrategy; use crate::layouts::table::TableStrategy; + use crate::segments::TestSegments; + use crate::sequence::SequenceId; + use crate::sequence::SequentialStreamAdapter; + use crate::sequence::SequentialStreamExt; + use crate::test::SESSION; #[test] #[should_panic( @@ -407,4 +427,41 @@ mod tests { ) .with_field_writer(FieldPath::root(), flat); } + + #[test] + #[should_panic(expected = "panic while transposing table stream")] + fn table_fanout_panic_propagates() { + let ctx = ArrayContext::empty(); + let segments = Arc::new(TestSegments::default()); + let (_, eof) = SequenceId::root().split(); + let dtype = DType::Struct( + StructFields::from_iter([( + "a", + DType::Primitive(PType::I32, Nullability::NonNullable), + )]), + Nullability::NonNullable, + ); + let stream = + futures::stream::poll_fn(|_| -> Poll>> { + panic!("panic while transposing table stream"); + }); + let strategy = TableStrategy::new( + Arc::new(FlatLayoutStrategy::default()), + Arc::new(FlatLayoutStrategy::default()), + ); + + block_on(|handle| async move { + let session = SESSION.clone().with_handle(handle); + strategy + .write_stream( + ctx, + segments, + SequentialStreamAdapter::new(dtype, stream).sendable(), + eof, + &session, + ) + .await + .unwrap(); + }); + } } From 1e91494449917d6d1aad6604c950cae13a13f465 Mon Sep 17 00:00:00 2001 From: myrrc Date: Tue, 7 Jul 2026 17:42:34 +0100 Subject: [PATCH 030/104] FFI: replace vx_string/vx_binary with vx_view (#8668) vx_string/vx_binary is not a zero-copy abstraction. For every utf/binary view you allocate memory. This PR replaces both with a vx_view non-owning view akin to C++'s string_view. Signed-off-by: Mikhail Kot --- docs/api/c/index.rst | 28 +---- docs/conf.py | 1 + vortex-ffi/cbindgen.toml | 15 +++ vortex-ffi/cinclude/vortex.h | 179 ++++++++++++--------------- vortex-ffi/examples/dtype.c | 14 +-- vortex-ffi/examples/scan.c | 7 +- vortex-ffi/examples/scan_to_arrow.c | 7 +- vortex-ffi/examples/write_sample.c | 19 ++- vortex-ffi/src/array.rs | 160 ++++++++++++------------ vortex-ffi/src/binary.rs | 104 ---------------- vortex-ffi/src/data_source.rs | 83 ++++++++++--- vortex-ffi/src/dtype.rs | 61 ++++------ vortex-ffi/src/error.rs | 14 +-- vortex-ffi/src/expression.rs | 33 +++-- vortex-ffi/src/file.rs | 11 +- vortex-ffi/src/lib.rs | 40 +++--- vortex-ffi/src/scalar.rs | 25 ++-- vortex-ffi/src/scan.rs | 26 ++-- vortex-ffi/src/sink.rs | 42 +++---- vortex-ffi/src/string.rs | 183 ++++++++++------------------ vortex-ffi/src/struct_array.rs | 12 +- vortex-ffi/src/struct_fields.rs | 37 +++--- vortex-ffi/test/array.cpp | 2 +- vortex-ffi/test/common.h | 10 +- vortex-ffi/test/main.cpp | 25 +--- vortex-ffi/test/scan.cpp | 106 +++++++++------- vortex-ffi/test/struct.cpp | 25 ++-- 27 files changed, 547 insertions(+), 722 deletions(-) delete mode 100644 vortex-ffi/src/binary.rs diff --git a/docs/api/c/index.rst b/docs/api/c/index.rst index 3d14a1a5d60..81d142d16c0 100644 --- a/docs/api/c/index.rst +++ b/docs/api/c/index.rst @@ -83,31 +83,5 @@ responsible for freeing them. .. c:autofunction:: vx_error_free :file: vortex.h -.. c:autofunction:: vx_error_get_message +.. c:autofunction:: vx_error_message :file: vortex.h - -Strings -------- - -Vortex strings wrap a Rust `Arc`, and therefore are reference-counted, UTF-8 encoded, and not null-terminated. - -.. c:autotype:: vx_string - :file: vortex.h - -.. c:autofunction:: vx_string_clone - :file: vortex.h - -.. c:autofunction:: vx_string_free - :file: vortex.h - -.. c:autofunction:: vx_string_new - :file: vortex.h - -.. c:autofunction:: vx_string_new_from_cstr - :file: vortex.h - -.. c:autofunction:: vx_string_len - :file: vortex.h - -.. c:autofunction:: vx_string_ptr - :file: vortex.h diff --git a/docs/conf.py b/docs/conf.py index adcd94f262f..edb6342aaee 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -171,6 +171,7 @@ ("c:identifier", "int16_t"), ("c:identifier", "uint8_t"), ("c:identifier", "int8_t"), + ("c:identifier", "vx_view"), ] hawkmoth_transform_default = "c_to_rust" diff --git a/vortex-ffi/cbindgen.toml b/vortex-ffi/cbindgen.toml index 7d28dacda63..a0ee3aaed7d 100644 --- a/vortex-ffi/cbindgen.toml +++ b/vortex-ffi/cbindgen.toml @@ -65,6 +65,21 @@ typedef struct ArrowArrayStream FFI_ArrowArrayStream; #endif """ +trailer = """ +#include + +/** + * Create a view over a null-terminated C string. + * View is valid as long as "str" is valid + */ +static inline vx_view vx_view_from_cstr(const char* str) { + vx_view s; + s.ptr = str; + s.len = strlen(str); + return s; +} +""" + [export.rename] "f16" = "uint16_t" diff --git a/vortex-ffi/cinclude/vortex.h b/vortex-ffi/cinclude/vortex.h index cbc263ebc38..cbd9f7f1406 100644 --- a/vortex-ffi/cinclude/vortex.h +++ b/vortex-ffi/cinclude/vortex.h @@ -490,11 +490,6 @@ typedef struct vx_array_iterator vx_array_iterator; */ typedef struct vx_array_sink vx_array_sink; -/** - * Strings for use within Vortex. - */ -typedef struct vx_binary vx_binary; - /** * A reference to one or more (possibly remote) paths. * Creating vx_data_source opens the first matched path to read the schema. @@ -562,11 +557,6 @@ typedef struct vx_scan vx_scan; */ typedef struct vx_session vx_session; -/** - * Strings for use within Vortex. - */ -typedef struct vx_string vx_string; - typedef struct vx_struct_column_builder vx_struct_column_builder; /** @@ -595,17 +585,34 @@ typedef struct { const vx_array *array; } vx_validity; +/** + * A non owning view over a byte range. + */ +typedef struct { + /** + * NULL "ptr" requires len == 0 + */ + const char *ptr; + /** + * Length in bytes. + */ + size_t len; +} vx_view; + /** * Options for creating a data source. */ typedef struct { /** - * Required: paths to files, tables, or layout trees. - * May be a glob pattern like "*.vortex". - * If you want to include multiple paths, concat them with a comma: - * "file1.vortex,../file2.vortex". + * Required: paths to files, tables, or layout trees. Each entry may be a + * glob pattern like "*.vortex". Must point to an array of size + * "paths_len". paths bytes are copied. + */ + const vx_view *paths; + /** + * Number of entries in `paths`. */ - const char *paths; + size_t paths_len; } vx_data_source_options; /** @@ -838,16 +845,24 @@ double vx_array_get_f64(const vx_array *array, size_t index); double vx_array_get_storage_f64(const vx_array *array, size_t index); /** - * Return the utf-8 string at `index` in the array. The pointer will be null if the value at `index` is null. - * The caller must free the returned pointer. + * Return UTF-8 string at "index" in a canonical Utf8 array. + * + * For invalid elements the returned value is unspecified, check validity via + * vx_array_get_validity. + * Returned view is valid as long as "array" is valid. + * Errors if index is out of bounds or array is not a canonical Utf8 array. */ -const vx_string *vx_array_get_utf8(const vx_array *array, uint32_t index); +vx_view vx_array_utf8_at(const vx_array *array, size_t index, vx_error **error_out); /** - * Return the binary at `index` in the array. The pointer will be null if the value at `index` is null. - * The caller must free the returned pointer. + * Return a binary string at "index" in a canonical Binary array. + * + * For invalid elements the returned value is unspecified, check validity via + * vx_array_get_validity. + * Returned view is valid as long as "array" is valid. + * Errors if index is out of bounds or array is not a canonical Binary array. */ -const vx_binary *vx_array_get_binary(const vx_array *array, uint32_t index); +vx_view vx_array_binary_at(const vx_array *array, size_t index, vx_error **error_out); /** * For a canonical Bool array, return bool at "index". @@ -908,31 +923,6 @@ void vx_array_iterator_free(const vx_array_iterator *ptr); */ const vx_array *vx_array_iterator_next(vx_array_iterator *iter, vx_error **error_out); -/** - * Clone a vx_binary. Returned handle must be release with vx_binary_free - */ -const vx_binary *vx_binary_clone(const vx_binary *ptr); - -/** - * Free a vx_binary - */ -void vx_binary_free(const vx_binary *ptr); - -/** - * Create a new Vortex UTF-8 string by copying from a pointer and length. - */ -const vx_binary *vx_binary_new(const char *ptr, size_t len); - -/** - * Return the length of the string in bytes. - */ -size_t vx_binary_len(const vx_binary *ptr); - -/** - * Return the pointer to the string data. - */ -const char *vx_binary_ptr(const vx_binary *ptr); - /** * Clone a vx_data_source. Returned handle must be release with vx_data_source_free */ @@ -1106,9 +1096,12 @@ bool vx_dtype_is_timestamp(const DType *dtype); uint8_t vx_dtype_time_unit(const DType *dtype); /** - * Returns the time zone, assuming the type is time. Caller is responsible for freeing the returned pointer. + * Return time zone assuming "dtype" is time. + * Returns {NULL, 0} when timestamp has no time zone. + * + * Returned view is valid as long as "dtype" is valid. */ -const vx_string *vx_dtype_time_zone(const DType *dtype); +vx_view vx_dtype_time_zone(const DType *dtype); /** * Convert a dtype to ArrowSchema. @@ -1135,9 +1128,10 @@ const vx_dtype *vx_dtype_from_arrow_schema(FFI_ArrowSchema *schema, vx_error **e void vx_error_free(const vx_error *ptr); /** - * Return an error message for this error + * Return error message for this error. + * Returned view is valid while "error" is valid. */ -const vx_string *vx_error_get_message(const vx_error *error); +vx_view vx_error_message(const vx_error *error); /** * Return category code for "error". @@ -1215,7 +1209,7 @@ vx_expression *vx_expression_literal(const vx_scalar *scalar, vx_error **err); * vx_expression_free(select); * vx_expression_free(root); */ -vx_expression *vx_expression_select(const char *const *names, size_t len, const vx_expression *child); +vx_expression *vx_expression_select(const vx_view *names, size_t len, const vx_expression *child); /** * Create an AND expression for multiple child expressions. @@ -1277,8 +1271,11 @@ vx_expression *vx_expression_is_null(const vx_expression *child); * * Example: if child is Struct { name=u8, age=u16 } and we do * vx_expression_get_item("name", child), output type will be DTYPE_U8 + * + * "item" is copied. Returns NULL if "child" is NULL or "item" is not valid + * UTF-8. */ -vx_expression *vx_expression_get_item(const char *item, const vx_expression *child); +vx_expression *vx_expression_get_item(vx_view item, const vx_expression *child); /** * Create an expression that checks if a value is contained in a list. @@ -1298,7 +1295,7 @@ const vx_file *vx_file_clone(const vx_file *ptr); void vx_file_free(const vx_file *ptr); void vx_file_write_array(const vx_session *session, - const char *path, + vx_view path, const vx_array *array, vx_error **error_out); @@ -1399,11 +1396,10 @@ vx_scalar *vx_scalar_new_f16_bits(uint16_t bits, bool is_nullable); /** * Create a UTF-8 scalar. * - * The byte range is copied into the scalar. A NULL data pointer is allowed only - * for an empty byte range. Invalid UTF-8 returns NULL and writes the error - * output. + * The string bytes are copied into the scalar. Invalid UTF-8 returns NULL and + * writes the error output. */ -vx_scalar *vx_scalar_new_utf8(const char *ptr, size_t len, bool is_nullable, vx_error **err); +vx_scalar *vx_scalar_new_utf8(vx_view value, bool is_nullable, vx_error **err); /** * Create a binary scalar. @@ -1636,11 +1632,10 @@ vx_session *vx_session_clone(const vx_session *session); /** * Opens a writable array stream, where sink is used to push values into the stream. * To close the stream close the sink with `vx_array_sink_close`. + * "path" is copied. */ -vx_array_sink *vx_array_sink_open_file(const vx_session *session, - const char *path, - const vx_dtype *dtype, - vx_error **error_out); +vx_array_sink * +vx_array_sink_open_file(const vx_session *session, vx_view path, const vx_dtype *dtype, vx_error **error_out); /** * Push an array into a file sink. @@ -1660,36 +1655,6 @@ void vx_array_sink_close(vx_array_sink *sink, vx_error **error_out); */ void vx_array_sink_abort(vx_array_sink *sink); -/** - * Clone a vx_string. Returned handle must be release with vx_string_free - */ -const vx_string *vx_string_clone(const vx_string *ptr); - -/** - * Free a vx_string - */ -void vx_string_free(const vx_string *ptr); - -/** - * Create a new Vortex UTF-8 string by copying from a pointer and length. - */ -const vx_string *vx_string_new(const char *ptr, size_t len); - -/** - * Create a new Vortex UTF-8 string by copying from a null-terminated C-style string. - */ -const vx_string *vx_string_new_from_cstr(const char *ptr); - -/** - * Return the length of the string in bytes. - */ -size_t vx_string_len(const vx_string *ptr); - -/** - * Return the pointer to the string data. - */ -const char *vx_string_ptr(const vx_string *ptr); - /** * Free an owned [`vx_struct_column_builder`] object. */ @@ -1712,7 +1677,7 @@ vx_struct_column_builder *vx_struct_column_builder_new(const vx_validity *validi * deallocate it using vx_struct_column_builder_free. */ void vx_struct_column_builder_add_field(vx_struct_column_builder *builder, - const char *name, + vx_view name, const vx_array *field, vx_error **error); @@ -1752,10 +1717,12 @@ void vx_struct_fields_free(const vx_struct_fields *ptr); uint64_t vx_struct_fields_nfields(const vx_struct_fields *dtype); /** - * Return an owned name of the field at a given index. - * If index is out of bounds, returns NULL. + * Return field name at a given index. + * If index is out of bounds, returns {NULL, 0}. + * + * Returned view is valid as long as "dtype" is valid. */ -const vx_string *vx_struct_fields_field_name(const vx_struct_fields *dtype, size_t idx); +vx_view vx_struct_fields_field_name(const vx_struct_fields *dtype, size_t idx); /** * Return an owned dtype of the field at a given index. @@ -1776,12 +1743,13 @@ vx_struct_fields_builder *vx_struct_fields_builder_new(void); /** * Add a field to the struct dtype builder. * - * Takes ownership of both the `name` and `dtype` pointers. - * Must either free or finalize the builder. + * "name" is copied. Takes ownership of "dtype". + * Caller must free or finalize the builder. */ void vx_struct_fields_builder_add_field(vx_struct_fields_builder *builder, - const vx_string *name, - const vx_dtype *dtype); + vx_view name, + const vx_dtype *dtype, + vx_error **error_out); /** * Finalize the struct dtype builder, returning a new `vx_struct_fields`. @@ -1793,3 +1761,16 @@ vx_struct_fields *vx_struct_fields_builder_finalize(vx_struct_fields_builder *bu #ifdef __cplusplus } // extern "C" #endif // __cplusplus + +#include + +/** + * Create a view over a null-terminated C string. + * View is valid as long as "str" is valid + */ +static inline vx_view vx_view_from_cstr(const char *str) { + vx_view s; + s.ptr = str; + s.len = strlen(str); + return s; +} diff --git a/vortex-ffi/examples/dtype.c b/vortex-ffi/examples/dtype.c index 1a319a80eb5..eb60cb72c4d 100644 --- a/vortex-ffi/examples/dtype.c +++ b/vortex-ffi/examples/dtype.c @@ -58,10 +58,9 @@ void print_struct_dtype(const vx_dtype *dtype) { printf("struct(\n"); for (uint64_t i = 0; i < vx_struct_fields_nfields(fields); ++i) { const vx_dtype *field_dtype = vx_struct_fields_field_dtype(fields, i); - const vx_string *field_name = vx_struct_fields_field_name(fields, i); - printf(" %.*s = ", (int)vx_string_len(field_name), vx_string_ptr(field_name)); + const vx_view field_name = vx_struct_fields_field_name(fields, i); + printf(" %.*s = ", (int)field_name.len, field_name.ptr); print_dtype(field_dtype); - vx_string_free(field_name); vx_dtype_free(field_dtype); } printf(")"); @@ -124,8 +123,8 @@ void print_dtype(const vx_dtype *dtype) { } void print_error(const char *what, const vx_error *error) { - const vx_string *str = vx_error_get_message(error); - fprintf(stderr, "%s: %.*s\n", what, (int)vx_string_len(str), vx_string_ptr(str)); + const vx_view str = vx_error_message(error); + fprintf(stderr, "%s: %.*s\n", what, (int)str.len, str.ptr); } int main(int argc, char **argv) { @@ -141,7 +140,8 @@ int main(int argc, char **argv) { return 1; } - vx_data_source_options ds_options = {.paths = argv[1]}; + vx_view path = vx_view_from_cstr(argv[1]); + vx_data_source_options ds_options = {.paths = &path, .paths_len = 1}; const vx_data_source *data_source = vx_data_source_new(session, &ds_options, &error); if (data_source == NULL) { print_error("Failed to create data source", error); @@ -151,7 +151,7 @@ int main(int argc, char **argv) { } printf("dtype: "); - const vx_dtype* dtype = vx_data_source_dtype(data_source); + const vx_dtype *dtype = vx_data_source_dtype(data_source); print_dtype(dtype); vx_dtype_free(dtype); diff --git a/vortex-ffi/examples/scan.c b/vortex-ffi/examples/scan.c index 43023642474..67df597c69f 100644 --- a/vortex-ffi/examples/scan.c +++ b/vortex-ffi/examples/scan.c @@ -25,8 +25,8 @@ void print_estimate(const char *what, const vx_estimate *estimate) { } void print_error(const char *what, const vx_error *error) { - const vx_string *str = vx_error_get_message(error); - fprintf(stderr, "%s: %.*s\n", what, (int)vx_string_len(str), vx_string_ptr(str)); + const vx_view str = vx_error_message(error); + fprintf(stderr, "%s: %.*s\n", what, (int)str.len, str.ptr); } struct scan_thread_info { @@ -162,7 +162,8 @@ int main(int argc, char *argv[]) { // A datasource is a reference to some files. // We can request multiple scans from a data source. - vx_data_source_options ds_options = {.paths = paths}; + vx_view path = vx_view_from_cstr(paths); + vx_data_source_options ds_options = {.paths = &path, .paths_len = 1}; vx_error *error = NULL; const vx_data_source *data_source = vx_data_source_new(session, &ds_options, &error); if (data_source == NULL) { diff --git a/vortex-ffi/examples/scan_to_arrow.c b/vortex-ffi/examples/scan_to_arrow.c index 988974aab13..a8679158de0 100644 --- a/vortex-ffi/examples/scan_to_arrow.c +++ b/vortex-ffi/examples/scan_to_arrow.c @@ -16,8 +16,8 @@ const char *usage = "Scan vortex files to Arrow\n" "Usage: scan_to_arrow \n"; void print_error(const char *what, const vx_error *error) { - const vx_string *str = vx_error_get_message(error); - fprintf(stderr, "%s: %.*s\n", what, (int)vx_string_len(str), vx_string_ptr(str)); + const vx_view str = vx_error_message(error); + fprintf(stderr, "%s: %.*s\n", what, (int)str.len, str.ptr); } void execute_scan(vx_session *session, vx_scan *scan) { @@ -97,7 +97,8 @@ int main(int argc, char *argv[]) { return -1; } - vx_data_source_options ds_options = {.paths = paths}; + vx_view path = vx_view_from_cstr(paths); + vx_data_source_options ds_options = {.paths = &path, .paths_len = 1}; vx_error *error = NULL; const vx_data_source *data_source = vx_data_source_new(session, &ds_options, &error); if (data_source == NULL) { diff --git a/vortex-ffi/examples/write_sample.c b/vortex-ffi/examples/write_sample.c index e807b21b5a9..456d3bba166 100644 --- a/vortex-ffi/examples/write_sample.c +++ b/vortex-ffi/examples/write_sample.c @@ -3,7 +3,6 @@ #include "vortex.h" #include #include -#include #define SAMPLE_ROWS 200 @@ -15,22 +14,22 @@ const vx_dtype *sample_dtype(void) { vx_struct_fields_builder *builder = vx_struct_fields_builder_new(); const char *age = "age"; - const vx_string *age_name = vx_string_new(age, strlen(age)); + const vx_dtype *age_type = vx_dtype_new_primitive(PTYPE_U8, false); - vx_struct_fields_builder_add_field(builder, age_name, age_type); + vx_struct_fields_builder_add_field(builder, vx_view_from_cstr(age), age_type, NULL); const char *height = "height"; - const vx_string *height_name = vx_string_new(height, strlen(height)); + const vx_dtype *height_type = vx_dtype_new_primitive(PTYPE_U16, true); - vx_struct_fields_builder_add_field(builder, height_name, height_type); + vx_struct_fields_builder_add_field(builder, vx_view_from_cstr(height), height_type, NULL); vx_struct_fields *fields = vx_struct_fields_builder_finalize(builder); return vx_dtype_new_struct(fields, false); } void print_error(const char *what, const vx_error *error) { - const vx_string *str = vx_error_get_message(error); - fprintf(stderr, "%s: %.*s\n", what, (int)vx_string_len(str), vx_string_ptr(str)); + const vx_view str = vx_error_message(error); + fprintf(stderr, "%s: %.*s\n", what, (int)str.len, str.ptr); } const vx_array *sample_array(void) { @@ -53,7 +52,7 @@ const vx_array *sample_array(void) { return NULL; } - vx_struct_column_builder_add_field(builder, "age", age_array, &error); + vx_struct_column_builder_add_field(builder, vx_view_from_cstr("age"), age_array, &error); vx_array_free(age_array); if (error != NULL) { print_error("Error adding age array field to root array", error); @@ -72,7 +71,7 @@ const vx_array *sample_array(void) { return NULL; } - vx_struct_column_builder_add_field(builder, "height", height_array, &error); + vx_struct_column_builder_add_field(builder, vx_view_from_cstr("height"), height_array, &error); vx_array_free(height_array); if (error != NULL) { print_error("Error adding height array field to root array", error); @@ -107,7 +106,7 @@ int main(int argc, char *argv[]) { const vx_dtype *dtype = sample_dtype(); vx_error *error = NULL; - vx_array_sink *sink = vx_array_sink_open_file(session, output, dtype, &error); + vx_array_sink *sink = vx_array_sink_open_file(session, vx_view_from_cstr(output), dtype, &error); vx_dtype_free(dtype); if (error != NULL) { diff --git a/vortex-ffi/src/array.rs b/vortex-ffi/src/array.rs index e097fd089c1..61a8ab00a08 100644 --- a/vortex-ffi/src/array.rs +++ b/vortex-ffi/src/array.rs @@ -21,6 +21,7 @@ use vortex::array::arrays::NullArray; use vortex::array::arrays::Primitive; use vortex::array::arrays::PrimitiveArray; use vortex::array::arrays::StructArray; +use vortex::array::arrays::VarBinView; use vortex::array::arrays::bool::BoolArrayExt; use vortex::array::arrays::struct_::StructArrayExt; use vortex::array::arrow::FromArrowArray; @@ -31,12 +32,12 @@ use vortex::dtype::DType; use vortex::dtype::half::f16; use vortex::error::VortexExpect; use vortex::error::VortexResult; +use vortex::error::vortex_bail; use vortex::error::vortex_ensure; use vortex::error::vortex_err; use vortex::error::vortex_panic; use crate::arc_wrapper; -use crate::binary::vx_binary; use crate::dtype::vx_dtype; use crate::dtype::vx_dtype_variant; use crate::error::try_or; @@ -47,7 +48,7 @@ use crate::expression::vx_expression; use crate::ptype::vx_ptype; use crate::session::vx_session; use crate::session::vx_session_ref; -use crate::string::vx_string; +use crate::string::vx_view; arc_wrapper!( /// Arrays are reference-counted handles to owned memory buffers that hold @@ -440,46 +441,62 @@ ffiarray_get_ptype!(f16); ffiarray_get_ptype!(f32); ffiarray_get_ptype!(f64); -/// Return the utf-8 string at `index` in the array. The pointer will be null if the value at `index` is null. -/// The caller must free the returned pointer. +/// SAFETY: "array" must be null or a valid "vx_array" +unsafe fn varbinview_at( + array: *const vx_array, + index: usize, + want_utf8: bool, + error_out: *mut *mut vx_error, +) -> vx_view { + try_or(error_out, vx_view::null(), || { + let array = unsafe { vx_array_ref(array) }?; + vortex_ensure!(index < array.len(), "index {index} out of bounds"); + let dtype_matches = if want_utf8 { + matches!(array.dtype(), DType::Utf8(_)) + } else { + matches!(array.dtype(), DType::Binary(_)) + }; + vortex_ensure!( + dtype_matches, + "expected a {} array, got {}", + if want_utf8 { "Utf8" } else { "Binary" }, + array.dtype() + ); + let Some(views) = array.as_opt::() else { + vortex_bail!("expected a canonical array, got {}", array.encoding_id()); + }; + Ok(vx_view::from_bytes(views.bytes_at(index).as_slice())) + }) +} + +/// Return UTF-8 string at "index" in a canonical Utf8 array. +/// +/// For invalid elements the returned value is unspecified, check validity via +/// vx_array_get_validity. +/// Returned view is valid as long as "array" is valid. +/// Errors if index is out of bounds or array is not a canonical Utf8 array. #[unsafe(no_mangle)] -#[allow(clippy::disallowed_methods)] -pub unsafe extern "C-unwind" fn vx_array_get_utf8( +pub unsafe extern "C-unwind" fn vx_array_utf8_at( array: *const vx_array, - index: u32, -) -> *const vx_string { - let array = vx_array::as_ref(array); - // TODO(joe): propagate this error up instead of expecting - let value = array - .execute_scalar(index as usize, &mut legacy_session().create_execution_ctx()) - .vortex_expect("scalar_at failed"); - let utf8_scalar = value.as_utf8(); - if let Some(buffer) = utf8_scalar.value() { - vx_string::new(Arc::from(buffer.as_str())) - } else { - ptr::null() - } + index: usize, + error_out: *mut *mut vx_error, +) -> vx_view { + unsafe { varbinview_at(array, index, true, error_out) } } -/// Return the binary at `index` in the array. The pointer will be null if the value at `index` is null. -/// The caller must free the returned pointer. +/// Return a binary string at "index" in a canonical Binary array. +/// +/// For invalid elements the returned value is unspecified, check validity via +/// vx_array_get_validity. +/// Returned view is valid as long as "array" is valid. +/// Errors if index is out of bounds or array is not a canonical Binary array. #[unsafe(no_mangle)] -#[allow(clippy::disallowed_methods)] -pub unsafe extern "C-unwind" fn vx_array_get_binary( +pub unsafe extern "C-unwind" fn vx_array_binary_at( array: *const vx_array, - index: u32, -) -> *const vx_binary { - let array = vx_array::as_ref(array); - // TODO(joe): propagate this error up instead of expecting - let value = array - .execute_scalar(index as usize, &mut legacy_session().create_execution_ctx()) - .vortex_expect("scalar_at failed"); - let binary_scalar = value.as_binary(); - if let Some(bytes) = binary_scalar.value() { - vx_binary::new(Arc::from(bytes.as_bytes())) - } else { - ptr::null() - } + index: usize, + error_out: *mut *mut vx_error, +) -> vx_view { + unsafe { varbinview_at(array, index, false, error_out) } } /// For a canonical Bool array, return bool at "index". @@ -616,7 +633,6 @@ mod tests { use vortex::expr::root; use crate::array::*; - use crate::binary::vx_binary_free; use crate::dtype::vx_dtype_free; use crate::dtype::vx_dtype_get_variant; use crate::dtype::vx_dtype_variant; @@ -624,7 +640,6 @@ mod tests { use crate::expression::vx_expression_free; use crate::session::vx_session_free; use crate::session::vx_session_new; - use crate::string::vx_string_free; use crate::tests::assert_error; use crate::tests::assert_no_error; @@ -844,56 +859,43 @@ mod tests { #[test] // TODO(joe): enable once this is fixed https://github.com/Amanieu/parking_lot/issues/477 #[cfg_attr(miri, ignore)] - fn test_get_utf8() { + fn test_utf8_binary_at() { unsafe { - let utf8_array = VarBinViewArray::from_iter_str(["hello", "world", "test"]); + let long = "a string that is longer than twelve bytes"; + let utf8_array = + VarBinViewArray::from_iter_nullable_str([Some("hello"), None, Some(long)]); let ffi_array = vx_array::new(Arc::new(utf8_array.into_array())); - assert!(vx_array_has_dtype(ffi_array, vx_dtype_variant::DTYPE_UTF8)); - - let vx_str1 = vx_array_get_utf8(ffi_array, 0); - assert_eq!(vx_string::as_str(vx_str1), "hello"); - vx_string_free(vx_str1); - - let vx_str2 = vx_array_get_utf8(ffi_array, 1); - assert_eq!(vx_string::as_str(vx_str2), "world"); - vx_string_free(vx_str2); - let vx_str3 = vx_array_get_utf8(ffi_array, 2); - assert_eq!(vx_string::as_str(vx_str3), "test"); - vx_string_free(vx_str3); + let mut error = ptr::null_mut(); + let inlined = vx_array_utf8_at(ffi_array, 0, &raw mut error); + assert!(error.is_null()); + assert_eq!(inlined.as_str().unwrap(), "hello"); - vx_array_free(ffi_array); - } - } + vx_array_utf8_at(ffi_array, 1, &raw mut error); + assert!(error.is_null()); - #[test] - // TODO(joe): enable once this is fixed https://github.com/Amanieu/parking_lot/issues/477 - #[cfg_attr(miri, ignore)] - fn test_get_binary() { - unsafe { - let binary_array = VarBinViewArray::from_iter_bin(vec![ - vec![0x01, 0x02, 0x03], - vec![0xFF, 0xEE], - vec![0xAA, 0xBB, 0xCC, 0xDD], - ]); - let ffi_array = vx_array::new(Arc::new(binary_array.into_array())); - assert!(vx_array_has_dtype( - ffi_array, - vx_dtype_variant::DTYPE_BINARY - )); + let buffered = vx_array_utf8_at(ffi_array, 2, &raw mut error); + assert!(error.is_null()); + assert_eq!(buffered.as_str().unwrap(), long); - let vx_bin1 = vx_array_get_binary(ffi_array, 0); - assert_eq!(vx_binary::as_slice(vx_bin1), &[0x01, 0x02, 0x03]); - vx_binary_free(vx_bin1); + vx_array_utf8_at(ffi_array, 3, &raw mut error); + assert_error(error); - let vx_bin2 = vx_array_get_binary(ffi_array, 1); - assert_eq!(vx_binary::as_slice(vx_bin2), &[0xFF, 0xEE]); - vx_binary_free(vx_bin2); + vx_array_free(ffi_array); - let vx_bin3 = vx_array_get_binary(ffi_array, 2); - assert_eq!(vx_binary::as_slice(vx_bin3), &[0xAA, 0xBB, 0xCC, 0xDD]); - vx_binary_free(vx_bin3); + let numbers = + PrimitiveArray::new(buffer![1i32, 2i32], Validity::NonNullable).into_array(); + let ffi_array = vx_array::new(Arc::new(numbers)); + let value = vx_array_utf8_at(ffi_array, 0, &raw mut error); + assert!(value.ptr.is_null()); + assert_error(error); + vx_array_free(ffi_array); + let binary_array = VarBinViewArray::from_iter_bin(vec![vec![0x01, 0x02, 0x03]]); + let ffi_array = vx_array::new(Arc::new(binary_array.into_array())); + let bin = vx_array_binary_at(ffi_array, 0, &raw mut error); + assert!(error.is_null()); + assert_eq!(bin.as_bytes().unwrap(), &[0x01, 0x02, 0x03]); vx_array_free(ffi_array); } } diff --git a/vortex-ffi/src/binary.rs b/vortex-ffi/src/binary.rs deleted file mode 100644 index a13379787a7..00000000000 --- a/vortex-ffi/src/binary.rs +++ /dev/null @@ -1,104 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -use std::ffi::c_char; -use std::slice; - -use crate::arc_dyn_wrapper; - -arc_dyn_wrapper!( - /// Strings for use within Vortex. - [u8], - vx_binary -); - -impl vx_binary { - #[allow(dead_code)] - pub(crate) fn as_slice(ptr: *const vx_binary) -> &'static [u8] { - unsafe { slice::from_raw_parts(vx_binary_ptr(ptr).cast(), vx_binary_len(ptr)) } - } -} - -/// Create a new Vortex UTF-8 string by copying from a pointer and length. -#[unsafe(no_mangle)] -pub unsafe extern "C-unwind" fn vx_binary_new(ptr: *const c_char, len: usize) -> *const vx_binary { - let slice = unsafe { slice::from_raw_parts(ptr.cast(), len) }; - vx_binary::new(slice.into()) -} - -/// Return the length of the string in bytes. -#[unsafe(no_mangle)] -pub unsafe extern "C-unwind" fn vx_binary_len(ptr: *const vx_binary) -> usize { - vx_binary::as_ref(ptr).len() -} - -/// Return the pointer to the string data. -#[unsafe(no_mangle)] -pub unsafe extern "C-unwind" fn vx_binary_ptr(ptr: *const vx_binary) -> *const c_char { - vx_binary::as_ref(ptr).as_ptr().cast() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_string_new() { - unsafe { - let test_str = "hello world"; - let ptr = test_str.as_ptr().cast(); - let len = test_str.len(); - - let vx_str = vx_binary_new(ptr, len); - assert_eq!(vx_binary_len(vx_str), 11); - assert_eq!(vx_binary::as_slice(vx_str), "hello world".as_bytes()); - - vx_binary_free(vx_str); - } - } - - #[test] - fn test_string_ptr() { - unsafe { - let test_str = "testing".as_bytes(); - let vx_str = vx_binary::new(test_str.into()); - - let ptr = vx_binary_ptr(vx_str); - let len = vx_binary_len(vx_str); - - let slice = slice::from_raw_parts(ptr.cast::(), len); - assert_eq!(slice, "testing".as_bytes()); - - vx_binary_free(vx_str); - } - } - - #[test] - fn test_empty_string() { - unsafe { - let empty = ""; - let ptr = empty.as_ptr().cast(); - let vx_str = vx_binary_new(ptr, 0); - - assert_eq!(vx_binary_len(vx_str), 0); - assert_eq!(vx_binary::as_slice(vx_str), "".as_bytes()); - - vx_binary_free(vx_str); - } - } - - #[test] - fn test_unicode_string() { - unsafe { - let unicode_str = "Hello 世界 🌍"; - let ptr = unicode_str.as_ptr().cast(); - let len = unicode_str.len(); - - let vx_str = vx_binary_new(ptr, len); - assert_eq!(vx_binary_len(vx_str), unicode_str.len()); - assert_eq!(vx_binary::as_slice(vx_str), unicode_str.as_bytes()); - - vx_binary_free(vx_str); - } - } -} diff --git a/vortex-ffi/src/data_source.rs b/vortex-ffi/src/data_source.rs index 2d596be0675..96395e90969 100644 --- a/vortex-ffi/src/data_source.rs +++ b/vortex-ffi/src/data_source.rs @@ -3,8 +3,6 @@ #![allow(non_camel_case_types)] #![deny(missing_docs)] -use std::ffi::CStr; -use std::ffi::c_char; use std::ffi::c_void; use std::ptr; use std::slice; @@ -31,6 +29,7 @@ use crate::error::vx_error; use crate::scan::vx_estimate; use crate::scan::vx_estimate_type; use crate::session::vx_session; +use crate::string::vx_view; crate::arc_dyn_wrapper!( /// A reference to one or more (possibly remote) paths. @@ -42,13 +41,23 @@ crate::arc_dyn_wrapper!( /// Options for creating a data source. #[repr(C)] -#[cfg_attr(test, derive(Default))] pub struct vx_data_source_options { - /// Required: paths to files, tables, or layout trees. - /// May be a glob pattern like "*.vortex". - /// If you want to include multiple paths, concat them with a comma: - /// "file1.vortex,../file2.vortex". - pub paths: *const c_char, + /// Required: paths to files, tables, or layout trees. Each entry may be a + /// glob pattern like "*.vortex". Must point to an array of size + /// "paths_len". paths bytes are copied. + pub paths: *const vx_view, + /// Number of entries in `paths`. + pub paths_len: usize, +} + +#[cfg(test)] +impl Default for vx_data_source_options { + fn default() -> Self { + vx_data_source_options { + paths: ptr::null(), + paths_len: 0, + } + } } #[cfg(vortex_asan)] @@ -68,13 +77,12 @@ unsafe fn data_source_new( let opts = unsafe { &*opts }; vortex_ensure!(!opts.paths.is_null()); + vortex_ensure!(opts.paths_len > 0, "empty paths"); - let glob = unsafe { CStr::from_ptr(opts.paths) } - .to_string_lossy() - .into_owned(); + let paths = unsafe { slice::from_raw_parts(opts.paths, opts.paths_len) }; let mut data_source = MultiFileDataSource::new(session.clone()); - for glob in glob.split(',') { - data_source = data_source.with_glob(glob, None); + for path in paths { + data_source = data_source.with_glob(unsafe { path.as_str() }?, None); } let data_source = RUNTIME.block_on(async { @@ -177,7 +185,6 @@ pub unsafe extern "C-unwind" fn vx_data_source_get_row_count( #[cfg(not(windows))] #[cfg(test)] mod tests { - use std::ffi::CString; use std::ffi::c_void; use std::fs::read; use std::ptr; @@ -194,6 +201,7 @@ mod tests { use crate::scan::vx_estimate_type; use crate::session::vx_session_free; use crate::session::vx_session_new; + use crate::string::vx_view; use crate::tests::SAMPLE_ROWS; use crate::tests::assert_error; use crate::tests::assert_no_error; @@ -219,12 +227,15 @@ mod tests { assert_error(error); assert!(ds.is_null()); - opts.paths = c"test.vortex".as_ptr(); + let missing = vx_view::from_str("test.vortex"); + opts.paths = &raw const missing; + opts.paths_len = 1; let ds = vx_data_source_new(session, &raw const opts, &raw mut error); assert_error(error); assert!(ds.is_null()); - opts.paths = c"definitely-missing-dir/*.vortex".as_ptr(); + let missing_glob = vx_view::from_str("definitely-missing-dir/*.vortex"); + opts.paths = &raw const missing_glob; let ds = vx_data_source_new(session, &raw const opts, &raw mut error); assert_error(error); assert!(ds.is_null()); @@ -240,9 +251,10 @@ mod tests { let session = vx_session_new(); let (sample, struct_array) = write_sample(session); - let path = CString::new(sample.path().to_str().unwrap()).unwrap(); + let path = vx_view::from_str(sample.path().to_str().unwrap()); let opts = vx_data_source_options { - paths: path.as_ptr(), + paths: &raw const path, + paths_len: 1, }; let mut error = ptr::null_mut(); @@ -265,6 +277,41 @@ mod tests { } } + #[test] + #[cfg_attr(miri, ignore)] + fn test_many_paths() { + let dir = tempfile::tempdir().unwrap(); + + unsafe { + let session = vx_session_new(); + let (sample, _) = write_sample(session); + + let comma_path = dir.path().join("with,comma.vortex"); + std::fs::copy(sample.path(), &comma_path).unwrap(); + + let paths = [ + vx_view::from_str(sample.path().to_str().unwrap()), + vx_view::from_str(comma_path.to_str().unwrap()), + ]; + let opts = vx_data_source_options { + paths: paths.as_ptr(), + paths_len: paths.len(), + }; + + let mut error = ptr::null_mut(); + let ds = vx_data_source_new(session, &raw const opts, &raw mut error); + assert_no_error(error); + assert!(!ds.is_null()); + + let mut row_count = vx_estimate::default(); + vx_data_source_get_row_count(ds, &raw mut row_count); + assert_eq!(row_count.estimate, 2 * SAMPLE_ROWS as u64); + + vx_data_source_free(ds); + vx_session_free(session); + } + } + #[test] #[cfg_attr(miri, ignore)] fn test_buffer() { diff --git a/vortex-ffi/src/dtype.rs b/vortex-ffi/src/dtype.rs index 8f96d9e6e02..fa1f32c46ac 100644 --- a/vortex-ffi/src/dtype.rs +++ b/vortex-ffi/src/dtype.rs @@ -23,7 +23,7 @@ use crate::error::try_or; use crate::error::try_or_default; use crate::error::vx_error; use crate::ptype::vx_ptype; -use crate::string::vx_string; +use crate::string::vx_view; use crate::struct_fields::vx_struct_fields; arc_wrapper!( @@ -306,9 +306,12 @@ pub unsafe extern "C-unwind" fn vx_dtype_time_unit(dtype: *const DType) -> u8 { opts.time_unit().into() } -/// Returns the time zone, assuming the type is time. Caller is responsible for freeing the returned pointer. +/// Return time zone assuming "dtype" is time. +/// Returns {NULL, 0} when timestamp has no time zone. +/// +/// Returned view is valid as long as "dtype" is valid. #[unsafe(no_mangle)] -pub unsafe extern "C-unwind" fn vx_dtype_time_zone(dtype: *const DType) -> *const vx_string { +pub unsafe extern "C-unwind" fn vx_dtype_time_zone(dtype: *const DType) -> vx_view { // TODO(joe): propagate this error up instead of expecting let dtype = unsafe { dtype.as_ref() }.vortex_expect("dtype null"); @@ -322,8 +325,8 @@ pub unsafe extern "C-unwind" fn vx_dtype_time_zone(dtype: *const DType) -> *cons }; match opts.tz.as_ref() { - Some(zone) => vx_string::new(Arc::clone(zone)), - None => ptr::null(), + Some(zone) => vx_view::from_str(zone), + None => vx_view::null(), } } @@ -370,7 +373,6 @@ pub unsafe extern "C-unwind" fn vx_dtype_from_arrow_schema( #[cfg(test)] #[expect(clippy::cast_possible_truncation)] mod tests { - use std::slice; use vortex::array::ArrayRef; use vortex::array::IntoArray; @@ -391,10 +393,7 @@ mod tests { use crate::dtype::vx_dtype_new_utf8; use crate::dtype::vx_dtype_variant; use crate::ptype::vx_ptype; - use crate::string::vx_string; - use crate::string::vx_string_free; - use crate::string::vx_string_len; - use crate::string::vx_string_ptr; + use crate::string::vx_view; use crate::struct_fields::vx_struct_fields_builder_add_field; use crate::struct_fields::vx_struct_fields_builder_finalize; use crate::struct_fields::vx_struct_fields_builder_new; @@ -426,26 +425,28 @@ mod tests { fn test_struct() { unsafe { let builder = vx_struct_fields_builder_new(); + let mut error = ptr::null_mut(); vx_struct_fields_builder_add_field( builder, - vx_string::new("name".into()), + vx_view::from_str("name"), vx_dtype_new_utf8(false), + &raw mut error, ); + assert!(error.is_null()); vx_struct_fields_builder_add_field( builder, - vx_string::new("age".into()), + vx_view::from_str("age"), vx_dtype_new_primitive(vx_ptype::PTYPE_U8, true), + &raw mut error, ); + assert!(error.is_null()); let person = vx_struct_fields_builder_finalize(builder); assert_eq!(vx_struct_fields_nfields(person), 2); let name = vx_struct_fields_field_name(person, 0); - assert_eq!(vx_string::as_str(name), "name"); + assert_eq!(name.as_str().unwrap(), "name"); let age = vx_struct_fields_field_name(person, 1); - assert_eq!(vx_string::as_str(age), "age"); - - vx_string_free(name); - vx_string_free(age); + assert_eq!(age.as_str().unwrap(), "age"); let dtype0 = vx_struct_fields_field_dtype(person, 0); let dtype1 = vx_struct_fields_field_dtype(person, 1); @@ -718,17 +719,11 @@ mod tests { let struct_fields_ptr = unsafe { vx_dtype_struct_dtype(dtype_ptr) }; // Test field name access - let field_name_ptr = unsafe { vx_struct_fields_field_name(struct_fields_ptr, 0) }; - assert!(!field_name_ptr.is_null()); - - let name_len = unsafe { vx_string_len(field_name_ptr) }; - let name_ptr = unsafe { vx_string_ptr(field_name_ptr) }; - let name_slice = unsafe { slice::from_raw_parts(name_ptr.cast::(), name_len) }; - let name_str = str::from_utf8(name_slice).unwrap(); - assert_eq!(name_str, "nums"); + let field_name = unsafe { vx_struct_fields_field_name(struct_fields_ptr, 0) }; + assert!(!field_name.ptr.is_null()); + assert_eq!(unsafe { field_name.as_str() }.unwrap(), "nums"); unsafe { - vx_string_free(field_name_ptr); vx_struct_fields_free(struct_fields_ptr); vx_dtype_free(dtype_ptr); vx_array_free(vx_arr); @@ -749,19 +744,11 @@ mod tests { // Test both field names for i in 0..n_fields { - let field_name_ptr = - unsafe { vx_struct_fields_field_name(struct_fields_ptr, i as usize) }; - assert!(!field_name_ptr.is_null()); - - let name_len = unsafe { vx_string_len(field_name_ptr) }; - let name_ptr = unsafe { vx_string_ptr(field_name_ptr) }; - let name_slice = unsafe { slice::from_raw_parts(name_ptr.cast::(), name_len) }; - let name_str = str::from_utf8(name_slice).unwrap(); + let field_name = unsafe { vx_struct_fields_field_name(struct_fields_ptr, i as usize) }; + assert!(!field_name.ptr.is_null()); let expected_name = if i == 0 { "nums" } else { "floats" }; - assert_eq!(name_str, expected_name); - - unsafe { vx_string_free(field_name_ptr) }; + assert_eq!(unsafe { field_name.as_str() }.unwrap(), expected_name); } unsafe { diff --git a/vortex-ffi/src/error.rs b/vortex-ffi/src/error.rs index 0775d18b18c..179c0c85f79 100644 --- a/vortex-ffi/src/error.rs +++ b/vortex-ffi/src/error.rs @@ -11,7 +11,7 @@ use vortex::error::VortexError; use vortex::error::VortexResult; use crate::box_wrapper; -use crate::string::vx_string; +use crate::string::vx_view; /// Error category for vx_error. #[repr(C)] @@ -160,10 +160,11 @@ pub fn try_or( } } -/// Return an error message for this error +/// Return error message for this error. +/// Returned view is valid while "error" is valid. #[unsafe(no_mangle)] -pub unsafe extern "C-unwind" fn vx_error_get_message(error: *const vx_error) -> *const vx_string { - vx_string::new(Arc::clone(&vx_error::as_ref(error).message)) +pub unsafe extern "C-unwind" fn vx_error_message(error: *const vx_error) -> vx_view { + vx_view::from_str(&vx_error::as_ref(error).message) } /// Return category code for "error". @@ -215,12 +216,11 @@ mod tests { assert_eq!(try_or(&raw mut error, -1, || panic!("boom")), -1); assert!(!error.is_null()); - let message = unsafe { vx_error_get_message(error) }; + let message = unsafe { vx_error_message(error) }; assert_eq!( - vx_string::as_ref(message).as_ref(), + unsafe { message.as_str() }.unwrap(), "panic in Vortex FFI function: boom" ); - unsafe { crate::string::vx_string_free(message) }; unsafe { vx_error_free(error) }; } diff --git a/vortex-ffi/src/expression.rs b/vortex-ffi/src/expression.rs index 80471537e0d..87e15c5f843 100644 --- a/vortex-ffi/src/expression.rs +++ b/vortex-ffi/src/expression.rs @@ -2,8 +2,6 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors #![expect(non_camel_case_types)] -use std::ffi::CStr; -use std::ffi::c_char; use std::ptr; use std::slice; use std::sync::Arc; @@ -28,6 +26,7 @@ use vortex::scalar_fn::fns::operators::Operator; use crate::error::try_or; use crate::error::vx_error; use crate::scalar::vx_scalar; +use crate::string::vx_view; use crate::to_field_names; // Expressions are Arc'ed inside @@ -119,7 +118,7 @@ pub unsafe extern "C-unwind" fn vx_expression_literal( /// vx_expression_free(root); #[unsafe(no_mangle)] pub unsafe extern "C" fn vx_expression_select( - names: *const *const c_char, + names: *const vx_view, len: usize, child: *const vx_expression, ) -> *mut vx_expression { @@ -287,22 +286,19 @@ pub unsafe extern "C" fn vx_expression_is_null(child: *const vx_expression) -> * /// /// Example: if child is Struct { name=u8, age=u16 } and we do /// vx_expression_get_item("name", child), output type will be DTYPE_U8 +/// +/// "item" is copied. Returns NULL if "child" is NULL or "item" is not valid +/// UTF-8. #[unsafe(no_mangle)] pub unsafe extern "C" fn vx_expression_get_item( - item: *const c_char, + item: vx_view, child: *const vx_expression, ) -> *mut vx_expression { if child.is_null() { return ptr::null_mut(); } - if item.is_null() { + let Ok(item) = (unsafe { item.as_str() }) else { return ptr::null_mut(); - } - #[expect(clippy::expect_used)] - let item = unsafe { - CStr::from_ptr(item) - .to_str() - .expect("converting pointer to str") }; let item: Arc = Arc::from(item); let item: FieldName = item.into(); @@ -365,6 +361,7 @@ mod tests { use crate::expression::vx_expression_select; use crate::scalar::vx_scalar_free; use crate::scalar::vx_scalar_new_i32; + use crate::string::vx_view; #[test] #[cfg_attr(miri, ignore)] @@ -395,7 +392,7 @@ mod tests { let (array, names_array, ages_array) = struct_array(); unsafe { let root = vx_expression_root(); - let column = vx_expression_get_item(c"age".as_ptr(), root); + let column = vx_expression_get_item(vx_view::from_str("age"), root); assert_ne!(column, ptr::null_mut()); let array = vx_array::new(Arc::new(array.into_array())); @@ -417,7 +414,7 @@ mod tests { vx_expression_free(column); - let column = vx_expression_get_item(c"ololo".as_ptr(), root); + let column = vx_expression_get_item(vx_view::from_str("ololo"), root); assert_ne!(column, ptr::null_mut()); let applied_array = vx_array_apply(array, column, &raw mut error); @@ -494,7 +491,7 @@ mod tests { let array = vx_array::new(Arc::new(array.into_array())); - let columns = [c"name".as_ptr(), c"age".as_ptr()]; + let columns = [vx_view::from_str("name"), vx_view::from_str("age")]; let column = vx_expression_select(columns.as_ptr(), 2, root); assert_ne!(column, ptr::null_mut()); @@ -510,7 +507,7 @@ mod tests { vx_array_free(applied_array); vx_expression_free(column); - let columns = [c"age".as_ptr(), c"ololo".as_ptr()]; + let columns = [vx_view::from_str("age"), vx_view::from_str("ololo")]; let column = vx_expression_select(columns.as_ptr(), 2, root); let applied_array = vx_array_apply(array, column, &raw mut error); assert!(applied_array.is_null()); @@ -538,9 +535,9 @@ mod tests { let array = vx_array::new(Arc::new(array.unwrap().into_array())); let root = vx_expression_root(); - let expression_col1 = vx_expression_get_item(c"col1".as_ptr(), root); - let expression_col2 = vx_expression_get_item(c"col2".as_ptr(), root); - let expression_col3 = vx_expression_get_item(c"col3".as_ptr(), root); + let expression_col1 = vx_expression_get_item(vx_view::from_str("col1"), root); + let expression_col2 = vx_expression_get_item(vx_view::from_str("col2"), root); + let expression_col3 = vx_expression_get_item(vx_view::from_str("col3"), root); let expression_12 = vx_expression_binary( vx_binary_operator::VX_OPERATOR_EQ, expression_col1, diff --git a/vortex-ffi/src/file.rs b/vortex-ffi/src/file.rs index ff228a6c5c9..da962e5af3b 100644 --- a/vortex-ffi/src/file.rs +++ b/vortex-ffi/src/file.rs @@ -1,10 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -use std::ffi::CStr; -use std::ffi::c_char; - -use vortex::error::vortex_err; use vortex::file::VortexFile; use vortex::file::WriteOptionsSessionExt; use vortex::io::runtime::BlockingRuntime; @@ -15,6 +11,7 @@ use crate::array::vx_array; use crate::error::try_or_default; use crate::error::vx_error; use crate::session::vx_session; +use crate::string::vx_view; arc_wrapper!( /// A handle to a Vortex file encapsulating the footer and logic for instantiating a reader. @@ -25,7 +22,7 @@ arc_wrapper!( #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_file_write_array( session: *const vx_session, - path: *const c_char, + path: vx_view, array: *const vx_array, error_out: *mut *mut vx_error, ) { @@ -33,9 +30,7 @@ pub unsafe extern "C-unwind" fn vx_file_write_array( let options = session.write_options(); let array = vx_array::as_ref(array); try_or_default(error_out, || { - let path = unsafe { CStr::from_ptr(path) } - .to_str() - .map_err(|e| vortex_err!("invalid utf-8: {e}"))?; + let path = unsafe { path.as_str() }?; RUNTIME.block_on(async move { options diff --git a/vortex-ffi/src/lib.rs b/vortex-ffi/src/lib.rs index dadd5701f6f..f50a853b8dc 100644 --- a/vortex-ffi/src/lib.rs +++ b/vortex-ffi/src/lib.rs @@ -7,7 +7,6 @@ mod array; mod array_iterator; -mod binary; mod data_source; mod dtype; mod error; @@ -24,8 +23,6 @@ mod string; mod struct_array; mod struct_fields; -use std::ffi::CStr; -use std::ffi::c_char; use std::sync::Arc; use std::sync::LazyLock; @@ -42,11 +39,12 @@ pub use session::vx_session_free; pub use session::vx_session_new_with; pub use session::vx_session_ref; use vortex::dtype::FieldName; -use vortex::error::VortexExpect; use vortex::error::VortexResult; -use vortex::error::vortex_err; +use vortex::error::vortex_ensure; use vortex::io::runtime::current::CurrentThreadRuntime; +use crate::string::vx_view; + #[cfg(all(feature = "mimalloc", not(miri)))] #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; @@ -65,31 +63,25 @@ pub fn ffi_runtime() -> &'static CurrentThreadRuntime { &RUNTIME } -/// SAFETY: name must be a non-NULL pointer -pub(crate) unsafe fn to_field_name(name: *const c_char) -> VortexResult { - let name = unsafe { CStr::from_ptr(name) } - .to_str() - .map_err(|e| vortex_err!("{e}"))?; - let name: Arc = Arc::from(name); +/// SAFETY: name must be a vx_view with non-NULL pointer +pub(crate) unsafe fn to_field_name(name: vx_view) -> VortexResult { + let name: Arc = Arc::from(unsafe { name.as_str() }?); Ok(name.into()) } /// SAFETY: names must be a non-NULL pointer valid for reads up to len. pub(crate) unsafe fn to_field_names( - names: *const *const c_char, + names: *const vx_view, len: usize, ) -> VortexResult> { + vortex_ensure!(!names.is_null() || len == 0, "null names pointer"); (0..len) - .map(|i| unsafe { - let name = *names.offset(i.try_into().vortex_expect("pointer offset overflow")); - to_field_name(name) - }) + .map(|i| unsafe { to_field_name(*names.add(i)) }) .collect() } #[cfg(test)] mod tests { - use std::ffi::CString; use std::ptr; use std::sync::Arc; @@ -107,13 +99,12 @@ mod tests { use crate::dtype::vx_dtype_free; use crate::error::vx_error; use crate::error::vx_error_free; - use crate::error::vx_error_get_message; + use crate::error::vx_error_message; use crate::session::vx_session; use crate::sink::vx_array_sink_close; use crate::sink::vx_array_sink_open_file; use crate::sink::vx_array_sink_push; - use crate::string::vx_string; - use crate::string::vx_string_free; + use crate::string::vx_view; /// Panic if error is NULL. Free the error if it's not pub(crate) fn assert_error(error: *mut vx_error) { @@ -126,9 +117,7 @@ mod tests { if !error.is_null() { let message; unsafe { - let msg_ptr = vx_error_get_message(error); - message = vx_string::as_str(msg_ptr).to_owned(); - vx_string_free(msg_ptr); + message = vx_error_message(error).as_str().unwrap().to_owned(); vx_error_free(error); } panic!("{message}"); @@ -170,14 +159,13 @@ mod tests { .unwrap(); let file = NamedTempFile::new().unwrap(); - let path = CString::new(file.path().to_str().unwrap()).unwrap(); + let path = vx_view::from_str(file.path().to_str().unwrap()); let dtype = struct_array.dtype(); unsafe { let vx_dtype_ptr = vx_dtype::new(Arc::new(dtype.clone())); let mut error = ptr::null_mut(); - let sink = - vx_array_sink_open_file(session, path.as_ptr(), vx_dtype_ptr, &raw mut error); + let sink = vx_array_sink_open_file(session, path, vx_dtype_ptr, &raw mut error); let array = vx_array::new(Arc::new(struct_array.clone().into_array())); vx_array_sink_push(sink, array, &raw mut error); vx_array_sink_close(sink, &raw mut error); diff --git a/vortex-ffi/src/scalar.rs b/vortex-ffi/src/scalar.rs index 64dd670feb0..9f01f799050 100644 --- a/vortex-ffi/src/scalar.rs +++ b/vortex-ffi/src/scalar.rs @@ -3,7 +3,6 @@ //! FFI interface for working with Vortex scalar values. -use std::ffi::c_char; use std::ptr; use std::slice; use std::str; @@ -17,7 +16,6 @@ use vortex::dtype::i256; use vortex::error::VortexResult; use vortex::error::vortex_bail; use vortex::error::vortex_ensure; -use vortex::error::vortex_err; use vortex::scalar::DecimalValue; use vortex::scalar::Scalar; use vortex::scalar::ScalarValue; @@ -25,6 +23,7 @@ use vortex::scalar::ScalarValue; use crate::dtype::vx_dtype; use crate::error::try_or; use crate::error::vx_error; +use crate::string::vx_view; crate::box_wrapper!( /// A typed scalar value. @@ -154,19 +153,16 @@ pub unsafe extern "C-unwind" fn vx_scalar_new_f16_bits( /// Create a UTF-8 scalar. /// -/// The byte range is copied into the scalar. A NULL data pointer is allowed only -/// for an empty byte range. Invalid UTF-8 returns NULL and writes the error -/// output. +/// The string bytes are copied into the scalar. Invalid UTF-8 returns NULL and +/// writes the error output. #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_scalar_new_utf8( - ptr: *const c_char, - len: usize, + value: vx_view, is_nullable: bool, err: *mut *mut vx_error, ) -> *mut vx_scalar { try_or(err, ptr::null_mut(), || { - let bytes = bytes_from_raw(ptr.cast(), len, "utf8")?; - let value = str::from_utf8(bytes).map_err(|e| vortex_err!("invalid utf-8: {e}"))?; + let value = unsafe { value.as_str() }?; Ok(vx_scalar::new(Scalar::utf8( value.to_owned(), Nullability::from(is_nullable), @@ -509,6 +505,7 @@ mod tests { use crate::scalar::vx_scalar_new_u32; use crate::scalar::vx_scalar_new_u64; use crate::scalar::vx_scalar_new_utf8; + use crate::string::vx_view; use crate::tests::assert_error; use crate::tests::assert_no_error; @@ -580,18 +577,14 @@ mod tests { let mut error = ptr::null_mut(); let value = "literal"; assert_scalar( - vx_scalar_new_utf8(value.as_ptr().cast(), value.len(), false, &raw mut error), + vx_scalar_new_utf8(vx_view::from_str(value), false, &raw mut error), Scalar::utf8(value, Nullability::NonNullable), ); assert_no_error(error); let invalid_utf8 = [0xffu8]; - let scalar = vx_scalar_new_utf8( - invalid_utf8.as_ptr().cast(), - invalid_utf8.len(), - false, - &raw mut error, - ); + let scalar = + vx_scalar_new_utf8(vx_view::from_bytes(&invalid_utf8), false, &raw mut error); assert!(scalar.is_null()); assert_error(error); diff --git a/vortex-ffi/src/scan.rs b/vortex-ffi/src/scan.rs index 2bc748edeb9..fdf8615d065 100644 --- a/vortex-ffi/src/scan.rs +++ b/vortex-ffi/src/scan.rs @@ -441,7 +441,6 @@ pub unsafe extern "C-unwind" fn vx_partition_next( #[cfg(not(windows))] #[cfg(test)] mod tests { - use std::ffi::CString; use std::ptr; use vortex::VortexSessionDefault; @@ -477,6 +476,7 @@ mod tests { use crate::scan::vx_scan_selection_include; use crate::session::vx_session_free; use crate::session::vx_session_new; + use crate::string::vx_view; use crate::tests::SAMPLE_ROWS; use crate::tests::assert_no_error; use crate::tests::write_sample; @@ -487,9 +487,10 @@ mod tests { unsafe { let session = vx_session_new(); let (sample, struct_array) = write_sample(session); - let path = CString::new(sample.path().to_str().unwrap()).unwrap(); + let path = vx_view::from_str(sample.path().to_str().unwrap()); let ds_options = vx_data_source_options { - paths: path.as_ptr(), + paths: &raw const path, + paths_len: 1, }; let mut error = ptr::null_mut(); @@ -551,8 +552,8 @@ mod tests { let root = vx_expression_root(); let mut opts = vx_scan_options::default(); - for (field, c_field) in [("age", c"age"), ("height", c"height"), ("name", c"name")] { - let field_expr = vx_expression_get_item(c_field.as_ptr(), root); + for field in ["age", "height", "name"] { + let field_expr = vx_expression_get_item(vx_view::from_str(field), root); assert!(!field_expr.is_null()); opts.projection = field_expr; let (array, struct_array) = scan(&raw const opts); @@ -577,8 +578,8 @@ mod tests { let root = vx_expression_root(); let mut opts = vx_scan_options::default(); - let expr_age = vx_expression_get_item(c"age".as_ptr(), root); - let expr_height = vx_expression_get_item(c"height".as_ptr(), root); + let expr_age = vx_expression_get_item(vx_view::from_str("age"), root); + let expr_height = vx_expression_get_item(vx_view::from_str("height"), root); let expr_sum = vx_expression_binary(vx_binary_operator::VX_OPERATOR_ADD, expr_age, expr_height); @@ -608,7 +609,7 @@ mod tests { fn test_filter() { unsafe { let root = vx_expression_root(); - let age_expr = vx_expression_get_item(c"age".as_ptr(), root); + let age_expr = vx_expression_get_item(vx_view::from_str("age"), root); let value = vx_scalar_new_u64(100, false); let mut error = ptr::null_mut(); let lit_100 = vx_expression_literal(value, &raw mut error); @@ -637,7 +638,7 @@ mod tests { fn test_filter_project() { unsafe { let root = vx_expression_root(); - let age_expr = vx_expression_get_item(c"age".as_ptr(), root); + let age_expr = vx_expression_get_item(vx_view::from_str("age"), root); let value = vx_scalar_new_u64(100, false); let mut error = ptr::null_mut(); let lit_100 = vx_expression_literal(value, &raw mut error); @@ -645,7 +646,7 @@ mod tests { vx_scalar_free(value); let filter = vx_expression_binary(vx_binary_operator::VX_OPERATOR_GTE, age_expr, lit_100); - let projection = vx_expression_get_item(c"age".as_ptr(), root); + let projection = vx_expression_get_item(vx_view::from_str("age"), root); let opts = vx_scan_options { projection, @@ -725,9 +726,10 @@ mod tests { unsafe { let session = vx_session_new(); let (sample, _) = write_sample(session); - let path = CString::new(sample.path().to_str().unwrap()).unwrap(); + let path = vx_view::from_str(sample.path().to_str().unwrap()); let ds_options = vx_data_source_options { - paths: path.as_ptr(), + paths: &raw const path, + paths_len: 1, }; let mut error = ptr::null_mut(); diff --git a/vortex-ffi/src/sink.rs b/vortex-ffi/src/sink.rs index 063a26dc259..fa8dae19ed8 100644 --- a/vortex-ffi/src/sink.rs +++ b/vortex-ffi/src/sink.rs @@ -1,9 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -use std::ffi::CStr; -use std::ffi::c_char; - use futures::SinkExt; use futures::TryStreamExt; use futures::channel::mpsc; @@ -26,6 +23,7 @@ use crate::dtype::vx_dtype; use crate::error::try_or_default; use crate::error::vx_error; use crate::session::vx_session; +use crate::string::vx_view; #[expect(non_camel_case_types)] /// The `sink` interface is used to collect array chunks and place them into a resource @@ -48,22 +46,21 @@ pub struct vx_array_sink { /// Opens a writable array stream, where sink is used to push values into the stream. /// To close the stream close the sink with `vx_array_sink_close`. +/// "path" is copied. #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_array_sink_open_file( session: *const vx_session, - path: *const c_char, + path: vx_view, dtype: *const vx_dtype, error_out: *mut *mut vx_error, ) -> *mut vx_array_sink { try_or_default(error_out, || { let session = vx_session::as_ref(session); - if path.is_null() { + if path.ptr.is_null() { vortex_bail!("null path"); } - let path = unsafe { CStr::from_ptr(path) } - .to_string_lossy() - .to_string(); + let path = unsafe { path.as_str() }?.to_string(); let file_dtype = vx_dtype::as_ref(dtype); // The channel size 32 was chosen arbitrarily. @@ -131,7 +128,6 @@ pub unsafe extern "C-unwind" fn vx_array_sink_abort(sink: *mut vx_array_sink) { #[cfg(test)] mod tests { - use std::ffi::CString; use std::sync::Arc; use tempfile::NamedTempFile; @@ -159,14 +155,13 @@ mod tests { let session = vx_session_new(); let temp_file = NamedTempFile::new().unwrap(); - let path = CString::new(temp_file.path().to_str().unwrap()).unwrap(); + let path = vx_view::from_str(temp_file.path().to_str().unwrap()); let dtype = DType::Primitive(vortex::dtype::PType::I32, false.into()); let vx_dtype_ptr = vx_dtype::new(Arc::new(dtype)); let mut error = std::ptr::null_mut(); - let sink = - vx_array_sink_open_file(session, path.as_ptr(), vx_dtype_ptr, &raw mut error); + let sink = vx_array_sink_open_file(session, path, vx_dtype_ptr, &raw mut error); assert!(error.is_null()); assert!(!sink.is_null()); @@ -195,14 +190,13 @@ mod tests { let session = vx_session_new(); let temp_file = NamedTempFile::new().unwrap(); - let path = CString::new(temp_file.path().to_str().unwrap()).unwrap(); + let path = vx_view::from_str(temp_file.path().to_str().unwrap()); let dtype = DType::Primitive(vortex::dtype::PType::U64, false.into()); let vx_dtype_ptr = vx_dtype::new(Arc::new(dtype)); let mut error = std::ptr::null_mut(); - let sink = - vx_array_sink_open_file(session, path.as_ptr(), vx_dtype_ptr, &raw mut error); + let sink = vx_array_sink_open_file(session, path, vx_dtype_ptr, &raw mut error); assert!(error.is_null()); // Push multiple arrays @@ -235,17 +229,12 @@ mod tests { let session = vx_session_new(); // Use a path that will fail during file creation (read-only directory on most systems) - let invalid_path = CString::new("/dev/null/invalid.vortex").unwrap(); + let invalid_path = vx_view::from_str("/dev/null/invalid.vortex"); let dtype = DType::Primitive(vortex::dtype::PType::I32, false.into()); let vx_dtype_ptr = vx_dtype::new(Arc::new(dtype)); let mut error = std::ptr::null_mut(); - let sink = vx_array_sink_open_file( - session, - invalid_path.as_ptr(), - vx_dtype_ptr, - &raw mut error, - ); + let sink = vx_array_sink_open_file(session, invalid_path, vx_dtype_ptr, &raw mut error); // The sink creation may succeed but close should fail due to invalid path if !sink.is_null() { @@ -280,14 +269,14 @@ mod tests { let array = PrimitiveArray::new(buffer![1i32, 2i32, 3i32], Validity::NonNullable); let file = NamedTempFile::new().unwrap(); - let path = CString::new(file.path().to_str().unwrap()).unwrap(); + let path = vx_view::from_str(file.path().to_str().unwrap()); unsafe { let session = vx_session_new(); let dtype = vx_dtype::new(Arc::new(dtype)); let mut error = std::ptr::null_mut(); - let sink = vx_array_sink_open_file(session, path.as_ptr(), dtype, &raw mut error); + let sink = vx_array_sink_open_file(session, path, dtype, &raw mut error); assert!(error.is_null()); let array = vx_array::new(Arc::new(array.into_array())); @@ -298,7 +287,8 @@ mod tests { vx_array_sink_abort(sink); let opts = vx_data_source_options { - paths: path.as_ptr(), + paths: &raw const path, + paths_len: 1, }; let ds = vx_data_source_new(session, &raw const opts, &raw mut error); assert!(ds.is_null()); @@ -322,7 +312,7 @@ mod tests { let mut error = std::ptr::null_mut(); // This should return null and set error due to null path let sink = - vx_array_sink_open_file(session, std::ptr::null(), vx_dtype_ptr, &raw mut error); + vx_array_sink_open_file(session, vx_view::null(), vx_dtype_ptr, &raw mut error); assert!(sink.is_null()); assert!(!error.is_null()); diff --git a/vortex-ffi/src/string.rs b/vortex-ffi/src/string.rs index a3dc0937702..dd77508f048 100644 --- a/vortex-ffi/src/string.rs +++ b/vortex-ffi/src/string.rs @@ -1,144 +1,91 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -use std::ffi::CStr; use std::ffi::c_char; +use std::ptr; use std::slice; -use vortex::error::VortexExpect; +use vortex::error::VortexResult; +use vortex::error::vortex_ensure; use vortex::error::vortex_err; -use crate::arc_dyn_wrapper; - -arc_dyn_wrapper!( - /// Strings for use within Vortex. - str, - vx_string -); - -impl vx_string { - #[allow(dead_code)] - pub(crate) fn as_str(ptr: *const vx_string) -> &'static str { - unsafe { - str::from_utf8_unchecked(slice::from_raw_parts( - vx_string_ptr(ptr).cast(), - vx_string_len(ptr), - )) - } - } -} - -/// Create a new Vortex UTF-8 string by copying from a pointer and length. -#[unsafe(no_mangle)] -pub unsafe extern "C-unwind" fn vx_string_new(ptr: *const c_char, len: usize) -> *const vx_string { - let slice = unsafe { slice::from_raw_parts(ptr.cast(), len) }; - // TODO(joe): propagate this error up instead of expecting - let string = String::from_utf8(slice.to_vec()) - .map_err(|e| vortex_err!("invalid utf-8: {e}")) - .vortex_expect("CString creation should succeed"); - vx_string::new(string.into()) -} - -/// Create a new Vortex UTF-8 string by copying from a null-terminated C-style string. -#[unsafe(no_mangle)] -pub unsafe extern "C-unwind" fn vx_string_new_from_cstr(ptr: *const c_char) -> *const vx_string { - // TODO(joe): propagate this error up instead of expecting - let string = unsafe { CStr::from_ptr(ptr) } - .to_str() - .map_err(|e| vortex_err!("invalid utf-8: {e}")) - .vortex_expect("CString creation should succeed"); - vx_string::new(string.into()) -} - -/// Return the length of the string in bytes. -#[unsafe(no_mangle)] -pub unsafe extern "C-unwind" fn vx_string_len(ptr: *const vx_string) -> usize { - vx_string::as_ref(ptr).len() +/// A non owning view over a byte range. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct vx_view { + /// NULL "ptr" requires len == 0 + pub ptr: *const c_char, + /// Length in bytes. + pub len: usize, } -/// Return the pointer to the string data. -#[unsafe(no_mangle)] -pub unsafe extern "C-unwind" fn vx_string_ptr(ptr: *const vx_string) -> *const c_char { - vx_string::as_ref(ptr).as_ptr().cast() -} - -#[cfg(test)] -mod tests { - use std::ffi::CString; - - use super::*; - - #[test] - fn test_string_new() { - unsafe { - let test_str = "hello world"; - let ptr = test_str.as_ptr().cast(); - let len = test_str.len(); - - let vx_str = vx_string_new(ptr, len); - assert_eq!(vx_string_len(vx_str), 11); - assert_eq!(vx_string::as_str(vx_str), "hello world"); - - vx_string_free(vx_str); +impl vx_view { + /// {NULL, 0} for absent values + pub(crate) const fn null() -> vx_view { + vx_view { + ptr: ptr::null(), + len: 0, } } - #[test] - fn test_string_new_from_cstr() { - unsafe { - let c_string = CString::new("test string").unwrap(); - let vx_str = vx_string_new_from_cstr(c_string.as_ptr()); - - assert_eq!(vx_string_len(vx_str), 11); - assert_eq!(vx_string::as_str(vx_str), "test string"); - - vx_string_free(vx_str); + /// Borrow a Rust string + pub(crate) fn from_str(value: &str) -> vx_view { + vx_view { + ptr: value.as_ptr().cast(), + len: value.len(), } } - #[test] - fn test_string_ptr() { - unsafe { - let test_str = "testing"; - let vx_str = vx_string::new(test_str.into()); - - let ptr = vx_string_ptr(vx_str); - let len = vx_string_len(vx_str); - - let slice = slice::from_raw_parts(ptr.cast::(), len); - let recovered = str::from_utf8_unchecked(slice); - assert_eq!(recovered, "testing"); - - vx_string_free(vx_str); + /// Borrow a Rust byte slice + pub(crate) fn from_bytes(value: &[u8]) -> vx_view { + vx_view { + ptr: value.as_ptr().cast(), + len: value.len(), } } - #[test] - fn test_empty_string() { - unsafe { - let empty = ""; - let ptr = empty.as_ptr().cast(); - let vx_str = vx_string_new(ptr, 0); - - assert_eq!(vx_string_len(vx_str), 0); - assert_eq!(vx_string::as_str(vx_str), ""); - - vx_string_free(vx_str); + /// View vx_view as bytes + /// + /// # Safety + /// + /// "ptr" must be valid for "len" reads or NULL with "len == 0". + pub(crate) unsafe fn as_bytes<'a>(&self) -> VortexResult<&'a [u8]> { + if self.ptr.is_null() { + vortex_ensure!(self.len == 0, "null vx_view pointer with non-zero length"); + return Ok(&[]); } + Ok(unsafe { slice::from_raw_parts(self.ptr.cast(), self.len) }) } - #[test] - fn test_unicode_string() { - unsafe { - let unicode_str = "Hello 世界 🌍"; - let ptr = unicode_str.as_ptr().cast(); - let len = unicode_str.len(); + /// View vx_view as UTF-8. + /// + /// # Safety + /// + /// Same requirements as in as_bytes + pub(crate) unsafe fn as_str<'a>(&self) -> VortexResult<&'a str> { + str::from_utf8(unsafe { self.as_bytes() }?).map_err(|e| vortex_err!("invalid utf-8: {e}")) + } +} - let vx_str = vx_string_new(ptr, len); - assert_eq!(vx_string_len(vx_str), unicode_str.len()); - assert_eq!(vx_string::as_str(vx_str), unicode_str); +#[cfg(test)] +mod tests { + use super::*; - vx_string_free(vx_str); - } + #[test] + fn test_vx_view() -> VortexResult<()> { + let source = "Hello 世界 🌍"; + let view = vx_view::from_str(source); + assert_eq!(unsafe { view.as_str() }?, source); + assert_eq!(unsafe { view.as_bytes() }?, source.as_bytes()); + + assert_eq!(unsafe { vx_view::null().as_str() }?, ""); + let bad = vx_view { + ptr: ptr::null(), + len: 3, + }; + assert!(unsafe { bad.as_str() }.is_err()); + + assert!(unsafe { vx_view::from_bytes(&[0xFFu8, 0xFE]).as_str() }.is_err()); + Ok(()) } } diff --git a/vortex-ffi/src/struct_array.rs b/vortex-ffi/src/struct_array.rs index 765864a6176..21fab2fda7a 100644 --- a/vortex-ffi/src/struct_array.rs +++ b/vortex-ffi/src/struct_array.rs @@ -1,6 +1,5 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -use std::ffi::c_char; use std::ptr; use std::sync::Arc; @@ -16,6 +15,7 @@ use crate::array::vx_array; use crate::array::vx_validity; use crate::error::try_or_default; use crate::error::vx_error; +use crate::string::vx_view; use crate::to_field_name; pub(crate) struct StructBuilder { @@ -59,13 +59,12 @@ pub unsafe extern "C" fn vx_struct_column_builder_new( #[unsafe(no_mangle)] pub unsafe extern "C" fn vx_struct_column_builder_add_field( builder: *mut vx_struct_column_builder, - name: *const c_char, + name: vx_view, field: *const vx_array, error: *mut *mut vx_error, ) { try_or_default(error, || { vortex_ensure!(!builder.is_null()); - vortex_ensure!(!name.is_null()); vortex_ensure!(!field.is_null()); let builder = vx_struct_column_builder::as_mut(builder); @@ -149,6 +148,7 @@ mod tests { use crate::array::vx_validity_type; use crate::error::vx_error_free; use crate::ptype::vx_ptype; + use crate::string::vx_view; use crate::struct_array::vx_struct_column_builder_add_field; use crate::struct_array::vx_struct_column_builder_finalize; use crate::struct_array::vx_struct_column_builder_free; @@ -205,7 +205,7 @@ mod tests { vx_struct_column_builder_add_field( builder, - c"age".as_ptr(), + vx_view::from_str("age"), ffi_age_field, &raw mut error, ); @@ -215,7 +215,7 @@ mod tests { let ffi_null_field = vx_array_new_null(5); vx_struct_column_builder_add_field( builder, - c"null".as_ptr(), + vx_view::from_str("null"), ffi_null_field, &raw mut error, ); @@ -227,7 +227,7 @@ mod tests { let ffi_name_field = vx_array::new(Arc::new(name_field.into_array())); vx_struct_column_builder_add_field( builder, - c"name".as_ptr(), + vx_view::from_str("name"), ffi_name_field, &raw mut error, ); diff --git a/vortex-ffi/src/struct_fields.rs b/vortex-ffi/src/struct_fields.rs index ec56304cfa6..6950b62a302 100644 --- a/vortex-ffi/src/struct_fields.rs +++ b/vortex-ffi/src/struct_fields.rs @@ -11,7 +11,9 @@ use vortex::error::VortexExpect; use crate::box_wrapper; use crate::dtype::vx_dtype; -use crate::string::vx_string; +use crate::error::try_or_default; +use crate::error::vx_error; +use crate::string::vx_view; box_wrapper!( /// Represents a Vortex struct data type, without top-level nullability. @@ -29,21 +31,22 @@ pub unsafe extern "C-unwind" fn vx_struct_fields_nfields(dtype: *const vx_struct .nfields() as u64 } -/// Return an owned name of the field at a given index. -/// If index is out of bounds, returns NULL. +/// Return field name at a given index. +/// If index is out of bounds, returns {NULL, 0}. +/// +/// Returned view is valid as long as "dtype" is valid. #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_struct_fields_field_name( dtype: *const vx_struct_fields, idx: usize, -) -> *const vx_string { +) -> vx_view { // TODO(joe): propagate this error up instead of expecting let ptr = unsafe { dtype.as_ref() }.vortex_expect("null ptr"); let struct_dtype = &ptr.0; if idx >= struct_dtype.nfields() { - return ptr::null(); + return vx_view::null(); } - let name = struct_dtype.names()[idx].inner(); - vx_string::new(Arc::clone(name)) + vx_view::from_str(struct_dtype.names()[idx].inner()) } /// Return an owned dtype of the field at a given index. @@ -89,19 +92,23 @@ pub unsafe extern "C-unwind" fn vx_struct_fields_builder_new() -> *mut vx_struct /// Add a field to the struct dtype builder. /// -/// Takes ownership of both the `name` and `dtype` pointers. -/// Must either free or finalize the builder. +/// "name" is copied. Takes ownership of "dtype". +/// Caller must free or finalize the builder. #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_struct_fields_builder_add_field( builder: *mut vx_struct_fields_builder, - name: *const vx_string, + name: vx_view, dtype: *const vx_dtype, + error_out: *mut *mut vx_error, ) { - let builder = vx_struct_fields_builder::as_mut(builder); - builder.names.push(vx_string::into_arc(name)); - builder - .fields - .push(vx_dtype::into_arc(dtype).deref().clone()); + try_or_default(error_out, || { + let builder = vx_struct_fields_builder::as_mut(builder); + builder.names.push(Arc::from(unsafe { name.as_str() }?)); + builder + .fields + .push(vx_dtype::into_arc(dtype).deref().clone()); + Ok(()) + }) } /// Finalize the struct dtype builder, returning a new `vx_struct_fields`. diff --git a/vortex-ffi/test/array.cpp b/vortex-ffi/test/array.cpp index f558243525a..f8db2661b8d 100644 --- a/vortex-ffi/test/array.cpp +++ b/vortex-ffi/test/array.cpp @@ -62,7 +62,7 @@ TEST_CASE("Struct array creation", "[array]") { vx_struct_column_builder *builder = vx_struct_column_builder_new(&validity, 2); CHECK(builder != nullptr); - vx_struct_column_builder_add_field(builder, "age", field_array, &error); + vx_struct_column_builder_add_field(builder, vx_view_from_cstr("age"), field_array, &error); vx_array_free(field_array); SECTION("Struct array builder free") { diff --git a/vortex-ffi/test/common.h b/vortex-ffi/test/common.h index 60975d22015..81974b65bb9 100644 --- a/vortex-ffi/test/common.h +++ b/vortex-ffi/test/common.h @@ -6,14 +6,12 @@ #include "vortex.h" inline std::string to_string(vx_error *err) { - const vx_string *msg = vx_error_get_message(err); - const std::string out {vx_string_ptr(msg), vx_string_len(msg)}; - vx_string_free(msg); - return out; + const vx_view msg = vx_error_message(err); + return {msg.ptr, msg.len}; } -inline std::string_view to_string_view(const vx_string *msg) { - return {vx_string_ptr(msg), vx_string_len(msg)}; +inline std::string_view to_string_view(vx_view str) { + return {str.ptr, str.len}; } inline void require_no_error(vx_error *error, bool assert = true) { diff --git a/vortex-ffi/test/main.cpp b/vortex-ffi/test/main.cpp index 188e8739d9f..9df02b9f798 100644 --- a/vortex-ffi/test/main.cpp +++ b/vortex-ffi/test/main.cpp @@ -17,25 +17,12 @@ TEST_CASE("Session creation", "[session]") { vx_session_free(session2); } -TEST_CASE("Creating and iterating binaries", "[binary]") { - for (std::string_view str : {"ololo"sv, "Широкая строка"sv, "مرحبا بالعالم"sv}) { - const vx_binary *binary = vx_binary_new(str.data(), str.size()); - - REQUIRE(binary != nullptr); - const size_t len = vx_binary_len(binary); - REQUIRE(len == str.size()); - - const char *ptr = vx_binary_ptr(binary); - REQUIRE(std::string_view {ptr, len} == str); - - const vx_binary *binary2 = vx_binary_clone(binary); - vx_binary_free(binary); - - ptr = vx_binary_ptr(binary2); - REQUIRE(std::string_view {ptr, len} == str); - - vx_binary_free(binary2); - } +TEST_CASE("vx_view from C string", "[str]") { + const std::string_view str = "Широкая строка"sv; + const std::string owned {str}; + const vx_view view = vx_view_from_cstr(owned.c_str()); + REQUIRE(view.len == str.size()); + REQUIRE(std::string_view {view.ptr, view.len} == str); } TEST_CASE("Creating dtypes", "[dtype]") { diff --git a/vortex-ffi/test/scan.cpp b/vortex-ffi/test/scan.cpp index b1d4b8ce3f8..e1f629dfde0 100644 --- a/vortex-ffi/test/scan.cpp +++ b/vortex-ffi/test/scan.cpp @@ -57,15 +57,14 @@ struct TempPath : fs::path { [[nodiscard]] const vx_dtype *sample_dtype() { vx_struct_fields_builder *builder = vx_struct_fields_builder_new(); - constexpr auto age = "age"sv; - const vx_string *age_name = vx_string_new(age.data(), age.size()); + vx_error *error = nullptr; const vx_dtype *age_type = vx_dtype_new_primitive(PTYPE_U8, false); - vx_struct_fields_builder_add_field(builder, age_name, age_type); + vx_struct_fields_builder_add_field(builder, vx_view_from_cstr("age"), age_type, &error); + require_no_error(error); - constexpr auto height = "height"sv; - const vx_string *height_name = vx_string_new(height.data(), height.size()); const vx_dtype *height_type = vx_dtype_new_primitive(PTYPE_U16, true); - vx_struct_fields_builder_add_field(builder, height_name, height_type); + vx_struct_fields_builder_add_field(builder, vx_view_from_cstr("height"), height_type, &error); + require_no_error(error); vx_struct_fields *fields = vx_struct_fields_builder_finalize(builder); return vx_dtype_new_struct(fields, false); @@ -103,7 +102,7 @@ std::vector sample_height() { }; require_no_error(error); - vx_struct_column_builder_add_field(builder, "age", age_array, &error); + vx_struct_column_builder_add_field(builder, vx_view_from_cstr("age"), age_array, &error); require_no_error(error); std::vector height_buffer = sample_height(); @@ -115,7 +114,7 @@ std::vector sample_height() { }; require_no_error(error); - vx_struct_column_builder_add_field(builder, "height", height_array, &error); + vx_struct_column_builder_add_field(builder, vx_view_from_cstr("height"), height_array, &error); require_no_error(error); const vx_array *array = vx_struct_column_builder_finalize(builder, &error); @@ -168,7 +167,7 @@ UniqueArrayStream sample_array_stream() { }; vx_error *error = nullptr; - vx_array_sink *sink = vx_array_sink_open_file(session, path.c_str(), dtype, &error); + vx_array_sink *sink = vx_array_sink_open_file(session, vx_view_from_cstr(path.c_str()), dtype, &error); REQUIRE(sink != nullptr); require_no_error(error); @@ -206,14 +205,16 @@ TEST_CASE("Creating datasources", "[datasource]") { vx_error_free(error); // First file is opened eagerly - opts.paths = "nonexistent"; + vx_view path_view = vx_view_from_cstr("nonexistent"); + opts.paths = &path_view; + opts.paths_len = 1; ds = vx_data_source_new(session, &opts, &error); REQUIRE(ds == nullptr); REQUIRE(error != nullptr); REQUIRE_THAT(to_string(error), ContainsSubstring("No files matched the glob pattern")); vx_error_free(error); - opts.paths = "/tmp2/*.vortex"; + path_view = vx_view_from_cstr("/tmp2/*.vortex"); ds = vx_data_source_new(session, &opts, &error); REQUIRE(ds == nullptr); REQUIRE(error != nullptr); @@ -225,7 +226,7 @@ TEST_CASE("Creating datasources", "[datasource]") { vx_error_free(error); TempPath file = write_sample(session); - opts.paths = file.c_str(); + path_view = vx_view_from_cstr(file.c_str()); ds = vx_data_source_new(session, &opts, &error); require_no_error(error); REQUIRE(ds != nullptr); @@ -249,7 +250,9 @@ TEST_CASE("Write file and read dtypes", "[datasource]") { vx_error *error = nullptr; vx_data_source_options opts = {}; - opts.paths = path.c_str(); + const vx_view opts_path = vx_view_from_cstr(path.c_str()); + opts.paths = &opts_path; + opts.paths_len = 1; const vx_data_source *ds = vx_data_source_new(session, &opts, &error); require_no_error(error); @@ -278,10 +281,9 @@ TEST_CASE("Write file and read dtypes", "[datasource]") { REQUIRE(len == 2); const vx_dtype *age_dtype = vx_struct_fields_field_dtype(fields, 0); - const vx_string *age_name = vx_struct_fields_field_name(fields, 0); + const vx_view age_name = vx_struct_fields_field_name(fields, 0); defer { vx_dtype_free(age_dtype); - vx_string_free(age_name); }; REQUIRE(vx_dtype_get_variant(age_dtype) == DTYPE_PRIMITIVE); @@ -290,10 +292,9 @@ TEST_CASE("Write file and read dtypes", "[datasource]") { REQUIRE(to_string_view(age_name) == "age"); const vx_dtype *height_dtype = vx_struct_fields_field_dtype(fields, 1); - const vx_string *height_name = vx_struct_fields_field_name(fields, 1); + const vx_view height_name = vx_struct_fields_field_name(fields, 1); defer { vx_dtype_free(height_dtype); - vx_string_free(height_name); }; REQUIRE(vx_dtype_get_variant(height_dtype) == DTYPE_PRIMITIVE); REQUIRE(vx_dtype_primitive_ptype(height_dtype) == PTYPE_U16); @@ -342,17 +343,15 @@ void verify_sample_array(const vx_array *array) { REQUIRE(vx_dtype_get_variant(age_dtype) == DTYPE_PRIMITIVE); REQUIRE(vx_dtype_primitive_ptype(age_dtype) == PTYPE_U8); vx_dtype_free(age_dtype); - const vx_string *age_name = vx_struct_fields_field_name(fields, 0); + const vx_view age_name = vx_struct_fields_field_name(fields, 0); REQUIRE(to_string_view(age_name) == "age"); - vx_string_free(age_name); const vx_dtype *height_dtype = vx_struct_fields_field_dtype(fields, 1); REQUIRE(vx_dtype_get_variant(height_dtype) == DTYPE_PRIMITIVE); REQUIRE(vx_dtype_primitive_ptype(height_dtype) == PTYPE_U16); vx_dtype_free(height_dtype); - const vx_string *height_name = vx_struct_fields_field_name(fields, 1); + const vx_view height_name = vx_struct_fields_field_name(fields, 1); REQUIRE(to_string_view(height_name) == "height"); - vx_string_free(height_name); vx_struct_fields_free(fields); @@ -385,7 +384,9 @@ TEST_CASE("Requesting scans", "[datasource]") { TempPath path = write_sample(session); vx_data_source_options ds_options = {}; - ds_options.paths = path.c_str(); + const vx_view ds_path = vx_view_from_cstr(path.c_str()); + ds_options.paths = &ds_path; + ds_options.paths_len = 1; vx_error *error = nullptr; const vx_data_source *ds = vx_data_source_new(session, &ds_options, &error); @@ -461,7 +462,9 @@ TEST_CASE("Basic scan", "[datasource]") { vx_error *error = nullptr; vx_data_source_options ds_options = {}; - ds_options.paths = path.c_str(); + const vx_view ds_path = vx_view_from_cstr(path.c_str()); + ds_options.paths = &ds_path; + ds_options.paths_len = 1; const vx_data_source *ds = vx_data_source_new(session, &ds_options, &error); require_no_error(error); REQUIRE(ds != nullptr); @@ -504,18 +507,15 @@ TEST_CASE("Multithreaded scan", "[datasource]") { constexpr size_t NUM_FILES = 10; std::vector paths(NUM_FILES); - std::string paths_str; + std::vector path_views(NUM_FILES); for (size_t i = 0; i < NUM_FILES; ++i) { paths[i] = write_sample(session); - if (i == 0) { - paths_str = paths[i].c_str(); - } else { - paths_str += ","s + paths[i].c_str(); - } + path_views[i] = vx_view_from_cstr(paths[i].c_str()); } vx_data_source_options ds_options = {}; - ds_options.paths = paths_str.c_str(); + ds_options.paths = path_views.data(); + ds_options.paths_len = path_views.size(); vx_error *error = nullptr; const vx_data_source *ds = vx_data_source_new(session, &ds_options, &error); @@ -603,7 +603,9 @@ const vx_array *scan_with_options(vx_scan_options &options) { vx_error *error = nullptr; vx_data_source_options ds_options = {}; - ds_options.paths = path.c_str(); + const vx_view ds_path = vx_view_from_cstr(path.c_str()); + ds_options.paths = &ds_path; + ds_options.paths_len = 1; const vx_data_source *ds = vx_data_source_new(session, &ds_options, &error); require_no_error(error); @@ -663,7 +665,7 @@ TEST_CASE("Project single field", "[projection]") { }; vx_scan_options opts = {}; - vx_expression *age_field = vx_expression_get_item("age", root); + vx_expression *age_field = vx_expression_get_item(vx_view_from_cstr("age"), root); REQUIRE(age_field != nullptr); defer { vx_expression_free(age_field); @@ -678,7 +680,7 @@ TEST_CASE("Project single field", "[projection]") { verify_age_field(array); } - vx_expression *height_field = vx_expression_get_item("height", root); + vx_expression *height_field = vx_expression_get_item(vx_view_from_cstr("height"), root); REQUIRE(height_field != nullptr); defer { vx_expression_free(height_field); @@ -700,7 +702,7 @@ TEST_CASE("Filter with literal expression", "[filter]") { vx_expression_free(root); }; - vx_expression *age_field = vx_expression_get_item("age", root); + vx_expression *age_field = vx_expression_get_item(vx_view_from_cstr("age"), root); REQUIRE(age_field != nullptr); defer { vx_expression_free(age_field); @@ -752,7 +754,8 @@ TEST_CASE("Filter with literal expression", "[filter]") { TEST_CASE("Project UTF-8 literal expression", "[projection]") { constexpr auto value = "constant"sv; vx_error *scalar_error = nullptr; - vx_scalar *literal_scalar = vx_scalar_new_utf8(value.data(), value.size(), false, &scalar_error); + vx_scalar *literal_scalar = + vx_scalar_new_utf8(vx_view {value.data(), value.size()}, false, &scalar_error); require_no_error(scalar_error); REQUIRE(literal_scalar != nullptr); defer { @@ -776,12 +779,21 @@ TEST_CASE("Project UTF-8 literal expression", "[projection]") { REQUIRE(vx_array_len(array) == SAMPLE_ROWS); + vx_session *session = vx_session_new(); + defer { + vx_session_free(session); + }; + vx_error *canon_error = nullptr; + const vx_array *canonical = vx_array_canonicalize(session, array, &canon_error); + require_no_error(canon_error); + defer { + vx_array_free(canonical); + }; for (size_t i : {size_t {0}, SAMPLE_ROWS - 1}) { - const vx_string *actual = vx_array_get_utf8(array, static_cast(i)); - REQUIRE(actual != nullptr); - defer { - vx_string_free(actual); - }; + vx_error *at_error = nullptr; + const vx_view actual = vx_array_utf8_at(canonical, i, &at_error); + require_no_error(at_error); + REQUIRE(actual.ptr != nullptr); REQUIRE(to_string_view(actual) == value); } } @@ -861,7 +873,9 @@ TEST_CASE("Scan Arrow schema", "[scan]") { vx_error *error = nullptr; vx_data_source_options ds_options = {}; - ds_options.paths = path.c_str(); + const vx_view ds_path = vx_view_from_cstr(path.c_str()); + ds_options.paths = &ds_path; + ds_options.paths_len = 1; const vx_data_source *ds = vx_data_source_new(session, &ds_options, &error); require_no_error(error); @@ -903,7 +917,9 @@ TEST_CASE("Scan to Arrow", "[scan]") { vx_error *error = nullptr; vx_data_source_options ds_options = {}; - ds_options.paths = path.c_str(); + const vx_view ds_path = vx_view_from_cstr(path.c_str()); + ds_options.paths = &ds_path; + ds_options.paths_len = 1; const vx_data_source *ds = vx_data_source_new(session, &ds_options, &error); require_no_error(error); @@ -943,7 +959,9 @@ TEST_CASE("Broken scan with DType mismatch in filter", "[filter]") { vx_error *error = nullptr; vx_data_source_options ds_opts = {}; - ds_opts.paths = path.c_str(); + const vx_view ds_path = vx_view_from_cstr(path.c_str()); + ds_opts.paths = &ds_path; + ds_opts.paths_len = 1; const vx_data_source *ds = vx_data_source_new(session, &ds_opts, &error); require_no_error(error); defer { @@ -955,7 +973,7 @@ TEST_CASE("Broken scan with DType mismatch in filter", "[filter]") { vx_expression_free(root); }; - vx_expression *age_col = vx_expression_get_item("age", root); + vx_expression *age_col = vx_expression_get_item(vx_view_from_cstr("age"), root); REQUIRE(age_col != nullptr); defer { vx_expression_free(age_col); diff --git a/vortex-ffi/test/struct.cpp b/vortex-ffi/test/struct.cpp index 4a48e9390d2..2a45fccf56f 100644 --- a/vortex-ffi/test/struct.cpp +++ b/vortex-ffi/test/struct.cpp @@ -3,21 +3,22 @@ #include #include +#include "common.h" + using namespace std::string_view_literals; using namespace std::string_literals; TEST_CASE("Struct builder", "[struct]") { vx_struct_fields_builder *builder = vx_struct_fields_builder_new(); + vx_error *error = nullptr; - constexpr auto col1 = "col1"sv; - const vx_string *col1_name = vx_string_new(col1.data(), col1.size()); const vx_dtype *col1_dtype = vx_dtype_new_primitive(PTYPE_U8, false); - vx_struct_fields_builder_add_field(builder, col1_name, col1_dtype); + vx_struct_fields_builder_add_field(builder, vx_view_from_cstr("col1"), col1_dtype, &error); + require_no_error(error); - constexpr auto col2 = "col2"sv; - const vx_string *col2_name = vx_string_new(col2.data(), col2.size()); const vx_dtype *col2_dtype = vx_dtype_new_binary(true); - vx_struct_fields_builder_add_field(builder, col2_name, col2_dtype); + vx_struct_fields_builder_add_field(builder, vx_view_from_cstr("col2"), col2_dtype, &error); + require_no_error(error); SECTION("Struct builder free") { vx_struct_fields_builder_free(builder); @@ -41,12 +42,13 @@ constexpr size_t STRUCT_LEN = 10; TEST_CASE("Creating structs", "[struct]") { vx_struct_fields_builder *builder = vx_struct_fields_builder_new(); REQUIRE(builder != nullptr); + vx_error *error = nullptr; for (size_t i = 0; i < STRUCT_LEN; ++i) { const std::string target_name = "name"s + std::to_string(i); - const vx_string *name = vx_string_new(target_name.data(), target_name.size()); const vx_dtype *dtype = i % 2 ? vx_dtype_new_binary(false) : vx_dtype_new_primitive(PTYPE_F32, true); - vx_struct_fields_builder_add_field(builder, name, dtype); + vx_struct_fields_builder_add_field(builder, vx_view_from_cstr(target_name.c_str()), dtype, &error); + require_no_error(error); } vx_struct_fields *fields = vx_struct_fields_builder_finalize(builder); REQUIRE(fields != nullptr); @@ -54,13 +56,11 @@ TEST_CASE("Creating structs", "[struct]") { const size_t len = vx_struct_fields_nfields(fields); CHECK(len == STRUCT_LEN); for (size_t i = 0; i < len; ++i) { - const vx_string *name = vx_struct_fields_field_name(fields, i); + const vx_view name = vx_struct_fields_field_name(fields, i); const vx_dtype *dtype = vx_struct_fields_field_dtype(fields, i); - std::string_view name_view {vx_string_ptr(name), vx_string_len(name)}; std::string target_name = "name"s + std::to_string(i); - - CHECK(name_view == target_name); + CHECK(to_string_view(name) == target_name); if (i % 2) { CHECK_FALSE(vx_dtype_is_nullable(dtype)); @@ -71,7 +71,6 @@ TEST_CASE("Creating structs", "[struct]") { } vx_dtype_free(dtype); - vx_string_free(name); } vx_struct_fields_free(fields); From b9779d641af0fd9a3646f2b4b15f2220431d5cca Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Tue, 7 Jul 2026 23:02:30 +0100 Subject: [PATCH 031/104] Better error message in case of unknown encoding id (#8681) Unify error messages between layout and array encoding id missing --- vortex-array/src/serde.rs | 2 +- vortex-layout/src/children.rs | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/vortex-array/src/serde.rs b/vortex-array/src/serde.rs index 637b57324c0..9fdf72f39fb 100644 --- a/vortex-array/src/serde.rs +++ b/vortex-array/src/serde.rs @@ -329,7 +329,7 @@ impl SerializedArray { if session.allows_unknown() { return self.decode_foreign(encoding_id, dtype, len, ctx); } - return Err(vortex_err!("Unknown encoding: {}", encoding_id)); + vortex_bail!("Unknown encoding: {}", encoding_id); }; let children = SerializedArrayChildren { diff --git a/vortex-layout/src/children.rs b/vortex-layout/src/children.rs index dade2cbc4a4..4903265890e 100644 --- a/vortex-layout/src/children.rs +++ b/vortex-layout/src/children.rs @@ -218,15 +218,14 @@ impl LayoutChildren for ViewedLayoutChildren { let encoding_id = self .layout_read_ctx .resolve(fb_child.encoding()) - .ok_or_else(|| vortex_err!("Encoding not found: {}", fb_child.encoding()))?; + .ok_or_else(|| { + vortex_err!("Unknown layout encoding index: {}", fb_child.encoding()) + })?; let Some(encoding) = self.layouts.find(&encoding_id) else { if self.allow_unknown { return viewed_children.foreign_layout_from_fb(fb_child, dtype); } - return Err(vortex_err!( - "Encoding not found in registry: {}", - fb_child.encoding() - )); + vortex_bail!("Unknown layout encoding: {encoding_id}"); }; let build_ctx = LayoutBuildContext { From 3e32163098ebf5825df7fef07e31e0fdb5e544df Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Wed, 8 Jul 2026 14:52:18 +0100 Subject: [PATCH 032/104] Direct issues to templates instead of GitHub Discussions (#8685) ## Rationale for this change We want to move away from discussions in general because they are not great to work with and everyone is more familiar with issues. ## What changes are included in this PR? Remove the Discussions redirect from the issue chooser and add Feature Request and Question issue forms in its place. Update CONTRIBUTING.md and the docs to point at issues (or Slack) rather than Discussions, and fix bug_report.yml to apply the existing "bug" label instead of the nonexistent "needs-triage". ## What APIs are changed? Are there any user-facing changes? N/A Signed-off-by: Connor Tsui --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/ISSUE_TEMPLATE/config.yml | 6 ++--- .github/ISSUE_TEMPLATE/feature_request.yml | 27 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/question.yml | 24 +++++++++++++++++++ CONTRIBUTING.md | 10 ++++---- docs/developer-guide/embedding/index.md | 2 +- docs/developer-guide/extending/index.md | 2 +- docs/project/contributing.md | 12 ++++++---- 8 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/question.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 46e90627eca..2f656d25d63 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,6 @@ name: Bug Report description: Report something that isn't working correctly -labels: ["needs-triage"] +labels: ["bug"] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 61c4f9a55ed..f6694316c49 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,5 @@ blank_issues_enabled: true contact_links: - - name: Features, Questions - url: https://github.com/vortex-data/vortex/discussions/new/choose - about: Our preferred starting point if you have any questions or suggestions about configuration, features or behavior. + - name: Community Slack + url: https://vortex.dev/slack + about: Chat with the community for quick questions and discussion. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000000..e15b7ccca4d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,27 @@ +name: Feature Request +description: Suggest a new feature or improvement +labels: ["feature"] +body: + - type: textarea + id: problem + attributes: + label: Problem or motivation + description: What are you trying to do that Vortex doesn't support today? + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What would you like to see happen? + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional context + description: Alternatives considered, links, or anything else helpful + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 00000000000..c208dcdb954 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,24 @@ +name: Question +description: Ask a question about using or configuring Vortex +labels: ["question"] +body: + - type: markdown + attributes: + value: | + Please check the [documentation](https://docs.vortex.dev) first. For quick + back-and-forth, try the [Vortex Slack channel](https://vortex.dev/slack). + + - type: textarea + id: question + attributes: + label: What's your question? + validations: + required: true + + - type: textarea + id: context + attributes: + label: Context + description: What you've tried, relevant code, or your environment + validations: + required: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d32374293a..dcdf1cbfe19 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,11 +75,13 @@ At the time of writing, the following individuals serve as Committers & Maintain Our CI process enforces an extensive set of linter (e.g., `clippy`) rules, as well as language-specific formatters (e.g., `cargo fmt`). Beyond that, we document additional style guidelines in [STYLE.md](STYLE.md). -## Reporting Issues +## Issues and Questions -Bugs should be filed as [GitHub Issues](https://github.com/vortex-data/vortex/issues). Open-ended -questions and feature requests should be filed as -[GitHub Discussions](https://github.com/vortex-data/vortex/discussions). +Bugs, feature requests, and questions should all be filed as +[GitHub Issues](https://github.com/vortex-data/vortex/issues/new/choose). We strongly prefer that +you use one of the provided issue templates rather than opening a blank issue; templates make sure +we get the information needed to act on your report. For quick questions, the +[Vortex Slack channel](https://vortex.dev/slack) is also a good option. ## Developer Certificate of Origin (DCO) diff --git a/docs/developer-guide/embedding/index.md b/docs/developer-guide/embedding/index.md index e204939431b..8c8154718ca 100644 --- a/docs/developer-guide/embedding/index.md +++ b/docs/developer-guide/embedding/index.md @@ -3,7 +3,7 @@ :::{warning} This section is under construction. For guidance on embedding Vortex, please join the [Vortex Slack channel](https://vortex.dev/slack) -or start a [GitHub Discussion](https://github.com/vortex-data/vortex/discussions). +or open a [GitHub Issue](https://github.com/vortex-data/vortex/issues/new/choose). ::: Vortex can be embedded into applications and services via its C FFI, C++ wrapper, or the Scan API. diff --git a/docs/developer-guide/extending/index.md b/docs/developer-guide/extending/index.md index ff004a69bc5..c8e862c27be 100644 --- a/docs/developer-guide/extending/index.md +++ b/docs/developer-guide/extending/index.md @@ -3,7 +3,7 @@ :::{warning} This section is under construction. For guidance on extending Vortex, please join the [Vortex Slack channel](https://vortex.dev/slack) -or start a [GitHub Discussion](https://github.com/vortex-data/vortex/discussions). +or open a [GitHub Issue](https://github.com/vortex-data/vortex/issues/new/choose). ::: Vortex is designed to be extended with custom types, encodings, layouts, and compute functions. diff --git a/docs/project/contributing.md b/docs/project/contributing.md index fa6ea57d926..4721f335dfc 100644 --- a/docs/project/contributing.md +++ b/docs/project/contributing.md @@ -7,15 +7,17 @@ The full contributing guide lives in the repository: Below is a brief summary. -## Reporting Issues +## Issues and Questions -Bugs should be filed as [GitHub Issues](https://github.com/vortex-data/vortex/issues). Open-ended -questions and feature requests should be filed as -[GitHub Discussions](https://github.com/vortex-data/vortex/discussions). +Bugs, feature requests, and questions should all be filed as +[GitHub Issues](https://github.com/vortex-data/vortex/issues/new/choose). We strongly prefer that +you use one of the provided issue templates rather than opening a blank issue; templates make sure +we get the information needed to act on your report. For quick questions, the +[Vortex Slack channel](https://vortex.dev/slack) is also a good option. ## Code Contributions -1. Start a discussion on GitHub (unless the change is trivial). +1. Start a discussion by creating or commenting on a GitHub Issue (unless the change is trivial). 2. Implement the change, including tests for new functionality or bug fixes. 3. Open a pull request — ensure CI passes and that you sign off your commits (see below). CI requires approval from a committer for first-time contributors. From f1465af93d593e1ff58b05ba7abdc0fc7e793642 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Wed, 8 Jul 2026 14:58:46 +0100 Subject: [PATCH 033/104] Consolidate benchmark SQL queries into `vortex-bench/sql`directory (#8684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Rationale for this change This PR reorganizes benchmark SQL queries by consolidating them into a centralized `vortex-bench/sql/` directory structure. Previously, queries were either hardcoded as string arrays in Rust source files or scattered across different locations. This change improves maintainability by: 1. **Centralizing query definitions**: All SQL queries are now in dedicated `.sql` files, making them easier to review, edit, and version control 2. **Improving query discoverability**: Queries are now organized by benchmark name in a consistent directory structure 3. **Enabling better documentation**: SQL files can include comments explaining each query's purpose 4. **Reducing code duplication**: Eliminates the need to maintain query strings in both Rust code and documentation ## What changes are included in this PR? ### New SQL files created: - `vortex-bench/sql/fineweb.sql` - 9 string-focused queries (Q0-Q8) for the FineWeb dataset - `vortex-bench/sql/gharchive.sql` - 5 queries (Q0-Q4) for GitHub Archive nested event data ### SQL files reorganized: - Moved existing query files to `vortex-bench/sql/` subdirectories: - `appian/q{1..8}.sql` → `sql/appian/q{1..8}.sql` - `tpch/q*.sql` → `sql/tpch/q*.sql` - `tpcds/{01..99}.sql` → `sql/tpcds/{01..99}.sql` - `clickbench_queries.sql` → `sql/clickbench_queries.sql` - `polarsignals.sql` → `sql/polarsignals.sql` - `spatialbench.sql` → `sql/spatialbench.sql` - `statpopgen.sql` → `sql/statpopgen.sql` ### Rust source changes: - **`vortex-bench/src/fineweb/mod.rs`**: Replaced hardcoded `QUERIES` array with file-based loading from `sql/fineweb.sql` - **`vortex-bench/src/realnest/gharchive.rs`**: Replaced hardcoded `QUERIES` array with file-based loading from `sql/gharchive.sql` - **`vortex-bench/src/appian/mod.rs`**: Updated documentation to reflect new `sql/appian/` path - **`vortex-bench/src/clickbench/benchmark.rs`**: Updated default query file path to `sql/clickbench_queries.sql` - **`vortex-bench/src/tpch/mod.rs`**, **`vortex-bench/src/tpcds/mod.rs`**: Updated to use new `sql/` directory structure - **Other benchmarks** (`polarsignals`, `spatialbench`, `statpopgen`): Updated query file paths to use new `sql/` directory All query loading now uses a consistent pattern: read the SQL file, split on semicolons, trim whitespace, filter empty statements, and enumerate for query numbering. ## What APIs are changed? Are there any user-facing changes? No public APIs are changed. This is an internal reorganization of benchmark infrastructure. The benchmark behavior remains identical—queries are loaded and executed in the same order, just from external files instead of hardcoded strings. Users running benchmarks will not notice any difference in functionality or output. https://claude.ai/code/session_015ANEJi5xgm5tpb16ezGGzd Signed-off-by: Claude Co-authored-by: Claude --- vortex-bench/{ => sql}/appian/q1.sql | 0 vortex-bench/{ => sql}/appian/q2.sql | 0 vortex-bench/{ => sql}/appian/q3.sql | 0 vortex-bench/{ => sql}/appian/q4.sql | 0 vortex-bench/{ => sql}/appian/q5.sql | 0 vortex-bench/{ => sql}/appian/q6.sql | 0 vortex-bench/{ => sql}/appian/q7.sql | 0 vortex-bench/{ => sql}/appian/q8.sql | 0 vortex-bench/{ => sql}/clickbench_queries.sql | 0 vortex-bench/sql/fineweb.sql | 30 +++++++++++++++ vortex-bench/sql/gharchive.sql | 17 +++++++++ vortex-bench/{ => sql}/polarsignals.sql | 0 vortex-bench/{ => sql}/spatialbench.sql | 0 vortex-bench/{ => sql}/statpopgen.sql | 0 vortex-bench/{ => sql}/tpcds/01.sql | 0 vortex-bench/{ => sql}/tpcds/02.sql | 0 vortex-bench/{ => sql}/tpcds/03.sql | 0 vortex-bench/{ => sql}/tpcds/04.sql | 0 vortex-bench/{ => sql}/tpcds/05.sql | 0 vortex-bench/{ => sql}/tpcds/06.sql | 0 vortex-bench/{ => sql}/tpcds/07.sql | 0 vortex-bench/{ => sql}/tpcds/08.sql | 0 vortex-bench/{ => sql}/tpcds/09.sql | 0 vortex-bench/{ => sql}/tpcds/10.sql | 0 vortex-bench/{ => sql}/tpcds/11.sql | 0 vortex-bench/{ => sql}/tpcds/12.sql | 0 vortex-bench/{ => sql}/tpcds/13.sql | 0 vortex-bench/{ => sql}/tpcds/14.sql | 0 vortex-bench/{ => sql}/tpcds/15.sql | 0 vortex-bench/{ => sql}/tpcds/16.sql | 0 vortex-bench/{ => sql}/tpcds/17.sql | 0 vortex-bench/{ => sql}/tpcds/18.sql | 0 vortex-bench/{ => sql}/tpcds/19.sql | 0 vortex-bench/{ => sql}/tpcds/20.sql | 0 vortex-bench/{ => sql}/tpcds/21.sql | 0 vortex-bench/{ => sql}/tpcds/22.sql | 0 vortex-bench/{ => sql}/tpcds/23.sql | 0 vortex-bench/{ => sql}/tpcds/24.sql | 0 vortex-bench/{ => sql}/tpcds/25.sql | 0 vortex-bench/{ => sql}/tpcds/26.sql | 0 vortex-bench/{ => sql}/tpcds/27.sql | 0 vortex-bench/{ => sql}/tpcds/28.sql | 0 vortex-bench/{ => sql}/tpcds/29.sql | 0 vortex-bench/{ => sql}/tpcds/30.sql | 0 vortex-bench/{ => sql}/tpcds/31.sql | 0 vortex-bench/{ => sql}/tpcds/32.sql | 0 vortex-bench/{ => sql}/tpcds/33.sql | 0 vortex-bench/{ => sql}/tpcds/34.sql | 0 vortex-bench/{ => sql}/tpcds/35.sql | 0 vortex-bench/{ => sql}/tpcds/36.sql | 0 vortex-bench/{ => sql}/tpcds/37.sql | 0 vortex-bench/{ => sql}/tpcds/38.sql | 0 vortex-bench/{ => sql}/tpcds/39.sql | 0 vortex-bench/{ => sql}/tpcds/40.sql | 0 vortex-bench/{ => sql}/tpcds/41.sql | 0 vortex-bench/{ => sql}/tpcds/42.sql | 0 vortex-bench/{ => sql}/tpcds/43.sql | 0 vortex-bench/{ => sql}/tpcds/44.sql | 0 vortex-bench/{ => sql}/tpcds/45.sql | 0 vortex-bench/{ => sql}/tpcds/46.sql | 0 vortex-bench/{ => sql}/tpcds/47.sql | 0 vortex-bench/{ => sql}/tpcds/48.sql | 0 vortex-bench/{ => sql}/tpcds/49.sql | 0 vortex-bench/{ => sql}/tpcds/50.sql | 0 vortex-bench/{ => sql}/tpcds/51.sql | 0 vortex-bench/{ => sql}/tpcds/52.sql | 0 vortex-bench/{ => sql}/tpcds/53.sql | 0 vortex-bench/{ => sql}/tpcds/54.sql | 0 vortex-bench/{ => sql}/tpcds/55.sql | 0 vortex-bench/{ => sql}/tpcds/56.sql | 0 vortex-bench/{ => sql}/tpcds/57.sql | 0 vortex-bench/{ => sql}/tpcds/58.sql | 0 vortex-bench/{ => sql}/tpcds/59.sql | 0 vortex-bench/{ => sql}/tpcds/60.sql | 0 vortex-bench/{ => sql}/tpcds/61.sql | 0 vortex-bench/{ => sql}/tpcds/62.sql | 0 vortex-bench/{ => sql}/tpcds/63.sql | 0 vortex-bench/{ => sql}/tpcds/64.sql | 0 vortex-bench/{ => sql}/tpcds/65.sql | 0 vortex-bench/{ => sql}/tpcds/66.sql | 0 vortex-bench/{ => sql}/tpcds/67.sql | 0 vortex-bench/{ => sql}/tpcds/68.sql | 0 vortex-bench/{ => sql}/tpcds/69.sql | 0 vortex-bench/{ => sql}/tpcds/70.sql | 0 vortex-bench/{ => sql}/tpcds/71.sql | 0 vortex-bench/{ => sql}/tpcds/72.sql | 0 vortex-bench/{ => sql}/tpcds/73.sql | 0 vortex-bench/{ => sql}/tpcds/74.sql | 0 vortex-bench/{ => sql}/tpcds/75.sql | 0 vortex-bench/{ => sql}/tpcds/76.sql | 0 vortex-bench/{ => sql}/tpcds/77.sql | 0 vortex-bench/{ => sql}/tpcds/78.sql | 0 vortex-bench/{ => sql}/tpcds/79.sql | 0 vortex-bench/{ => sql}/tpcds/80.sql | 0 vortex-bench/{ => sql}/tpcds/81.sql | 0 vortex-bench/{ => sql}/tpcds/82.sql | 0 vortex-bench/{ => sql}/tpcds/83.sql | 0 vortex-bench/{ => sql}/tpcds/84.sql | 0 vortex-bench/{ => sql}/tpcds/85.sql | 0 vortex-bench/{ => sql}/tpcds/86.sql | 0 vortex-bench/{ => sql}/tpcds/87.sql | 0 vortex-bench/{ => sql}/tpcds/88.sql | 0 vortex-bench/{ => sql}/tpcds/89.sql | 0 vortex-bench/{ => sql}/tpcds/90.sql | 0 vortex-bench/{ => sql}/tpcds/91.sql | 0 vortex-bench/{ => sql}/tpcds/92.sql | 0 vortex-bench/{ => sql}/tpcds/93.sql | 0 vortex-bench/{ => sql}/tpcds/94.sql | 0 vortex-bench/{ => sql}/tpcds/95.sql | 0 vortex-bench/{ => sql}/tpcds/96.sql | 0 vortex-bench/{ => sql}/tpcds/97.sql | 0 vortex-bench/{ => sql}/tpcds/98.sql | 0 vortex-bench/{ => sql}/tpcds/99.sql | 0 vortex-bench/{ => sql}/tpch/q1.sql | 0 vortex-bench/{ => sql}/tpch/q10.sql | 0 vortex-bench/{ => sql}/tpch/q11.sql | 0 vortex-bench/{ => sql}/tpch/q12.sql | 0 vortex-bench/{ => sql}/tpch/q13.sql | 0 vortex-bench/{ => sql}/tpch/q14.sql | 0 vortex-bench/{ => sql}/tpch/q15.sql | 0 vortex-bench/{ => sql}/tpch/q16.sql | 0 vortex-bench/{ => sql}/tpch/q17.sql | 0 vortex-bench/{ => sql}/tpch/q18.sql | 0 vortex-bench/{ => sql}/tpch/q19.sql | 0 vortex-bench/{ => sql}/tpch/q2.sql | 0 vortex-bench/{ => sql}/tpch/q20.sql | 0 vortex-bench/{ => sql}/tpch/q21.sql | 0 vortex-bench/{ => sql}/tpch/q22.sql | 0 vortex-bench/{ => sql}/tpch/q3.sql | 0 vortex-bench/{ => sql}/tpch/q4.sql | 0 vortex-bench/{ => sql}/tpch/q5.sql | 0 vortex-bench/{ => sql}/tpch/q6.sql | 0 vortex-bench/{ => sql}/tpch/q7.sql | 0 vortex-bench/{ => sql}/tpch/q8.sql | 0 vortex-bench/{ => sql}/tpch/q9.sql | 0 vortex-bench/src/appian/mod.rs | 5 ++- vortex-bench/src/clickbench/benchmark.rs | 4 +- vortex-bench/src/fineweb/mod.rs | 38 +++++++++---------- vortex-bench/src/polarsignals/benchmark.rs | 1 + vortex-bench/src/realnest/gharchive.rs | 26 ++++++++----- vortex-bench/src/spatialbench/benchmark.rs | 1 + .../src/statpopgen/statpopgen_benchmark.rs | 1 + vortex-bench/src/tpcds/mod.rs | 1 + vortex-bench/src/tpch/mod.rs | 1 + 144 files changed, 92 insertions(+), 33 deletions(-) rename vortex-bench/{ => sql}/appian/q1.sql (100%) rename vortex-bench/{ => sql}/appian/q2.sql (100%) rename vortex-bench/{ => sql}/appian/q3.sql (100%) rename vortex-bench/{ => sql}/appian/q4.sql (100%) rename vortex-bench/{ => sql}/appian/q5.sql (100%) rename vortex-bench/{ => sql}/appian/q6.sql (100%) rename vortex-bench/{ => sql}/appian/q7.sql (100%) rename vortex-bench/{ => sql}/appian/q8.sql (100%) rename vortex-bench/{ => sql}/clickbench_queries.sql (100%) create mode 100644 vortex-bench/sql/fineweb.sql create mode 100644 vortex-bench/sql/gharchive.sql rename vortex-bench/{ => sql}/polarsignals.sql (100%) rename vortex-bench/{ => sql}/spatialbench.sql (100%) rename vortex-bench/{ => sql}/statpopgen.sql (100%) rename vortex-bench/{ => sql}/tpcds/01.sql (100%) rename vortex-bench/{ => sql}/tpcds/02.sql (100%) rename vortex-bench/{ => sql}/tpcds/03.sql (100%) rename vortex-bench/{ => sql}/tpcds/04.sql (100%) rename vortex-bench/{ => sql}/tpcds/05.sql (100%) rename vortex-bench/{ => sql}/tpcds/06.sql (100%) rename vortex-bench/{ => sql}/tpcds/07.sql (100%) rename vortex-bench/{ => sql}/tpcds/08.sql (100%) rename vortex-bench/{ => sql}/tpcds/09.sql (100%) rename vortex-bench/{ => sql}/tpcds/10.sql (100%) rename vortex-bench/{ => sql}/tpcds/11.sql (100%) rename vortex-bench/{ => sql}/tpcds/12.sql (100%) rename vortex-bench/{ => sql}/tpcds/13.sql (100%) rename vortex-bench/{ => sql}/tpcds/14.sql (100%) rename vortex-bench/{ => sql}/tpcds/15.sql (100%) rename vortex-bench/{ => sql}/tpcds/16.sql (100%) rename vortex-bench/{ => sql}/tpcds/17.sql (100%) rename vortex-bench/{ => sql}/tpcds/18.sql (100%) rename vortex-bench/{ => sql}/tpcds/19.sql (100%) rename vortex-bench/{ => sql}/tpcds/20.sql (100%) rename vortex-bench/{ => sql}/tpcds/21.sql (100%) rename vortex-bench/{ => sql}/tpcds/22.sql (100%) rename vortex-bench/{ => sql}/tpcds/23.sql (100%) rename vortex-bench/{ => sql}/tpcds/24.sql (100%) rename vortex-bench/{ => sql}/tpcds/25.sql (100%) rename vortex-bench/{ => sql}/tpcds/26.sql (100%) rename vortex-bench/{ => sql}/tpcds/27.sql (100%) rename vortex-bench/{ => sql}/tpcds/28.sql (100%) rename vortex-bench/{ => sql}/tpcds/29.sql (100%) rename vortex-bench/{ => sql}/tpcds/30.sql (100%) rename vortex-bench/{ => sql}/tpcds/31.sql (100%) rename vortex-bench/{ => sql}/tpcds/32.sql (100%) rename vortex-bench/{ => sql}/tpcds/33.sql (100%) rename vortex-bench/{ => sql}/tpcds/34.sql (100%) rename vortex-bench/{ => sql}/tpcds/35.sql (100%) rename vortex-bench/{ => sql}/tpcds/36.sql (100%) rename vortex-bench/{ => sql}/tpcds/37.sql (100%) rename vortex-bench/{ => sql}/tpcds/38.sql (100%) rename vortex-bench/{ => sql}/tpcds/39.sql (100%) rename vortex-bench/{ => sql}/tpcds/40.sql (100%) rename vortex-bench/{ => sql}/tpcds/41.sql (100%) rename vortex-bench/{ => sql}/tpcds/42.sql (100%) rename vortex-bench/{ => sql}/tpcds/43.sql (100%) rename vortex-bench/{ => sql}/tpcds/44.sql (100%) rename vortex-bench/{ => sql}/tpcds/45.sql (100%) rename vortex-bench/{ => sql}/tpcds/46.sql (100%) rename vortex-bench/{ => sql}/tpcds/47.sql (100%) rename vortex-bench/{ => sql}/tpcds/48.sql (100%) rename vortex-bench/{ => sql}/tpcds/49.sql (100%) rename vortex-bench/{ => sql}/tpcds/50.sql (100%) rename vortex-bench/{ => sql}/tpcds/51.sql (100%) rename vortex-bench/{ => sql}/tpcds/52.sql (100%) rename vortex-bench/{ => sql}/tpcds/53.sql (100%) rename vortex-bench/{ => sql}/tpcds/54.sql (100%) rename vortex-bench/{ => sql}/tpcds/55.sql (100%) rename vortex-bench/{ => sql}/tpcds/56.sql (100%) rename vortex-bench/{ => sql}/tpcds/57.sql (100%) rename vortex-bench/{ => sql}/tpcds/58.sql (100%) rename vortex-bench/{ => sql}/tpcds/59.sql (100%) rename vortex-bench/{ => sql}/tpcds/60.sql (100%) rename vortex-bench/{ => sql}/tpcds/61.sql (100%) rename vortex-bench/{ => sql}/tpcds/62.sql (100%) rename vortex-bench/{ => sql}/tpcds/63.sql (100%) rename vortex-bench/{ => sql}/tpcds/64.sql (100%) rename vortex-bench/{ => sql}/tpcds/65.sql (100%) rename vortex-bench/{ => sql}/tpcds/66.sql (100%) rename vortex-bench/{ => sql}/tpcds/67.sql (100%) rename vortex-bench/{ => sql}/tpcds/68.sql (100%) rename vortex-bench/{ => sql}/tpcds/69.sql (100%) rename vortex-bench/{ => sql}/tpcds/70.sql (100%) rename vortex-bench/{ => sql}/tpcds/71.sql (100%) rename vortex-bench/{ => sql}/tpcds/72.sql (100%) rename vortex-bench/{ => sql}/tpcds/73.sql (100%) rename vortex-bench/{ => sql}/tpcds/74.sql (100%) rename vortex-bench/{ => sql}/tpcds/75.sql (100%) rename vortex-bench/{ => sql}/tpcds/76.sql (100%) rename vortex-bench/{ => sql}/tpcds/77.sql (100%) rename vortex-bench/{ => sql}/tpcds/78.sql (100%) rename vortex-bench/{ => sql}/tpcds/79.sql (100%) rename vortex-bench/{ => sql}/tpcds/80.sql (100%) rename vortex-bench/{ => sql}/tpcds/81.sql (100%) rename vortex-bench/{ => sql}/tpcds/82.sql (100%) rename vortex-bench/{ => sql}/tpcds/83.sql (100%) rename vortex-bench/{ => sql}/tpcds/84.sql (100%) rename vortex-bench/{ => sql}/tpcds/85.sql (100%) rename vortex-bench/{ => sql}/tpcds/86.sql (100%) rename vortex-bench/{ => sql}/tpcds/87.sql (100%) rename vortex-bench/{ => sql}/tpcds/88.sql (100%) rename vortex-bench/{ => sql}/tpcds/89.sql (100%) rename vortex-bench/{ => sql}/tpcds/90.sql (100%) rename vortex-bench/{ => sql}/tpcds/91.sql (100%) rename vortex-bench/{ => sql}/tpcds/92.sql (100%) rename vortex-bench/{ => sql}/tpcds/93.sql (100%) rename vortex-bench/{ => sql}/tpcds/94.sql (100%) rename vortex-bench/{ => sql}/tpcds/95.sql (100%) rename vortex-bench/{ => sql}/tpcds/96.sql (100%) rename vortex-bench/{ => sql}/tpcds/97.sql (100%) rename vortex-bench/{ => sql}/tpcds/98.sql (100%) rename vortex-bench/{ => sql}/tpcds/99.sql (100%) rename vortex-bench/{ => sql}/tpch/q1.sql (100%) rename vortex-bench/{ => sql}/tpch/q10.sql (100%) rename vortex-bench/{ => sql}/tpch/q11.sql (100%) rename vortex-bench/{ => sql}/tpch/q12.sql (100%) rename vortex-bench/{ => sql}/tpch/q13.sql (100%) rename vortex-bench/{ => sql}/tpch/q14.sql (100%) rename vortex-bench/{ => sql}/tpch/q15.sql (100%) rename vortex-bench/{ => sql}/tpch/q16.sql (100%) rename vortex-bench/{ => sql}/tpch/q17.sql (100%) rename vortex-bench/{ => sql}/tpch/q18.sql (100%) rename vortex-bench/{ => sql}/tpch/q19.sql (100%) rename vortex-bench/{ => sql}/tpch/q2.sql (100%) rename vortex-bench/{ => sql}/tpch/q20.sql (100%) rename vortex-bench/{ => sql}/tpch/q21.sql (100%) rename vortex-bench/{ => sql}/tpch/q22.sql (100%) rename vortex-bench/{ => sql}/tpch/q3.sql (100%) rename vortex-bench/{ => sql}/tpch/q4.sql (100%) rename vortex-bench/{ => sql}/tpch/q5.sql (100%) rename vortex-bench/{ => sql}/tpch/q6.sql (100%) rename vortex-bench/{ => sql}/tpch/q7.sql (100%) rename vortex-bench/{ => sql}/tpch/q8.sql (100%) rename vortex-bench/{ => sql}/tpch/q9.sql (100%) diff --git a/vortex-bench/appian/q1.sql b/vortex-bench/sql/appian/q1.sql similarity index 100% rename from vortex-bench/appian/q1.sql rename to vortex-bench/sql/appian/q1.sql diff --git a/vortex-bench/appian/q2.sql b/vortex-bench/sql/appian/q2.sql similarity index 100% rename from vortex-bench/appian/q2.sql rename to vortex-bench/sql/appian/q2.sql diff --git a/vortex-bench/appian/q3.sql b/vortex-bench/sql/appian/q3.sql similarity index 100% rename from vortex-bench/appian/q3.sql rename to vortex-bench/sql/appian/q3.sql diff --git a/vortex-bench/appian/q4.sql b/vortex-bench/sql/appian/q4.sql similarity index 100% rename from vortex-bench/appian/q4.sql rename to vortex-bench/sql/appian/q4.sql diff --git a/vortex-bench/appian/q5.sql b/vortex-bench/sql/appian/q5.sql similarity index 100% rename from vortex-bench/appian/q5.sql rename to vortex-bench/sql/appian/q5.sql diff --git a/vortex-bench/appian/q6.sql b/vortex-bench/sql/appian/q6.sql similarity index 100% rename from vortex-bench/appian/q6.sql rename to vortex-bench/sql/appian/q6.sql diff --git a/vortex-bench/appian/q7.sql b/vortex-bench/sql/appian/q7.sql similarity index 100% rename from vortex-bench/appian/q7.sql rename to vortex-bench/sql/appian/q7.sql diff --git a/vortex-bench/appian/q8.sql b/vortex-bench/sql/appian/q8.sql similarity index 100% rename from vortex-bench/appian/q8.sql rename to vortex-bench/sql/appian/q8.sql diff --git a/vortex-bench/clickbench_queries.sql b/vortex-bench/sql/clickbench_queries.sql similarity index 100% rename from vortex-bench/clickbench_queries.sql rename to vortex-bench/sql/clickbench_queries.sql diff --git a/vortex-bench/sql/fineweb.sql b/vortex-bench/sql/fineweb.sql new file mode 100644 index 00000000000..7f97d303236 --- /dev/null +++ b/vortex-bench/sql/fineweb.sql @@ -0,0 +1,30 @@ +-- Some basic string-focused queries against the HuggingFace FineWeb dataset, numbered from +-- Q0 in file order. The harness splits the file on semicolons, so a comment must never +-- contain one. + +-- Q0: simple summary. +SELECT count(DISTINCT dump) FROM fineweb; + +-- Q1: selective string equality filter. +SELECT * FROM fineweb WHERE dump = 'CC-MAIN-2016-30'; + +-- Q2: LIKE with prefix filter. +SELECT * FROM fineweb WHERE date LIKE '2020-10-%'; + +-- Q3: LIKE with simple containment filter. +SELECT * FROM fineweb WHERE url LIKE '%google%' AND text LIKE '%Google%'; + +-- Q4: LIKE with larger containment filter. +SELECT * FROM fineweb WHERE url LIKE '%.google.%' OR text LIKE '% Google %'; + +-- Q5: LIKE with rare containment filter. +SELECT * FROM fineweb WHERE text LIKE '% vortex %'; + +-- Q6: more LIKE filters. +SELECT * FROM fineweb WHERE url LIKE '%espn%' AND language = 'en' AND language_score > 0.92; + +-- Q7: more LIKE filters. +SELECT * FROM fineweb WHERE url LIKE '%espn%' OR url LIKE '%www.espn.go.com%' OR url LIKE '%espn.go.com%'; + +-- Q8: no results, stats cannot prune but tokenized bloom filters could. +SELECT * FROM fineweb WHERE file_path LIKE '%/CC-MAIN-2014-%'; diff --git a/vortex-bench/sql/gharchive.sql b/vortex-bench/sql/gharchive.sql new file mode 100644 index 00000000000..a3280bd25d4 --- /dev/null +++ b/vortex-bench/sql/gharchive.sql @@ -0,0 +1,17 @@ +-- GitHub Archive queries over nested event data, numbered from Q0 in file order. The +-- harness splits the file on semicolons, so a comment must never contain one. + +-- Q0. +select count(*) from events where payload.ref = 'refs/heads/main'; + +-- Q1. +select distinct repo.name from events where repo.name like 'spiraldb/%'; + +-- Q2. +select distinct org.id as org_id from events order by org_id limit 100; + +-- Q3. +select actor.login, count() as freq from events group by actor.login order by freq desc limit 10; + +-- Q4. +select actor.avatar_url from events where actor.login = 'renovate[bot]'; diff --git a/vortex-bench/polarsignals.sql b/vortex-bench/sql/polarsignals.sql similarity index 100% rename from vortex-bench/polarsignals.sql rename to vortex-bench/sql/polarsignals.sql diff --git a/vortex-bench/spatialbench.sql b/vortex-bench/sql/spatialbench.sql similarity index 100% rename from vortex-bench/spatialbench.sql rename to vortex-bench/sql/spatialbench.sql diff --git a/vortex-bench/statpopgen.sql b/vortex-bench/sql/statpopgen.sql similarity index 100% rename from vortex-bench/statpopgen.sql rename to vortex-bench/sql/statpopgen.sql diff --git a/vortex-bench/tpcds/01.sql b/vortex-bench/sql/tpcds/01.sql similarity index 100% rename from vortex-bench/tpcds/01.sql rename to vortex-bench/sql/tpcds/01.sql diff --git a/vortex-bench/tpcds/02.sql b/vortex-bench/sql/tpcds/02.sql similarity index 100% rename from vortex-bench/tpcds/02.sql rename to vortex-bench/sql/tpcds/02.sql diff --git a/vortex-bench/tpcds/03.sql b/vortex-bench/sql/tpcds/03.sql similarity index 100% rename from vortex-bench/tpcds/03.sql rename to vortex-bench/sql/tpcds/03.sql diff --git a/vortex-bench/tpcds/04.sql b/vortex-bench/sql/tpcds/04.sql similarity index 100% rename from vortex-bench/tpcds/04.sql rename to vortex-bench/sql/tpcds/04.sql diff --git a/vortex-bench/tpcds/05.sql b/vortex-bench/sql/tpcds/05.sql similarity index 100% rename from vortex-bench/tpcds/05.sql rename to vortex-bench/sql/tpcds/05.sql diff --git a/vortex-bench/tpcds/06.sql b/vortex-bench/sql/tpcds/06.sql similarity index 100% rename from vortex-bench/tpcds/06.sql rename to vortex-bench/sql/tpcds/06.sql diff --git a/vortex-bench/tpcds/07.sql b/vortex-bench/sql/tpcds/07.sql similarity index 100% rename from vortex-bench/tpcds/07.sql rename to vortex-bench/sql/tpcds/07.sql diff --git a/vortex-bench/tpcds/08.sql b/vortex-bench/sql/tpcds/08.sql similarity index 100% rename from vortex-bench/tpcds/08.sql rename to vortex-bench/sql/tpcds/08.sql diff --git a/vortex-bench/tpcds/09.sql b/vortex-bench/sql/tpcds/09.sql similarity index 100% rename from vortex-bench/tpcds/09.sql rename to vortex-bench/sql/tpcds/09.sql diff --git a/vortex-bench/tpcds/10.sql b/vortex-bench/sql/tpcds/10.sql similarity index 100% rename from vortex-bench/tpcds/10.sql rename to vortex-bench/sql/tpcds/10.sql diff --git a/vortex-bench/tpcds/11.sql b/vortex-bench/sql/tpcds/11.sql similarity index 100% rename from vortex-bench/tpcds/11.sql rename to vortex-bench/sql/tpcds/11.sql diff --git a/vortex-bench/tpcds/12.sql b/vortex-bench/sql/tpcds/12.sql similarity index 100% rename from vortex-bench/tpcds/12.sql rename to vortex-bench/sql/tpcds/12.sql diff --git a/vortex-bench/tpcds/13.sql b/vortex-bench/sql/tpcds/13.sql similarity index 100% rename from vortex-bench/tpcds/13.sql rename to vortex-bench/sql/tpcds/13.sql diff --git a/vortex-bench/tpcds/14.sql b/vortex-bench/sql/tpcds/14.sql similarity index 100% rename from vortex-bench/tpcds/14.sql rename to vortex-bench/sql/tpcds/14.sql diff --git a/vortex-bench/tpcds/15.sql b/vortex-bench/sql/tpcds/15.sql similarity index 100% rename from vortex-bench/tpcds/15.sql rename to vortex-bench/sql/tpcds/15.sql diff --git a/vortex-bench/tpcds/16.sql b/vortex-bench/sql/tpcds/16.sql similarity index 100% rename from vortex-bench/tpcds/16.sql rename to vortex-bench/sql/tpcds/16.sql diff --git a/vortex-bench/tpcds/17.sql b/vortex-bench/sql/tpcds/17.sql similarity index 100% rename from vortex-bench/tpcds/17.sql rename to vortex-bench/sql/tpcds/17.sql diff --git a/vortex-bench/tpcds/18.sql b/vortex-bench/sql/tpcds/18.sql similarity index 100% rename from vortex-bench/tpcds/18.sql rename to vortex-bench/sql/tpcds/18.sql diff --git a/vortex-bench/tpcds/19.sql b/vortex-bench/sql/tpcds/19.sql similarity index 100% rename from vortex-bench/tpcds/19.sql rename to vortex-bench/sql/tpcds/19.sql diff --git a/vortex-bench/tpcds/20.sql b/vortex-bench/sql/tpcds/20.sql similarity index 100% rename from vortex-bench/tpcds/20.sql rename to vortex-bench/sql/tpcds/20.sql diff --git a/vortex-bench/tpcds/21.sql b/vortex-bench/sql/tpcds/21.sql similarity index 100% rename from vortex-bench/tpcds/21.sql rename to vortex-bench/sql/tpcds/21.sql diff --git a/vortex-bench/tpcds/22.sql b/vortex-bench/sql/tpcds/22.sql similarity index 100% rename from vortex-bench/tpcds/22.sql rename to vortex-bench/sql/tpcds/22.sql diff --git a/vortex-bench/tpcds/23.sql b/vortex-bench/sql/tpcds/23.sql similarity index 100% rename from vortex-bench/tpcds/23.sql rename to vortex-bench/sql/tpcds/23.sql diff --git a/vortex-bench/tpcds/24.sql b/vortex-bench/sql/tpcds/24.sql similarity index 100% rename from vortex-bench/tpcds/24.sql rename to vortex-bench/sql/tpcds/24.sql diff --git a/vortex-bench/tpcds/25.sql b/vortex-bench/sql/tpcds/25.sql similarity index 100% rename from vortex-bench/tpcds/25.sql rename to vortex-bench/sql/tpcds/25.sql diff --git a/vortex-bench/tpcds/26.sql b/vortex-bench/sql/tpcds/26.sql similarity index 100% rename from vortex-bench/tpcds/26.sql rename to vortex-bench/sql/tpcds/26.sql diff --git a/vortex-bench/tpcds/27.sql b/vortex-bench/sql/tpcds/27.sql similarity index 100% rename from vortex-bench/tpcds/27.sql rename to vortex-bench/sql/tpcds/27.sql diff --git a/vortex-bench/tpcds/28.sql b/vortex-bench/sql/tpcds/28.sql similarity index 100% rename from vortex-bench/tpcds/28.sql rename to vortex-bench/sql/tpcds/28.sql diff --git a/vortex-bench/tpcds/29.sql b/vortex-bench/sql/tpcds/29.sql similarity index 100% rename from vortex-bench/tpcds/29.sql rename to vortex-bench/sql/tpcds/29.sql diff --git a/vortex-bench/tpcds/30.sql b/vortex-bench/sql/tpcds/30.sql similarity index 100% rename from vortex-bench/tpcds/30.sql rename to vortex-bench/sql/tpcds/30.sql diff --git a/vortex-bench/tpcds/31.sql b/vortex-bench/sql/tpcds/31.sql similarity index 100% rename from vortex-bench/tpcds/31.sql rename to vortex-bench/sql/tpcds/31.sql diff --git a/vortex-bench/tpcds/32.sql b/vortex-bench/sql/tpcds/32.sql similarity index 100% rename from vortex-bench/tpcds/32.sql rename to vortex-bench/sql/tpcds/32.sql diff --git a/vortex-bench/tpcds/33.sql b/vortex-bench/sql/tpcds/33.sql similarity index 100% rename from vortex-bench/tpcds/33.sql rename to vortex-bench/sql/tpcds/33.sql diff --git a/vortex-bench/tpcds/34.sql b/vortex-bench/sql/tpcds/34.sql similarity index 100% rename from vortex-bench/tpcds/34.sql rename to vortex-bench/sql/tpcds/34.sql diff --git a/vortex-bench/tpcds/35.sql b/vortex-bench/sql/tpcds/35.sql similarity index 100% rename from vortex-bench/tpcds/35.sql rename to vortex-bench/sql/tpcds/35.sql diff --git a/vortex-bench/tpcds/36.sql b/vortex-bench/sql/tpcds/36.sql similarity index 100% rename from vortex-bench/tpcds/36.sql rename to vortex-bench/sql/tpcds/36.sql diff --git a/vortex-bench/tpcds/37.sql b/vortex-bench/sql/tpcds/37.sql similarity index 100% rename from vortex-bench/tpcds/37.sql rename to vortex-bench/sql/tpcds/37.sql diff --git a/vortex-bench/tpcds/38.sql b/vortex-bench/sql/tpcds/38.sql similarity index 100% rename from vortex-bench/tpcds/38.sql rename to vortex-bench/sql/tpcds/38.sql diff --git a/vortex-bench/tpcds/39.sql b/vortex-bench/sql/tpcds/39.sql similarity index 100% rename from vortex-bench/tpcds/39.sql rename to vortex-bench/sql/tpcds/39.sql diff --git a/vortex-bench/tpcds/40.sql b/vortex-bench/sql/tpcds/40.sql similarity index 100% rename from vortex-bench/tpcds/40.sql rename to vortex-bench/sql/tpcds/40.sql diff --git a/vortex-bench/tpcds/41.sql b/vortex-bench/sql/tpcds/41.sql similarity index 100% rename from vortex-bench/tpcds/41.sql rename to vortex-bench/sql/tpcds/41.sql diff --git a/vortex-bench/tpcds/42.sql b/vortex-bench/sql/tpcds/42.sql similarity index 100% rename from vortex-bench/tpcds/42.sql rename to vortex-bench/sql/tpcds/42.sql diff --git a/vortex-bench/tpcds/43.sql b/vortex-bench/sql/tpcds/43.sql similarity index 100% rename from vortex-bench/tpcds/43.sql rename to vortex-bench/sql/tpcds/43.sql diff --git a/vortex-bench/tpcds/44.sql b/vortex-bench/sql/tpcds/44.sql similarity index 100% rename from vortex-bench/tpcds/44.sql rename to vortex-bench/sql/tpcds/44.sql diff --git a/vortex-bench/tpcds/45.sql b/vortex-bench/sql/tpcds/45.sql similarity index 100% rename from vortex-bench/tpcds/45.sql rename to vortex-bench/sql/tpcds/45.sql diff --git a/vortex-bench/tpcds/46.sql b/vortex-bench/sql/tpcds/46.sql similarity index 100% rename from vortex-bench/tpcds/46.sql rename to vortex-bench/sql/tpcds/46.sql diff --git a/vortex-bench/tpcds/47.sql b/vortex-bench/sql/tpcds/47.sql similarity index 100% rename from vortex-bench/tpcds/47.sql rename to vortex-bench/sql/tpcds/47.sql diff --git a/vortex-bench/tpcds/48.sql b/vortex-bench/sql/tpcds/48.sql similarity index 100% rename from vortex-bench/tpcds/48.sql rename to vortex-bench/sql/tpcds/48.sql diff --git a/vortex-bench/tpcds/49.sql b/vortex-bench/sql/tpcds/49.sql similarity index 100% rename from vortex-bench/tpcds/49.sql rename to vortex-bench/sql/tpcds/49.sql diff --git a/vortex-bench/tpcds/50.sql b/vortex-bench/sql/tpcds/50.sql similarity index 100% rename from vortex-bench/tpcds/50.sql rename to vortex-bench/sql/tpcds/50.sql diff --git a/vortex-bench/tpcds/51.sql b/vortex-bench/sql/tpcds/51.sql similarity index 100% rename from vortex-bench/tpcds/51.sql rename to vortex-bench/sql/tpcds/51.sql diff --git a/vortex-bench/tpcds/52.sql b/vortex-bench/sql/tpcds/52.sql similarity index 100% rename from vortex-bench/tpcds/52.sql rename to vortex-bench/sql/tpcds/52.sql diff --git a/vortex-bench/tpcds/53.sql b/vortex-bench/sql/tpcds/53.sql similarity index 100% rename from vortex-bench/tpcds/53.sql rename to vortex-bench/sql/tpcds/53.sql diff --git a/vortex-bench/tpcds/54.sql b/vortex-bench/sql/tpcds/54.sql similarity index 100% rename from vortex-bench/tpcds/54.sql rename to vortex-bench/sql/tpcds/54.sql diff --git a/vortex-bench/tpcds/55.sql b/vortex-bench/sql/tpcds/55.sql similarity index 100% rename from vortex-bench/tpcds/55.sql rename to vortex-bench/sql/tpcds/55.sql diff --git a/vortex-bench/tpcds/56.sql b/vortex-bench/sql/tpcds/56.sql similarity index 100% rename from vortex-bench/tpcds/56.sql rename to vortex-bench/sql/tpcds/56.sql diff --git a/vortex-bench/tpcds/57.sql b/vortex-bench/sql/tpcds/57.sql similarity index 100% rename from vortex-bench/tpcds/57.sql rename to vortex-bench/sql/tpcds/57.sql diff --git a/vortex-bench/tpcds/58.sql b/vortex-bench/sql/tpcds/58.sql similarity index 100% rename from vortex-bench/tpcds/58.sql rename to vortex-bench/sql/tpcds/58.sql diff --git a/vortex-bench/tpcds/59.sql b/vortex-bench/sql/tpcds/59.sql similarity index 100% rename from vortex-bench/tpcds/59.sql rename to vortex-bench/sql/tpcds/59.sql diff --git a/vortex-bench/tpcds/60.sql b/vortex-bench/sql/tpcds/60.sql similarity index 100% rename from vortex-bench/tpcds/60.sql rename to vortex-bench/sql/tpcds/60.sql diff --git a/vortex-bench/tpcds/61.sql b/vortex-bench/sql/tpcds/61.sql similarity index 100% rename from vortex-bench/tpcds/61.sql rename to vortex-bench/sql/tpcds/61.sql diff --git a/vortex-bench/tpcds/62.sql b/vortex-bench/sql/tpcds/62.sql similarity index 100% rename from vortex-bench/tpcds/62.sql rename to vortex-bench/sql/tpcds/62.sql diff --git a/vortex-bench/tpcds/63.sql b/vortex-bench/sql/tpcds/63.sql similarity index 100% rename from vortex-bench/tpcds/63.sql rename to vortex-bench/sql/tpcds/63.sql diff --git a/vortex-bench/tpcds/64.sql b/vortex-bench/sql/tpcds/64.sql similarity index 100% rename from vortex-bench/tpcds/64.sql rename to vortex-bench/sql/tpcds/64.sql diff --git a/vortex-bench/tpcds/65.sql b/vortex-bench/sql/tpcds/65.sql similarity index 100% rename from vortex-bench/tpcds/65.sql rename to vortex-bench/sql/tpcds/65.sql diff --git a/vortex-bench/tpcds/66.sql b/vortex-bench/sql/tpcds/66.sql similarity index 100% rename from vortex-bench/tpcds/66.sql rename to vortex-bench/sql/tpcds/66.sql diff --git a/vortex-bench/tpcds/67.sql b/vortex-bench/sql/tpcds/67.sql similarity index 100% rename from vortex-bench/tpcds/67.sql rename to vortex-bench/sql/tpcds/67.sql diff --git a/vortex-bench/tpcds/68.sql b/vortex-bench/sql/tpcds/68.sql similarity index 100% rename from vortex-bench/tpcds/68.sql rename to vortex-bench/sql/tpcds/68.sql diff --git a/vortex-bench/tpcds/69.sql b/vortex-bench/sql/tpcds/69.sql similarity index 100% rename from vortex-bench/tpcds/69.sql rename to vortex-bench/sql/tpcds/69.sql diff --git a/vortex-bench/tpcds/70.sql b/vortex-bench/sql/tpcds/70.sql similarity index 100% rename from vortex-bench/tpcds/70.sql rename to vortex-bench/sql/tpcds/70.sql diff --git a/vortex-bench/tpcds/71.sql b/vortex-bench/sql/tpcds/71.sql similarity index 100% rename from vortex-bench/tpcds/71.sql rename to vortex-bench/sql/tpcds/71.sql diff --git a/vortex-bench/tpcds/72.sql b/vortex-bench/sql/tpcds/72.sql similarity index 100% rename from vortex-bench/tpcds/72.sql rename to vortex-bench/sql/tpcds/72.sql diff --git a/vortex-bench/tpcds/73.sql b/vortex-bench/sql/tpcds/73.sql similarity index 100% rename from vortex-bench/tpcds/73.sql rename to vortex-bench/sql/tpcds/73.sql diff --git a/vortex-bench/tpcds/74.sql b/vortex-bench/sql/tpcds/74.sql similarity index 100% rename from vortex-bench/tpcds/74.sql rename to vortex-bench/sql/tpcds/74.sql diff --git a/vortex-bench/tpcds/75.sql b/vortex-bench/sql/tpcds/75.sql similarity index 100% rename from vortex-bench/tpcds/75.sql rename to vortex-bench/sql/tpcds/75.sql diff --git a/vortex-bench/tpcds/76.sql b/vortex-bench/sql/tpcds/76.sql similarity index 100% rename from vortex-bench/tpcds/76.sql rename to vortex-bench/sql/tpcds/76.sql diff --git a/vortex-bench/tpcds/77.sql b/vortex-bench/sql/tpcds/77.sql similarity index 100% rename from vortex-bench/tpcds/77.sql rename to vortex-bench/sql/tpcds/77.sql diff --git a/vortex-bench/tpcds/78.sql b/vortex-bench/sql/tpcds/78.sql similarity index 100% rename from vortex-bench/tpcds/78.sql rename to vortex-bench/sql/tpcds/78.sql diff --git a/vortex-bench/tpcds/79.sql b/vortex-bench/sql/tpcds/79.sql similarity index 100% rename from vortex-bench/tpcds/79.sql rename to vortex-bench/sql/tpcds/79.sql diff --git a/vortex-bench/tpcds/80.sql b/vortex-bench/sql/tpcds/80.sql similarity index 100% rename from vortex-bench/tpcds/80.sql rename to vortex-bench/sql/tpcds/80.sql diff --git a/vortex-bench/tpcds/81.sql b/vortex-bench/sql/tpcds/81.sql similarity index 100% rename from vortex-bench/tpcds/81.sql rename to vortex-bench/sql/tpcds/81.sql diff --git a/vortex-bench/tpcds/82.sql b/vortex-bench/sql/tpcds/82.sql similarity index 100% rename from vortex-bench/tpcds/82.sql rename to vortex-bench/sql/tpcds/82.sql diff --git a/vortex-bench/tpcds/83.sql b/vortex-bench/sql/tpcds/83.sql similarity index 100% rename from vortex-bench/tpcds/83.sql rename to vortex-bench/sql/tpcds/83.sql diff --git a/vortex-bench/tpcds/84.sql b/vortex-bench/sql/tpcds/84.sql similarity index 100% rename from vortex-bench/tpcds/84.sql rename to vortex-bench/sql/tpcds/84.sql diff --git a/vortex-bench/tpcds/85.sql b/vortex-bench/sql/tpcds/85.sql similarity index 100% rename from vortex-bench/tpcds/85.sql rename to vortex-bench/sql/tpcds/85.sql diff --git a/vortex-bench/tpcds/86.sql b/vortex-bench/sql/tpcds/86.sql similarity index 100% rename from vortex-bench/tpcds/86.sql rename to vortex-bench/sql/tpcds/86.sql diff --git a/vortex-bench/tpcds/87.sql b/vortex-bench/sql/tpcds/87.sql similarity index 100% rename from vortex-bench/tpcds/87.sql rename to vortex-bench/sql/tpcds/87.sql diff --git a/vortex-bench/tpcds/88.sql b/vortex-bench/sql/tpcds/88.sql similarity index 100% rename from vortex-bench/tpcds/88.sql rename to vortex-bench/sql/tpcds/88.sql diff --git a/vortex-bench/tpcds/89.sql b/vortex-bench/sql/tpcds/89.sql similarity index 100% rename from vortex-bench/tpcds/89.sql rename to vortex-bench/sql/tpcds/89.sql diff --git a/vortex-bench/tpcds/90.sql b/vortex-bench/sql/tpcds/90.sql similarity index 100% rename from vortex-bench/tpcds/90.sql rename to vortex-bench/sql/tpcds/90.sql diff --git a/vortex-bench/tpcds/91.sql b/vortex-bench/sql/tpcds/91.sql similarity index 100% rename from vortex-bench/tpcds/91.sql rename to vortex-bench/sql/tpcds/91.sql diff --git a/vortex-bench/tpcds/92.sql b/vortex-bench/sql/tpcds/92.sql similarity index 100% rename from vortex-bench/tpcds/92.sql rename to vortex-bench/sql/tpcds/92.sql diff --git a/vortex-bench/tpcds/93.sql b/vortex-bench/sql/tpcds/93.sql similarity index 100% rename from vortex-bench/tpcds/93.sql rename to vortex-bench/sql/tpcds/93.sql diff --git a/vortex-bench/tpcds/94.sql b/vortex-bench/sql/tpcds/94.sql similarity index 100% rename from vortex-bench/tpcds/94.sql rename to vortex-bench/sql/tpcds/94.sql diff --git a/vortex-bench/tpcds/95.sql b/vortex-bench/sql/tpcds/95.sql similarity index 100% rename from vortex-bench/tpcds/95.sql rename to vortex-bench/sql/tpcds/95.sql diff --git a/vortex-bench/tpcds/96.sql b/vortex-bench/sql/tpcds/96.sql similarity index 100% rename from vortex-bench/tpcds/96.sql rename to vortex-bench/sql/tpcds/96.sql diff --git a/vortex-bench/tpcds/97.sql b/vortex-bench/sql/tpcds/97.sql similarity index 100% rename from vortex-bench/tpcds/97.sql rename to vortex-bench/sql/tpcds/97.sql diff --git a/vortex-bench/tpcds/98.sql b/vortex-bench/sql/tpcds/98.sql similarity index 100% rename from vortex-bench/tpcds/98.sql rename to vortex-bench/sql/tpcds/98.sql diff --git a/vortex-bench/tpcds/99.sql b/vortex-bench/sql/tpcds/99.sql similarity index 100% rename from vortex-bench/tpcds/99.sql rename to vortex-bench/sql/tpcds/99.sql diff --git a/vortex-bench/tpch/q1.sql b/vortex-bench/sql/tpch/q1.sql similarity index 100% rename from vortex-bench/tpch/q1.sql rename to vortex-bench/sql/tpch/q1.sql diff --git a/vortex-bench/tpch/q10.sql b/vortex-bench/sql/tpch/q10.sql similarity index 100% rename from vortex-bench/tpch/q10.sql rename to vortex-bench/sql/tpch/q10.sql diff --git a/vortex-bench/tpch/q11.sql b/vortex-bench/sql/tpch/q11.sql similarity index 100% rename from vortex-bench/tpch/q11.sql rename to vortex-bench/sql/tpch/q11.sql diff --git a/vortex-bench/tpch/q12.sql b/vortex-bench/sql/tpch/q12.sql similarity index 100% rename from vortex-bench/tpch/q12.sql rename to vortex-bench/sql/tpch/q12.sql diff --git a/vortex-bench/tpch/q13.sql b/vortex-bench/sql/tpch/q13.sql similarity index 100% rename from vortex-bench/tpch/q13.sql rename to vortex-bench/sql/tpch/q13.sql diff --git a/vortex-bench/tpch/q14.sql b/vortex-bench/sql/tpch/q14.sql similarity index 100% rename from vortex-bench/tpch/q14.sql rename to vortex-bench/sql/tpch/q14.sql diff --git a/vortex-bench/tpch/q15.sql b/vortex-bench/sql/tpch/q15.sql similarity index 100% rename from vortex-bench/tpch/q15.sql rename to vortex-bench/sql/tpch/q15.sql diff --git a/vortex-bench/tpch/q16.sql b/vortex-bench/sql/tpch/q16.sql similarity index 100% rename from vortex-bench/tpch/q16.sql rename to vortex-bench/sql/tpch/q16.sql diff --git a/vortex-bench/tpch/q17.sql b/vortex-bench/sql/tpch/q17.sql similarity index 100% rename from vortex-bench/tpch/q17.sql rename to vortex-bench/sql/tpch/q17.sql diff --git a/vortex-bench/tpch/q18.sql b/vortex-bench/sql/tpch/q18.sql similarity index 100% rename from vortex-bench/tpch/q18.sql rename to vortex-bench/sql/tpch/q18.sql diff --git a/vortex-bench/tpch/q19.sql b/vortex-bench/sql/tpch/q19.sql similarity index 100% rename from vortex-bench/tpch/q19.sql rename to vortex-bench/sql/tpch/q19.sql diff --git a/vortex-bench/tpch/q2.sql b/vortex-bench/sql/tpch/q2.sql similarity index 100% rename from vortex-bench/tpch/q2.sql rename to vortex-bench/sql/tpch/q2.sql diff --git a/vortex-bench/tpch/q20.sql b/vortex-bench/sql/tpch/q20.sql similarity index 100% rename from vortex-bench/tpch/q20.sql rename to vortex-bench/sql/tpch/q20.sql diff --git a/vortex-bench/tpch/q21.sql b/vortex-bench/sql/tpch/q21.sql similarity index 100% rename from vortex-bench/tpch/q21.sql rename to vortex-bench/sql/tpch/q21.sql diff --git a/vortex-bench/tpch/q22.sql b/vortex-bench/sql/tpch/q22.sql similarity index 100% rename from vortex-bench/tpch/q22.sql rename to vortex-bench/sql/tpch/q22.sql diff --git a/vortex-bench/tpch/q3.sql b/vortex-bench/sql/tpch/q3.sql similarity index 100% rename from vortex-bench/tpch/q3.sql rename to vortex-bench/sql/tpch/q3.sql diff --git a/vortex-bench/tpch/q4.sql b/vortex-bench/sql/tpch/q4.sql similarity index 100% rename from vortex-bench/tpch/q4.sql rename to vortex-bench/sql/tpch/q4.sql diff --git a/vortex-bench/tpch/q5.sql b/vortex-bench/sql/tpch/q5.sql similarity index 100% rename from vortex-bench/tpch/q5.sql rename to vortex-bench/sql/tpch/q5.sql diff --git a/vortex-bench/tpch/q6.sql b/vortex-bench/sql/tpch/q6.sql similarity index 100% rename from vortex-bench/tpch/q6.sql rename to vortex-bench/sql/tpch/q6.sql diff --git a/vortex-bench/tpch/q7.sql b/vortex-bench/sql/tpch/q7.sql similarity index 100% rename from vortex-bench/tpch/q7.sql rename to vortex-bench/sql/tpch/q7.sql diff --git a/vortex-bench/tpch/q8.sql b/vortex-bench/sql/tpch/q8.sql similarity index 100% rename from vortex-bench/tpch/q8.sql rename to vortex-bench/sql/tpch/q8.sql diff --git a/vortex-bench/tpch/q9.sql b/vortex-bench/sql/tpch/q9.sql similarity index 100% rename from vortex-bench/tpch/q9.sql rename to vortex-bench/sql/tpch/q9.sql diff --git a/vortex-bench/src/appian/mod.rs b/vortex-bench/src/appian/mod.rs index 3f22bf9c07f..cdc82ac200a 100644 --- a/vortex-bench/src/appian/mod.rs +++ b/vortex-bench/src/appian/mod.rs @@ -74,14 +74,15 @@ const TABLES: &[&str] = &[ ]; /// Eight join-heavy queries from `duckdb/duckdb:benchmark/appian_benchmarks/queries/`, -/// stored byte-identically under `vortex-bench/appian/q{1..8}.sql` (sibling of the TPC-H -/// `tpch/q*.sql` layout). Upstream refreshes are a pure copy into that directory. +/// stored byte-identically under `vortex-bench/sql/appian/q{1..8}.sql` (sibling of the TPC-H +/// `sql/tpch/q*.sql` layout). Upstream refreshes are a pure copy into that directory. pub fn appian_queries() -> impl Iterator { (1..=8).map(|q| (q, appian_query(q))) } fn appian_query(query_idx: usize) -> String { let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("sql") .join("appian") .join(format!("q{query_idx}")) .with_extension("sql"); diff --git a/vortex-bench/src/clickbench/benchmark.rs b/vortex-bench/src/clickbench/benchmark.rs index c036d1c084b..346496dbda0 100644 --- a/vortex-bench/src/clickbench/benchmark.rs +++ b/vortex-bench/src/clickbench/benchmark.rs @@ -60,7 +60,9 @@ impl ClickBenchSortedBenchmark { fn read_clickbench_queries(queries_file: Option<&str>) -> Result> { let queries_filepath = match queries_file { Some(file) => file.into(), - None => Path::new(env!("CARGO_MANIFEST_DIR")).join("clickbench_queries.sql"), + None => Path::new(env!("CARGO_MANIFEST_DIR")) + .join("sql") + .join("clickbench_queries.sql"), }; Ok(fs::read_to_string(queries_filepath)? diff --git a/vortex-bench/src/fineweb/mod.rs b/vortex-bench/src/fineweb/mod.rs index 93151cc6fca..b8c86925905 100644 --- a/vortex-bench/src/fineweb/mod.rs +++ b/vortex-bench/src/fineweb/mod.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +use std::fs; use std::path::PathBuf; use tracing::info; @@ -11,30 +12,11 @@ use crate::BenchmarkDataset; use crate::TableSpec; use crate::datasets::data_downloads::download_data; use crate::utils::file::resolve_data_url; +use crate::workspace_root; /// URL to the sample file const SAMPLE_URL: &str = "https://huggingface.co/datasets/HuggingFaceFW/fineweb/resolve/v1.4.0/sample/10BT/001_00000.parquet"; -/// Some basic string-focused queries. -const QUERIES: &[&str] = &[ - // simple summary - "SELECT count(DISTINCT dump) FROM fineweb", - // selective string equality filter - "SELECT * FROM fineweb WHERE dump = 'CC-MAIN-2016-30'", - // LIKE with prefix filter - "SELECT * FROM fineweb WHERE date LIKE '2020-10-%'", - // LIKE with simple containment filter - "SELECT * FROM fineweb WHERE url LIKE '%google%' AND text LIKE '%Google%'", - // LIKE with larger containment filter - "SELECT * FROM fineweb WHERE url LIKE '%.google.%' OR text LIKE '% Google %'", - "SELECT * FROM fineweb WHERE text LIKE '% vortex %'", - // More LIKE filters - "SELECT * FROM fineweb WHERE url LIKE '%espn%' AND language = 'en' AND language_score > 0.92", - "SELECT * FROM fineweb WHERE url LIKE '%espn%' OR url LIKE '%www.espn.go.com%' OR url LIKE '%espn.go.com%'", - // no results, stats cannot prune but tokenized bloom filters could - "SELECT * FROM fineweb WHERE file_path LIKE '%/CC-MAIN-2014-%'", -]; - /// A benchmark using the HuggingFace FineWeb dataset. /// /// This is a very string-heavy dataset, and exercises dictionary and FSST encoding heavily. @@ -71,8 +53,22 @@ impl FinewebBenchmark { #[async_trait::async_trait] impl Benchmark for FinewebBenchmark { + /// Some basic string-focused queries, numbered from Q0 in `sql/fineweb.sql` file order. fn queries(&self) -> anyhow::Result> { - Ok(QUERIES.iter().map(|s| s.to_string()).enumerate().collect()) + // `;`-separated; a `;` must not appear in a comment, or it would split a statement in two. + let queries_file = workspace_root() + .join("vortex-bench") + .join("sql") + .join("fineweb") + .with_extension("sql"); + let contents = fs::read_to_string(queries_file)?; + Ok(contents + .split_terminator(';') + .map(str::trim) + .filter(|stmt| !stmt.is_empty()) + .map(str::to_string) + .enumerate() + .collect()) } async fn generate_base_data(&self) -> anyhow::Result<()> { diff --git a/vortex-bench/src/polarsignals/benchmark.rs b/vortex-bench/src/polarsignals/benchmark.rs index 0ef7b4246e6..fce692ceb68 100644 --- a/vortex-bench/src/polarsignals/benchmark.rs +++ b/vortex-bench/src/polarsignals/benchmark.rs @@ -62,6 +62,7 @@ impl Benchmark for PolarSignalsBenchmark { fn queries(&self) -> Result> { let queries_file = workspace_root() .join("vortex-bench") + .join("sql") .join("polarsignals") .with_extension("sql"); let contents = fs::read_to_string(queries_file)?; diff --git a/vortex-bench/src/realnest/gharchive.rs b/vortex-bench/src/realnest/gharchive.rs index 3ec6a67f340..a84395f4a10 100644 --- a/vortex-bench/src/realnest/gharchive.rs +++ b/vortex-bench/src/realnest/gharchive.rs @@ -5,6 +5,7 @@ //! //! This dataset applies a bunch of events this way +use std::fs; use std::path::PathBuf; use std::process::Command; @@ -18,6 +19,7 @@ use crate::TableSpec; use crate::idempotent; use crate::idempotent_async; use crate::utils::file::resolve_data_url; +use crate::workspace_root; /// Template URL for raw JSON dataset fn raw_json_url(hour: usize) -> String { @@ -25,14 +27,6 @@ fn raw_json_url(hour: usize) -> String { format!("https://data.gharchive.org/2024-10-01-{hour}.json.gz") } -const QUERIES: &[&str] = &[ - "select count(*) from events where payload.ref = 'refs/heads/main'", - "select distinct repo.name from events where repo.name like 'spiraldb/%'", - "select distinct org.id as org_id from events order by org_id limit 100", - "select actor.login, count() as freq from events group by actor.login order by freq desc limit 10", - "select actor.avatar_url from events where actor.login = 'renovate[bot]'", -]; - pub struct GithubArchiveBenchmark { data_url: Url, } @@ -72,8 +66,22 @@ impl GithubArchiveBenchmark { #[async_trait::async_trait] impl Benchmark for GithubArchiveBenchmark { + /// GitHub Archive queries, numbered from Q0 in `sql/gharchive.sql` file order. fn queries(&self) -> anyhow::Result> { - Ok(QUERIES.iter().map(|s| s.to_string()).enumerate().collect()) + // `;`-separated; a `;` must not appear in a comment, or it would split a statement in two. + let queries_file = workspace_root() + .join("vortex-bench") + .join("sql") + .join("gharchive") + .with_extension("sql"); + let contents = fs::read_to_string(queries_file)?; + Ok(contents + .split_terminator(';') + .map(str::trim) + .filter(|stmt| !stmt.is_empty()) + .map(str::to_string) + .enumerate() + .collect()) } async fn generate_base_data(&self) -> anyhow::Result<()> { diff --git a/vortex-bench/src/spatialbench/benchmark.rs b/vortex-bench/src/spatialbench/benchmark.rs index d7b9d94061d..f2a6016a5ac 100644 --- a/vortex-bench/src/spatialbench/benchmark.rs +++ b/vortex-bench/src/spatialbench/benchmark.rs @@ -63,6 +63,7 @@ impl Benchmark for SpatialBenchBenchmark { // `;`-separated; a `;` must not appear in a comment, or it would split a statement in two. let queries_file = workspace_root() .join("vortex-bench") + .join("sql") .join("spatialbench") .with_extension("sql"); let contents = fs::read_to_string(queries_file)?; diff --git a/vortex-bench/src/statpopgen/statpopgen_benchmark.rs b/vortex-bench/src/statpopgen/statpopgen_benchmark.rs index 58c4b6aed51..0861f7bfc88 100644 --- a/vortex-bench/src/statpopgen/statpopgen_benchmark.rs +++ b/vortex-bench/src/statpopgen/statpopgen_benchmark.rs @@ -106,6 +106,7 @@ impl Benchmark for StatPopGenBenchmark { fn queries(&self) -> Result> { let queries_file = workspace_root() .join("vortex-bench") + .join("sql") .join("statpopgen") .with_extension("sql"); let contents = fs::read_to_string(queries_file)?; diff --git a/vortex-bench/src/tpcds/mod.rs b/vortex-bench/src/tpcds/mod.rs index 3ece61f5941..b8eda522e48 100644 --- a/vortex-bench/src/tpcds/mod.rs +++ b/vortex-bench/src/tpcds/mod.rs @@ -17,6 +17,7 @@ pub fn tpcds_queries() -> impl Iterator { // A few tpcds queries have multiple statements, this handles that fn tpcds_query(query_idx: usize) -> String { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("sql") .join("tpcds") .join(format!("{query_idx:02}")) .with_extension("sql"); diff --git a/vortex-bench/src/tpch/mod.rs b/vortex-bench/src/tpch/mod.rs index 7c36ffb6950..5a2bba97c84 100644 --- a/vortex-bench/src/tpch/mod.rs +++ b/vortex-bench/src/tpch/mod.rs @@ -29,6 +29,7 @@ pub fn tpch_queries() -> impl Iterator { // A few tpch queries have multiple statements, this handles that fn tpch_query(query_idx: usize) -> String { let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("sql") .join("tpch") .join(format!("q{query_idx}")) .with_extension("sql"); From 596099c647f8d75d17d6fc6a0c70300c0713ff44 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Wed, 8 Jul 2026 15:04:14 +0100 Subject: [PATCH 034/104] ci: remove v3 benchmark ingest, make v4 Postgres ingest required (#8683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Removes the v3 (EC2/DuckDB) leg of the benchmark-results dual-write and promotes the v4 (RDS Postgres) ingest from best-effort to required. - Deletes the three "Ingest ... to v3 server" steps (`bench.yml`, `sql-benchmarks.yml`, `commit-metadata.yml`), each gated on `vars.V3_INGEST_URL`. - Removes `continue-on-error: true` from the v4 ingest step groups so a v4 ingest failure now fails the job (and alerts incident.io on develop). - Rewrites the stale soak-era comments, which claimed v3 was hard-required and v4 best-effort — reality is now inverted. ## Why The benchmarks website fully cut over to v4 (Vercel + RDS Postgres) on 2026-06-19 and has been stable since; the pre-cutover history was backfilled into v4 on 2026-07-07, so v3 holds nothing v4 lacks. This unblocks terminating the v3 EC2 host. ## Deliberately untouched - The `--gh-json-v3 results.v3.jsonl` emitters, `scripts/post-ingest.py` (including its now-unused `--server` mode), and `scripts/_measurement_id.py` — the v3 JSONL format **is** v4's wire format; only the HTTP-to-EC2 leg is removed. - The v2 legacy S3 uploads (`cat-s3.sh`, commit-metadata job in `bench.yml`) — still consumed by PR-benchmark comparison; separate teardown. After merge, the repo-level `V3_INGEST_URL` var and `INGEST_BEARER_TOKEN`/`ADMIN_BEARER_TOKEN` secrets can be deleted (every removed step was gated on the var, so ordering is safe). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Connor Tsui Co-authored-by: Claude Fable 5 --- .github/workflows/bench.yml | 30 +++++------------------ .github/workflows/commit-metadata.yml | 34 +++++++-------------------- .github/workflows/sql-benchmarks.yml | 30 ++++++----------------- 3 files changed, 21 insertions(+), 73 deletions(-) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 13cfa9050ba..7eccc0e234f 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -117,26 +117,11 @@ jobs: run: | bash scripts/cat-s3.sh vortex-ci-benchmark-results data.json.gz results.json - - name: Ingest results to v3 server - if: vars.V3_INGEST_URL != '' - shell: bash - env: - INGEST_BEARER_TOKEN: ${{ secrets.INGEST_BEARER_TOKEN }} - run: | - python3 scripts/post-ingest.py results.v3.jsonl \ - --server "${{ vars.V3_INGEST_URL }}" \ - --commit-sha "${{ github.sha }}" \ - --benchmark-id "${{ matrix.benchmark.id }}" \ - --repo-url "${{ github.server_url }}/${{ github.repository }}" - - # v4 (Postgres) dual-write -- BEST-EFFORT during the migration soak. The - # proven v3 step above is hard-required; a v4 failure must NOT fail the job - # (v4 is promoted to required at cutover, PR-5.1). Gated on the ingest-role - # ARN var (the assume-role input that MUST exist for OIDC to succeed) so it - # no-ops until v4 infra is wired, and every step is - # `continue-on-error` so an OIDC / uv / connect hiccup never breaks the v3 - # pipeline. post-ingest.py mints the RDS IAM token internally (boto3) from - # the assumed GitHubBenchmarkIngestRole; sslmode=verify-full validates the cert. + # v4 (Postgres) ingest -- the REQUIRED benchmark-results pipeline feeding the live + # benchmarks website; a failure here fails the job. Gated on the ingest-role ARN var + # (the assume-role input that MUST exist for OIDC to succeed). post-ingest.py mints + # the RDS IAM token internally (boto3) from the assumed GitHubBenchmarkIngestRole; + # sslmode=verify-full validates the cert. # # ORDER MATTERS: "Install uv" runs BEFORE "Configure AWS credentials". # configure-aws-credentials persists the assumed ingest-role (rds-db:connect @@ -146,18 +131,15 @@ jobs: # is assumed immediately before the ingest, which needs only rds-db:connect. - name: Install uv for v4 ingest if: vars.GH_BENCH_INGEST_ROLE_ARN != '' - continue-on-error: true uses: spiraldb/actions/.github/actions/setup-uv@a746510eafaa926484c354541cfc49b2ec06cc63 # 0.18.6 - name: Configure AWS credentials for v4 ingest (OIDC) if: vars.GH_BENCH_INGEST_ROLE_ARN != '' - continue-on-error: true uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 with: role-to-assume: ${{ vars.GH_BENCH_INGEST_ROLE_ARN }} aws-region: ${{ vars.RDS_BENCH_REGION }} - - name: Ingest results to v4 Postgres (best-effort) + - name: Ingest results to v4 Postgres if: vars.GH_BENCH_INGEST_ROLE_ARN != '' - continue-on-error: true shell: bash env: RDS_BENCH_INSTANCE_ENDPOINT: ${{ vars.RDS_BENCH_INSTANCE_ENDPOINT }} diff --git a/.github/workflows/commit-metadata.yml b/.github/workflows/commit-metadata.yml index 272bbaad9c9..5ca10fcf704 100644 --- a/.github/workflows/commit-metadata.yml +++ b/.github/workflows/commit-metadata.yml @@ -1,7 +1,6 @@ # Uploads commit metadata (an empty ingest envelope -- no benchmark records) on -# every push to develop, to both the v3 server and the v4 Postgres, so the -# `commits` dim stays populated even when no benchmark ran. Needed by both -# backends, hence not named for either. +# every push to develop, to the v4 Postgres, so the `commits` dim stays +# populated even when no benchmark ran. name: Commit metadata @@ -11,7 +10,7 @@ on: workflow_dispatch: { } permissions: - id-token: write # enables AWS-GitHub OIDC for the best-effort v4 ingest step + id-token: write # enables AWS-GitHub OIDC for the v4 ingest step contents: read jobs: @@ -23,24 +22,10 @@ jobs: with: fetch-depth: 2 - - name: Ingest commit metadata to v3 server - if: vars.V3_INGEST_URL != '' - shell: bash - env: - INGEST_BEARER_TOKEN: ${{ secrets.INGEST_BEARER_TOKEN }} - run: | - echo -n > empty.jsonl - python3 scripts/post-ingest.py empty.jsonl \ - --server "${{ vars.V3_INGEST_URL }}" \ - --commit-sha "${{ github.sha }}" \ - --benchmark-id "commit-metadata" \ - --repo-url "${{ github.server_url }}/${{ github.repository }}" - - # v4 (Postgres) dual-write -- BEST-EFFORT (see bench.yml rationale). Empty records: - # post-ingest.py --postgres upserts the commit row only. v3 above stays required; - # a v4 failure never fails the job (promoted to required at cutover, PR-5.1). - # Gated on the ingest-role ARN var (the assume-role input that MUST exist) so - # it no-ops until v4 infra is wired. + # v4 (Postgres) ingest -- REQUIRED (see bench.yml rationale). Empty records: + # post-ingest.py --postgres upserts the commit row only. Gated on the + # ingest-role ARN var (the assume-role input that MUST exist for OIDC to + # succeed). # # ORDER MATTERS: "Install uv" runs BEFORE "Configure AWS credentials". # configure-aws-credentials persists the assumed ingest-role (rds-db:connect @@ -50,18 +35,15 @@ jobs: # is assumed immediately before the ingest, which needs only rds-db:connect. - name: Install uv for v4 ingest if: vars.GH_BENCH_INGEST_ROLE_ARN != '' - continue-on-error: true uses: spiraldb/actions/.github/actions/setup-uv@a746510eafaa926484c354541cfc49b2ec06cc63 # 0.18.6 - name: Configure AWS credentials for v4 ingest (OIDC) if: vars.GH_BENCH_INGEST_ROLE_ARN != '' - continue-on-error: true uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 with: role-to-assume: ${{ vars.GH_BENCH_INGEST_ROLE_ARN }} aws-region: ${{ vars.RDS_BENCH_REGION }} - - name: Ingest commit metadata to v4 Postgres (best-effort) + - name: Ingest commit metadata to v4 Postgres if: vars.GH_BENCH_INGEST_ROLE_ARN != '' - continue-on-error: true shell: bash env: RDS_BENCH_INSTANCE_ENDPOINT: ${{ vars.RDS_BENCH_INSTANCE_ENDPOINT }} diff --git a/.github/workflows/sql-benchmarks.yml b/.github/workflows/sql-benchmarks.yml index 0f9fdcb9eb1..71c0e3fe82c 100644 --- a/.github/workflows/sql-benchmarks.yml +++ b/.github/workflows/sql-benchmarks.yml @@ -709,25 +709,12 @@ jobs: run: | bash scripts/cat-s3.sh vortex-ci-benchmark-results data.json.gz results.json - - name: Ingest results to v3 server - if: inputs.mode == 'develop' && vars.V3_INGEST_URL != '' - shell: bash - env: - INGEST_BEARER_TOKEN: ${{ secrets.INGEST_BEARER_TOKEN }} - run: | - python3 scripts/post-ingest.py results.v3.jsonl \ - --server "${{ vars.V3_INGEST_URL }}" \ - --commit-sha "${{ github.sha }}" \ - --benchmark-id "${{ matrix.id }}" \ - --repo-url "${{ github.server_url }}/${{ github.repository }}" - - # v4 (Postgres) dual-write -- BEST-EFFORT during the migration soak (see bench.yml - # for the full rationale). The v3 step above is hard-required; a v4 failure must NOT - # fail the job (v4 is promoted to required at cutover, PR-5.1). Gated on - # inputs.mode == 'develop' (matching v3) + the ingest-role ARN var (the assume-role - # input that MUST exist for OIDC to succeed; it no-ops until v4 infra is wired), and - # every step is continue-on-error. post-ingest.py mints the RDS IAM token (boto3) from - # the assumed GitHubBenchmarkIngestRole; sslmode=verify-full validates the cert. + # v4 (Postgres) ingest -- the REQUIRED benchmark-results pipeline feeding the live + # benchmarks website (see bench.yml for the full rationale); a failure here fails the + # job. Gated on inputs.mode == 'develop' + the ingest-role ARN var (the assume-role + # input that MUST exist for OIDC to succeed). post-ingest.py mints the RDS IAM token + # (boto3) from the assumed GitHubBenchmarkIngestRole; sslmode=verify-full validates + # the cert. # # ORDER MATTERS: "Install uv" runs BEFORE "Configure AWS credentials". # configure-aws-credentials persists the assumed ingest-role (rds-db:connect @@ -737,18 +724,15 @@ jobs: # is assumed immediately before the ingest, which needs only rds-db:connect. - name: Install uv for v4 ingest if: inputs.mode == 'develop' && vars.GH_BENCH_INGEST_ROLE_ARN != '' - continue-on-error: true uses: spiraldb/actions/.github/actions/setup-uv@a746510eafaa926484c354541cfc49b2ec06cc63 # 0.18.6 - name: Configure AWS credentials for v4 ingest (OIDC) if: inputs.mode == 'develop' && vars.GH_BENCH_INGEST_ROLE_ARN != '' - continue-on-error: true uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 with: role-to-assume: ${{ vars.GH_BENCH_INGEST_ROLE_ARN }} aws-region: ${{ vars.RDS_BENCH_REGION }} - - name: Ingest results to v4 Postgres (best-effort) + - name: Ingest results to v4 Postgres if: inputs.mode == 'develop' && vars.GH_BENCH_INGEST_ROLE_ARN != '' - continue-on-error: true shell: bash env: RDS_BENCH_INSTANCE_ENDPOINT: ${{ vars.RDS_BENCH_INSTANCE_ENDPOINT }} From 1ba151517623697946c7f52249be8c97985de551 Mon Sep 17 00:00:00 2001 From: Adam Gutglick Date: Wed, 8 Jul 2026 15:36:47 +0100 Subject: [PATCH 035/104] Bump locked version of crossbeam-epoch to address security advisory (#8686) ## Rationale for this change This is basically a cosmetic change to lock a newer version of `crossbeam-epoch` that isn't affected by the latest issue reported to RUSTSEC https://rustsec.org/advisories/RUSTSEC-2026-0204.html Signed-off-by: Adam Gutglick --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d4a9f942e97..01000903b08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1861,9 +1861,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] From b1f2d4f0ae93877c89800689870837a46b272db2 Mon Sep 17 00:00:00 2001 From: myrrc Date: Wed, 8 Jul 2026 16:27:46 +0100 Subject: [PATCH 036/104] Cast pushdown for duckdb (#8620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backport of https://github.com/duckdb/duckdb/pull/22788 to push down casts, optimizer pass. Extract both passes common parts to optimizer.cpp - Map 8- and 16-bit types in sqllogictest runner to INTEGER. - Add tracing subscriber logging to sqllogictest runner to support RUST_LOG=debug. - Support of CAST (not TRY_CAST) over primitives pushdown on Rust side. U128/I128 are not pushed. We are overly restrictive here because we don't want clients to face issues like `No CastReduce to cast constant array from vortex.date[days](i32) to vortex.timestamp[µs](i64?)`. We can lift the restriction later. Signed-off-by: Mikhail Kot --- Cargo.lock | 1 + vortex-duckdb/build.rs | 4 +- vortex-duckdb/cpp/cast_pushdown.cpp | 178 +++++++ vortex-duckdb/cpp/expr.cpp | 13 + vortex-duckdb/cpp/include/cast_pushdown.hpp | 43 ++ vortex-duckdb/cpp/include/expr.h | 4 + vortex-duckdb/cpp/include/optimizer.hpp | 137 ++++++ .../cpp/include/scalar_fn_pushdown.hpp | 103 +--- vortex-duckdb/cpp/optimizer.cpp | 93 ++++ vortex-duckdb/cpp/scalar_fn_pushdown.cpp | 149 +----- vortex-duckdb/cpp/vortex_duckdb.cpp | 4 +- vortex-duckdb/src/convert/dtype.rs | 4 +- vortex-duckdb/src/convert/expr.rs | 94 +++- vortex-duckdb/src/duckdb/expr.rs | 14 + vortex-duckdb/src/duckdb/logical_type.rs | 31 ++ vortex-sqllogictest/Cargo.toml | 1 + .../bin/sqllogictests-runner.rs | 5 + .../slt/duckdb/cast_pushdown.slt | 465 ++++++++++++++++++ .../duckdb/projection_expression_pushdown.slt | 11 +- vortex-sqllogictest/src/duckdb.rs | 8 +- 20 files changed, 1091 insertions(+), 271 deletions(-) create mode 100644 vortex-duckdb/cpp/cast_pushdown.cpp create mode 100644 vortex-duckdb/cpp/include/cast_pushdown.hpp create mode 100644 vortex-duckdb/cpp/include/optimizer.hpp create mode 100644 vortex-duckdb/cpp/optimizer.cpp create mode 100644 vortex-sqllogictest/slt/duckdb/cast_pushdown.slt diff --git a/Cargo.lock b/Cargo.lock index 01000903b08..5104dbe285f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10786,6 +10786,7 @@ dependencies = [ "sqllogictest", "thiserror 2.0.18", "tokio", + "tracing-subscriber", "vortex", "vortex-datafusion", "vortex-duckdb", diff --git a/vortex-duckdb/build.rs b/vortex-duckdb/build.rs index 7647011e366..186dba9757b 100644 --- a/vortex-duckdb/build.rs +++ b/vortex-duckdb/build.rs @@ -27,11 +27,13 @@ const DEFAULT_DUCKDB_VERSION: &str = "1.5.3"; const BUILD_ARTIFACTS: [&str; 3] = ["libduckdb.dylib", "libduckdb.so", "libduckdb_static.a"]; -const SOURCE_FILES: [&str; 7] = [ +const SOURCE_FILES: [&str; 9] = [ "cpp/vortex_duckdb.cpp", "cpp/copy_function.cpp", "cpp/expr.cpp", + "cpp/optimizer.cpp", "cpp/scalar_fn_pushdown.cpp", + "cpp/cast_pushdown.cpp", "cpp/table_filter.cpp", "cpp/table_function.cpp", "cpp/vector.cpp", diff --git a/vortex-duckdb/cpp/cast_pushdown.cpp b/vortex-duckdb/cpp/cast_pushdown.cpp new file mode 100644 index 00000000000..3acd5b04161 --- /dev/null +++ b/vortex-duckdb/cpp/cast_pushdown.cpp @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#include "cast_pushdown.hpp" +#include "table_function.hpp" + +#include "duckdb/planner/operator/logical_get.hpp" +#include "duckdb/planner/operator/logical_projection.hpp" +#include "duckdb/planner/expression/bound_cast_expression.hpp" +#include "duckdb/planner/expression/bound_columnref_expression.hpp" + +/** + * A GET reachable through a single-child chain of projections. A join or any + * other multi-child operator breaks the chain. + * + * LOGICAL_FILTER also breaks the chain. In SELECT CAST/TRY_CAST(x) WHERE g(x) + * we can't push down cast if g(x) isn't pushed. If g(x) filters some rows + * on which cast would fail, the query passed by default but fails if cast is pushed + * and runs before g(x). + * + * See test/sql/copy/csv/test_insert_into_types.test in duckdb (cast not pushed past a join) + */ +static bool ReachesPushdownGet(const LogicalOperator &op) { + const LogicalOperator *cur = &op; + while (cur->children.size() == 1) { + cur = cur->children[0].get(); + switch (cur->type) { + case LogicalOperatorType::LOGICAL_GET: + return cur->Cast().function.bind == duckdb_vx_table_function_bind; + case LogicalOperatorType::LOGICAL_PROJECTION: + continue; + default: + return false; + } + } + return false; +} + +void CastCollect::VisitOperator(LogicalOperator &op) { + /* + * Logical projection expressions are columns which reference underlying + * GETs. Don't process them, as they would add conflicts for every column + * used in projection. Example: PROJECTION(col) -> GET(col). We don't want + * to visit BoundColumnRefExpression in PROJECTION to avoid registering a + * non-existent conflict. + * + * However, CastReplace will visit them because we need to update their + * types if pushdown succeeded. + */ + if (op.type != LogicalOperatorType::LOGICAL_PROJECTION) { + return LogicalOperatorVisitor::VisitOperator(op); + } + auto &projection = op.Cast(); + + // Only push casts from a projection that forwards just column refs and + // casts and reaches a GET without a join in between. A constant or other + // expression makes the projection ineligible. + // See test/sql/copy/csv/test_csv_error_message_type.test (top-level cast + // to VARCHAR must still push) and test_large_integer_detection.test (a + // nested cast to VARCHAR must not) in duckdb. + bool clean = ReachesPushdownGet(projection); + for (const auto &e : projection.expressions) { + switch (e->GetExpressionClass()) { + case ExpressionClass::BOUND_COLUMN_REF: + case ExpressionClass::BOUND_CAST: + continue; + default: + clean = false; + break; + } + } + if (clean) { + for (const auto &e : projection.expressions) { + if (e->GetExpressionClass() == ExpressionClass::BOUND_CAST) { + top_level_casts.insert(e.get()); + } + } + } + if (projections.count(projection.table_index)) { + VisitOperatorChildren(op); + return; + } + + LogicalOperatorVisitor::VisitOperator(op); +} + +ExpressionPtr CastCollect::VisitReplace(BoundColumnRefExpression &expr, ExpressionPtr *ptr) { + if (const auto binding = Resolve(expr.binding, analyses, projections)) { + // Column is used without cast applied to it, register a conflict. + // Not emplace() as we need to update the value if it was present + binding->analysis.col_to_expr[binding->column_index] = nullptr; + } + return std::move(*ptr); +} + +ExpressionPtr CastCollect::VisitReplace(BoundCastExpression &expr, ExpressionPtr *ptr) { + if (expr.child->GetExpressionType() != ExpressionType::BOUND_COLUMN_REF) { + // Descend into children so e.g. fn(col, other) still sees "col" and + // registers a conflict + return nullptr; + } + const auto &bound_col = expr.child->Cast(); + const auto binding = Resolve(bound_col.binding, analyses, projections); + if (!binding) { + return nullptr; + } + auto &col_to_expr = binding->analysis.col_to_expr; + + if (auto it = col_to_expr.find(binding->column_index); it == col_to_expr.end()) { + // Only a top-level projection cast starts a candidate. + if (top_level_casts.count(&expr)) { + col_to_expr.emplace(binding->column_index, &expr); + } + } else if (it->second == nullptr || + it->second->Cast().return_type != expr.return_type || + // TODO(myrrc) this line needs upstreaming + it->second->Cast().try_cast != expr.try_cast) { + // Different target type, or already a conflict. + it->second = nullptr; + } + + return std::move(*ptr); +} + +ExpressionPtr CastReplace::VisitReplace(BoundColumnRefExpression &expr, ExpressionPtr *ptr) { + const auto binding = Resolve(expr.binding, analyses, projections); + if (!binding) { + return std::move(*ptr); + } + + const auto &[analysis, column_index, projection] = *binding; + if (CanPushdownColumn(analysis, column_index)) { + const idx_t storage_index = analysis.get.GetColumnIds()[column_index].GetPrimaryIndex(); + const LogicalType return_type = analysis.get.returned_types[storage_index]; + expr.return_type = return_type; + // LogicalProjection types are resolved by calling + // LogicalProjection::ResolveTypes, so we need to check whether types in + // projection have been resolved, and updated them only if needed. + if (projection != nullptr && !projection->types.empty()) { + projection->types[column_index] = return_type; + } + } + + return std::move(*ptr); +} + +ExpressionPtr CastReplace::VisitReplace(BoundCastExpression &expr, ExpressionPtr *ptr) { + if (expr.child->GetExpressionType() != ExpressionType::BOUND_COLUMN_REF) { + return nullptr; // Same as in ScalarFnCollect::VisitReplace + } + auto &bound_col_base = expr.child; + const auto &bound_col = bound_col_base->Cast(); + const auto binding = Resolve(bound_col.binding, analyses, projections); + if (!binding) { + return nullptr; + } + + const auto &[analysis, column_index, projection] = *binding; + if (!CanPushdownColumn(analysis, column_index)) { + return std::move(*ptr); + } + + const idx_t storage_index = analysis.get.GetColumnIds()[column_index].GetPrimaryIndex(); + const LogicalType return_type = analysis.get.returned_types[storage_index]; + bound_col_base->return_type = return_type; + // Same as in CastReplace::VisitReplace(BoundColumnRefExpression) + if (projection != nullptr && !projection->types.empty()) { + projection->types[column_index] = return_type; + } + return std::move(bound_col_base); +} + +CastCollect::CastCollect(Analyses &analyses, const Projections &projections) + : analyses(analyses), projections(projections) { +} + +CastReplace::CastReplace(Analyses &analyses, const Projections &projections) + : analyses(analyses), projections(projections) { +} diff --git a/vortex-duckdb/cpp/expr.cpp b/vortex-duckdb/cpp/expr.cpp index 22b520ec7a0..8a871e9d7ee 100644 --- a/vortex-duckdb/cpp/expr.cpp +++ b/vortex-duckdb/cpp/expr.cpp @@ -4,6 +4,7 @@ #include "expr.h" #include "duckdb/function/scalar_function.hpp" #include "duckdb/planner/expression/bound_between_expression.hpp" +#include "duckdb/planner/expression/bound_cast_expression.hpp" #include "duckdb/planner/expression/bound_columnref_expression.hpp" #include "duckdb/planner/expression/bound_comparison_expression.hpp" #include "duckdb/planner/expression/bound_constant_expression.hpp" @@ -188,3 +189,15 @@ extern "C" void duckdb_vx_expr_get_bound_function(duckdb_vx_expr ffi_expr, out->scalar_function = reinterpret_cast(&expr.function); out->bind_info = expr.bind_info.get(); } + +extern "C" duckdb_vx_expr duckdb_vx_expr_get_bound_cast_child(duckdb_vx_expr ffi_expr) { + D_ASSERT(ffi_expr); + auto &expr = reinterpret_cast(ffi_expr)->Cast(); + return reinterpret_cast(expr.child.get()); +} + +extern "C" bool duckdb_vx_expr_get_bound_cast_is_try(duckdb_vx_expr ffi_expr) { + D_ASSERT(ffi_expr); + auto &expr = reinterpret_cast(ffi_expr)->Cast(); + return expr.try_cast; +} diff --git a/vortex-duckdb/cpp/include/cast_pushdown.hpp b/vortex-duckdb/cpp/include/cast_pushdown.hpp new file mode 100644 index 00000000000..f64e9da8b00 --- /dev/null +++ b/vortex-duckdb/cpp/include/cast_pushdown.hpp @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once +#include "optimizer.hpp" + +#include "duckdb/common/unordered_set.hpp" +#include "duckdb/main/client_context.hpp" +#include "duckdb/planner/expression.hpp" +#include "duckdb/planner/logical_operator.hpp" + +using namespace duckdb; + +/** + * Collect CAST(col) expressions. If "col" is used without CAST in "plan", + * record in "analyses.col_to_expr" + */ +struct CastCollect final : LogicalOperatorVisitor { + Analyses &analyses; + const Projections &projections; + // Casts that are direct outputs of a clean projection over a GET. Only these + // start a pushdown candidate; a nested cast may push down a different value. + // See test/sql/copy/csv/auto/test_large_integer_detection.test in duckdb + unordered_set top_level_casts; + + CastCollect(Analyses &analyses, const Projections &projections); + void VisitOperator(LogicalOperator &op) override; + ExpressionPtr VisitReplace(BoundColumnRefExpression &expr, ExpressionPtr *ptr) override; + ExpressionPtr VisitReplace(BoundCastExpression &expr, ExpressionPtr *ptr) override; +}; + +/* + * For "col" in columns collected by CastCollect, replace CAST(col) to "col" + * if "col" doesn't have conflicting usage. Update return types for bound + * columns and logical projections referencing this column. + */ +struct CastReplace final : LogicalOperatorVisitor { + Analyses &analyses; + const Projections &projections; + + CastReplace(Analyses &analyses, const Projections &projections); + ExpressionPtr VisitReplace(BoundColumnRefExpression &expr, ExpressionPtr *ptr) override; + ExpressionPtr VisitReplace(BoundCastExpression &expr, ExpressionPtr *ptr) override; +}; diff --git a/vortex-duckdb/cpp/include/expr.h b/vortex-duckdb/cpp/include/expr.h index adc706540bc..20ae4300584 100644 --- a/vortex-duckdb/cpp/include/expr.h +++ b/vortex-duckdb/cpp/include/expr.h @@ -269,6 +269,10 @@ typedef struct { void duckdb_vx_expr_get_bound_function(duckdb_vx_expr expr, duckdb_vx_expr_bound_function *out); +duckdb_vx_expr duckdb_vx_expr_get_bound_cast_child(duckdb_vx_expr expr); + +bool duckdb_vx_expr_get_bound_cast_is_try(duckdb_vx_expr expr); + #ifdef __cplusplus /* End C ABI */ } #endif diff --git a/vortex-duckdb/cpp/include/optimizer.hpp b/vortex-duckdb/cpp/include/optimizer.hpp new file mode 100644 index 00000000000..acb797928f6 --- /dev/null +++ b/vortex-duckdb/cpp/include/optimizer.hpp @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once +#include "table_function.hpp" + +#include "duckdb/planner/expression.hpp" +#include "duckdb/planner/operator/logical_get.hpp" + +#include + +// Aliases here are for ease of migration to duckdb 2.0 where these are +// separate types + +using namespace duckdb; + +/** + * Column index in requested scan. Example: + * + * CREATE TABLE t (a1 INTEGER, a2 INTEGER, a3 INTEGER); + * SELECT a2, a3 FROM t; + * + * a2's TableColumnScanIndex is 0, a3's TableColumnScanIndex is 1, + * index is index in SELECT clause. + */ +using TableColumnScanIndex = idx_t; +using ProjectionIndex = TableColumnScanIndex; + +/** + * Column index in table's storage. Example: + * + * CREATE TABLE t (a1 INTEGER, a2 INTEGER, a3 INTEGER); + * SELECT a2, a3 FROM t; + * + * a2's TableColumnStorageIndex is 1, a3's TableColumnStorageIndex is 2, + * index is index of column in table storage. + * + * for i: TableColumnScanIndex, column_ids[i].GetPrimaryIndex() is + * TableColumnStorageIndex + */ +using TableColumnStorageIndex = idx_t; + +using TableIndex = idx_t; + +using ExpressionPtr = unique_ptr; +using LogicalOperatorPtr = unique_ptr; + +struct GetAnalysis { + LogicalGet &get; + /** + * for fn(col), mapping of "col scan index" -> "expression applied to function". + * "expression" is nullptr iff column is used with a different expression + * or without expression application in the query plan (i.e. SELECT col). + */ + unordered_map col_to_expr; + + TableColumnStorageIndex StorageIndex(TableColumnScanIndex idx) const; +}; + +using Analyses = unordered_map; + +/* + * Using scalar function pushdown as a specific example, + * SELECT fn(col) FROM '*.vortex' yields a PROJECTION fn(col) -> GET (vortex) + * plan. PROJECTION's "col" table_index is 1, vortex GET's table_index is 0. + * So we want to track original table_index for GET in case column is found + * in filter we failed to push down (i.e. WHERE prefix(col, 'h')) as well as + * projection's table_index. + * + * So we keep a mapping of + * + * "projection table index" to "projection operator". + * + * to resolve this. + * For simplicity, current implementation is limited to one level i.e. + * PROJECTION -> GET (i.e. read from VIEW) is pushed down but VIEW->VIEW->GET + * or VIEW->CTE->GET is not. + * + * Storing a reference is fine because the plan outlives the optimizer pass. + */ +using Projections = unordered_map; + +void FindGetsAndProjections(LogicalOperator &op, Analyses &analyses, Projections &aliases); + +struct GetBinding { + GetAnalysis &analysis; + TableColumnScanIndex column_index; + // If column binding was part of a projection, this is non-nullptr + LogicalProjection *projection; +}; + +/* + * Given a column binding, resolve it to a GET and a GET's column scan index. + * Returns nullopt for virtual columns and columns which are neither part of + * GET nor part of PROJECTION wrapping a GET. + */ +std::optional Resolve(ColumnBinding binding, Analyses &analyses, const Projections &projections); + +// A passthrough projection only forwards its child columns, e.g. a VIEW's +// "SELECT col". +bool IsPassthrough(const LogicalProjection &projection); + +// There are no conflicting column usages in the plan +bool CanPushdownColumn(const GetAnalysis &analysis, TableColumnScanIndex idx); + +template +LogicalOperatorPtr TryPushdown(ClientContext &context, LogicalOperatorPtr plan) { + Analyses analyses; + Projections projections; + FindGetsAndProjections(*plan, analyses, projections); + if (analyses.empty()) { + return plan; + } + Collect(analyses, projections).VisitOperator(*plan); + + bool any_pushed = false; + for (auto &[_, analysis] : analyses) { + for (auto &[column_index, expr] : analysis.col_to_expr) { + if (expr == nullptr) { // Conflict for column + continue; + } + const TableColumnStorageIndex storage_index = analysis.StorageIndex(column_index); + TableFunctionProjectionExpressionInput input {analysis.get, *expr, storage_index}; + if (projection_expression_pushdown(context, input)) { + // LOGICAL_GET doesn't initialize .types of LogicalOperator + analysis.get.returned_types[storage_index] = expr->return_type; + any_pushed = true; + } else { // failed to push down expression, can't replace it + expr = nullptr; + } + } + } + + if (any_pushed) { + Replace(analyses, projections).VisitOperator(*plan); + } + return plan; +} diff --git a/vortex-duckdb/cpp/include/scalar_fn_pushdown.hpp b/vortex-duckdb/cpp/include/scalar_fn_pushdown.hpp index 24c0fd8bd1a..c94fab80408 100644 --- a/vortex-duckdb/cpp/include/scalar_fn_pushdown.hpp +++ b/vortex-duckdb/cpp/include/scalar_fn_pushdown.hpp @@ -1,97 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors #pragma once -#include "duckdb.h" - -#include "duckdb/optimizer/optimizer_extension.hpp" +#include "optimizer.hpp" #include "duckdb/planner/expression/bound_columnref_expression.hpp" #include "duckdb/planner/expression/bound_function_expression.hpp" -#include "duckdb/planner/operator/logical_get.hpp" -#include using namespace duckdb; -using ExpressionPtr = unique_ptr; -using LogicalOperatorPtr = unique_ptr; - -/** - * Column index in requested scan. Example: - * - * CREATE TABLE t (a1 INTEGER, a2 INTEGER, a3 INTEGER); - * SELECT a2, a3 FROM t; - * - * a2's TableColumnScanIndex is 0, a3's TableColumnScanIndex is 1, - * index is index in SELECT clause. - */ -using TableColumnScanIndex = idx_t; - -/** - * Column index in table's storage. Example: - * - * CREATE TABLE t (a1 INTEGER, a2 INTEGER, a3 INTEGER); - * SELECT a2, a3 FROM t; - * - * a2's TableColumnStorageIndex is 1, a3's TableColumnScanIndex is 2, - * index is index of column in table storage. - * - * for i: TableColumnScanIndex, column_ids[i].GetPrimaryIndex() is - * TableColumnStorageIndex - */ -using TableColumnStorageIndex = idx_t; - -using TableIndex = idx_t; - -struct GetAnalysis { - LogicalGet &get; - /** - * for fn(col), mapping of "col scan index" -> "fn expression". - * "fn expression" is nullptr iff column is used with a different function - * or without function application in the query plan. - */ - unordered_map col_to_fn; - - TableColumnStorageIndex StorageIndex(TableColumnScanIndex idx) const; -}; - -using Analyses = unordered_map; - -/* - * SELECT fn(col) FROM '*.vortex' yields a PROJECTION fn(col) -> GET (vortex) - * plan. PROJECTION's "col" table_index is 1, vortex GET's table_index is 0. - * So we want to track original table_index for GET in case column is found - * in filter we failed to push down (i.e. WHERE prefix(col, 'h')) as well as - * projection's table_index. - * - * So we keep a mapping of - * - * "projection table index" to "projection operator". - * - * to resolve this. - * For simplicity, current implementation is limited to one level i.e. - * PROJECTION -> GET (i.e. read from VIEW) is pushed down but VIEW->VIEW->GET - * or VIEW->CTE->GET is not. - * - * Storing a reference is fine because the plan outlives the optimizer pass. - */ -using Projections = unordered_map; - -LogicalOperatorPtr TryPushdownScalarFunctions(ClientContext &context, LogicalOperatorPtr plan); - -/* - * We override spatial's `ST_DWithin` with a copy that keeps the radius as a plain third - * argument (see expr.h); the original folds it into bind data at bind time. We need the - * radius visible to push the filter into a Vortex scan, but spatial's join optimizer only - * recognizes the folded 2-argument form. So this pass rebinds join conditions through - * spatial's original function and leaves filters alone: - * - * FILTER st_dwithin(t.geom, 'POINT(0 0)', 10.0) -- untouched, pushed into the scan - * JOIN ON st_dwithin(a.geom, b.geom, 10.0) -- rebound: st_dwithin(a.geom, b.geom) - * with the radius in bind data - * - * Runs in the pre-optimize hook, before any extension's optimizer pass. - */ -void RestoreStDWithin(ClientContext &context, LogicalOperator &plan); - /** * Collect fn(col) expressions i.e. expressions where a single function (not * a function chain) wraps a single bound column. If "col" is used without @@ -121,18 +36,4 @@ struct ScalarFnReplace final : LogicalOperatorVisitor { ExpressionPtr VisitReplace(BoundFunctionExpression &expr, ExpressionPtr *ptr) override; }; -void FindGetsAndProjections(LogicalOperator &op, Analyses &analyses, Projections &aliases); - -struct GetBinding { - GetAnalysis &analysis; - TableColumnScanIndex column_index; - // If column binding was part of a projection, this is non-nullptr - LogicalProjection *projection; -}; - -/* - * Given a column binding, resolve it to a GET and a GET's column scan index. - * Returns nullopt for virtual columns and columns which are neither part of - * GET nor part of PROJECTION wrapping a GET. - */ -std::optional Resolve(ColumnBinding binding, Analyses &analyses, const Projections &projections); +void RestoreStDWithin(ClientContext &context, LogicalOperator &plan); diff --git a/vortex-duckdb/cpp/optimizer.cpp b/vortex-duckdb/cpp/optimizer.cpp new file mode 100644 index 00000000000..87082d57790 --- /dev/null +++ b/vortex-duckdb/cpp/optimizer.cpp @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#include "optimizer.hpp" +#include "table_function.hpp" + +#include "duckdb/planner/expression/bound_columnref_expression.hpp" +#include "duckdb/planner/operator/logical_get.hpp" +#include "duckdb/planner/operator/logical_projection.hpp" + +void FindGetsAndProjections(LogicalOperator &op, Analyses &analyses, Projections &projections) { + using enum LogicalOperatorType; + switch (op.type) { + case LOGICAL_GET: { + if (auto &get = op.Cast(); get.function.bind == duckdb_vx_table_function_bind) { + analyses.emplace(get.table_index, GetAnalysis {get, {}}); + } + break; + } + case LOGICAL_PROJECTION: { + LogicalProjection &projection = op.Cast(); + D_ASSERT(projection.children.size() == 1); + auto &child = *projection.children[0]; + if (!IsPassthrough(projection) || child.type != LOGICAL_GET) { + break; + } + // The GET itself is recorded when recursion reaches it below. Only + // passthrough projections wrapping a vortex GET act as aliases. + if (auto &get = child.Cast(); get.function.bind == duckdb_vx_table_function_bind) { + projections.emplace(projection.table_index, projection); + } + break; + } + default: + break; + } + + for (auto &child : op.children) { + FindGetsAndProjections(*child, analyses, projections); + } +} + +TableColumnStorageIndex GetAnalysis::StorageIndex(TableColumnScanIndex idx) const { + return get.GetColumnIds()[idx].GetPrimaryIndex(); +} + +static bool IsVirtualColumn(const GetAnalysis &analysis, TableColumnScanIndex idx) { + return analysis.get.GetColumnIds()[idx].IsVirtualColumn(); +} + +std::optional Resolve(ColumnBinding binding, Analyses &analyses, const Projections &projections) { + if (const auto it = analyses.find(binding.table_index); it != analyses.end()) { + if (IsVirtualColumn(it->second, binding.column_index)) { + return std::nullopt; + } + return {{it->second, binding.column_index, nullptr}}; + } + + const auto projection_it = projections.find(binding.table_index); + if (projection_it == projections.end()) { + return std::nullopt; + } + + LogicalProjection &projection = projection_it->second; + const ExpressionPtr &inner = projection.expressions[binding.column_index]; + if (inner->GetExpressionType() != ExpressionType::BOUND_COLUMN_REF) { + return std::nullopt; + } + const ColumnBinding &get_binding = inner->Cast().binding; + if (const auto it = analyses.find(get_binding.table_index); it != analyses.end()) { + if (IsVirtualColumn(it->second, get_binding.column_index)) { + return std::nullopt; + } + return {{it->second, get_binding.column_index, &projection}}; + } + return std::nullopt; +} + +bool CanPushdownColumn(const GetAnalysis &analysis, TableColumnScanIndex idx) { + const auto it = analysis.col_to_expr.find(idx); + return it != analysis.col_to_expr.end() && it->second != nullptr; +} + +bool IsPassthrough(const LogicalProjection &projection) { + if (projection.expressions.empty()) { + return false; // don't register empty projections in Projections + } + for (const auto &e : projection.expressions) { + if (e->GetExpressionType() != ExpressionType::BOUND_COLUMN_REF) { + return false; + } + } + return true; +} diff --git a/vortex-duckdb/cpp/scalar_fn_pushdown.cpp b/vortex-duckdb/cpp/scalar_fn_pushdown.cpp index 8bac6d86749..b3986ef49cc 100644 --- a/vortex-duckdb/cpp/scalar_fn_pushdown.cpp +++ b/vortex-duckdb/cpp/scalar_fn_pushdown.cpp @@ -1,11 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +#include "scalar_fn_pushdown.hpp" + #include "duckdb/catalog/catalog.hpp" #include "duckdb/catalog/catalog_entry/scalar_function_catalog_entry.hpp" #include "duckdb/function/function_binder.hpp" #include "duckdb/planner/operator/logical_projection.hpp" -#include "scalar_fn_pushdown.hpp" -#include "table_function.hpp" +#include "duckdb/planner/operator/logical_get.hpp" + #include /** @@ -14,114 +16,6 @@ * If there are any functions left, this means they were not pushed down and * may produce conflicts (e.g. WHERE prefix("str", 'h')). */ - -LogicalOperatorPtr TryPushdownScalarFunctions(ClientContext &context, LogicalOperatorPtr plan) { - Analyses analyses; - Projections projections; - FindGetsAndProjections(*plan, analyses, projections); - if (analyses.empty()) { - return plan; - } - ScalarFnCollect(analyses, projections).VisitOperator(*plan); - - bool any_pushed = false; - for (auto &[_, analysis] : analyses) { - for (auto &[column_index, expr] : analysis.col_to_fn) { - if (expr == nullptr) { // Conflict for column - continue; - } - const TableColumnStorageIndex storage_index = analysis.StorageIndex(column_index); - TableFunctionProjectionExpressionInput input {analysis.get, *expr, storage_index}; - if (projection_expression_pushdown(context, input)) { - analysis.get.types[column_index] = expr->return_type; - analysis.get.returned_types[storage_index] = expr->return_type; - any_pushed = true; - } else { // failed to push down expression, can't replace it - expr = nullptr; - } - } - } - - if (any_pushed) { - ScalarFnReplace(analyses, projections).VisitOperator(*plan); - } - return plan; -} - -// A passthrough projection only forwards its child columns, e.g. a VIEW's -// "SELECT col". -static bool is_passthrough(const LogicalProjection &projection) { - if (projection.expressions.empty()) { - return false; // don't register empty projections in Projections - } - for (const auto &e : projection.expressions) { - if (e->GetExpressionType() != ExpressionType::BOUND_COLUMN_REF) { - return false; - } - } - return true; -} - -void FindGetsAndProjections(LogicalOperator &op, Analyses &analyses, Projections &projections) { - using enum LogicalOperatorType; - switch (op.type) { - case LOGICAL_GET: { - if (auto &get = op.Cast(); get.function.bind == duckdb_vx_table_function_bind) { - analyses.emplace(get.table_index, GetAnalysis {get, {}}); - } - break; - } - case LOGICAL_PROJECTION: { - LogicalProjection &projection = op.Cast(); - D_ASSERT(projection.children.size() == 1); - auto &child = *projection.children[0]; - if (!is_passthrough(projection) || child.type != LOGICAL_GET) { - break; - } - // The GET itself is recorded when recursion reaches it below. Only - // passthrough projections wrapping a vortex GET act as aliases. - if (auto &get = child.Cast(); get.function.bind == duckdb_vx_table_function_bind) { - projections.emplace(projection.table_index, projection); - } - break; - } - default: - break; - } - - for (auto &child : op.children) { - FindGetsAndProjections(*child, analyses, projections); - } -} - -std::optional Resolve(ColumnBinding binding, Analyses &analyses, const Projections &projections) { - if (IsVirtualColumn(binding.column_index)) { - return std::nullopt; - } - if (const auto it = analyses.find(binding.table_index); it != analyses.end()) { - return {{it->second, binding.column_index, nullptr}}; - } - - const auto projection_it = projections.find(binding.table_index); - if (projection_it == projections.end()) { - return std::nullopt; - } - - LogicalProjection &projection = projection_it->second; - const ExpressionPtr &inner = projection.expressions[binding.column_index]; - if (inner->GetExpressionType() != ExpressionType::BOUND_COLUMN_REF) { - return std::nullopt; - } - const ColumnBinding &get_binding = inner->Cast().binding; - if (IsVirtualColumn(get_binding.column_index)) { - return std::nullopt; - } - if (const auto it = analyses.find(get_binding.table_index); it != analyses.end()) { - return {{it->second, get_binding.column_index, &projection}}; - } - return std::nullopt; -} - void ScalarFnCollect::VisitOperator(LogicalOperator &op) { /* * Logical projection expressions are columns which reference underlying @@ -145,7 +39,7 @@ ExpressionPtr ScalarFnCollect::VisitReplace(BoundColumnRefExpression &expr, Expr if (const auto binding = Resolve(expr.binding, analyses, projections)) { // Column is used without function applied to it, register a conflict. // Not emplace() as we need to update the value if it was present - binding->analysis.col_to_fn[binding->column_index] = nullptr; + binding->analysis.col_to_expr[binding->column_index] = nullptr; } return std::move(*ptr); } @@ -162,11 +56,11 @@ ExpressionPtr ScalarFnCollect::VisitReplace(BoundFunctionExpression &expr, Expre if (!binding) { return nullptr; } - auto &col_to_fn = binding->analysis.col_to_fn; + auto &col_to_expr = binding->analysis.col_to_expr; - if (auto it = col_to_fn.find(binding->column_index); it == col_to_fn.end()) { + if (auto it = col_to_expr.find(binding->column_index); it == col_to_expr.end()) { // This is the first time we see the column used by a single function. - col_to_fn.emplace(binding->column_index, &expr); + col_to_expr.emplace(binding->column_index, &expr); } else if (it->second == nullptr || !it->second->Equals(expr)) { // Either column is used with different function in "expr" or // there already is a conflict. @@ -176,11 +70,6 @@ ExpressionPtr ScalarFnCollect::VisitReplace(BoundFunctionExpression &expr, Expre return std::move(*ptr); } -static bool can_pushdown_column(const GetAnalysis &analysis, TableColumnScanIndex idx) { - const auto it = analysis.col_to_fn.find(idx); - return it != analysis.col_to_fn.end() && it->second != nullptr; -} - ExpressionPtr ScalarFnReplace::VisitReplace(BoundColumnRefExpression &expr, ExpressionPtr *ptr) { const auto binding = Resolve(expr.binding, analyses, projections); if (!binding) { @@ -188,9 +77,11 @@ ExpressionPtr ScalarFnReplace::VisitReplace(BoundColumnRefExpression &expr, Expr } const auto &[analysis, column_index, projection] = *binding; - if (can_pushdown_column(analysis, column_index)) { - expr.return_type = analysis.get.types[column_index]; - if (projection != nullptr) { + if (CanPushdownColumn(analysis, column_index)) { + const idx_t storage_index = analysis.get.GetColumnIds()[column_index].GetPrimaryIndex(); + const LogicalType return_type = analysis.get.returned_types[storage_index]; + expr.return_type = return_type; + if (projection != nullptr && !projection->types.empty()) { projection->types[column_index] = expr.return_type; } } @@ -211,13 +102,15 @@ ExpressionPtr ScalarFnReplace::VisitReplace(BoundFunctionExpression &expr, Expre } const auto &[analysis, column_index, projection] = *binding; - if (!can_pushdown_column(analysis, column_index)) { + if (!CanPushdownColumn(analysis, column_index)) { return std::move(*ptr); } - bound_col_base->return_type = analysis.get.types[column_index]; - if (projection != nullptr) { - projection->types[column_index] = bound_col_base->return_type; + const idx_t storage_index = analysis.get.GetColumnIds()[column_index].GetPrimaryIndex(); + const LogicalType return_type = analysis.get.returned_types[storage_index]; + bound_col_base->return_type = return_type; + if (projection != nullptr && !projection->types.empty()) { + projection->types[column_index] = return_type; } return std::move(bound_col_base); } @@ -230,10 +123,6 @@ ScalarFnReplace::ScalarFnReplace(Analyses &analyses, const Projections &projecti : analyses(analyses), projections(projections) { } -TableColumnStorageIndex GetAnalysis::StorageIndex(TableColumnScanIndex idx) const { - return get.GetColumnIds()[idx].GetPrimaryIndex(); -} - namespace { // See RestoreStDWithin: rebinding through spatial's own entry lets spatial build its own bind diff --git a/vortex-duckdb/cpp/vortex_duckdb.cpp b/vortex-duckdb/cpp/vortex_duckdb.cpp index bafa777a061..b1241fc9435 100644 --- a/vortex-duckdb/cpp/vortex_duckdb.cpp +++ b/vortex-duckdb/cpp/vortex_duckdb.cpp @@ -4,6 +4,7 @@ #include "data.hpp" #include "error.hpp" #include "scalar_fn_pushdown.hpp" +#include "cast_pushdown.hpp" #include "vortex_duckdb.h" #include "duckdb/catalog/catalog.hpp" @@ -268,7 +269,8 @@ extern "C" duckdb_blob duckdb_vx_value_get_geometry(duckdb_value value) { } static void VortexOptimizeFunction(OptimizerExtensionInput &input, unique_ptr &plan) { - plan = TryPushdownScalarFunctions(input.context, std::move(plan)); + plan = TryPushdown(input.context, std::move(plan)); + plan = TryPushdown(input.context, std::move(plan)); } static void VortexPreOptimizeFunction(OptimizerExtensionInput &input, unique_ptr &plan) { diff --git a/vortex-duckdb/src/convert/dtype.rs b/vortex-duckdb/src/convert/dtype.rs index d05fdb9f22e..816fa7868f6 100644 --- a/vortex-duckdb/src/convert/dtype.rs +++ b/vortex-duckdb/src/convert/dtype.rs @@ -91,8 +91,8 @@ impl FromLogicalType for DType { DUCKDB_TYPE::DUCKDB_TYPE_USMALLINT => DType::Primitive(U16, nullability), DUCKDB_TYPE::DUCKDB_TYPE_UINTEGER => DType::Primitive(U32, nullability), DUCKDB_TYPE::DUCKDB_TYPE_UBIGINT => DType::Primitive(U64, nullability), - DUCKDB_TYPE::DUCKDB_TYPE_HUGEINT => todo!(), - DUCKDB_TYPE::DUCKDB_TYPE_UHUGEINT => todo!(), + DUCKDB_TYPE::DUCKDB_TYPE_HUGEINT => vortex_bail!("I128 is not in Vortex type system"), + DUCKDB_TYPE::DUCKDB_TYPE_UHUGEINT => vortex_bail!("U128 is not in Vortex type system"), DUCKDB_TYPE::DUCKDB_TYPE_FLOAT => DType::Primitive(F32, nullability), DUCKDB_TYPE::DUCKDB_TYPE_DOUBLE => DType::Primitive(F64, nullability), DUCKDB_TYPE::DUCKDB_TYPE_VARCHAR => DType::Utf8(nullability), diff --git a/vortex-duckdb/src/convert/expr.rs b/vortex-duckdb/src/convert/expr.rs index 32778870845..abf0b68c3b4 100644 --- a/vortex-duckdb/src/convert/expr.rs +++ b/vortex-duckdb/src/convert/expr.rs @@ -45,6 +45,7 @@ use vortex_geo::extension::WellKnownBinary; use vortex_geo::extension::native_geometry_scalar_from_wkb; use vortex_geo::scalar_fn::distance::GeoDistance; +use crate::convert::dtype::FromLogicalType; use crate::cpp::DUCKDB_TYPE; use crate::cpp::DUCKDB_VX_EXPR_TYPE; use crate::duckdb; @@ -52,6 +53,7 @@ use crate::duckdb::BoundFunction; use crate::duckdb::BoundOperator; use crate::duckdb::ExpressionClass; use crate::duckdb::ExpressionClass::BoundBetween; +use crate::duckdb::ExpressionClass::BoundCast; use crate::duckdb::ExpressionClass::BoundColumnRef; use crate::duckdb::ExpressionClass::BoundComparison; use crate::duckdb::ExpressionClass::BoundConjunction; @@ -212,8 +214,7 @@ fn try_from_bound_function( let col = byte_length(col); // byte_length returns u64, strlen expects i64. // At this point we don't know column's dtype so we ultimately - // set it to be nullable. For non-nullable column the nullability - // will be AllValid so it's a marginal cost. + // set it to be nullable. let dtype = DType::Primitive(PType::I64, Nullability::Nullable); cast(col, dtype) } @@ -266,7 +267,7 @@ fn try_from_bound_function( return Ok(None); }; - // We don't know the column's nullability here, so we set it to nullable. + // We don't know the column's nullability here build_list_length(col, Nullability::Nullable) } // len/length semantics depend on the return type of underlying expr. @@ -280,7 +281,7 @@ fn try_from_bound_function( return Ok(None); }; - // Same nullability rationale as in "array_length" branch. + // We don't know the column's nullability here let list_len_expr = build_list_length(col, Nullability::Nullable); return Ok(Some(list_len_expr)); } else { @@ -327,6 +328,19 @@ fn is_supported_length_alias(func: &BoundFunction) -> bool { children.len() == 1 && returns_a_list(children[0]) } +// We limit casting to Primitive types, because some conversions yield an error +// like vortex.date[days](i32) -> vortex.timestamp[µs](i64?). However, when we +// push down the cast, we don't have access to column's dtype, so we need to +// be overly restrictive. +// TODO(myrrc) change after https://github.com/vortex-data/vortex/issues/8570 +// is resolved +// +// We also don't push floats and doubles because Vortex truncates to zero and +// Duckdb rounds the result +fn can_push_cast(cast: &duckdb::BoundCast<'_>, target: &duckdb::LogicalTypeRef) -> bool { + !cast.is_try && target.is_primitive_integer() && cast.child.return_type().is_primitive_integer() +} + // Called before pushdown_complex_filter or a table filter expression call. // As we support complex filter pushdown, Duckdb pushes expressions to Vortex. // However, it doesn't know what type of expressions we can handle. Here we list @@ -337,15 +351,19 @@ fn is_supported_length_alias(func: &BoundFunction) -> bool { // // Example: we don't support substr() expression so we tell Duckdb we can't // push it. +// Example: we support CAST but not TRY_CAST. // Example: optional filters may fail to parse on our side (we return // Ok(None)), so we don't allow pushing these. pub fn can_push_expression(value: &duckdb::ExpressionRef) -> bool { - let Some(value) = value.as_class() else { + let Some(class) = value.as_class() else { return false; }; - match value { + match class { BoundColumnRef(_) => true, BoundConstant(_) => true, + BoundCast(cast) => { + can_push_cast(&cast, value.return_type()) && can_push_expression(cast.child) + } BoundRef => true, BoundComparison(comp) => can_push_expression(comp.left) && can_push_expression(comp.right), BoundBetween(between) => { @@ -396,27 +414,41 @@ pub fn try_from_projection_expression( value: &duckdb::ExpressionRef, field: &DuckdbField, ) -> VortexResult> { - let Some(value) = value.as_class() else { - return Ok(None); - }; - let ExpressionClass::BoundFunction(func) = value else { + let Some(class) = value.as_class() else { return Ok(None); }; - Ok(match func.scalar_function.name() { - "strlen" => { - let col = byte_length(get_item(field.name.as_str(), root())); - // byte_length returns u64, strlen expects i64 - let dtype = DType::Primitive(PType::I64, field.dtype.nullability()); - let col = cast(col, dtype); - Some(col) + Ok(match class { + ExpressionClass::BoundFunction(func) => { + match func.scalar_function.name() { + "strlen" => { + let col = byte_length(get_item(field.name.as_str(), root())); + // byte_length returns u64, strlen expects i64 + let dtype = DType::Primitive(PType::I64, field.dtype.nullability()); + let col = cast(col, dtype); + Some(col) + } + "array_length" => { + // Only accept array_length(expr) rather than array_length(expr, dim). + (func.children().count() == 1).then(|| list_length_on_field(field)) + } + // len/length have different semantics depending on field dtype. + "len" | "length" => { + matches!(field.dtype, DType::List(..) | DType::FixedSizeList(..)) + .then(|| list_length_on_field(field)) + } + _ => None, + } } - "array_length" => { - // Only accept array_length(expr) rather than array_length(expr, dim). - (func.children().count() == 1).then(|| list_length_on_field(field)) + BoundCast(c) => { + let target = value.return_type(); + if !can_push_cast(&c, target) { + None + } else { + let dtype = DType::from_logical_type(target, field.dtype.nullability())?; + let col = get_item(field.name.as_str(), root()); + Some(cast(col, dtype)) + } } - // len/length have different semantics depending on field dtype. - "len" | "length" => matches!(field.dtype, DType::List(..) | DType::FixedSizeList(..)) - .then(|| list_length_on_field(field)), _ => None, }) } @@ -427,14 +459,14 @@ fn try_from_expression_inner( value: &duckdb::ExpressionRef, ctx: ConvertCtx<'_>, ) -> VortexResult> { - let Some(value) = value.as_class() else { + let Some(class) = value.as_class() else { debug!( class_id = ?value.as_class_id(), "unknown expression class id" ); return Ok(None); }; - Ok(Some(match value { + Ok(Some(match class { BoundRef => { let Some(col) = ctx.col_sub else { vortex_bail!("BoundRef requested but no column supplied"); @@ -513,6 +545,18 @@ fn try_from_expression_inner( ExpressionClass::BoundFunction(func) => { return try_from_bound_function(&func, ctx); } + BoundCast(cast_inner) => { + let target = value.return_type(); + if !can_push_cast(&cast_inner, target) { + return Ok(None); + } + let Some(child) = try_from_expression_inner(cast_inner.child, ctx)? else { + return Ok(None); + }; + // We don't know the column's nullability here + let dtype = DType::from_logical_type(target, Nullability::Nullable)?; + cast(child, dtype) + } BoundConjunction(conj) => { let Some(children) = conj .children() diff --git a/vortex-duckdb/src/duckdb/expr.rs b/vortex-duckdb/src/duckdb/expr.rs index 48f255f744e..76f4f386d36 100644 --- a/vortex-duckdb/src/duckdb/expr.rs +++ b/vortex-duckdb/src/duckdb/expr.rs @@ -44,6 +44,14 @@ impl ExpressionRef { pub fn as_class(&self) -> Option> { Some( match unsafe { cpp::duckdb_vx_expr_get_class(self.as_ptr()) } { + cpp::DUCKDB_VX_EXPR_CLASS::DUCKDB_VX_EXPR_CLASS_BOUND_CAST => { + let child = unsafe { + Expression::borrow(cpp::duckdb_vx_expr_get_bound_cast_child(self.as_ptr())) + }; + let is_try = + unsafe { cpp::duckdb_vx_expr_get_bound_cast_is_try(self.as_ptr()) }; + ExpressionClass::BoundCast(BoundCast { child, is_try }) + } cpp::DUCKDB_VX_EXPR_CLASS::DUCKDB_VX_EXPR_CLASS_BOUND_COLUMN_REF => { let name = unsafe { let ptr = cpp::duckdb_vx_expr_get_bound_column_ref_get_name(self.as_ptr()); @@ -165,10 +173,16 @@ pub enum ExpressionClass<'a> { BoundBetween(BoundBetween<'a>), BoundOperator(BoundOperator<'a>), BoundFunction(BoundFunction<'a>), + BoundCast(BoundCast<'a>), /// Column inside ExpressionFilter for expression pushed down to Vortex. BoundRef, } +pub struct BoundCast<'a> { + pub child: &'a ExpressionRef, + pub is_try: bool, +} + pub struct BoundColumnRef { pub name: DDBString, } diff --git a/vortex-duckdb/src/duckdb/logical_type.rs b/vortex-duckdb/src/duckdb/logical_type.rs index 7a4627e6953..28c17cbcf02 100644 --- a/vortex-duckdb/src/duckdb/logical_type.rs +++ b/vortex-duckdb/src/duckdb/logical_type.rs @@ -153,6 +153,14 @@ impl LogicalType { Self::new(DUCKDB_TYPE::DUCKDB_TYPE_BLOB) } + pub fn uint8() -> Self { + Self::new(DUCKDB_TYPE::DUCKDB_TYPE_UTINYINT) + } + + pub fn uint16() -> Self { + Self::new(DUCKDB_TYPE::DUCKDB_TYPE_USMALLINT) + } + pub fn uint32() -> Self { Self::new(DUCKDB_TYPE::DUCKDB_TYPE_UINTEGER) } @@ -165,6 +173,14 @@ impl LogicalType { Self::new(DUCKDB_TYPE::DUCKDB_TYPE_UHUGEINT) } + pub fn int8() -> Self { + Self::new(DUCKDB_TYPE::DUCKDB_TYPE_TINYINT) + } + + pub fn int16() -> Self { + Self::new(DUCKDB_TYPE::DUCKDB_TYPE_SMALLINT) + } + pub fn int32() -> Self { Self::new(DUCKDB_TYPE::DUCKDB_TYPE_INTEGER) } @@ -234,6 +250,21 @@ impl LogicalTypeRef { matches!(self.as_type_id(), DUCKDB_TYPE::DUCKDB_TYPE_DECIMAL) } + /// True if this type maps to a Vortex Primitive and isn't a floating point + pub fn is_primitive_integer(&self) -> bool { + matches!( + self.as_type_id(), + DUCKDB_TYPE::DUCKDB_TYPE_TINYINT + | DUCKDB_TYPE::DUCKDB_TYPE_SMALLINT + | DUCKDB_TYPE::DUCKDB_TYPE_INTEGER + | DUCKDB_TYPE::DUCKDB_TYPE_BIGINT + | DUCKDB_TYPE::DUCKDB_TYPE_UTINYINT + | DUCKDB_TYPE::DUCKDB_TYPE_USMALLINT + | DUCKDB_TYPE::DUCKDB_TYPE_UINTEGER + | DUCKDB_TYPE::DUCKDB_TYPE_UBIGINT + ) + } + pub fn geometry_crs(&self) -> Option { unsafe { let c_string = duckdb_geometry_type_get_crs(self.as_ptr()); diff --git a/vortex-sqllogictest/Cargo.toml b/vortex-sqllogictest/Cargo.toml index ddfd79ef99e..2da45eec01b 100644 --- a/vortex-sqllogictest/Cargo.toml +++ b/vortex-sqllogictest/Cargo.toml @@ -26,6 +26,7 @@ rstest = { workspace = true } sqllogictest = "0.29.1" thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } +tracing-subscriber = { workspace = true, features = ["env-filter"] } vortex = { workspace = true, features = ["tokio"] } vortex-datafusion = { workspace = true } vortex-duckdb = { workspace = true } diff --git a/vortex-sqllogictest/bin/sqllogictests-runner.rs b/vortex-sqllogictest/bin/sqllogictests-runner.rs index 80335ac8951..fd5aa0cec60 100644 --- a/vortex-sqllogictest/bin/sqllogictests-runner.rs +++ b/vortex-sqllogictest/bin/sqllogictests-runner.rs @@ -198,6 +198,11 @@ fn complete_files( } fn main() -> anyhow::Result { + drop( + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .try_init(), + ); let mut raw_args: Vec = std::env::args().collect(); // We remove the `--complete` flag that isn't standard before we pass the rest. let complete = { diff --git a/vortex-sqllogictest/slt/duckdb/cast_pushdown.slt b/vortex-sqllogictest/slt/duckdb/cast_pushdown.slt new file mode 100644 index 00000000000..6c95279b94a --- /dev/null +++ b/vortex-sqllogictest/slt/duckdb/cast_pushdown.slt @@ -0,0 +1,465 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + +include ../setup.slt.no + +statement ok +COPY (SELECT '2020-01-01 02:20:30' ts) TO '$__TEST_DIR__/cast_pushdown-date.vortex'; + +# Date cast is not pushed down +query TT +EXPLAIN SELECT * FROM '$__TEST_DIR__/cast_pushdown-date.vortex' +WHERE ts::DATE = '2020-01-01'; +---- +:.*FILTER.* + +query T +SELECT ts FROM '$__TEST_DIR__/cast_pushdown-date.vortex' +WHERE ts::DATE = '2020-01-01'; +---- +2020-01-01 02:20:30 + +statement ok +COPY (SELECT * FROM ( + VALUES (1, 0),(1, 0),(1, 0),(1, 0),(1, 0),(1, 0),(2, 0),(3, 0),(3, 0),(3, 257) +) AS t(column00, column01)) +TO '$__TEST_DIR__/cast_pushdown.vortex'; + +# Column is used uncasted +query TT +EXPLAIN SELECT * FROM '$__TEST_DIR__/cast_pushdown.vortex'; +---- +:.*PROJECTION.* + +query I +SELECT DISTINCT column00 FROM '$__TEST_DIR__/cast_pushdown.vortex' ORDER BY 1; +---- +1 +2 +3 + +statement ok +CREATE VIEW v AS SELECT column01 AS x, column00 AS y FROM '$__TEST_DIR__/cast_pushdown.vortex' WHERE x > 0; + +query II +SELECT x::BIGINT, y::SMALLINT FROM v; +---- +257 3 + +statement ok +CREATE VIEW vdup AS SELECT column01 AS x, column01 AS y FROM '$__TEST_DIR__/cast_pushdown.vortex'; + +query II +SELECT x::BIGINT, y::BIGINT FROM vdup ORDER BY 1 DESC LIMIT 1; +---- +257 257 + +# Column is used casted +query TT +EXPLAIN SELECT column00::UTINYINT FROM '$__TEST_DIR__/cast_pushdown.vortex'; +---- +:.*PROJECTION.* + +query I +SELECT DISTINCT column00::UTINYINT FROM '$__TEST_DIR__/cast_pushdown.vortex' ORDER BY 1; +---- +1 +2 +3 + +# Column is used casted and uncasted +query TT +EXPLAIN SELECT column00, column00::UTINYINT FROM '$__TEST_DIR__/cast_pushdown.vortex'; +---- +:.*PROJECTION.* + +query II +SELECT column00, column00::UTINYINT FROM '$__TEST_DIR__/cast_pushdown.vortex' ORDER BY 1, 2; +---- +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +2 2 +3 3 +3 3 +3 3 + +# Column is used uncasted with filter +query TT +EXPLAIN SELECT column00 FROM '$__TEST_DIR__/cast_pushdown.vortex' +WHERE column00 > 0; +---- +:.*PROJECTION.* + +query I +SELECT column00 FROM '$__TEST_DIR__/cast_pushdown.vortex' +WHERE column00 > 0 ORDER BY 1; +---- +1 +1 +1 +1 +1 +1 +2 +3 +3 +3 + +# Column is used uncasted with filter on casted. +# Cast is pushed in WHERE separately +query TT +EXPLAIN SELECT column00 FROM '$__TEST_DIR__/cast_pushdown.vortex' +WHERE column00::UTINYINT > 0; +---- +:.*FILTER.* + +query I +SELECT column00 FROM '$__TEST_DIR__/cast_pushdown.vortex' +WHERE column00::UTINYINT > 0 ORDER BY 1; +---- +1 +1 +1 +1 +1 +1 +2 +3 +3 +3 + +# Same cast in SELECT and ORDER BY: no conflict +query TT +EXPLAIN SELECT column00::UTINYINT +FROM '$__TEST_DIR__/cast_pushdown.vortex' +ORDER BY column00::UTINYINT; +---- +:.*PROJECTION.* + +query I +SELECT column00::UTINYINT +FROM '$__TEST_DIR__/cast_pushdown.vortex' +ORDER BY column00::UTINYINT; +---- +1 +1 +1 +1 +1 +1 +2 +3 +3 +3 + +# Two different casts of the same column: conflict +query TT +EXPLAIN SELECT column00::UTINYINT, column00::USMALLINT +FROM '$__TEST_DIR__/cast_pushdown.vortex'; +---- +:.*PROJECTION.* + +query II +SELECT column00::UTINYINT, column00::USMALLINT +FROM '$__TEST_DIR__/cast_pushdown.vortex' ORDER BY 1, 2; +---- +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +2 2 +3 3 +3 3 +3 3 + +# TRY_CAST is not pushed down +query TT +EXPLAIN SELECT TRY_CAST(column00 AS UTINYINT) FROM '$__TEST_DIR__/cast_pushdown.vortex'; +---- +:.*PROJECTION.* + +query I +SELECT TRY_CAST(column00 AS UTINYINT) FROM '$__TEST_DIR__/cast_pushdown.vortex' ORDER BY 1; +---- +1 +1 +1 +1 +1 +1 +2 +3 +3 +3 + +query I +SELECT TRY_CAST(column01 AS UTINYINT) FROM '$__TEST_DIR__/cast_pushdown.vortex' ORDER BY 1 NULLS LAST; +---- +0 +0 +0 +0 +0 +0 +0 +0 +0 +NULL + + +statement error +SELECT CAST(column01 AS UTINYINT) FROM '$__TEST_DIR__/cast_pushdown.vortex'; + +# TRY_CAST is not pushed in WHERE -> conflict +query TT +EXPLAIN SELECT column00 FROM '$__TEST_DIR__/cast_pushdown.vortex' +WHERE TRY_CAST(column00 AS UTINYINT) > 0; +---- +:.*FILTER.* + +query I +SELECT column00 FROM '$__TEST_DIR__/cast_pushdown.vortex' +WHERE TRY_CAST(column00 AS UTINYINT) > 0 ORDER BY 1; +---- +1 +1 +1 +1 +1 +1 +2 +3 +3 +3 + +# Same TRY_CAST in SELECT and WHERE: CAST stays in the plan. +query TT +EXPLAIN SELECT TRY_CAST(column00 AS UTINYINT) +FROM '$__TEST_DIR__/cast_pushdown.vortex' +WHERE TRY_CAST(column00 AS UTINYINT) > 0; +---- +:.*CAST.* + +query I +SELECT TRY_CAST(column00 AS UTINYINT) +FROM '$__TEST_DIR__/cast_pushdown.vortex' +WHERE TRY_CAST(column00 AS UTINYINT) > 0 ORDER BY 1; +---- +1 +1 +1 +1 +1 +1 +2 +3 +3 +3 + +query TT +EXPLAIN SELECT column00::UTINYINT, TRY_CAST(column00 AS UTINYINT) +FROM '$__TEST_DIR__/cast_pushdown.vortex'; +---- +:.*CAST.* + +query TT +EXPLAIN SELECT column00::UTINYINT, TRY_CAST(column00 AS UTINYINT) +FROM '$__TEST_DIR__/cast_pushdown.vortex'; +---- +:.*SELECT projections.* + +query II +SELECT column00::UTINYINT, TRY_CAST(column00 AS UTINYINT) +FROM '$__TEST_DIR__/cast_pushdown.vortex' ORDER BY 1, 2; +---- +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +2 2 +3 3 +3 3 +3 3 + +# CAST and TRY_CAST of different target types: conflict, pushdown blocked +query TT +EXPLAIN SELECT column00::UTINYINT, TRY_CAST(column00 AS USMALLINT) +FROM '$__TEST_DIR__/cast_pushdown.vortex'; +---- +:.*PROJECTION.* + +query TT +EXPLAIN SELECT column00::UTINYINT, TRY_CAST(column00 AS USMALLINT) +FROM '$__TEST_DIR__/cast_pushdown.vortex'; +---- +:.*SELECT projections.* + +query II +SELECT column00::UTINYINT, TRY_CAST(column00 AS USMALLINT) +FROM '$__TEST_DIR__/cast_pushdown.vortex' ORDER BY 1, 2; +---- +1 1 +1 1 +1 1 +1 1 +1 1 +1 1 +2 2 +3 3 +3 3 +3 3 + +# i128 and u128 casts are not allowed as Vortex doesn't support these types + +query TT +EXPLAIN SELECT column00::HUGEINT FROM '$__TEST_DIR__/cast_pushdown.vortex'; +---- +:.*PROJECTION.* + +query TT +EXPLAIN SELECT column00::UHUGEINT FROM '$__TEST_DIR__/cast_pushdown.vortex'; +---- +:.*PROJECTION.* + +query TT +EXPLAIN SELECT column00 +FROM '$__TEST_DIR__/cast_pushdown.vortex' +WHERE column01::HUGEINT >= column00; +---- +:.*FILTER.* + +query TT +EXPLAIN SELECT column01 +FROM '$__TEST_DIR__/cast_pushdown.vortex' +WHERE column00::UHUGEINT >= column01 +---- +:.*FILTER.* + +statement ok +COPY (SELECT * FROM ( + VALUES ('1993-07-15'::DATE), ('1993-08-20'::DATE), ('1993-10-15'::DATE) +) AS t(d)) +TO '$__TEST_DIR__/cast_pushdown-date-filter.vortex'; + +query D +SELECT d FROM '$__TEST_DIR__/cast_pushdown-date-filter.vortex' +WHERE d >= DATE '1993-07-01' + AND d < DATE '1993-07-01' + INTERVAL '3' MONTH +ORDER BY d; +---- +1993-07-15 +1993-08-20 + +query TT +EXPLAIN SELECT d FROM '$__TEST_DIR__/cast_pushdown-date-filter.vortex' +WHERE d < DATE '1993-07-01' + INTERVAL '3' MONTH; +---- +:.*FILTER.* + +statement ok +CREATE OR REPLACE TABLE tbl(val FLOAT) + +statement ok +INSERT INTO tbl VALUES (1), (-1.2), (-1.5) + +statement ok +COPY (SELECT * FROM tbl) TO '$__TEST_DIR__/cast_pushdown-float.vortex'; + +# floating point casts are not pushed down because Vortex's behaviour, truncate +# to zero, differs from Duckdb's rounding +query TT +EXPLAIN SELECT CAST(val AS INTEGER) FROM '$__TEST_DIR__/cast_pushdown-float.vortex'; +---- +:.*PROJECTION.* + +query I +SELECT CAST(val AS INTEGER) FROM '$__TEST_DIR__/cast_pushdown-float.vortex' ORDER BY 1; +---- +-2 +-1 +1 + +statement ok +COPY (SELECT 1 AS x) TO '$__TEST_DIR__/cast_pushdown-vcol.vortex'; + +query I +SELECT file_row_number::BIGINT FROM '$__TEST_DIR__/cast_pushdown-vcol.vortex'; +---- +0 + +statement ok +CREATE VIEW virt_col AS SELECT x, file_row_number FROM '$__TEST_DIR__/cast_pushdown-vcol.vortex'; + +query I +SELECT file_row_number::BIGINT FROM virt_col; +---- +0 + +statement ok +COPY (SELECT * FROM (VALUES (0), (0), (257)) AS t(col)) +TO '$__TEST_DIR__/cast_pushdown-many.vortex'; + +query TT +EXPLAIN SELECT col::UTINYINT FROM '$__TEST_DIR__/cast_pushdown-many.vortex' +WHERE TRY_CAST(col AS UTINYINT) IS NOT NULL; +---- +:.*FILTER.*TRY_CAST.* + +query TT +EXPLAIN SELECT col::UTINYINT FROM '$__TEST_DIR__/cast_pushdown-many.vortex' +WHERE TRY_CAST(col AS UTINYINT) IS NOT NULL; +---- +:.*SELECT projections.* + +query I +SELECT col::UTINYINT FROM '$__TEST_DIR__/cast_pushdown-many.vortex' +WHERE TRY_CAST(col AS UTINYINT) IS NOT NULL; +---- +0 +0 + +# A cast must not be pushed below a filter that stayed in the plan +statement ok +COPY (SELECT * FROM (VALUES (1, 'a'), (257, 'b')) AS t(n, s)) +TO '$__TEST_DIR__/cast_pushdown-residual-filter.vortex'; + +query TT +EXPLAIN SELECT n::UTINYINT FROM '$__TEST_DIR__/cast_pushdown-residual-filter.vortex' +WHERE s || 'x' = 'ax'; +---- +:.*FILTER.* + +query TT +EXPLAIN SELECT n::UTINYINT FROM '$__TEST_DIR__/cast_pushdown-residual-filter.vortex' +WHERE s || 'x' = 'ax'; +---- +:.*SELECT projections.* + +query I +SELECT n::UTINYINT FROM '$__TEST_DIR__/cast_pushdown-residual-filter.vortex' +WHERE s || 'x' = 'ax'; +---- +1 + +# A fully pushed filter does not block cast pushdown +query TT +EXPLAIN SELECT column00::UTINYINT FROM '$__TEST_DIR__/cast_pushdown.vortex' +WHERE column01 = 0; +---- +:.*(FILTER|PROJECTION).* + +query I +SELECT DISTINCT column00::UTINYINT FROM '$__TEST_DIR__/cast_pushdown.vortex' +WHERE column01 = 0 ORDER BY 1; +---- +1 +2 +3 diff --git a/vortex-sqllogictest/slt/duckdb/projection_expression_pushdown.slt b/vortex-sqllogictest/slt/duckdb/projection_expression_pushdown.slt index 9c431097bd7..36e099004cd 100644 --- a/vortex-sqllogictest/slt/duckdb/projection_expression_pushdown.slt +++ b/vortex-sqllogictest/slt/duckdb/projection_expression_pushdown.slt @@ -351,13 +351,13 @@ ORDER BY strlen(str); 3 5 -# prefix isn't pushed down as a complex filter so WHERE is not pushed down +# || isn't pushed down as a complex filter so WHERE is not pushed down # although this is only usage of str in SELECT, we can't push strlen down # as there is usage in WHERE. # This also tests functions with multiple arguments using "str" inside query I SELECT strlen(str) FROM '${WORK_DIR}/pe-pushdown.vortex' -WHERE prefix("str", 'H') > 0 +WHERE prefix(str || 'O', 'H') > 0 ORDER BY strlen(str); ---- 2 @@ -373,13 +373,6 @@ ORDER BY strlen(str); 3 5 -# explain: prefix()/suffix() in WHERE are multi-arg uses of str, no pushdown -query TT -EXPLAIN (FORMAT JSON) -SELECT strlen(str) FROM '${WORK_DIR}/pe-pushdown.vortex' WHERE prefix("str", 'H') > 0; ----- -:SELECT projections - # conflict with concat(), no pushdown query I SELECT strlen(str) FROM '${WORK_DIR}/pe-pushdown.vortex' diff --git a/vortex-sqllogictest/src/duckdb.rs b/vortex-sqllogictest/src/duckdb.rs index 1052781d430..cbf42feae7a 100644 --- a/vortex-sqllogictest/src/duckdb.rs +++ b/vortex-sqllogictest/src/duckdb.rs @@ -63,11 +63,15 @@ impl DuckDB { fn normalize_column_type(logical_type: &LogicalTypeRef) -> DFColumnType { let type_id = logical_type.as_type_id(); - if type_id == LogicalType::int32().as_type_id() + if type_id == LogicalType::int8().as_type_id() + || type_id == LogicalType::int16().as_type_id() + || type_id == LogicalType::int32().as_type_id() || type_id == LogicalType::int64().as_type_id() + || type_id == LogicalType::int128().as_type_id() + || type_id == LogicalType::uint8().as_type_id() + || type_id == LogicalType::uint16().as_type_id() || type_id == LogicalType::uint32().as_type_id() || type_id == LogicalType::uint64().as_type_id() - || type_id == LogicalType::int128().as_type_id() || type_id == LogicalType::uint128().as_type_id() { DFColumnType::Integer From fa000fff973c947bf961e92a2b525236b5defbe4 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Wed, 8 Jul 2026 16:50:39 +0100 Subject: [PATCH 037/104] ci: pin the `measurement_id` hash with golden vectors in-repo (#8687) ## Summary Right now, there is no way for us to ensure that our `measurement_id` key that we generate in CI matches the ones that we have on the [`benchmarks-website`](https://github.com/vortex-data/benchmarks-website) code. This adds a bunch of "golden" hashes so that we can immediately see if something changes here that would break the contract with the benchmarks website server. Merge this before vortex-data/benchmarks-website#7 so the hash is never unpinned. --------- Signed-off-by: Connor Tsui Co-authored-by: Claude Fable 5 --- .github/workflows/ci.yml | 16 + _typos.toml | 5 +- scripts/_measurement_id.py | 28 +- scripts/measurement_id_golden.json | 825 +++++++++++++++++++++++++++ scripts/tests/test_measurement_id.py | 62 ++ 5 files changed, 921 insertions(+), 15 deletions(-) create mode 100644 scripts/measurement_id_golden.json create mode 100644 scripts/tests/test_measurement_id.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e6451d22b1..372e2639bb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,6 +136,22 @@ jobs: uv run --all-packages make html working-directory: docs/ + # Pins the measurement_id hash that scripts/post-ingest.py uses as the primary key of every + # benchmarks-database fact row. The golden vectors are a frozen artifact (see the note inside + # the JSON); this job fails if a change to scripts/_measurement_id.py drifts the hash, which + # would break ON CONFLICT idempotency against the rows already in production. + measurement-id-golden: + name: "measurement_id golden vectors" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: spiraldb/actions/.github/actions/setup-uv@a746510eafaa926484c354541cfc49b2ec06cc63 # 0.18.6 + - name: Pytest - measurement_id golden vectors + run: | + uv run --no-project --with pytest --with xxhash \ + pytest scripts/tests/test_measurement_id.py + python-cuda-test: name: "Python CUDA (test)" if: github.repository == 'vortex-data/vortex' diff --git a/_typos.toml b/_typos.toml index e2a5ff330b8..d5d2a8e4db3 100644 --- a/_typos.toml +++ b/_typos.toml @@ -8,7 +8,10 @@ extend-ignore-re = [ ] [files] -extend-exclude = ["/vortex-bench/**", "/docs/references.bib", "benchmarks/**", "vortex-sqllogictest/slt/**", "encodings/fsst/src/dfa/tests.rs", "encodings/fsst/src/dfa/flat_contains.rs", "benchmarks-website/server/static/**", "benchmarks-website/server/tests/snapshots/**"] +# scripts/measurement_id_golden.json is a frozen hash-pin artifact whose vectors include +# deliberate Unicode edge cases (JSON-escaped, so "café" reads as the token "caf"); its bytes +# must never change, so it is excluded rather than "fixed". +extend-exclude = ["/vortex-bench/**", "/docs/references.bib", "benchmarks/**", "vortex-sqllogictest/slt/**", "encodings/fsst/src/dfa/tests.rs", "encodings/fsst/src/dfa/flat_contains.rs", "benchmarks-website/server/static/**", "benchmarks-website/server/tests/snapshots/**", "scripts/measurement_id_golden.json"] [type.py] extend-ignore-identifiers-re = [ diff --git a/scripts/_measurement_id.py b/scripts/_measurement_id.py index e8834f4c097..6e670c38dd0 100644 --- a/scripts/_measurement_id.py +++ b/scripts/_measurement_id.py @@ -1,20 +1,20 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright the Vortex contributors -"""Python port of the server-internal `measurement_id` xxhash64 functions. - -This is a byte-for-byte port of the `vortex-data/benchmarks-website` repo's -`server/src/db.rs` (`measurement_id_*`, `hasher_for`, -`write_str`, `write_opt_str`, `write_i32`, `write_f64`, `finish`). The v4 ingest -writer computes the same `measurement_id` the v3 Rust server computes, so -re-ingesting an existing `(commit, dim-tuple)` upserts the existing row via -`ON CONFLICT (measurement_id) DO UPDATE` instead of inserting a duplicate. - -The equivalence is NOT assumed -- it is pinned by golden vectors generated from -the Rust source of truth (the `measurement_id_golden_vectors` test in the -`vortex-data/benchmarks-website` repo's `server/src/db.rs`). The Python port is -checked against those vectors; any drift in either implementation fails that -check. +"""The `measurement_id` xxhash64 functions -- the primary-key hash of the benchmarks database. + +This began as a byte-for-byte port of the Rust implementation in the `vortex-data/ +benchmarks-website` repo's `server/src/db.rs` (`measurement_id_*`, `hasher_for`, `write_str`, +`write_opt_str`, `write_i32`, `write_f64`, `finish`); with that server crate retired, this module +is now the only implementation. Re-ingesting an existing `(commit, dim-tuple)` upserts the +existing row via `ON CONFLICT (measurement_id) DO UPDATE` instead of inserting a duplicate, so +the hash is frozen forever: every row already in the production Postgres carries an id computed +this way. + +The freeze is NOT assumed -- it is pinned by `scripts/measurement_id_golden.json`, whose vectors +were generated by the original Rust source of truth and must never be regenerated. +`scripts/tests/test_measurement_id.py` asserts this module reproduces every vector; any drift +fails that check. ## The hash, precisely diff --git a/scripts/measurement_id_golden.json b/scripts/measurement_id_golden.json new file mode 100644 index 00000000000..5df8fe9775d --- /dev/null +++ b/scripts/measurement_id_golden.json @@ -0,0 +1,825 @@ +{ + "note": "Frozen golden vectors for the measurement_id_* hash, generated by the original Rust implementation (server/src/db.rs in the retired vortex-data/benchmarks-website server crate) that wrote the production rows. NEVER regenerate this file: the ids it pins are the primary keys already stored in the benchmarks RDS Postgres, and any drift would break ON CONFLICT (measurement_id) idempotency. scripts/tests/test_measurement_id.py asserts the Python port in scripts/_measurement_id.py reproduces every measurement_id here.", + "seed": 0, + "vectors": [ + { + "fields": { + "commit_sha": "0000000000000000000000000000000000000000", + "dataset": "dataset-0", + "dataset_variant": null, + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -20, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": 8941066430020068021, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0000000000000000000000009e3779b97f4a7c15", + "dataset": "dataset-1", + "dataset_variant": "variant-1", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -19, + "scale_factor": "sf-1", + "storage": "s3" + }, + "measurement_id": 3149995011396621799, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0000000000000000000000003c6ef372fe94f82a", + "dataset": "dataset-2", + "dataset_variant": "variant-2", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -18, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": -6215740072949592228, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000daa66d2c7ddf743f", + "dataset": "dataset-3", + "dataset_variant": null, + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -17, + "scale_factor": "sf-3", + "storage": "s3" + }, + "measurement_id": 8041805059478031959, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "00000000000000000000000078dde6e5fd29f054", + "dataset": "dataset-4", + "dataset_variant": "variant-4", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -16, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": 4772600626526399345, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0000000000000000000000001715609f7c746c69", + "dataset": "dataset-5", + "dataset_variant": "variant-5", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -15, + "scale_factor": "sf-5", + "storage": "s3" + }, + "measurement_id": -9016235375010445132, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000b54cda58fbbee87e", + "dataset": "dataset-6", + "dataset_variant": null, + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -14, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": -6411293753624450917, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000538454127b096493", + "dataset": "dataset-7", + "dataset_variant": "variant-7", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -13, + "scale_factor": "sf-7", + "storage": "s3" + }, + "measurement_id": 8460757218662476151, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000f1bbcdcbfa53e0a8", + "dataset": "dataset-8", + "dataset_variant": "variant-8", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -12, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": 7670946045315319280, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0000000000000000000000008ff34785799e5cbd", + "dataset": "dataset-9", + "dataset_variant": null, + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -11, + "scale_factor": "sf-9", + "storage": "s3" + }, + "measurement_id": 1978441881557979666, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0000000000000000000000002e2ac13ef8e8d8d2", + "dataset": "dataset-10", + "dataset_variant": "variant-10", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -10, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": 8135912901467482164, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000cc623af8783354e7", + "dataset": "dataset-11", + "dataset_variant": "variant-11", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -9, + "scale_factor": "sf-11", + "storage": "s3" + }, + "measurement_id": 805733765350646006, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0000000000000000000000006a99b4b1f77dd0fc", + "dataset": "dataset-12", + "dataset_variant": null, + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -8, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": -4091026328830870798, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "00000000000000000000000008d12e6b76c84d11", + "dataset": "dataset-13", + "dataset_variant": "variant-13", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -7, + "scale_factor": "sf-13", + "storage": "s3" + }, + "measurement_id": -6061385115965648155, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000a708a824f612c926", + "dataset": "dataset-14", + "dataset_variant": "variant-14", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -6, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": -6684883584448916863, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000454021de755d453b", + "dataset": "dataset-15", + "dataset_variant": null, + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -5, + "scale_factor": "sf-15", + "storage": "s3" + }, + "measurement_id": 8814896453978445645, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000e3779b97f4a7c150", + "dataset": "dataset-16", + "dataset_variant": "variant-16", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -4, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": 6686567714742361044, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "00000000000000000000000081af155173f23d65", + "dataset": "dataset-17", + "dataset_variant": "variant-17", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -3, + "scale_factor": "sf-17", + "storage": "s3" + }, + "measurement_id": 3557917168552872470, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0000000000000000000000001fe68f0af33cb97a", + "dataset": "dataset-18", + "dataset_variant": null, + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -2, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": -6165762661706299592, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000be1e08c47287358f", + "dataset": "dataset-19", + "dataset_variant": "variant-19", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": -1, + "scale_factor": "sf-19", + "storage": "s3" + }, + "measurement_id": -535031789163192453, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0000000000000000000000005c55827df1d1b1a4", + "dataset": "dataset-20", + "dataset_variant": "variant-20", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 0, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": 4634990895302954655, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000fa8cfc37711c2db9", + "dataset": "dataset-21", + "dataset_variant": null, + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 1, + "scale_factor": "sf-21", + "storage": "s3" + }, + "measurement_id": 5936866269340399925, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "00000000000000000000000098c475f0f066a9ce", + "dataset": "dataset-22", + "dataset_variant": "variant-22", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 2, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": 4471015533681449689, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "00000000000000000000000036fbefaa6fb125e3", + "dataset": "dataset-23", + "dataset_variant": "variant-23", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 3, + "scale_factor": "sf-23", + "storage": "s3" + }, + "measurement_id": 3270457410965634007, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000d5336963eefba1f8", + "dataset": "dataset-24", + "dataset_variant": null, + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 4, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": -6630843892626754319, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000736ae31d6e461e0d", + "dataset": "dataset-25", + "dataset_variant": "variant-25", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 5, + "scale_factor": "sf-25", + "storage": "s3" + }, + "measurement_id": 5238548701197127815, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "00000000000000000000000011a25cd6ed909a22", + "dataset": "dataset-26", + "dataset_variant": "variant-26", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 6, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": -869863853831808211, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000afd9d6906cdb1637", + "dataset": "dataset-27", + "dataset_variant": null, + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 7, + "scale_factor": "sf-27", + "storage": "s3" + }, + "measurement_id": -6175971040302506030, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0000000000000000000000004e115049ec25924c", + "dataset": "dataset-28", + "dataset_variant": "variant-28", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 8, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": 4758474844754348116, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000ec48ca036b700e61", + "dataset": "dataset-29", + "dataset_variant": "variant-29", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 9, + "scale_factor": "sf-29", + "storage": "s3" + }, + "measurement_id": 4027371045171197062, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0000000000000000000000008a8043bceaba8a76", + "dataset": "dataset-30", + "dataset_variant": null, + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 10, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": 6213625536713947544, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "00000000000000000000000028b7bd766a05068b", + "dataset": "dataset-31", + "dataset_variant": "variant-31", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 11, + "scale_factor": "sf-31", + "storage": "s3" + }, + "measurement_id": 3264730627889711995, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000c6ef372fe94f82a0", + "dataset": "dataset-32", + "dataset_variant": "variant-32", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 12, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": 6426500585598625566, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0000000000000000000000006526b0e96899feb5", + "dataset": "dataset-33", + "dataset_variant": null, + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 13, + "scale_factor": "sf-33", + "storage": "s3" + }, + "measurement_id": -470855441762601549, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000035e2aa2e7e47aca", + "dataset": "dataset-34", + "dataset_variant": "variant-34", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 14, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": -8702944831802119265, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000a195a45c672ef6df", + "dataset": "dataset-35", + "dataset_variant": "variant-35", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 15, + "scale_factor": "sf-35", + "storage": "s3" + }, + "measurement_id": 6237767006190293538, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0000000000000000000000003fcd1e15e67972f4", + "dataset": "dataset-36", + "dataset_variant": null, + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 16, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": 5322839569260259127, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "000000000000000000000000de0497cf65c3ef09", + "dataset": "dataset-37", + "dataset_variant": "variant-37", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 17, + "scale_factor": "sf-37", + "storage": "s3" + }, + "measurement_id": 1995931045924102080, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0000000000000000000000007c3c1188e50e6b1e", + "dataset": "dataset-38", + "dataset_variant": "variant-38", + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 18, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": -7932518466629682266, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0000000000000000000000001a738b426458e733", + "dataset": "dataset-39", + "dataset_variant": null, + "engine": "vortex", + "format": "vortex-file-compressed", + "query_idx": 19, + "scale_factor": "sf-39", + "storage": "s3" + }, + "measurement_id": 7227626056758470989, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "tpch", + "dataset_variant": null, + "engine": "vortex", + "format": "parquet", + "query_idx": 7, + "scale_factor": "1", + "storage": "nvme" + }, + "measurement_id": 6778969912115220139, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "\ub370\uc774\ud130\uc14b-\u65e5\u672c\u8a9e", + "dataset_variant": "caf\u00e9-\u03a9", + "engine": "\u30f4", + "format": "format-\u2713", + "query_idx": 0, + "scale_factor": "\u03c3", + "storage": "s3" + }, + "measurement_id": 8038081617766937373, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "boundary", + "dataset_variant": null, + "engine": "e", + "format": "f", + "query_idx": -2147483648, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": -1997262439178828726, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "boundary", + "dataset_variant": null, + "engine": "e", + "format": "f", + "query_idx": 2147483647, + "scale_factor": null, + "storage": "nvme" + }, + "measurement_id": -1111737216055603595, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "", + "dataset_variant": "", + "engine": "", + "format": "", + "query_idx": 0, + "scale_factor": "", + "storage": "" + }, + "measurement_id": -4121125782605521286, + "table": "query_measurements" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "chimp", + "dataset_variant": null, + "format": "vortex", + "op": "encode" + }, + "measurement_id": -1351591591404870516, + "table": "compression_times" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "chimp", + "dataset_variant": null, + "format": "vortex", + "op": "decode" + }, + "measurement_id": 8221793816172450272, + "table": "compression_times" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "taxi", + "dataset_variant": "v2", + "format": "parquet", + "op": "encode" + }, + "measurement_id": -192124532873811017, + "table": "compression_times" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "\uc555\ucd95-\u30c6\u30b9\u30c8", + "dataset_variant": "\u5909\u7a2e", + "format": "vortex-\u2713", + "op": "decode" + }, + "measurement_id": -2942165613664654219, + "table": "compression_times" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "chimp", + "dataset_variant": null, + "format": "vortex" + }, + "measurement_id": 4543757287978490921, + "table": "compression_sizes" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "taxi", + "dataset_variant": "v2", + "format": "parquet" + }, + "measurement_id": -7313339766868556874, + "table": "compression_sizes" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "\ud06c\uae30-\u30b5\u30a4\u30ba", + "dataset_variant": "\u5909\u7a2e-\u03a9", + "format": "vortex-\u2713" + }, + "measurement_id": -8576081510595521142, + "table": "compression_sizes" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "chimp", + "format": "vortex" + }, + "measurement_id": 4734678989716641189, + "table": "random_access_times" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "taxi", + "format": "parquet" + }, + "measurement_id": 164255076009922704, + "table": "random_access_times" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "\u7121\u4f5c\u70ba", + "format": "\u2713" + }, + "measurement_id": 832111591737631247, + "table": "random_access_times" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "cohere-large-10m", + "flavor": "vortex", + "layout": "trained", + "threshold": 0.0 + }, + "measurement_id": 2427216480405807826, + "table": "vector_search_runs" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "cohere-large-10m", + "flavor": "vortex", + "layout": "trained", + "threshold": 0.5 + }, + "measurement_id": -1711405494360824574, + "table": "vector_search_runs" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "cohere-large-10m", + "flavor": "vortex", + "layout": "trained", + "threshold": 0.83 + }, + "measurement_id": 2194658981253663824, + "table": "vector_search_runs" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "cohere-large-10m", + "flavor": "vortex", + "layout": "trained", + "threshold": 1.0 + }, + "measurement_id": -5633397304139473003, + "table": "vector_search_runs" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "cohere-large-10m", + "flavor": "vortex", + "layout": "trained", + "threshold": -1.5 + }, + "measurement_id": -5505640980838817192, + "table": "vector_search_runs" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "cohere-large-10m", + "flavor": "vortex", + "layout": "trained", + "threshold": 1e-09 + }, + "measurement_id": -812426564273547024, + "table": "vector_search_runs" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "cohere-large-10m", + "flavor": "vortex", + "layout": "trained", + "threshold": 1000000000000.0 + }, + "measurement_id": 8044969450004818277, + "table": "vector_search_runs" + }, + { + "fields": { + "commit_sha": "0123456789abcdef0123456789abcdef01234567", + "dataset": "cohere-large-10m", + "flavor": "vortex", + "layout": "trained", + "threshold": 3.141592653589793 + }, + "measurement_id": 3687626387279485390, + "table": "vector_search_runs" + } + ] +} diff --git a/scripts/tests/test_measurement_id.py b/scripts/tests/test_measurement_id.py new file mode 100644 index 00000000000..c8baf0cfb8d --- /dev/null +++ b/scripts/tests/test_measurement_id.py @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + +"""Golden-vector pin for the `measurement_id` hash in `scripts/_measurement_id.py`. + +The `measurement_id` computed by `scripts/post-ingest.py --postgres` is the primary key of every +fact row in the benchmarks-website RDS Postgres. Re-ingesting an existing `(commit, dim-tuple)` +upserts via `ON CONFLICT (measurement_id)`; if the hash ever drifted, re-ingests would insert +duplicate rows next to millions of existing ones instead. The hash is therefore frozen forever. + +`scripts/measurement_id_golden.json` pins it: the vectors were generated by the original Rust +implementation (`server/src/db.rs` in the now-deleted `vortex-data/benchmarks-website` `server/` +crate, the source of truth at the time the production rows were written). With the Rust side +retired, the golden file is a frozen artifact -- it must NEVER be regenerated, because the ids it +pins are the ids already stored in production. This test asserts the Python port reproduces every +vector byte-for-byte. + +Run with: + + uv run --no-project --with pytest --with xxhash pytest scripts/tests/test_measurement_id.py +""" + +import importlib.util +import json +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +MODULE_PATH = REPO_ROOT / "scripts" / "_measurement_id.py" +GOLDEN_PATH = REPO_ROOT / "scripts" / "measurement_id_golden.json" + + +def load_measurement_id_module(): + spec = importlib.util.spec_from_file_location("_measurement_id", MODULE_PATH) + assert spec is not None + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_every_golden_vector_reproduces(): + module = load_measurement_id_module() + golden = json.loads(GOLDEN_PATH.read_text()) + assert golden["seed"] == 0 + + for vector in golden["vectors"]: + port_fn = module.MEASUREMENT_ID_BY_TABLE[vector["table"]] + computed = port_fn(**vector["fields"]) + assert computed == vector["measurement_id"], ( + f"measurement_id drift for table {vector['table']!r} fields {vector['fields']!r}: " + f"computed {computed}, golden {vector['measurement_id']}" + ) + + +def test_every_fact_table_is_covered(): + """Every port function must have at least one golden vector, so a new fact table cannot be + wired into `MEASUREMENT_ID_BY_TABLE` without also pinning its hash.""" + module = load_measurement_id_module() + golden = json.loads(GOLDEN_PATH.read_text()) + + covered_tables = {vector["table"] for vector in golden["vectors"]} + assert covered_tables == set(module.MEASUREMENT_ID_BY_TABLE) From c7c28d8c01d7c5d63abbbdaa08c28a22088ed5b9 Mon Sep 17 00:00:00 2001 From: Nemo Yu <83347615+HarukiMoriarty@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:26:51 -0400 Subject: [PATCH 038/104] feat(geo): add native `LineString`, `MultiPoint`, `MultiLineString` types (#8679) Complete the set of OGC simple-feature geometries alongside the existing Point/Polygon/MultiPolygon: - vortex.geo.linestring - List> - vortex.geo.multipoint - List> - vortex.geo.multilinestring - List>> Each mirrors the existing Polygon/MultiPolygon extension types. Wire them through the DuckDB integration so they surface as GEOMETRY like the existing native types: the dtype -> GEOMETRY mapping, per-type WKB exporters, and native-geometry-column recognition for predicate pushdown. Tests cover dtype validation across all four dimensions, Arrow field import/export (including that the shape-identical types resolve by name), and WKB-literal decode round-trips. Signed-off-by: Nemo Yu --- vortex-duckdb/src/convert/dtype.rs | 8 +- vortex-duckdb/src/convert/expr.rs | 12 +- vortex-duckdb/src/exporter/extension.rs | 18 + vortex-duckdb/src/exporter/geo.rs | 35 ++ vortex-geo/src/extension/linestring.rs | 341 +++++++++++++++++++ vortex-geo/src/extension/mod.rs | 110 ++++++ vortex-geo/src/extension/multilinestring.rs | 359 ++++++++++++++++++++ vortex-geo/src/extension/multipoint.rs | 336 ++++++++++++++++++ vortex-geo/src/lib.rs | 12 + vortex-geo/src/tests/linestring.rs | 88 +++++ vortex-geo/src/tests/mod.rs | 3 + vortex-geo/src/tests/multilinestring.rs | 92 +++++ vortex-geo/src/tests/multipoint.rs | 87 +++++ 13 files changed, 1499 insertions(+), 2 deletions(-) create mode 100644 vortex-geo/src/extension/linestring.rs create mode 100644 vortex-geo/src/extension/multilinestring.rs create mode 100644 vortex-geo/src/extension/multipoint.rs create mode 100644 vortex-geo/src/tests/linestring.rs create mode 100644 vortex-geo/src/tests/multilinestring.rs create mode 100644 vortex-geo/src/tests/multipoint.rs diff --git a/vortex-duckdb/src/convert/dtype.rs b/vortex-duckdb/src/convert/dtype.rs index 816fa7868f6..863a41cd869 100644 --- a/vortex-duckdb/src/convert/dtype.rs +++ b/vortex-duckdb/src/convert/dtype.rs @@ -58,6 +58,9 @@ use vortex::extension::datetime::Time; use vortex::extension::datetime::TimeUnit; use vortex::extension::datetime::Timestamp; use vortex_geo::extension::GeoMetadata; +use vortex_geo::extension::LineString; +use vortex_geo::extension::MultiLineString; +use vortex_geo::extension::MultiPoint; use vortex_geo::extension::MultiPolygon; use vortex_geo::extension::Point; use vortex_geo::extension::Polygon; @@ -248,10 +251,13 @@ impl TryFrom<&DType> for LogicalType { return temporal_to_duckdb(temporal); } - // Native Point/Polygon and WKB all surface to DuckDB as GEOMETRY so `ST_*` bind. + // Native geometry types and WKB all surface to DuckDB as GEOMETRY so `ST_*` bind. if let Some(geo) = ext_dtype .metadata_opt::() + .or_else(|| ext_dtype.metadata_opt::()) + .or_else(|| ext_dtype.metadata_opt::()) .or_else(|| ext_dtype.metadata_opt::()) + .or_else(|| ext_dtype.metadata_opt::()) .or_else(|| ext_dtype.metadata_opt::()) .or_else(|| ext_dtype.metadata_opt::()) { diff --git a/vortex-duckdb/src/convert/expr.rs b/vortex-duckdb/src/convert/expr.rs index abf0b68c3b4..b2c763e0613 100644 --- a/vortex-duckdb/src/convert/expr.rs +++ b/vortex-duckdb/src/convert/expr.rs @@ -38,6 +38,9 @@ use vortex::scalar_fn::fns::like::Like; use vortex::scalar_fn::fns::like::LikeOptions; use vortex::scalar_fn::fns::literal::Literal; use vortex::scalar_fn::fns::operators::Operator; +use vortex_geo::extension::LineString; +use vortex_geo::extension::MultiLineString; +use vortex_geo::extension::MultiPoint; use vortex_geo::extension::MultiPolygon; use vortex_geo::extension::Point; use vortex_geo::extension::Polygon; @@ -107,7 +110,14 @@ fn is_native_geo_column(fields: Option<&[DuckdbField]>, name: &str) -> bool { .flatten() .filter(|field| field.name == name) .any(|field| match field.dtype.as_extension_opt() { - Some(ext) => ext.is::() || ext.is::() || ext.is::(), + Some(ext) => { + ext.is::() + || ext.is::() + || ext.is::() + || ext.is::() + || ext.is::() + || ext.is::() + } None => false, }) } diff --git a/vortex-duckdb/src/exporter/extension.rs b/vortex-duckdb/src/exporter/extension.rs index 30eb3715cc2..c07dc25a886 100644 --- a/vortex-duckdb/src/exporter/extension.rs +++ b/vortex-duckdb/src/exporter/extension.rs @@ -8,6 +8,12 @@ use vortex::array::arrays::extension::ExtensionArrayExt; use vortex::array::extension::datetime::AnyTemporal; use vortex::error::VortexResult; use vortex::error::vortex_bail; +use vortex_geo::extension::LineString; +use vortex_geo::extension::LineStringData; +use vortex_geo::extension::MultiLineString; +use vortex_geo::extension::MultiLineStringData; +use vortex_geo::extension::MultiPoint; +use vortex_geo::extension::MultiPointData; use vortex_geo::extension::MultiPolygon; use vortex_geo::extension::MultiPolygonData; use vortex_geo::extension::Point; @@ -37,10 +43,22 @@ pub(crate) fn new_exporter( return geo::new_point_exporter(PointData::try_from(ext)?, ctx); } + if ext.ext_dtype().is::() { + return geo::new_linestring_exporter(LineStringData::try_from(ext)?, ctx); + } + + if ext.ext_dtype().is::() { + return geo::new_multipoint_exporter(MultiPointData::try_from(ext)?, ctx); + } + if ext.ext_dtype().is::() { return geo::new_polygon_exporter(PolygonData::try_from(ext)?, ctx); } + if ext.ext_dtype().is::() { + return geo::new_multilinestring_exporter(MultiLineStringData::try_from(ext)?, ctx); + } + if ext.ext_dtype().is::() { return geo::new_multipolygon_exporter(MultiPolygonData::try_from(ext)?, ctx); } diff --git a/vortex-duckdb/src/exporter/geo.rs b/vortex-duckdb/src/exporter/geo.rs index fc6634c121c..ccc1893ea18 100644 --- a/vortex-duckdb/src/exporter/geo.rs +++ b/vortex-duckdb/src/exporter/geo.rs @@ -4,6 +4,9 @@ use vortex::array::ExecutionCtx; use vortex::array::arrays::VarBinViewArray; use vortex::error::VortexResult; +use vortex_geo::extension::LineStringData; +use vortex_geo::extension::MultiLineStringData; +use vortex_geo::extension::MultiPointData; use vortex_geo::extension::MultiPolygonData; use vortex_geo::extension::PointData; use vortex_geo::extension::PolygonData; @@ -51,3 +54,35 @@ pub(crate) fn new_multipolygon_exporter( let values = multipolygon.to_wkb(ctx)?.execute::(ctx)?; new_exporter(values, ctx) } + +/// Create an exporter for a native `LineString` column, serialized to WKB via +/// [`LineStringData::to_wkb`] (see [`new_point_exporter`]). +pub(crate) fn new_linestring_exporter( + linestring: LineStringData, + ctx: &mut ExecutionCtx, +) -> VortexResult> { + let values = linestring.to_wkb(ctx)?.execute::(ctx)?; + new_exporter(values, ctx) +} + +/// Create an exporter for a native `MultiPoint` column, serialized to WKB via +/// [`MultiPointData::to_wkb`] (see [`new_point_exporter`]). +pub(crate) fn new_multipoint_exporter( + multipoint: MultiPointData, + ctx: &mut ExecutionCtx, +) -> VortexResult> { + let values = multipoint.to_wkb(ctx)?.execute::(ctx)?; + new_exporter(values, ctx) +} + +/// Create an exporter for a native `MultiLineString` column, serialized to WKB via +/// [`MultiLineStringData::to_wkb`] (see [`new_point_exporter`]). +pub(crate) fn new_multilinestring_exporter( + multilinestring: MultiLineStringData, + ctx: &mut ExecutionCtx, +) -> VortexResult> { + let values = multilinestring + .to_wkb(ctx)? + .execute::(ctx)?; + new_exporter(values, ctx) +} diff --git a/vortex-geo/src/extension/linestring.rs b/vortex-geo/src/extension/linestring.rs new file mode 100644 index 00000000000..f7d8fcff1de --- /dev/null +++ b/vortex-geo/src/extension/linestring.rs @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! The [`LineString`] geometry extension type (`vortex.geo.linestring`): an ordered path of the +//! [`Point`](super::Point) coordinate struct, stored as `List>` and tagged +//! with [`GeoMetadata`] (CRS). + +use std::sync::Arc; + +use arrow_array::ArrayRef as ArrowArrayRef; +use arrow_schema::DataType; +use arrow_schema::Field; +use arrow_schema::extension::ExtensionType; +use geo_traits::to_geo::ToGeoGeometry; +use geo_types::Geometry; +use geoarrow::array::GeoArrowArrayAccessor; +use geoarrow::array::IntoArrow; +use geoarrow::array::LineStringArray; +use geoarrow::datatypes::CoordType; +use geoarrow::datatypes::LineStringType; +use prost::Message; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::arrays::ExtensionArray; +use vortex_array::arrays::extension::ExtensionArrayExt; +use vortex_array::arrow::ArrowExport; +use vortex_array::arrow::ArrowExportVTable; +use vortex_array::arrow::ArrowImport; +use vortex_array::arrow::ArrowImportVTable; +use vortex_array::arrow::ArrowSession; +use vortex_array::arrow::ArrowSessionExt; +use vortex_array::arrow::FromArrowArray; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::arrow::FromArrowType; +use vortex_array::dtype::extension::ExtDType; +use vortex_array::dtype::extension::ExtId; +use vortex_array::dtype::extension::ExtVTable; +use vortex_array::scalar::ScalarValue; +use vortex_error::VortexError; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; +use vortex_error::vortex_ensure; +use vortex_error::vortex_err; +use vortex_session::registry::CachedId; +use vortex_session::registry::Id; + +use super::GeoMetadata; +use super::coordinate::Dimension; +use super::coordinate::coordinate_dimension; +use super::coordinate::coordinate_storage_dtype; +use super::geo_metadata_from_arrow; +use super::geoarrow_metadata; +use super::geoarrow_to_wkb; + +/// A line string: `geoarrow.linestring`, stored as `List>` (an ordered path +/// of vertices). +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct LineString; + +impl ExtVTable for LineString { + type Metadata = GeoMetadata; + // No cheap owned value like Point's `Coordinate`; expose the raw storage scalar. + type NativeValue<'a> = &'a ScalarValue; + + fn id(&self) -> ExtId { + static ID: CachedId = CachedId::new("vortex.geo.linestring"); + *ID + } + + fn serialize_metadata(&self, metadata: &Self::Metadata) -> VortexResult> { + Ok(metadata.encode_to_vec()) + } + + fn deserialize_metadata(&self, metadata: &[u8]) -> VortexResult { + Ok(GeoMetadata::decode(metadata)?) + } + + fn validate_dtype(ext_dtype: &ExtDType) -> VortexResult<()> { + linestring_dimension(ext_dtype.storage_dtype()).map(|_| ()) + } + + fn unpack_native<'a>( + _ext_dtype: &'a ExtDType, + storage_value: &'a ScalarValue, + ) -> VortexResult<&'a ScalarValue> { + Ok(storage_value) + } +} + +/// Canonical line-string storage: a list of the coordinate `Struct`. +pub(crate) fn linestring_storage_dtype(dim: Dimension, nullability: Nullability) -> DType { + let coords = coordinate_storage_dtype(dim, Nullability::NonNullable); + DType::List(Arc::new(coords), nullability) +} + +/// Validate `dtype` is `List` and return its [`Dimension`]. +pub(crate) fn linestring_dimension(dtype: &DType) -> VortexResult { + let DType::List(coords, _) = dtype else { + vortex_bail!("linestring storage must be a List of coordinates, was {dtype}"); + }; + coordinate_dimension(coords) +} + +static ARROW_LINESTRING: CachedId = CachedId::new(LineStringType::NAME); + +/// The `geoarrow.linestring` extension type for `dimension`, with separated (struct) coordinates +/// matching `LineString` storage. +fn linestring_type(geo_metadata: &GeoMetadata, dimension: Dimension) -> LineStringType { + LineStringType::new(dimension.into(), geoarrow_metadata(geo_metadata)) +} + +/// Decode `LineString` storage (`List`) to `geo_types` line strings, for the geo scalar +/// functions. CRS does not affect planar geometry ops, so default metadata is used. +pub(crate) fn linestring_geometries( + storage: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult>> { + linestring_array(storage, ctx)? + .iter() + .map(|geometry| -> VortexResult> { + Ok(geometry + .ok_or_else(|| vortex_err!("geo: null geometry is not supported"))? + .map_err(|e| vortex_err!("geo: geometry access failed: {e}"))? + .to_geometry()) + }) + .collect() +} + +/// Build a geoarrow `LineStringArray` from a `LineString`'s `List` storage. +fn linestring_array(storage: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + let linestring_type = linestring_type( + &GeoMetadata::default(), + linestring_dimension(storage.dtype())?, + ); + let session = ctx.session().clone(); + let arrow = session.arrow().execute_arrow(storage.clone(), None, ctx)?; + LineStringArray::try_from((arrow.as_ref(), linestring_type)) + .map_err(|e| vortex_err!("failed to construct LineStringArray: {e}")) +} + +/// A validated `LineString` array (`try_from` checks the extension type). +pub struct LineStringData(ExtensionArray); + +impl TryFrom for LineStringData { + type Error = VortexError; + + fn try_from(ext: ExtensionArray) -> Result { + vortex_ensure!( + ext.ext_dtype().is::(), + "expected a LineString extension array" + ); + Ok(LineStringData(ext)) + } +} + +impl LineStringData { + /// Serialize line strings to WKB (a view array) — the form DuckDB `GEOMETRY` takes. + pub fn to_wkb(&self, ctx: &mut ExecutionCtx) -> VortexResult { + geoarrow_to_wkb(&linestring_array(self.0.storage_array(), ctx)?) + } +} + +impl ArrowExportVTable for LineString { + fn arrow_ext_id(&self) -> Id { + *ARROW_LINESTRING + } + + fn vortex_id(&self) -> Id { + self.id() + } + + fn to_arrow_field( + &self, + name: &str, + dtype: &DType, + session: &ArrowSession, + ) -> VortexResult> { + let ext_type = dtype.as_extension(); + let geo_metadata = ext_type.metadata::(); + let dimension = linestring_dimension(ext_type.storage_dtype())?; + + let mut field = session.to_arrow_field(name, ext_type.storage_dtype())?; + field.try_with_extension_type(linestring_type(geo_metadata, dimension))?; + + Ok(Some(field)) + } + + fn execute_arrow( + &self, + array: ArrayRef, + target: &Field, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + let is_linestring = array + .dtype() + .as_extension_opt() + .map(|ext| ext.is::()) + .unwrap_or(false); + if !is_linestring { + return Ok(ArrowExport::Unsupported(array)); + } + + let Ok(linestring_meta) = target.try_extension_type::() else { + return Ok(ArrowExport::Unsupported(array)); + }; + if linestring_meta.coord_type() != CoordType::Separated { + return Ok(ArrowExport::Unsupported(array)); + } + + let executed = array.execute::(ctx)?; + let storage = executed.storage_array().clone(); + + let storage_field = Field::new( + String::new(), + target.data_type().clone(), + target.is_nullable(), + ); + let session = ctx.session().clone(); + let arrow_storage = session + .arrow() + .execute_arrow(storage, Some(&storage_field), ctx)?; + + // Round-trip through GeoArrow's line-string array; `into_arrow` is concrete, so wrap in `Arc`. + let linestrings = LineStringArray::try_from((arrow_storage.as_ref(), linestring_meta)) + .map_err(|e| vortex_err!("failed to construct LineStringArray: {e}"))?; + + Ok(ArrowExport::Exported(Arc::new(linestrings.into_arrow()))) + } +} + +impl ArrowImportVTable for LineString { + fn arrow_ext_id(&self) -> Id { + *ARROW_LINESTRING + } + + /// Import a `geoarrow.linestring` field as the [`LineString`] dtype. Keyed off the standard + /// GeoArrow name, so any producer resolves here. Accepts the full `LineStringType` extension, or + /// — for a metadata-less geometry literal — the name alone, inferring the dimension from the + /// coordinate field names. + fn from_arrow_field(&self, field: &Field) -> VortexResult> { + let (dimension, metadata) = + if let Ok(linestring_meta) = field.try_extension_type::() { + vortex_ensure!( + linestring_meta.coord_type() == CoordType::Separated, + "geoarrow.linestring with interleaved coordinates is not supported; \ + re-encode with separated (struct) coordinates" + ); + ( + linestring_meta.dimension().into(), + geo_metadata_from_arrow(linestring_meta.metadata()), + ) + } else { + // Literal: peel the `List` layer to the coordinate struct and read its dimension from + // the field names (the canonical check rejects nullable coordinates). + if field.extension_type_name() != Some(LineStringType::NAME) { + return Ok(None); + } + let DType::List(coords, _) = DType::from_arrow(field) else { + return Ok(None); + }; + let DType::Struct(fields, _) = coords.as_ref() else { + return Ok(None); + }; + let Ok(dimension) = Dimension::from_field_names(fields.names()) else { + return Ok(None); + }; + (dimension, GeoMetadata::default()) + }; + + let storage_dtype = linestring_storage_dtype(dimension, field.is_nullable().into()); + Ok(Some(DType::Extension( + ExtDType::try_with_vtable(LineString, metadata, storage_dtype)?.erased(), + ))) + } + + fn from_arrow_array( + &self, + array: ArrowArrayRef, + field: &Field, + dtype: &DType, + ) -> VortexResult { + let Some(ext_dtype) = dtype.as_extension_opt() else { + return Ok(ArrowImport::Unsupported(array)); + }; + if !ext_dtype.is::() + || field.try_extension_type::().is_err() + || !matches!(array.data_type(), DataType::List(_)) + { + return Ok(ArrowImport::Unsupported(array)); + } + + let storage = ArrayRef::from_arrow(array.as_ref(), field.is_nullable())?; + Ok(ArrowImport::Imported( + ExtensionArray::try_new(ext_dtype.clone(), storage)?.into_array(), + )) + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::dtype::extension::ExtDType; + use vortex_error::VortexResult; + + use super::LineString; + use super::linestring_storage_dtype; + use crate::extension::GeoMetadata; + use crate::extension::coordinate::Dimension; + + fn geo_meta() -> GeoMetadata { + GeoMetadata { + crs: Some("EPSG:4326".to_string()), + } + } + + /// `LineString` accepts the canonical `List` storage of every dimension. + #[rstest] + #[case::xy(Dimension::Xy)] + #[case::xyz(Dimension::Xyz)] + #[case::xym(Dimension::Xym)] + #[case::xyzm(Dimension::Xyzm)] + fn linestring_validates_every_dimension(#[case] dim: Dimension) -> VortexResult<()> { + let storage = linestring_storage_dtype(dim, Nullability::NonNullable); + ExtDType::::try_new(geo_meta(), storage)?; + Ok(()) + } + + /// Non-linestring storage is rejected at dtype construction: a bare coordinate struct (point) is + /// not a list of coordinates. + #[test] + fn linestring_rejects_invalid_storage() -> VortexResult<()> { + let primitive = DType::Primitive(PType::F64, Nullability::NonNullable); + assert!(ExtDType::::try_new(geo_meta(), primitive).is_err()); + Ok(()) + } +} diff --git a/vortex-geo/src/extension/mod.rs b/vortex-geo/src/extension/mod.rs index 4e4896aa5e5..9da09020dbf 100644 --- a/vortex-geo/src/extension/mod.rs +++ b/vortex-geo/src/extension/mod.rs @@ -2,6 +2,9 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors pub(crate) mod coordinate; +mod linestring; +mod multilinestring; +mod multipoint; mod multipolygon; mod point; mod polygon; @@ -19,12 +22,18 @@ use geoarrow::datatypes::CoordType; use geoarrow::datatypes::Crs; use geoarrow::datatypes::Dimension; use geoarrow::datatypes::GeoArrowType; +use geoarrow::datatypes::LineStringType; use geoarrow::datatypes::Metadata; +use geoarrow::datatypes::MultiLineStringType; +use geoarrow::datatypes::MultiPointType; use geoarrow::datatypes::MultiPolygonType; use geoarrow::datatypes::PointType; use geoarrow::datatypes::PolygonType; use geoarrow::datatypes::WkbType; use geoarrow_cast::cast::cast; +pub use linestring::*; +pub use multilinestring::*; +pub use multipoint::*; pub use multipolygon::*; pub use point::*; pub use polygon::*; @@ -61,8 +70,14 @@ pub(crate) fn geometries( .clone(); if ext.is::() { point_geometries(&storage, ctx) + } else if ext.is::() { + linestring_geometries(&storage, ctx) + } else if ext.is::() { + multipoint_geometries(&storage, ctx) } else if ext.is::() { polygon_geometries(&storage, ctx) + } else if ext.is::() { + multilinestring_geometries(&storage, ctx) } else if ext.is::() { multipolygon_geometries(&storage, ctx) } else { @@ -107,12 +122,31 @@ pub fn native_geometry_scalar_from_wkb(bytes: &[u8]) -> VortexResult { + let target = GeoArrowType::LineString( + LineStringType::new(Dimension::XY, metadata).with_coord_type(CoordType::Separated), + ); + geo_ext_scalar(LineString, to_storage(&target)?)? + } GeometryType::Polygon => { let target = GeoArrowType::Polygon( PolygonType::new(Dimension::XY, metadata).with_coord_type(CoordType::Separated), ); geo_ext_scalar(Polygon, to_storage(&target)?)? } + GeometryType::MultiPoint => { + let target = GeoArrowType::MultiPoint( + MultiPointType::new(Dimension::XY, metadata).with_coord_type(CoordType::Separated), + ); + geo_ext_scalar(MultiPoint, to_storage(&target)?)? + } + GeometryType::MultiLineString => { + let target = GeoArrowType::MultiLineString( + MultiLineStringType::new(Dimension::XY, metadata) + .with_coord_type(CoordType::Separated), + ); + geo_ext_scalar(MultiLineString, to_storage(&target)?)? + } GeometryType::MultiPolygon => { let target = GeoArrowType::MultiPolygon( MultiPolygonType::new(Dimension::XY, metadata) @@ -201,6 +235,9 @@ mod tests { use vortex_error::VortexResult; use vortex_error::vortex_err; + use super::LineString; + use super::MultiLineString; + use super::MultiPoint; use super::Point; use super::Polygon; use super::native_geometry_scalar_from_wkb; @@ -256,4 +293,77 @@ mod tests { assert!(ext.is::()); Ok(()) } + + /// A little-endian WKB `LINESTRING` literal decodes to the native `LineString` extension scalar. + #[test] + fn decodes_wkb_linestring_to_native() -> VortexResult<()> { + let points = [(0.0, 0.0), (1.0, 1.0)]; + let mut wkb = vec![1u8]; // little-endian byte order + wkb.extend_from_slice(&2u32.to_le_bytes()); // geometry type: linestring + let len = u32::try_from(points.len()).map_err(|e| vortex_err!("{e}"))?; + wkb.extend_from_slice(&len.to_le_bytes()); + for (x, y) in points { + wkb.extend_from_slice(&f64::to_le_bytes(x)); + wkb.extend_from_slice(&f64::to_le_bytes(y)); + } + + let scalar = native_geometry_scalar_from_wkb(&wkb)?.expect("a linestring scalar"); + let DType::Extension(ext) = scalar.dtype() else { + panic!("expected an extension dtype, got {}", scalar.dtype()); + }; + assert!(ext.is::()); + Ok(()) + } + + /// A little-endian WKB `MULTIPOINT` literal decodes to the native `MultiPoint` extension scalar. + #[test] + fn decodes_wkb_multipoint_to_native() -> VortexResult<()> { + let points = [(0.0, 0.0), (1.0, 1.0)]; + let mut wkb = vec![1u8]; // little-endian byte order + wkb.extend_from_slice(&4u32.to_le_bytes()); // geometry type: multipoint + let len = u32::try_from(points.len()).map_err(|e| vortex_err!("{e}"))?; + wkb.extend_from_slice(&len.to_le_bytes()); + for (x, y) in points { + // each member is a full WKB point + wkb.push(1u8); + wkb.extend_from_slice(&1u32.to_le_bytes()); + wkb.extend_from_slice(&f64::to_le_bytes(x)); + wkb.extend_from_slice(&f64::to_le_bytes(y)); + } + + let scalar = native_geometry_scalar_from_wkb(&wkb)?.expect("a multipoint scalar"); + let DType::Extension(ext) = scalar.dtype() else { + panic!("expected an extension dtype, got {}", scalar.dtype()); + }; + assert!(ext.is::()); + Ok(()) + } + + /// A little-endian WKB `MULTILINESTRING` literal decodes to the native `MultiLineString` scalar. + #[test] + fn decodes_wkb_multilinestring_to_native() -> VortexResult<()> { + let lines = [[(0.0, 0.0), (1.0, 1.0)], [(2.0, 2.0), (3.0, 3.0)]]; + let mut wkb = vec![1u8]; // little-endian byte order + wkb.extend_from_slice(&5u32.to_le_bytes()); // geometry type: multilinestring + let num_lines = u32::try_from(lines.len()).map_err(|e| vortex_err!("{e}"))?; + wkb.extend_from_slice(&num_lines.to_le_bytes()); + for line in lines { + // each member is a full WKB linestring + wkb.push(1u8); + wkb.extend_from_slice(&2u32.to_le_bytes()); + let len = u32::try_from(line.len()).map_err(|e| vortex_err!("{e}"))?; + wkb.extend_from_slice(&len.to_le_bytes()); + for (x, y) in line { + wkb.extend_from_slice(&f64::to_le_bytes(x)); + wkb.extend_from_slice(&f64::to_le_bytes(y)); + } + } + + let scalar = native_geometry_scalar_from_wkb(&wkb)?.expect("a multilinestring scalar"); + let DType::Extension(ext) = scalar.dtype() else { + panic!("expected an extension dtype, got {}", scalar.dtype()); + }; + assert!(ext.is::()); + Ok(()) + } } diff --git a/vortex-geo/src/extension/multilinestring.rs b/vortex-geo/src/extension/multilinestring.rs new file mode 100644 index 00000000000..edfb0d3554c --- /dev/null +++ b/vortex-geo/src/extension/multilinestring.rs @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! The [`MultiLineString`] extension type (`vortex.geo.multilinestring`), stored as +//! `List>>` (line strings → coordinates) and tagged with +//! [`GeoMetadata`]. The storage layout matches [`Polygon`](super::Polygon); the two are +//! distinguished by their GeoArrow extension name, not their shape. + +use std::sync::Arc; + +use arrow_array::ArrayRef as ArrowArrayRef; +use arrow_schema::DataType; +use arrow_schema::Field; +use arrow_schema::extension::ExtensionType; +use geo_traits::to_geo::ToGeoGeometry; +use geo_types::Geometry; +use geoarrow::array::GeoArrowArrayAccessor; +use geoarrow::array::IntoArrow; +use geoarrow::array::MultiLineStringArray; +use geoarrow::datatypes::CoordType; +use geoarrow::datatypes::MultiLineStringType; +use prost::Message; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::arrays::ExtensionArray; +use vortex_array::arrays::extension::ExtensionArrayExt; +use vortex_array::arrow::ArrowExport; +use vortex_array::arrow::ArrowExportVTable; +use vortex_array::arrow::ArrowImport; +use vortex_array::arrow::ArrowImportVTable; +use vortex_array::arrow::ArrowSession; +use vortex_array::arrow::ArrowSessionExt; +use vortex_array::arrow::FromArrowArray; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::arrow::FromArrowType; +use vortex_array::dtype::extension::ExtDType; +use vortex_array::dtype::extension::ExtId; +use vortex_array::dtype::extension::ExtVTable; +use vortex_array::scalar::ScalarValue; +use vortex_error::VortexError; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; +use vortex_error::vortex_ensure; +use vortex_error::vortex_err; +use vortex_session::registry::CachedId; +use vortex_session::registry::Id; + +use super::GeoMetadata; +use super::coordinate::Dimension; +use super::coordinate::coordinate_dimension; +use super::coordinate::coordinate_storage_dtype; +use super::geo_metadata_from_arrow; +use super::geoarrow_metadata; +use super::geoarrow_to_wkb; + +/// A multilinestring: `geoarrow.multilinestring`, stored as `List>>` +/// (line strings of vertices). +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct MultiLineString; + +impl ExtVTable for MultiLineString { + type Metadata = GeoMetadata; + // No cheap owned value like Point's `Coordinate`; expose the raw storage scalar. + type NativeValue<'a> = &'a ScalarValue; + + fn id(&self) -> ExtId { + static ID: CachedId = CachedId::new("vortex.geo.multilinestring"); + *ID + } + + fn serialize_metadata(&self, metadata: &Self::Metadata) -> VortexResult> { + Ok(metadata.encode_to_vec()) + } + + fn deserialize_metadata(&self, metadata: &[u8]) -> VortexResult { + Ok(GeoMetadata::decode(metadata)?) + } + + fn validate_dtype(ext_dtype: &ExtDType) -> VortexResult<()> { + multilinestring_dimension(ext_dtype.storage_dtype()).map(|_| ()) + } + + fn unpack_native<'a>( + _ext_dtype: &'a ExtDType, + storage_value: &'a ScalarValue, + ) -> VortexResult<&'a ScalarValue> { + Ok(storage_value) + } +} + +/// Canonical multilinestring storage: an outer list of line strings, each a list of the coordinate +/// `Struct`. +pub(crate) fn multilinestring_storage_dtype(dim: Dimension, nullability: Nullability) -> DType { + let coords = coordinate_storage_dtype(dim, Nullability::NonNullable); + let line = DType::List(Arc::new(coords), Nullability::NonNullable); + DType::List(Arc::new(line), nullability) +} + +/// Validate `dtype` is `List>` and return its [`Dimension`]. +pub(crate) fn multilinestring_dimension(dtype: &DType) -> VortexResult { + let DType::List(line, _) = dtype else { + vortex_bail!("multilinestring storage must be a List of line strings, was {dtype}"); + }; + let DType::List(coords, _) = line.as_ref() else { + vortex_bail!("multilinestring line storage must be a List of coordinates, was {line}"); + }; + coordinate_dimension(coords) +} + +static ARROW_MULTILINESTRING: CachedId = CachedId::new(MultiLineStringType::NAME); + +/// The `geoarrow.multilinestring` type for `dimension`, with separated (struct) coordinates. +fn multilinestring_type(geo_metadata: &GeoMetadata, dimension: Dimension) -> MultiLineStringType { + MultiLineStringType::new(dimension.into(), geoarrow_metadata(geo_metadata)) +} + +/// Decode storage to `geo_types` for the geo scalar functions (CRS is irrelevant to planar ops). +pub(crate) fn multilinestring_geometries( + storage: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult>> { + multilinestring_array(storage, ctx)? + .iter() + .map(|geometry| -> VortexResult> { + Ok(geometry + .ok_or_else(|| vortex_err!("geo: null geometry is not supported"))? + .map_err(|e| vortex_err!("geo: geometry access failed: {e}"))? + .to_geometry()) + }) + .collect() +} + +/// Build a geoarrow `MultiLineStringArray` from the `MultiLineString` storage. +fn multilinestring_array( + storage: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let multilinestring_type = multilinestring_type( + &GeoMetadata::default(), + multilinestring_dimension(storage.dtype())?, + ); + let session = ctx.session().clone(); + let arrow = session.arrow().execute_arrow(storage.clone(), None, ctx)?; + MultiLineStringArray::try_from((arrow.as_ref(), multilinestring_type)) + .map_err(|e| vortex_err!("failed to construct MultiLineStringArray: {e}")) +} + +/// A validated `MultiLineString` array (`try_from` checks the extension type). +pub struct MultiLineStringData(ExtensionArray); + +impl TryFrom for MultiLineStringData { + type Error = VortexError; + + fn try_from(ext: ExtensionArray) -> Result { + vortex_ensure!( + ext.ext_dtype().is::(), + "expected a MultiLineString extension array" + ); + Ok(MultiLineStringData(ext)) + } +} + +impl MultiLineStringData { + /// Serialize multilinestrings to WKB (a view array) — the form DuckDB `GEOMETRY` takes. + pub fn to_wkb(&self, ctx: &mut ExecutionCtx) -> VortexResult { + geoarrow_to_wkb(&multilinestring_array(self.0.storage_array(), ctx)?) + } +} + +impl ArrowExportVTable for MultiLineString { + fn arrow_ext_id(&self) -> Id { + *ARROW_MULTILINESTRING + } + + fn vortex_id(&self) -> Id { + self.id() + } + + fn to_arrow_field( + &self, + name: &str, + dtype: &DType, + session: &ArrowSession, + ) -> VortexResult> { + let ext_type = dtype.as_extension(); + let geo_metadata = ext_type.metadata::(); + let dimension = multilinestring_dimension(ext_type.storage_dtype())?; + + let mut field = session.to_arrow_field(name, ext_type.storage_dtype())?; + field.try_with_extension_type(multilinestring_type(geo_metadata, dimension))?; + + Ok(Some(field)) + } + + fn execute_arrow( + &self, + array: ArrayRef, + target: &Field, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + let is_multilinestring = array + .dtype() + .as_extension_opt() + .map(|ext| ext.is::()) + .unwrap_or(false); + if !is_multilinestring { + return Ok(ArrowExport::Unsupported(array)); + } + + let Ok(multilinestring_meta) = target.try_extension_type::() else { + return Ok(ArrowExport::Unsupported(array)); + }; + if multilinestring_meta.coord_type() != CoordType::Separated { + return Ok(ArrowExport::Unsupported(array)); + } + + let executed = array.execute::(ctx)?; + let storage = executed.storage_array().clone(); + + let storage_field = Field::new( + String::new(), + target.data_type().clone(), + target.is_nullable(), + ); + let session = ctx.session().clone(); + let arrow_storage = session + .arrow() + .execute_arrow(storage, Some(&storage_field), ctx)?; + + let multilinestrings = + MultiLineStringArray::try_from((arrow_storage.as_ref(), multilinestring_meta)) + .map_err(|e| vortex_err!("failed to construct MultiLineStringArray: {e}"))?; + + Ok(ArrowExport::Exported(Arc::new( + multilinestrings.into_arrow(), + ))) + } +} + +impl ArrowImportVTable for MultiLineString { + fn arrow_ext_id(&self) -> Id { + *ARROW_MULTILINESTRING + } + + /// Import a `geoarrow.multilinestring` field (matched by GeoArrow name). Accepts the full + /// `MultiLineStringType`, or a metadata-less literal (name only), inferring the dimension. + fn from_arrow_field(&self, field: &Field) -> VortexResult> { + let (dimension, metadata) = + if let Ok(multilinestring_meta) = field.try_extension_type::() { + vortex_ensure!( + multilinestring_meta.coord_type() == CoordType::Separated, + "geoarrow.multilinestring with interleaved coordinates is not supported; \ + re-encode with separated (struct) coordinates" + ); + ( + multilinestring_meta.dimension().into(), + geo_metadata_from_arrow(multilinestring_meta.metadata()), + ) + } else { + // Literal: peel the two `List` layers to the coordinate struct and read its dimension + // from the field names (the canonical check rejects nullable coordinates). + if field.extension_type_name() != Some(MultiLineStringType::NAME) { + return Ok(None); + } + let DType::List(line, _) = DType::from_arrow(field) else { + return Ok(None); + }; + let DType::List(coords, _) = line.as_ref() else { + return Ok(None); + }; + let DType::Struct(fields, _) = coords.as_ref() else { + return Ok(None); + }; + let Ok(dimension) = Dimension::from_field_names(fields.names()) else { + return Ok(None); + }; + (dimension, GeoMetadata::default()) + }; + + let storage_dtype = multilinestring_storage_dtype(dimension, field.is_nullable().into()); + Ok(Some(DType::Extension( + ExtDType::try_with_vtable(MultiLineString, metadata, storage_dtype)?.erased(), + ))) + } + + fn from_arrow_array( + &self, + array: ArrowArrayRef, + field: &Field, + dtype: &DType, + ) -> VortexResult { + let Some(ext_dtype) = dtype.as_extension_opt() else { + return Ok(ArrowImport::Unsupported(array)); + }; + if !ext_dtype.is::() + || field.try_extension_type::().is_err() + || !matches!(array.data_type(), DataType::List(_)) + { + return Ok(ArrowImport::Unsupported(array)); + } + + let storage = ArrayRef::from_arrow(array.as_ref(), field.is_nullable())?; + Ok(ArrowImport::Imported( + ExtensionArray::try_new(ext_dtype.clone(), storage)?.into_array(), + )) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use rstest::rstest; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::dtype::extension::ExtDType; + use vortex_error::VortexResult; + + use super::MultiLineString; + use super::multilinestring_storage_dtype; + use crate::extension::GeoMetadata; + use crate::extension::coordinate::Dimension; + use crate::extension::coordinate::coordinate_storage_dtype; + + fn geo_meta() -> GeoMetadata { + GeoMetadata { + crs: Some("EPSG:4326".to_string()), + } + } + + /// `MultiLineString` accepts the canonical `List>` storage of every + /// dimension. + #[rstest] + #[case::xy(Dimension::Xy)] + #[case::xyz(Dimension::Xyz)] + #[case::xym(Dimension::Xym)] + #[case::xyzm(Dimension::Xyzm)] + fn multilinestring_validates_every_dimension(#[case] dim: Dimension) -> VortexResult<()> { + let storage = multilinestring_storage_dtype(dim, Nullability::NonNullable); + ExtDType::::try_new(geo_meta(), storage)?; + Ok(()) + } + + /// Non-multilinestring storage is rejected: a bare coordinate struct (point) fails. + #[test] + fn multilinestring_rejects_invalid_storage() -> VortexResult<()> { + let primitive = DType::Primitive(PType::F64, Nullability::NonNullable); + assert!(ExtDType::::try_new(geo_meta(), primitive).is_err()); + + // A bare list of coordinates is a single line string, not a multilinestring. + let coords = coordinate_storage_dtype(Dimension::Xy, Nullability::NonNullable); + let line = DType::List(Arc::new(coords), Nullability::NonNullable); + assert!(ExtDType::::try_new(geo_meta(), line).is_err()); + Ok(()) + } +} diff --git a/vortex-geo/src/extension/multipoint.rs b/vortex-geo/src/extension/multipoint.rs new file mode 100644 index 00000000000..3e8778744e5 --- /dev/null +++ b/vortex-geo/src/extension/multipoint.rs @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! The [`MultiPoint`] geometry extension type (`vortex.geo.multipoint`): an unordered set of the +//! [`Point`](super::Point) coordinate struct, stored as `List>` and tagged +//! with [`GeoMetadata`] (CRS). The storage layout matches [`LineString`](super::LineString); the +//! two are distinguished by their GeoArrow extension name, not their shape. + +use std::sync::Arc; + +use arrow_array::ArrayRef as ArrowArrayRef; +use arrow_schema::DataType; +use arrow_schema::Field; +use arrow_schema::extension::ExtensionType; +use geo_traits::to_geo::ToGeoGeometry; +use geo_types::Geometry; +use geoarrow::array::GeoArrowArrayAccessor; +use geoarrow::array::IntoArrow; +use geoarrow::array::MultiPointArray; +use geoarrow::datatypes::CoordType; +use geoarrow::datatypes::MultiPointType; +use prost::Message; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::arrays::ExtensionArray; +use vortex_array::arrays::extension::ExtensionArrayExt; +use vortex_array::arrow::ArrowExport; +use vortex_array::arrow::ArrowExportVTable; +use vortex_array::arrow::ArrowImport; +use vortex_array::arrow::ArrowImportVTable; +use vortex_array::arrow::ArrowSession; +use vortex_array::arrow::ArrowSessionExt; +use vortex_array::arrow::FromArrowArray; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::arrow::FromArrowType; +use vortex_array::dtype::extension::ExtDType; +use vortex_array::dtype::extension::ExtId; +use vortex_array::dtype::extension::ExtVTable; +use vortex_array::scalar::ScalarValue; +use vortex_error::VortexError; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; +use vortex_error::vortex_ensure; +use vortex_error::vortex_err; +use vortex_session::registry::CachedId; +use vortex_session::registry::Id; + +use super::GeoMetadata; +use super::coordinate::Dimension; +use super::coordinate::coordinate_dimension; +use super::coordinate::coordinate_storage_dtype; +use super::geo_metadata_from_arrow; +use super::geoarrow_metadata; +use super::geoarrow_to_wkb; + +/// A multipoint: `geoarrow.multipoint`, stored as `List>` (a set of points). +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct MultiPoint; + +impl ExtVTable for MultiPoint { + type Metadata = GeoMetadata; + // No cheap owned value like Point's `Coordinate`; expose the raw storage scalar. + type NativeValue<'a> = &'a ScalarValue; + + fn id(&self) -> ExtId { + static ID: CachedId = CachedId::new("vortex.geo.multipoint"); + *ID + } + + fn serialize_metadata(&self, metadata: &Self::Metadata) -> VortexResult> { + Ok(metadata.encode_to_vec()) + } + + fn deserialize_metadata(&self, metadata: &[u8]) -> VortexResult { + Ok(GeoMetadata::decode(metadata)?) + } + + fn validate_dtype(ext_dtype: &ExtDType) -> VortexResult<()> { + multipoint_dimension(ext_dtype.storage_dtype()).map(|_| ()) + } + + fn unpack_native<'a>( + _ext_dtype: &'a ExtDType, + storage_value: &'a ScalarValue, + ) -> VortexResult<&'a ScalarValue> { + Ok(storage_value) + } +} + +/// Canonical multipoint storage: a list of the coordinate `Struct`. +pub(crate) fn multipoint_storage_dtype(dim: Dimension, nullability: Nullability) -> DType { + let coords = coordinate_storage_dtype(dim, Nullability::NonNullable); + DType::List(Arc::new(coords), nullability) +} + +/// Validate `dtype` is `List` and return its [`Dimension`]. +pub(crate) fn multipoint_dimension(dtype: &DType) -> VortexResult { + let DType::List(coords, _) = dtype else { + vortex_bail!("multipoint storage must be a List of coordinates, was {dtype}"); + }; + coordinate_dimension(coords) +} + +static ARROW_MULTIPOINT: CachedId = CachedId::new(MultiPointType::NAME); + +/// The `geoarrow.multipoint` extension type for `dimension`, with separated (struct) coordinates. +fn multipoint_type(geo_metadata: &GeoMetadata, dimension: Dimension) -> MultiPointType { + MultiPointType::new(dimension.into(), geoarrow_metadata(geo_metadata)) +} + +/// Decode `MultiPoint` storage (`List`) to `geo_types`, for the geo scalar functions. +pub(crate) fn multipoint_geometries( + storage: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult>> { + multipoint_array(storage, ctx)? + .iter() + .map(|geometry| -> VortexResult> { + Ok(geometry + .ok_or_else(|| vortex_err!("geo: null geometry is not supported"))? + .map_err(|e| vortex_err!("geo: geometry access failed: {e}"))? + .to_geometry()) + }) + .collect() +} + +/// Build a geoarrow `MultiPointArray` from a `MultiPoint`'s `List` storage. +fn multipoint_array(storage: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + let multipoint_type = multipoint_type( + &GeoMetadata::default(), + multipoint_dimension(storage.dtype())?, + ); + let session = ctx.session().clone(); + let arrow = session.arrow().execute_arrow(storage.clone(), None, ctx)?; + MultiPointArray::try_from((arrow.as_ref(), multipoint_type)) + .map_err(|e| vortex_err!("failed to construct MultiPointArray: {e}")) +} + +/// A validated `MultiPoint` array (`try_from` checks the extension type). +pub struct MultiPointData(ExtensionArray); + +impl TryFrom for MultiPointData { + type Error = VortexError; + + fn try_from(ext: ExtensionArray) -> Result { + vortex_ensure!( + ext.ext_dtype().is::(), + "expected a MultiPoint extension array" + ); + Ok(MultiPointData(ext)) + } +} + +impl MultiPointData { + /// Serialize multipoints to WKB (a view array) — the form DuckDB `GEOMETRY` takes. + pub fn to_wkb(&self, ctx: &mut ExecutionCtx) -> VortexResult { + geoarrow_to_wkb(&multipoint_array(self.0.storage_array(), ctx)?) + } +} + +impl ArrowExportVTable for MultiPoint { + fn arrow_ext_id(&self) -> Id { + *ARROW_MULTIPOINT + } + + fn vortex_id(&self) -> Id { + self.id() + } + + fn to_arrow_field( + &self, + name: &str, + dtype: &DType, + session: &ArrowSession, + ) -> VortexResult> { + let ext_type = dtype.as_extension(); + let geo_metadata = ext_type.metadata::(); + let dimension = multipoint_dimension(ext_type.storage_dtype())?; + + let mut field = session.to_arrow_field(name, ext_type.storage_dtype())?; + field.try_with_extension_type(multipoint_type(geo_metadata, dimension))?; + + Ok(Some(field)) + } + + fn execute_arrow( + &self, + array: ArrayRef, + target: &Field, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + let is_multipoint = array + .dtype() + .as_extension_opt() + .map(|ext| ext.is::()) + .unwrap_or(false); + if !is_multipoint { + return Ok(ArrowExport::Unsupported(array)); + } + + let Ok(multipoint_meta) = target.try_extension_type::() else { + return Ok(ArrowExport::Unsupported(array)); + }; + if multipoint_meta.coord_type() != CoordType::Separated { + return Ok(ArrowExport::Unsupported(array)); + } + + let executed = array.execute::(ctx)?; + let storage = executed.storage_array().clone(); + + let storage_field = Field::new( + String::new(), + target.data_type().clone(), + target.is_nullable(), + ); + let session = ctx.session().clone(); + let arrow_storage = session + .arrow() + .execute_arrow(storage, Some(&storage_field), ctx)?; + + // Round-trip through GeoArrow's multipoint array; `into_arrow` is concrete, so wrap in `Arc`. + let multipoints = MultiPointArray::try_from((arrow_storage.as_ref(), multipoint_meta)) + .map_err(|e| vortex_err!("failed to construct MultiPointArray: {e}"))?; + + Ok(ArrowExport::Exported(Arc::new(multipoints.into_arrow()))) + } +} + +impl ArrowImportVTable for MultiPoint { + fn arrow_ext_id(&self) -> Id { + *ARROW_MULTIPOINT + } + + /// Import a `geoarrow.multipoint` field as the [`MultiPoint`] dtype (matched by GeoArrow name). + /// Accepts the full `MultiPointType`, or a metadata-less literal (name only), inferring the + /// dimension from the coordinate field names. + fn from_arrow_field(&self, field: &Field) -> VortexResult> { + let (dimension, metadata) = + if let Ok(multipoint_meta) = field.try_extension_type::() { + vortex_ensure!( + multipoint_meta.coord_type() == CoordType::Separated, + "geoarrow.multipoint with interleaved coordinates is not supported; \ + re-encode with separated (struct) coordinates" + ); + ( + multipoint_meta.dimension().into(), + geo_metadata_from_arrow(multipoint_meta.metadata()), + ) + } else { + if field.extension_type_name() != Some(MultiPointType::NAME) { + return Ok(None); + } + let DType::List(coords, _) = DType::from_arrow(field) else { + return Ok(None); + }; + let DType::Struct(fields, _) = coords.as_ref() else { + return Ok(None); + }; + let Ok(dimension) = Dimension::from_field_names(fields.names()) else { + return Ok(None); + }; + (dimension, GeoMetadata::default()) + }; + + let storage_dtype = multipoint_storage_dtype(dimension, field.is_nullable().into()); + Ok(Some(DType::Extension( + ExtDType::try_with_vtable(MultiPoint, metadata, storage_dtype)?.erased(), + ))) + } + + fn from_arrow_array( + &self, + array: ArrowArrayRef, + field: &Field, + dtype: &DType, + ) -> VortexResult { + let Some(ext_dtype) = dtype.as_extension_opt() else { + return Ok(ArrowImport::Unsupported(array)); + }; + if !ext_dtype.is::() + || field.try_extension_type::().is_err() + || !matches!(array.data_type(), DataType::List(_)) + { + return Ok(ArrowImport::Unsupported(array)); + } + + let storage = ArrayRef::from_arrow(array.as_ref(), field.is_nullable())?; + Ok(ArrowImport::Imported( + ExtensionArray::try_new(ext_dtype.clone(), storage)?.into_array(), + )) + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::dtype::extension::ExtDType; + use vortex_error::VortexResult; + + use super::MultiPoint; + use super::multipoint_storage_dtype; + use crate::extension::GeoMetadata; + use crate::extension::coordinate::Dimension; + + fn geo_meta() -> GeoMetadata { + GeoMetadata { + crs: Some("EPSG:4326".to_string()), + } + } + + /// `MultiPoint` accepts the canonical `List` storage of every dimension. + #[rstest] + #[case::xy(Dimension::Xy)] + #[case::xyz(Dimension::Xyz)] + #[case::xym(Dimension::Xym)] + #[case::xyzm(Dimension::Xyzm)] + fn multipoint_validates_every_dimension(#[case] dim: Dimension) -> VortexResult<()> { + let storage = multipoint_storage_dtype(dim, Nullability::NonNullable); + ExtDType::::try_new(geo_meta(), storage)?; + Ok(()) + } + + /// Non-multipoint storage is rejected at dtype construction: a bare coordinate struct (point) is + /// not a list of coordinates. + #[test] + fn multipoint_rejects_invalid_storage() -> VortexResult<()> { + let primitive = DType::Primitive(PType::F64, Nullability::NonNullable); + assert!(ExtDType::::try_new(geo_meta(), primitive).is_err()); + Ok(()) + } +} diff --git a/vortex-geo/src/lib.rs b/vortex-geo/src/lib.rs index 2cc8004efc5..c2637ecac55 100644 --- a/vortex-geo/src/lib.rs +++ b/vortex-geo/src/lib.rs @@ -8,6 +8,9 @@ use vortex_array::dtype::session::DTypeSessionExt; use vortex_array::scalar_fn::session::ScalarFnSessionExt; use vortex_session::VortexSession; +use crate::extension::LineString; +use crate::extension::MultiLineString; +use crate::extension::MultiPoint; use crate::extension::MultiPolygon; use crate::extension::Point; use crate::extension::Polygon; @@ -30,9 +33,18 @@ pub fn initialize(session: &VortexSession) { session.dtypes().register(Point); session.arrow().register_exporter(Arc::new(Point)); session.arrow().register_importer(Arc::new(Point)); + session.dtypes().register(LineString); + session.arrow().register_exporter(Arc::new(LineString)); + session.arrow().register_importer(Arc::new(LineString)); + session.dtypes().register(MultiPoint); + session.arrow().register_exporter(Arc::new(MultiPoint)); + session.arrow().register_importer(Arc::new(MultiPoint)); session.dtypes().register(Polygon); session.arrow().register_exporter(Arc::new(Polygon)); session.arrow().register_importer(Arc::new(Polygon)); + session.dtypes().register(MultiLineString); + session.arrow().register_exporter(Arc::new(MultiLineString)); + session.arrow().register_importer(Arc::new(MultiLineString)); session.dtypes().register(MultiPolygon); session.arrow().register_exporter(Arc::new(MultiPolygon)); session.arrow().register_importer(Arc::new(MultiPolygon)); diff --git a/vortex-geo/src/tests/linestring.rs b/vortex-geo/src/tests/linestring.rs new file mode 100644 index 00000000000..a1a9b3a4845 --- /dev/null +++ b/vortex-geo/src/tests/linestring.rs @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Arrow interop for the `vortex.geo.linestring` extension type (`geoarrow.linestring`). + +use std::sync::Arc; + +use arrow_schema::DataType; +use arrow_schema::Field; +use arrow_schema::extension::ExtensionType as _; +use geoarrow::datatypes::CoordType; +use geoarrow::datatypes::Crs; +use geoarrow::datatypes::Dimension as GeoArrowDimension; +use geoarrow::datatypes::LineStringType; +use geoarrow::datatypes::Metadata; +use vortex_array::arrow::ArrowSessionExt; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_error::VortexResult; + +use super::SESSION; +use crate::extension::LineString; + +/// A `geoarrow.linestring` Arrow field with separated (struct) XY coordinates. +fn linestring_field(name: &str, nullable: bool, crs: Option<&str>) -> Field { + let crs = crs + .map(|crs| Crs::from_unknown_crs_type(crs.to_string())) + .unwrap_or_default(); + let metadata = Arc::new(Metadata::new(crs, None)); + LineStringType::new(GeoArrowDimension::XY, metadata).to_field(name, nullable) +} + +/// An imported `geoarrow.linestring` field maps to the LineString extension dtype, recovering the +/// CRS, the `List>` storage, and nullability. +#[test] +fn import_field_recovers_extension() -> VortexResult<()> { + let field = linestring_field("geom", true, Some("EPSG:4326")); + let dtype = SESSION.arrow().from_arrow_field(&field)?; + + let DType::Extension(ext) = &dtype else { + panic!("expected Extension dtype, got {dtype}"); + }; + assert!(ext.is::()); + assert_eq!( + ext.metadata::().crs.as_deref(), + Some("EPSG:4326") + ); + + // Storage peels one List layer (linestring → coordinates) to the coordinate struct. + let DType::List(coords, nullability) = ext.storage_dtype() else { + panic!("expected List storage, got {}", ext.storage_dtype()); + }; + assert_eq!(*nullability, Nullability::Nullable); + let DType::Struct(fields, _) = coords.as_ref() else { + panic!("expected coordinate Struct"); + }; + let names: Vec<&str> = fields.names().iter().map(|n| n.as_ref()).collect(); + assert_eq!(names, vec!["x", "y"]); + Ok(()) +} + +/// A field with interleaved (`FixedSizeList`) coordinates fails to import. +#[test] +fn import_interleaved_field_fails() { + let linestring_type = LineStringType::new(GeoArrowDimension::XY, Default::default()) + .with_coord_type(CoordType::Interleaved); + let field = linestring_type.to_field("geom", false); + assert!(SESSION.arrow().from_arrow_field(&field).is_err()); +} + +/// A field imported to the LineString dtype and exported back carries the `geoarrow.linestring` +/// extension over its `List` storage. +#[test] +fn export_field_carries_extension() -> VortexResult<()> { + let imported = + SESSION + .arrow() + .from_arrow_field(&linestring_field("geom", false, Some("EPSG:4326")))?; + let field = SESSION.arrow().to_arrow_field("geom", &imported)?; + + assert_eq!(field.extension_type_name(), Some(LineStringType::NAME)); + assert!( + matches!(field.data_type(), DataType::List(_)), + "expected List storage, got {}", + field.data_type() + ); + Ok(()) +} diff --git a/vortex-geo/src/tests/mod.rs b/vortex-geo/src/tests/mod.rs index 87b25ed1293..0d4de3ae6a3 100644 --- a/vortex-geo/src/tests/mod.rs +++ b/vortex-geo/src/tests/mod.rs @@ -4,6 +4,9 @@ //! Arrow interop tests for the geospatial extension types, exercising the session wiring set up //! by [`crate::initialize`]. +mod linestring; +mod multilinestring; +mod multipoint; mod multipolygon; mod point; mod wkb; diff --git a/vortex-geo/src/tests/multilinestring.rs b/vortex-geo/src/tests/multilinestring.rs new file mode 100644 index 00000000000..cf5b5204681 --- /dev/null +++ b/vortex-geo/src/tests/multilinestring.rs @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Arrow interop for the `vortex.geo.multilinestring` extension type (`geoarrow.multilinestring`). + +use std::sync::Arc; + +use arrow_schema::DataType; +use arrow_schema::Field; +use arrow_schema::extension::ExtensionType as _; +use geoarrow::datatypes::CoordType; +use geoarrow::datatypes::Crs; +use geoarrow::datatypes::Dimension as GeoArrowDimension; +use geoarrow::datatypes::Metadata; +use geoarrow::datatypes::MultiLineStringType; +use vortex_array::arrow::ArrowSessionExt; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_error::VortexResult; + +use super::SESSION; +use crate::extension::MultiLineString; + +/// A `geoarrow.multilinestring` Arrow field with separated (struct) XY coordinates. +fn multilinestring_field(name: &str, nullable: bool, crs: Option<&str>) -> Field { + let crs = crs + .map(|crs| Crs::from_unknown_crs_type(crs.to_string())) + .unwrap_or_default(); + let metadata = Arc::new(Metadata::new(crs, None)); + MultiLineStringType::new(GeoArrowDimension::XY, metadata).to_field(name, nullable) +} + +/// An imported `geoarrow.multilinestring` field maps to the MultiLineString extension dtype (not +/// Polygon, despite the identical `List>>` storage), recovering CRS and shape. +#[test] +fn import_field_recovers_extension() -> VortexResult<()> { + let field = multilinestring_field("geom", true, Some("EPSG:4326")); + let dtype = SESSION.arrow().from_arrow_field(&field)?; + + let DType::Extension(ext) = &dtype else { + panic!("expected Extension dtype, got {dtype}"); + }; + assert!(ext.is::()); + assert_eq!( + ext.metadata::().crs.as_deref(), + Some("EPSG:4326") + ); + + // Storage peels two List layers (multilinestring → line strings) to the coordinate struct. + let DType::List(lines, nullability) = ext.storage_dtype() else { + panic!("expected List storage, got {}", ext.storage_dtype()); + }; + assert_eq!(*nullability, Nullability::Nullable); + let DType::List(coords, _) = lines.as_ref() else { + panic!("expected List of line strings"); + }; + let DType::Struct(fields, _) = coords.as_ref() else { + panic!("expected coordinate Struct"); + }; + let names: Vec<&str> = fields.names().iter().map(|n| n.as_ref()).collect(); + assert_eq!(names, vec!["x", "y"]); + Ok(()) +} + +/// A field with interleaved (`FixedSizeList`) coordinates fails to import. +#[test] +fn import_interleaved_field_fails() { + let multilinestring_type = MultiLineStringType::new(GeoArrowDimension::XY, Default::default()) + .with_coord_type(CoordType::Interleaved); + let field = multilinestring_type.to_field("geom", false); + assert!(SESSION.arrow().from_arrow_field(&field).is_err()); +} + +/// A field imported to the MultiLineString dtype and exported back carries the +/// `geoarrow.multilinestring` extension over its `List` storage. +#[test] +fn export_field_carries_extension() -> VortexResult<()> { + let imported = SESSION.arrow().from_arrow_field(&multilinestring_field( + "geom", + false, + Some("EPSG:4326"), + ))?; + let field = SESSION.arrow().to_arrow_field("geom", &imported)?; + + assert_eq!(field.extension_type_name(), Some(MultiLineStringType::NAME)); + assert!( + matches!(field.data_type(), DataType::List(_)), + "expected List storage, got {}", + field.data_type() + ); + Ok(()) +} diff --git a/vortex-geo/src/tests/multipoint.rs b/vortex-geo/src/tests/multipoint.rs new file mode 100644 index 00000000000..c1de946f0ba --- /dev/null +++ b/vortex-geo/src/tests/multipoint.rs @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Arrow interop for the `vortex.geo.multipoint` extension type (`geoarrow.multipoint`). + +use std::sync::Arc; + +use arrow_schema::DataType; +use arrow_schema::Field; +use arrow_schema::extension::ExtensionType as _; +use geoarrow::datatypes::CoordType; +use geoarrow::datatypes::Crs; +use geoarrow::datatypes::Dimension as GeoArrowDimension; +use geoarrow::datatypes::Metadata; +use geoarrow::datatypes::MultiPointType; +use vortex_array::arrow::ArrowSessionExt; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_error::VortexResult; + +use super::SESSION; +use crate::extension::MultiPoint; + +/// A `geoarrow.multipoint` Arrow field with separated (struct) XY coordinates. +fn multipoint_field(name: &str, nullable: bool, crs: Option<&str>) -> Field { + let crs = crs + .map(|crs| Crs::from_unknown_crs_type(crs.to_string())) + .unwrap_or_default(); + let metadata = Arc::new(Metadata::new(crs, None)); + MultiPointType::new(GeoArrowDimension::XY, metadata).to_field(name, nullable) +} + +/// An imported `geoarrow.multipoint` field maps to the MultiPoint extension dtype (not LineString, +/// despite the identical `List>` storage), recovering the CRS and nullability. +#[test] +fn import_field_recovers_extension() -> VortexResult<()> { + let field = multipoint_field("geom", true, Some("EPSG:4326")); + let dtype = SESSION.arrow().from_arrow_field(&field)?; + + let DType::Extension(ext) = &dtype else { + panic!("expected Extension dtype, got {dtype}"); + }; + assert!(ext.is::()); + assert_eq!( + ext.metadata::().crs.as_deref(), + Some("EPSG:4326") + ); + + let DType::List(coords, nullability) = ext.storage_dtype() else { + panic!("expected List storage, got {}", ext.storage_dtype()); + }; + assert_eq!(*nullability, Nullability::Nullable); + let DType::Struct(fields, _) = coords.as_ref() else { + panic!("expected coordinate Struct"); + }; + let names: Vec<&str> = fields.names().iter().map(|n| n.as_ref()).collect(); + assert_eq!(names, vec!["x", "y"]); + Ok(()) +} + +/// A field with interleaved (`FixedSizeList`) coordinates fails to import. +#[test] +fn import_interleaved_field_fails() { + let multipoint_type = MultiPointType::new(GeoArrowDimension::XY, Default::default()) + .with_coord_type(CoordType::Interleaved); + let field = multipoint_type.to_field("geom", false); + assert!(SESSION.arrow().from_arrow_field(&field).is_err()); +} + +/// A field imported to the MultiPoint dtype and exported back carries the `geoarrow.multipoint` +/// extension over its `List` storage. +#[test] +fn export_field_carries_extension() -> VortexResult<()> { + let imported = + SESSION + .arrow() + .from_arrow_field(&multipoint_field("geom", false, Some("EPSG:4326")))?; + let field = SESSION.arrow().to_arrow_field("geom", &imported)?; + + assert_eq!(field.extension_type_name(), Some(MultiPointType::NAME)); + assert!( + matches!(field.data_type(), DataType::List(_)), + "expected List storage, got {}", + field.data_type() + ); + Ok(()) +} From 4c762f7b644acdf4efd497d24e7a1129935b8e16 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Wed, 8 Jul 2026 21:15:31 +0100 Subject: [PATCH 039/104] ci: install only the uv binary for v4 ingest (drop the workspace sync) (#8517) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v4 Postgres ingest runs `uv run --no-project --with psycopg[binary] --with boto3 --with xxhash`, which needs only the uv binary and an ephemeral env — never the workspace. But the "Install uv" step uses `spiraldb/actions/setup-uv`, which runs a full `uv sync --all-extras --dev` that rebuilds `vortex-python` via maturin → sccache → S3. On the `extras=s3-cache` bench runners that fails `s3:GetObject AccessDenied` under the just-assumed ingest role (which only has `rds-db:connect`). It's `continue-on-error`, so CI never broke, and production confirmed the sync is dead weight — the ingest succeeded even when "Install uv" went red, because the binary installs before the sync and the ingest doesn't use the synced workspace. Switch the three v4 "Install uv" steps to `astral-sh/setup-uv` (the same v7.6.0 binary that action vendors) with no sync, which removes the wasteful workspace build and the sccache→S3 dependency entirely. The benchmark job's own non-v4 `setup-uv` is unchanged. Follow-up to #8516, which fixed the same failure by reordering so the sync ran with good credentials; this drops the now-pointless sync instead, so step order no longer matters. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Connor Tsui Co-authored-by: Claude Fable 5 --- .github/workflows/bench.yml | 12 ++++++------ .github/workflows/commit-metadata.yml | 10 ++++------ .github/workflows/sql-benchmarks.yml | 14 +++----------- 3 files changed, 13 insertions(+), 23 deletions(-) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 7eccc0e234f..d78943fa059 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -123,15 +123,15 @@ jobs: # the RDS IAM token internally (boto3) from the assumed GitHubBenchmarkIngestRole; # sslmode=verify-full validates the cert. # - # ORDER MATTERS: "Install uv" runs BEFORE "Configure AWS credentials". - # configure-aws-credentials persists the assumed ingest-role (rds-db:connect - # only) as the job's ambient AWS creds; the uv setup compiles via sccache - # (S3-backed), so running it after the role switch fails with S3 AccessDenied. - # Installing uv first keeps sccache on the original S3-capable creds; the role - # is assumed immediately before the ingest, which needs only rds-db:connect. + # `sync: false` -- the ingest runs `uv run --no-project --with`, which needs only + # the uv binary, never the synced workspace. A full sync would rebuild + # vortex-python via sccache->S3, which fails under the ingest-role creds + # (rds-db:connect only) and is pure waste here. - name: Install uv for v4 ingest if: vars.GH_BENCH_INGEST_ROLE_ARN != '' uses: spiraldb/actions/.github/actions/setup-uv@a746510eafaa926484c354541cfc49b2ec06cc63 # 0.18.6 + with: + sync: false - name: Configure AWS credentials for v4 ingest (OIDC) if: vars.GH_BENCH_INGEST_ROLE_ARN != '' uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 diff --git a/.github/workflows/commit-metadata.yml b/.github/workflows/commit-metadata.yml index 5ca10fcf704..97f61989d35 100644 --- a/.github/workflows/commit-metadata.yml +++ b/.github/workflows/commit-metadata.yml @@ -27,15 +27,13 @@ jobs: # ingest-role ARN var (the assume-role input that MUST exist for OIDC to # succeed). # - # ORDER MATTERS: "Install uv" runs BEFORE "Configure AWS credentials". - # configure-aws-credentials persists the assumed ingest-role (rds-db:connect - # only) as the job's ambient AWS creds; the uv setup compiles via sccache - # (S3-backed), so running it after the role switch fails with S3 AccessDenied. - # Installing uv first keeps sccache on the original S3-capable creds; the role - # is assumed immediately before the ingest, which needs only rds-db:connect. + # `sync: false` -- the ingest runs `uv run --no-project --with`, which needs only + # the uv binary, never the synced workspace (see bench.yml rationale). - name: Install uv for v4 ingest if: vars.GH_BENCH_INGEST_ROLE_ARN != '' uses: spiraldb/actions/.github/actions/setup-uv@a746510eafaa926484c354541cfc49b2ec06cc63 # 0.18.6 + with: + sync: false - name: Configure AWS credentials for v4 ingest (OIDC) if: vars.GH_BENCH_INGEST_ROLE_ARN != '' uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 diff --git a/.github/workflows/sql-benchmarks.yml b/.github/workflows/sql-benchmarks.yml index 71c0e3fe82c..d9578a9bb15 100644 --- a/.github/workflows/sql-benchmarks.yml +++ b/.github/workflows/sql-benchmarks.yml @@ -714,17 +714,9 @@ jobs: # job. Gated on inputs.mode == 'develop' + the ingest-role ARN var (the assume-role # input that MUST exist for OIDC to succeed). post-ingest.py mints the RDS IAM token # (boto3) from the assumed GitHubBenchmarkIngestRole; sslmode=verify-full validates - # the cert. - # - # ORDER MATTERS: "Install uv" runs BEFORE "Configure AWS credentials". - # configure-aws-credentials persists the assumed ingest-role (rds-db:connect - # only) as the job's ambient AWS creds; the uv setup compiles via sccache - # (S3-backed), so running it after the role switch fails with S3 AccessDenied. - # Installing uv first keeps sccache on the original S3-capable creds; the role - # is assumed immediately before the ingest, which needs only rds-db:connect. - - name: Install uv for v4 ingest - if: inputs.mode == 'develop' && vars.GH_BENCH_INGEST_ROLE_ARN != '' - uses: spiraldb/actions/.github/actions/setup-uv@a746510eafaa926484c354541cfc49b2ec06cc63 # 0.18.6 + # the cert. No "Install uv" step here: this job already installed the uv binary + # in its "Install uv" step above, and the ingest runs `uv run --no-project --with`, + # which needs nothing beyond the binary. - name: Configure AWS credentials for v4 ingest (OIDC) if: inputs.mode == 'develop' && vars.GH_BENCH_INGEST_ROLE_ARN != '' uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 From 33c9a30148d0a3ea716c64fa5d6a124b93b9a0a2 Mon Sep 17 00:00:00 2001 From: Adam Gutglick Date: Wed, 8 Jul 2026 21:49:47 +0100 Subject: [PATCH 040/104] Audit panic handling in detached tasks (#8677) ## Rationale for this change Following #8672, I've audited a few other suspicous code paths that had similar shape, mostly on the read side. ## What changes are included in this PR? 1. Propagate panics and shutdown correctly for `FileSegmentSource`. 2. Handle panics correctly in the shared iterator used by DuckDB 3. Misc panic handling around our runtime abstraction. ## What APIs are changed? Are there any user-facing changes? None --------- Signed-off-by: Adam Gutglick --- vortex-duckdb/src/table_function.rs | 72 +++++++- vortex-file/src/segments/source.rs | 269 ++++++++++++++++++++++++++-- vortex-io/src/runtime/current.rs | 178 ++++++++++++++++-- vortex-io/src/runtime/handle.rs | 126 +++++++++++-- vortex-io/src/runtime/tests.rs | 47 +++++ 5 files changed, 650 insertions(+), 42 deletions(-) diff --git a/vortex-duckdb/src/table_function.rs b/vortex-duckdb/src/table_function.rs index 098d8bf30ee..892a2ce58a9 100644 --- a/vortex-duckdb/src/table_function.rs +++ b/vortex-duckdb/src/table_function.rs @@ -4,13 +4,19 @@ use std::cmp::max; use std::fmt::Formatter; use std::fmt::{self}; +use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; +use std::task::Context; +use std::task::Poll; use custom_labels::CURRENT_LABELSET; +use futures::FutureExt; +use futures::Stream; use futures::StreamExt; +use futures::future::BoxFuture; use itertools::Itertools; use num_traits::AsPrimitive; use static_assertions::assert_impl_all; @@ -108,7 +114,8 @@ impl<'a> TableInitInput<'a> { } } -type DataSourceIterator = ThreadSafeIterator)>>; +type ScanItem = VortexResult<(ArrayRef, Arc)>; +type DataSourceIterator = ThreadSafeIterator; pub struct TableFunctionGlobal { iterator: DataSourceIterator, @@ -268,10 +275,7 @@ pub fn init_global(init_input: &TableInitInput) -> VortexResult()).detach(); - - let iterator = RUNTIME.block_on_stream_thread_safe(|_handle| rx.into_stream()); + let iterator = RUNTIME.block_on_stream_thread_safe(|_handle| scan_driver_stream(stream, rx)); Ok(TableFunctionGlobal { iterator, @@ -283,6 +287,39 @@ pub fn init_global(init_input: &TableInitInput) -> VortexResult(stream: S, rx: kanal::AsyncReceiver) -> ScanDriverStream +where + S: Stream + Send + 'static, +{ + ScanDriverStream { + driver: Some(stream.collect::<()>().boxed()), + rx: rx.into_stream().boxed(), + } +} + +struct ScanDriverStream { + driver: Option>, + rx: futures::stream::BoxStream<'static, ScanItem>, +} + +impl Stream for ScanDriverStream { + type Item = ScanItem; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + if let Some(driver) = this.driver.as_mut() + && driver.as_mut().poll(cx).is_ready() + { + this.driver = None; + } + + match this.rx.as_mut().poll_next(cx) { + Poll::Ready(None) if this.driver.is_some() => Poll::Pending, + poll => poll, + } + } +} + pub fn init_local(global: &TableFunctionGlobal) -> TableFunctionLocal { unsafe { use custom_labels::sys; @@ -545,8 +582,11 @@ fn progress(bytes_read: &AtomicU64, bytes_total: &AtomicU64) -> f64 { mod tests { use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering::Relaxed; + use std::task::Poll; + use crate::RUNTIME; use crate::table_function::progress; + use crate::table_function::scan_driver_stream; #[test] fn test_table_scan_progress() { @@ -561,4 +601,26 @@ mod tests { bytes_total.fetch_add(100, Relaxed); assert!((progress(&bytes_read, &bytes_total) - 50.).abs() < f64::EPSILON); } + + #[test] + fn scan_driver_panic_propagates_through_iterator() { + let (tx, rx) = kanal::bounded_async(1); + let _tx = tx; + let stream = futures::stream::poll_fn(|_| -> Poll> { + panic!("duckdb scan driver panic"); + }); + + let mut iter = + RUNTIME.block_on_stream_thread_safe(|_handle| scan_driver_stream(stream, rx)); + let panic = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| iter.next())) { + Ok(_) => panic!("driver panic must propagate through iterator"), + Err(panic) => panic, + }; + let message = panic + .downcast_ref::<&'static str>() + .copied() + .or_else(|| panic.downcast_ref::().map(String::as_str)) + .unwrap_or(""); + assert!(message.contains("duckdb scan driver panic")); + } } diff --git a/vortex-file/src/segments/source.rs b/vortex-file/src/segments/source.rs index e7c9dc3b222..f7ef77e4bd5 100644 --- a/vortex-file/src/segments/source.rs +++ b/vortex-file/src/segments/source.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +use std::any::Any; +use std::future::Future; use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::AtomicUsize; @@ -12,6 +14,9 @@ use futures::FutureExt; use futures::StreamExt; use futures::channel::mpsc; use futures::future; +use futures::future::BoxFuture; +use futures::future::Shared; +use parking_lot::Mutex; use vortex_array::buffer::BufferHandle; use vortex_buffer::Alignment; use vortex_buffer::ByteBuffer; @@ -21,6 +26,7 @@ use vortex_error::vortex_err; use vortex_error::vortex_panic; use vortex_io::VortexReadAt; use vortex_io::runtime::Handle; +use vortex_io::runtime::JoinOutcome; use vortex_layout::segments::SegmentFuture; use vortex_layout::segments::SegmentId; use vortex_layout::segments::SegmentSource; @@ -67,10 +73,26 @@ pub enum ReadEvent { /// /// I/O requests will be processed in the order they are `registered`, however coalescing may mean /// other registered requests are lumped together into a single I/O operation. +/// A cloneable handle to the background read driver, shared by every in-flight [`ReadFuture`]. +/// +/// [`Shared`] fans a single completion out to all readers with correct waker bookkeeping, so a +/// reader is always woken when the driver finishes — even if another reader polled the driver more +/// recently and was then dropped. Its output is `()`; a driver panic is carried out of band in +/// [`DriverPanic`] so it can be re-raised on the reader side. +type SharedDriver = Shared>; + +/// Slot holding the driver's panic payload, if it panicked while driving reads. The first reader to +/// observe completion takes the payload and re-raises it; later readers report a graceful error. +type DriverPanic = Arc>>>; + pub struct FileSegmentSource { segments: Arc<[SegmentSpec]>, /// A queue for sending read request events to the I/O stream. events: mpsc::UnboundedSender, + /// Background request driver, joined by readers to surface a driver panic. + driver: SharedDriver, + /// Panic payload captured if the driver panicked while driving reads. + driver_panic: DriverPanic, /// The next read request ID. next_id: Arc, } @@ -144,11 +166,30 @@ impl FileSegmentSource { .await }; - handle.spawn(drive_fut).detach(); + // Spawn the driver so the runtime makes I/O progress independently of any reader. Readers + // join it (below) only to surface a panic raised while driving reads. + let mut task = handle.spawn(drive_fut); + let driver_panic: DriverPanic = Arc::new(Mutex::new(None)); + let driver = { + let driver_panic = Arc::clone(&driver_panic); + async move { + // Poll for the terminal outcome without re-raising: a benign abort (runtime + // teardown) resolves to `()` so readers report a graceful error, while a panic is + // stashed for the first reader to re-raise. + if let JoinOutcome::Panicked(panic) = future::poll_fn(|cx| task.poll_join(cx)).await + { + *driver_panic.lock() = Some(panic); + } + } + .boxed() + .shared() + }; Self { segments, events: send, + driver, + driver_panic, next_id: Arc::new(AtomicUsize::new(0)), } } @@ -192,6 +233,8 @@ impl SegmentSource for FileSegmentSource { polled: false, finished: false, events: self.events.clone(), + driver: self.driver.clone(), + driver_panic: Arc::clone(&self.driver_panic), }; // One allocation: we only box the returned SegmentFuture, not the inner ReadFuture. @@ -209,6 +252,8 @@ struct ReadFuture { polled: bool, finished: bool, events: mpsc::UnboundedSender, + driver: SharedDriver, + driver_panic: DriverPanic, } impl Future for ReadFuture { @@ -216,17 +261,28 @@ impl Future for ReadFuture { fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { match self.recv.poll_unpin(cx) { - Poll::Ready(result) => { + // note: we are skipping polled and dropped events for this if the future is ready on + // the first poll, that means this request was completed before it was polled, + // as part of a coalesced request. + Poll::Ready(Ok(result)) => { self.finished = true; - // note: we are skipping polled and dropped events for this if the future - // is ready on the first poll, that means this request was completed - // before it was polled, as part of a coalesced request. - Poll::Ready( - result.unwrap_or_else(|e| { - Err(vortex_err!("ReadRequest dropped by runtime: {e}")) - }), - ) + Poll::Ready(result) } + // The request's sender was dropped, so the driver has finished. Join it so a panic + // raised while driving reads is re-raised here rather than surfacing as a generic + // error. Only report the dropped error once the driver has finished. + Poll::Ready(Err(e)) => match self.driver.poll_unpin(cx) { + Poll::Ready(()) => { + self.finished = true; + // Re-raise the driver panic on the first reader to observe it; later readers + // fall through to the graceful dropped error. + if let Some(panic) = self.driver_panic.lock().take() { + std::panic::resume_unwind(panic); + } + Poll::Ready(Err(vortex_err!("ReadRequest dropped by runtime: {e}"))) + } + Poll::Pending => Poll::Pending, + }, Poll::Pending if !self.polled => { self.polled = true; // Notify the I/O stream that this request has been polled. @@ -321,3 +377,196 @@ impl SegmentSource for BufferSegmentSource { future::ready(Ok(BufferHandle::new_host(slice))).boxed() } } + +#[cfg(test)] +mod tests { + use std::panic::AssertUnwindSafe; + + use futures::future::BoxFuture; + use vortex_io::runtime::tokio::TokioRuntime; + use vortex_layout::segments::SegmentSource; + use vortex_metrics::DefaultMetricsRegistry; + + use super::*; + + #[derive(Clone)] + struct PanickingReadAt; + + impl VortexReadAt for PanickingReadAt { + fn concurrency(&self) -> usize { + 1 + } + + fn size(&self) -> BoxFuture<'static, VortexResult> { + async { Ok(4) }.boxed() + } + + fn read_at( + &self, + _offset: u64, + _length: usize, + _alignment: Alignment, + ) -> BoxFuture<'static, VortexResult> { + async { + panic!("read-at panic"); + } + .boxed() + } + } + + fn panicking_source() -> FileSegmentSource { + let segments: Arc<[SegmentSpec]> = Arc::from([SegmentSpec { + offset: 0, + length: 4, + alignment: Alignment::none(), + }]); + let metrics = DefaultMetricsRegistry::default(); + FileSegmentSource::open( + segments, + PanickingReadAt, + TokioRuntime::current(), + RequestMetrics::new(&metrics, vec![]), + ) + } + + fn panic_message(payload: &(dyn Any + Send)) -> &str { + payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or("") + } + + #[tokio::test] + #[should_panic(expected = "read-at panic")] + async fn file_segment_source_propagates_read_driver_panic() { + let source = panicking_source(); + let _result = source.request(SegmentId::from(0)).await; + } + + // A read-driver panic must propagate on *every* run rather than sometimes surfacing as a + // generic "dropped by runtime" error, which is nondeterministic. + #[tokio::test] + async fn file_segment_source_read_driver_panic_propagates_deterministically() { + for i in 0..100 { + let source = panicking_source(); + let outcome = AssertUnwindSafe(source.request(SegmentId::from(0))) + .catch_unwind() + .await; + assert!( + outcome.is_err(), + "read-driver panic was not propagated on iteration {i}; \ + the request resolved instead of panicking" + ); + } + } + + // A driver panic must surface to every concurrent reader sharing one driver: exactly one + // reader re-raises the original panic, the rest report a graceful error, and none hang. This + // also covers the fan-out invariant — `Shared` wakes every reader on completion, so a reader + // dropped mid-flight can never swallow another reader's wake-up. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn file_segment_source_panic_propagates_to_all_concurrent_readers() { + let source = Arc::new(panicking_source()); + let reader_count = 8; + + let readers: Vec<_> = (0..reader_count) + .map(|_| { + let source = Arc::clone(&source); + tokio::spawn(async move { + AssertUnwindSafe(source.request(SegmentId::from(0))) + .catch_unwind() + .await + }) + }) + .collect(); + + let joined = + tokio::time::timeout(std::time::Duration::from_secs(5), future::join_all(readers)) + .await + .expect("a reader hung instead of observing the driver panic"); + + let mut original_panics = 0; + for reader in joined { + match reader.expect("reader task panicked at the join boundary") { + // The first reader to observe completion re-raises the original panic. + Err(payload) => { + assert!( + panic_message(&*payload).contains("read-at panic"), + "got: {:?}", + panic_message(&*payload) + ); + original_panics += 1; + } + // Every other reader reports a graceful dropped-by-runtime error. + Ok(result) => assert!(result.is_err(), "expected a dropped-by-runtime error"), + } + } + + assert_eq!( + original_panics, 1, + "exactly one reader should re-raise the original driver panic" + ); + } + + #[derive(Clone)] + struct SlowErrReadAt; + + impl VortexReadAt for SlowErrReadAt { + fn concurrency(&self) -> usize { + 4 + } + + fn size(&self) -> BoxFuture<'static, VortexResult> { + async { Ok(1024) }.boxed() + } + + fn read_at( + &self, + offset: u64, + _length: usize, + _alignment: Alignment, + ) -> BoxFuture<'static, VortexResult> { + async move { + // Stagger completions so some reads finish while others are still in flight. + for _ in 0..(offset as usize % 5 + 1) { + tokio::task::yield_now().await; + } + vortex_bail!("slow read done") + } + .boxed() + } + } + + // Many segment reads driven on separate tasks must all make progress and complete. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn read_driver_concurrent_reads_make_progress() { + let n = 16u32; + let segments: Arc<[SegmentSpec]> = (0..n) + .map(|i| SegmentSpec { + offset: u64::from(i) * 4, + length: 4, + alignment: Alignment::none(), + }) + .collect(); + let metrics = DefaultMetricsRegistry::default(); + let source = Arc::new(FileSegmentSource::open( + segments, + SlowErrReadAt, + TokioRuntime::current(), + RequestMetrics::new(&metrics, vec![]), + )); + + let tasks: Vec<_> = (0..n) + .map(|i| { + let source = Arc::clone(&source); + tokio::spawn(async move { source.request(SegmentId::from(i)).await }) + }) + .collect(); + + let joined = + tokio::time::timeout(std::time::Duration::from_secs(5), future::join_all(tasks)).await; + + assert!(joined.is_ok(), "concurrent reads stalled before completing"); + } +} diff --git a/vortex-io/src/runtime/current.rs b/vortex-io/src/runtime/current.rs index c8ff91b4aec..93b698b43a2 100644 --- a/vortex-io/src/runtime/current.rs +++ b/vortex-io/src/runtime/current.rs @@ -1,11 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +use std::future::Future; use std::sync::Arc; use futures::Stream; use futures::StreamExt; use futures::stream::BoxStream; +use parking_lot::Mutex; use smol::block_on; use crate::runtime::BlockingRuntime; @@ -67,22 +69,21 @@ impl CurrentThreadRuntime { // This allows multiple worker threads to drive the execution while all waiting for results // on the channel. let (result_tx, result_rx) = kanal::bounded_async(1); - self.executor - .spawn(async move { - futures::pin_mut!(stream); - while let Some(item) = stream.next().await { - // If all receivers are dropped, we stop driving the stream. - if let Err(e) = result_tx.send(item).await { - tracing::trace!("all receivers dropped, stopping stream: {}", e); - break; - } + let driver = self.executor.spawn(async move { + futures::pin_mut!(stream); + while let Some(item) = stream.next().await { + // If all receivers are dropped, we stop driving the stream. + if let Err(e) = result_tx.send(item).await { + tracing::trace!("all receivers dropped, stopping stream: {}", e); + break; } - }) - .detach(); + } + }); ThreadSafeIterator { executor: Arc::clone(&self.executor), results: result_rx, + driver: Arc::new(Mutex::new(Some(driver))), } } } @@ -132,6 +133,10 @@ impl Iterator for CurrentThreadIterator<'_, T> { pub struct ThreadSafeIterator { executor: Arc>, results: kanal::AsyncReceiver, + /// Handle to the task driving the stream. Once the stream ends, the first consumer to + /// observe it joins the task so a panic raised while driving the stream is re-raised rather + /// than silently ending the iterator. + driver: Arc>>>, } // Manual clone implementation since `T` does not need to be `Clone`. @@ -140,6 +145,7 @@ impl Clone for ThreadSafeIterator { Self { executor: Arc::clone(&self.executor), results: self.results.clone(), + driver: Arc::clone(&self.driver), } } } @@ -148,17 +154,32 @@ impl Iterator for ThreadSafeIterator { type Item = T; fn next(&mut self) -> Option { - block_on(self.executor.run(self.results.recv())).ok() + match block_on(self.executor.run(self.results.recv())) { + Ok(item) => Some(item), + // The result channel closes when the driver task finishes. Join the task so a panic + // raised while driving the stream is re-raised here instead of being lost. The first + // consumer to observe closure joins it; any later consumer just sees the stream end. + Err(_) => { + let task = self.driver.lock().take(); + if let Some(task) = task { + block_on(self.executor.run(task)); + } + None + } + } } } #[expect(clippy::if_then_some_else_none)] // Clippy is wrong when if/else has await. #[cfg(test)] mod tests { + use std::any::Any; + use std::panic::AssertUnwindSafe; use std::sync::Arc; use std::sync::Barrier; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; + use std::task::Poll; use std::thread; use std::time::Duration; @@ -261,6 +282,139 @@ mod tests { assert_eq!(collected, (0..total_items).collect::>()); } + #[test] + fn test_block_on_stream_thread_safe_propagates_driver_panic() { + let runtime = CurrentThreadRuntime::new(); + let mut iter = runtime.block_on_stream_thread_safe(|_h| { + stream::poll_fn(|_| -> Poll> { + panic!("stream driver panic"); + }) + .boxed() + }); + + let panic = std::panic::catch_unwind(AssertUnwindSafe(|| iter.next())) + .expect_err("stream panic must propagate through iterator"); + let message = panic + .downcast_ref::<&'static str>() + .copied() + .or_else(|| panic.downcast_ref::().map(String::as_str)) + .unwrap_or(""); + assert!(message.contains("stream driver panic")); + } + + fn panic_message(panic: &(dyn Any + Send)) -> &str { + panic + .downcast_ref::<&'static str>() + .copied() + .or_else(|| panic.downcast_ref::().map(String::as_str)) + .unwrap_or("") + } + + // A driver panic must propagate on *every* run, regardless of executor scheduling. Running + // the scenario many times guards against a return to timing-dependent propagation. + #[test] + fn test_block_on_stream_thread_safe_panic_propagation_is_deterministic() { + for i in 0..2000 { + let mut iter = CurrentThreadRuntime::new().block_on_stream_thread_safe(|_h| { + stream::poll_fn(|_| -> Poll> { + panic!("deterministic driver panic"); + }) + .boxed() + }); + + let outcome = std::panic::catch_unwind(AssertUnwindSafe(|| iter.next())); + assert!( + outcome.is_err(), + "driver panic was swallowed on iteration {i}: next() returned {:?}", + outcome.ok().flatten(), + ); + } + } + + // A panic after some items were already produced must still surface, not be seen as a clean + // end of stream. + #[test] + fn test_block_on_stream_thread_safe_panic_after_items() { + let mut emitted = 0usize; + let iter = CurrentThreadRuntime::new().block_on_stream_thread_safe(move |_h| { + stream::poll_fn(move |_| -> Poll> { + if emitted < 3 { + emitted += 1; + Poll::Ready(Some(emitted)) + } else { + panic!("driver panic after items"); + } + }) + .boxed() + }); + + // Drain the iterator. The terminal event must be a propagated panic, never a clean + // `None`. This avoids depending on exactly how many buffered items survive channel close. + let outcome = std::panic::catch_unwind(AssertUnwindSafe(move || iter.collect::>())); + match outcome { + Ok(items) => panic!("driver panic was swallowed; stream ended cleanly with {items:?}"), + Err(panic) => assert!(panic_message(&*panic).contains("driver panic after items")), + } + } + + // With multiple consumers, a driver panic must reach *every* consumer that observes the end + // of the stream; it must never be swallowed by all of them. Exactly one consumer joins the + // driver and observes the panic; the rest see the stream end. + #[test] + fn test_block_on_stream_thread_safe_multi_consumer_panic_surfaced() { + let iter = CurrentThreadRuntime::new().block_on_stream_thread_safe(|_h| { + stream::poll_fn(|_| -> Poll> { + panic!("multi consumer driver panic"); + }) + .boxed() + }); + + let num_threads = 4; + let barrier = Arc::new(Barrier::new(num_threads)); + let panics = Arc::new(AtomicUsize::new(0)); + + let handles: Vec<_> = (0..num_threads) + .map(|_| { + let mut iter = iter.clone(); + let barrier = Arc::clone(&barrier); + let panics = Arc::clone(&panics); + thread::spawn(move || { + barrier.wait(); + match std::panic::catch_unwind(AssertUnwindSafe(|| iter.next())) { + // The driver panicked before producing anything, so a clean end is the + // only non-panic outcome a consumer may observe. + Ok(None) => {} + Ok(Some(_)) => panic!("no item was produced before the driver panicked"), + Err(panic) => { + assert!(panic_message(&*panic).contains("multi consumer driver panic")); + panics.fetch_add(1, Ordering::SeqCst); + } + } + }) + }) + .collect(); + + for handle in handles { + handle.join().expect("consumer thread panicked uncaught"); + } + + // The panic surfaces to exactly one consumer and is never swallowed by all of them. + assert_eq!(panics.load(Ordering::SeqCst), 1); + } + + // Clean completion must return `None` with no spurious panic. + #[test] + fn test_block_on_stream_thread_safe_clean_completion_returns_none() { + let mut iter = CurrentThreadRuntime::new() + .block_on_stream_thread_safe(|_h| stream::iter(vec![1usize, 2, 3]).boxed()); + + assert_eq!(iter.next(), Some(1)); + assert_eq!(iter.next(), Some(2)); + assert_eq!(iter.next(), Some(3)); + assert_eq!(iter.next(), None); + assert_eq!(iter.next(), None); + } + #[test] fn test_block_on_stream_concurrent_clone_and_drive() { let num_items = 50; diff --git a/vortex-io/src/runtime/handle.rs b/vortex-io/src/runtime/handle.rs index aa9f0c2100a..bebbaac5f97 100644 --- a/vortex-io/src/runtime/handle.rs +++ b/vortex-io/src/runtime/handle.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +use std::any::Any; +use std::panic::AssertUnwindSafe; use std::pin::Pin; use std::sync::Arc; use std::sync::Weak; @@ -77,8 +79,11 @@ impl Handle { let span = tracing::trace_span!(target: "vortex_io::spawn", "spawn"); let abort_handle = self.runtime().spawn( async move { + // Catch a panic so it can be re-raised on the joining side (see `Task::poll`) + // rather than being lost, matching `tokio::JoinError` / `smol::Task` semantics. + let output = AssertUnwindSafe(f).catch_unwind().await; // Task::detach allows the receiver to be dropped, so we ignore send errors. - drop(send.send(f.await)); + drop(send.send(output)); } .instrument(span) .boxed(), @@ -116,8 +121,10 @@ impl Handle { let span = tracing::trace_span!(target: "vortex_io::spawn_io", "spawn_io"); let abort_handle = self.runtime().spawn_io( async move { + // See `spawn`: catch a panic so it re-raises on the joining side. + let output = AssertUnwindSafe(f).catch_unwind().await; // Task::detach allows the receiver to be dropped, so we ignore send errors. - drop(send.send(f.await)); + drop(send.send(output)); } .instrument(span) .boxed(), @@ -148,8 +155,10 @@ impl Handle { let _guard = span.enter(); // Optimistically avoid the work if the result won't be used. if !send.is_closed() { + // Catch a panic so it re-raises on the joining side (see `Task::poll`). + let output = std::panic::catch_unwind(AssertUnwindSafe(f)); // Task::detach allows the receiver to be dropped, so we ignore send errors. - drop(send.send(f())); + drop(send.send(output)); } })); Task { @@ -170,8 +179,10 @@ impl Handle { let _guard = span.enter(); // Optimistically avoid the work if the result won't be used. if !send.is_closed() { + // Catch a panic so it re-raises on the joining side (see `Task::poll`). + let output = std::panic::catch_unwind(AssertUnwindSafe(f)); // Task::detach allows the receiver to be dropped, so we ignore send errors. - drop(send.send(f())); + drop(send.send(output)); } })); Task { @@ -181,13 +192,30 @@ impl Handle { } } +/// The value carried from a spawned task back to its [`Task`] handle: either the task's output, +/// or the panic payload if the task panicked, so it can be re-raised on the joining side. +type TaskOutput = Result>; + +/// The terminal outcome of joining a spawned [`Task`] with [`Task::poll_join`], without +/// re-raising a panic. +pub enum JoinOutcome { + /// The task ran to completion and produced this value. + Completed(T), + /// The task panicked. Carries the original panic payload, ready to be re-raised with + /// [`std::panic::resume_unwind`]. + Panicked(Box), + /// The runtime dropped the task's future before it completed, for example because the task + /// was aborted or the runtime shut down. No value or panic payload is available. + Aborted, +} + /// A handle to a spawned Task. /// /// If this handle is dropped, the task is cancelled where possible. In order to allow the task to /// continue running in the background, call [`Task::detach`]. #[must_use = "When a Task is dropped without being awaited, it is cancelled"] pub struct Task { - recv: oneshot::AsyncReceiver, + recv: oneshot::AsyncReceiver>, abort_handle: Option, } @@ -197,24 +225,43 @@ impl Task { pub fn detach(mut self) { drop(self.abort_handle.take()); } + + /// Poll the task to completion, reporting the terminal state as a [`JoinOutcome`] instead of + /// re-raising a panic or panicking on abort. + /// + /// This lets a caller distinguish a task panic (which it may re-raise via + /// [`std::panic::resume_unwind`]) from the runtime dropping the task before it completed — for + /// example a runtime shutting down while work is in flight — which is benign. The [`Future`] + /// implementation, by contrast, re-raises a panic and itself panics on abort. + pub fn poll_join(&mut self, cx: &mut Context<'_>) -> Poll> { + match ready!(self.recv.poll_unpin(cx)) { + Ok(Ok(output)) => Poll::Ready(JoinOutcome::Completed(output)), + Ok(Err(panic)) => Poll::Ready(JoinOutcome::Panicked(panic)), + // The result channel closed without delivering a value: the runtime dropped the task's + // future before it completed (for example the task was aborted or the runtime shut + // down). + Err(_recv_err) => Poll::Ready(JoinOutcome::Aborted), + } + } } impl Future for Task { type Output = T; #[expect(clippy::panic)] - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - match ready!(self.recv.poll_unpin(cx)) { - Ok(result) => Poll::Ready(result), - Err(_recv_err) => { - // If the other end of the channel was dropped, it means the runtime dropped - // the future without ever completing it. If the caller aborted this task by - // dropping it, then they wouldn't be able to poll it anymore. - // So we consider a closed channel to be a Runtime programming error and therefore - // we panic. + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + match ready!(self.get_mut().poll_join(cx)) { + JoinOutcome::Completed(output) => Poll::Ready(output), + // The task panicked: re-raise the original panic on the joining side, preserving its + // message and payload rather than collapsing it into a generic error. + JoinOutcome::Panicked(panic) => std::panic::resume_unwind(panic), + JoinOutcome::Aborted => { + // The runtime dropped the task's future before it completed. If the caller aborted + // this task by dropping it, they wouldn't be able to poll it anymore, so we + // consider a closed channel a runtime programming error and panic. // NOTE(ngates): we don't use vortex_panic to avoid printing a useless backtrace. - panic!("Runtime dropped task without completing it, likely it panicked") + panic!("Runtime dropped task without completing it") } } } @@ -228,3 +275,52 @@ impl Drop for Task { } } } + +#[cfg(test)] +mod tests { + use futures::task::noop_waker; + + use super::*; + + // A task whose result channel closed without a value — the runtime dropped its future before + // it sent a result — must report `Aborted`, so callers can treat a benign teardown as a + // recoverable stop rather than a propagated panic. + #[test] + fn poll_join_reports_aborted_when_channel_closed_without_value() { + let (send, recv) = oneshot::channel::>(); + // Simulate the runtime dropping the task's future before it sent a result. + drop(send); + + let mut task = Task::<()> { + recv: recv.into_future(), + abort_handle: None, + }; + + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + assert!(matches!( + task.poll_join(&mut cx), + Poll::Ready(JoinOutcome::Aborted) + )); + } + + // A completed task reports its value through `poll_join` rather than re-raising or panicking. + #[test] + fn poll_join_reports_completed_value() { + let (send, recv) = oneshot::channel::>(); + // Ignore the send result: the payload type is not `Debug`, and the receiver is alive. + drop(send.send(Ok(7))); + + let mut task = Task:: { + recv: recv.into_future(), + abort_handle: None, + }; + + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + assert!(matches!( + task.poll_join(&mut cx), + Poll::Ready(JoinOutcome::Completed(7)) + )); + } +} diff --git a/vortex-io/src/runtime/tests.rs b/vortex-io/src/runtime/tests.rs index e35d9db6f78..940566b4fc1 100644 --- a/vortex-io/src/runtime/tests.rs +++ b/vortex-io/src/runtime/tests.rs @@ -4,6 +4,7 @@ #![cfg(feature = "tokio")] #![expect(clippy::cast_possible_truncation)] +use std::panic::AssertUnwindSafe; use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; @@ -18,6 +19,7 @@ use vortex_buffer::ByteBufferMut; use vortex_error::VortexResult; use crate::VortexReadAt; +use crate::runtime::Task; use crate::runtime::single::block_on; use crate::runtime::tokio::TokioRuntime; use crate::std_file::FileReadAt; @@ -225,6 +227,51 @@ async fn test_handle_spawn_cpu() { assert_eq!(counter.load(Ordering::SeqCst), 1); } +fn panic_message(payload: &(dyn std::any::Any + Send)) -> &str { + payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)) + .unwrap_or("") +} + +// Joining a spawned future that panicked must re-raise the *original* panic, not a generic +// "Runtime dropped task" message. +#[test] +fn test_spawned_future_panic_reraises_original() { + let outcome = block_on(|handle| { + async move { + let task: Task<()> = handle.spawn(async move { panic!("original spawn panic") }); + AssertUnwindSafe(task).catch_unwind().await + } + .boxed_local() + }); + + let payload = outcome.expect_err("panicking task must propagate a panic"); + assert!( + panic_message(&*payload).contains("original spawn panic"), + "got: {:?}", + panic_message(&*payload) + ); +} + +// Same for CPU tasks. +#[tokio::test] +async fn test_spawned_cpu_task_panic_reraises_original() { + let handle = TokioRuntime::current(); + let task: Task<()> = handle.spawn_cpu(|| panic!("original cpu panic")); + + let payload = AssertUnwindSafe(task) + .catch_unwind() + .await + .expect_err("panicking cpu task must propagate a panic"); + assert!( + panic_message(&*payload).contains("original cpu panic"), + "got: {:?}", + panic_message(&*payload) + ); +} + // ============================================================================ // Test custom VortexRead implementation // ============================================================================ From 11f15a90715eaa3c1b0b79db5b4e844ad290d1a8 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Wed, 8 Jul 2026 22:17:57 +0100 Subject: [PATCH 041/104] Implement compare in vortex instead of falling back to arrow (#8661) Instead of going through arrow compare logic implement it in vortex Lets us leverage optimised bitbuffer optimistaions and ultimately remove arrow dependency from vortex-array Signed-off-by: Robert Kruszewski --- vortex-array/benches/compare.rs | 208 +++++- .../src/arrays/extension/compute/compare.rs | 6 + .../src/scalar_fn/fns/binary/compare.rs | 590 --------------- .../scalar_fn/fns/binary/compare/boolean.rs | 126 ++++ .../src/scalar_fn/fns/binary/compare/bytes.rs | 400 +++++++++++ .../scalar_fn/fns/binary/compare/decimal.rs | 188 +++++ .../src/scalar_fn/fns/binary/compare/mod.rs | 320 +++++++++ .../scalar_fn/fns/binary/compare/nested.rs | 256 +++++++ .../scalar_fn/fns/binary/compare/primitive.rs | 130 ++++ .../src/scalar_fn/fns/binary/compare/tests.rs | 673 ++++++++++++++++++ .../src/scalar_fn/fns/binary/numeric.rs | 10 +- vortex-compute/src/lane_kernels/map_into.rs | 98 +++ 12 files changed, 2381 insertions(+), 624 deletions(-) delete mode 100644 vortex-array/src/scalar_fn/fns/binary/compare.rs create mode 100644 vortex-array/src/scalar_fn/fns/binary/compare/boolean.rs create mode 100644 vortex-array/src/scalar_fn/fns/binary/compare/bytes.rs create mode 100644 vortex-array/src/scalar_fn/fns/binary/compare/decimal.rs create mode 100644 vortex-array/src/scalar_fn/fns/binary/compare/mod.rs create mode 100644 vortex-array/src/scalar_fn/fns/binary/compare/nested.rs create mode 100644 vortex-array/src/scalar_fn/fns/binary/compare/primitive.rs create mode 100644 vortex-array/src/scalar_fn/fns/binary/compare/tests.rs diff --git a/vortex-array/benches/compare.rs b/vortex-array/benches/compare.rs index 95702c9f8f5..aaeda5ab3b6 100644 --- a/vortex-array/benches/compare.rs +++ b/vortex-array/benches/compare.rs @@ -8,12 +8,21 @@ use rand::RngExt; use rand::SeedableRng; use rand::distr::Uniform; use rand::prelude::StdRng; +use vortex_array::ArrayRef; use vortex_array::Canonical; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; use vortex_array::arrays::BoolArray; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::DecimalArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::arrays::StructArray; +use vortex_array::arrays::VarBinViewArray; use vortex_array::builtins::ArrayBuiltins; +use vortex_array::dtype::DecimalDType; +use vortex_array::scalar::Scalar; use vortex_array::scalar_fn::fns::operators::Operator; +use vortex_array::validity::Validity; use vortex_buffer::Buffer; fn main() { @@ -22,51 +31,190 @@ fn main() { const ARRAY_SIZE: usize = 65_536; -#[divan::bench] -fn compare_bool(bencher: Bencher) { - let mut rng = StdRng::seed_from_u64(0); - let range = Uniform::new(0u8, 1).unwrap(); - - let arr1 = BoolArray::from_iter((0..ARRAY_SIZE).map(|_| rng.sample(range) == 0)).into_array(); - let arr2 = BoolArray::from_iter((0..ARRAY_SIZE).map(|_| rng.sample(range) == 0)).into_array(); +fn bench_compare(bencher: Bencher, lhs: ArrayRef, rhs: ArrayRef, op: Operator) { let session = vortex_array::array_session(); - bencher - .with_inputs(|| (&arr1, &arr2, session.create_execution_ctx())) + .with_inputs(|| (&lhs, &rhs, session.create_execution_ctx())) .bench_refs(|input| { input .0 .clone() - .binary(input.1.clone(), Operator::Gte) + .binary(input.1.clone(), op) .unwrap() .execute::(&mut input.2) }); } -#[divan::bench] -fn compare_int(bencher: Bencher) { - let mut rng = StdRng::seed_from_u64(0); - let range = Uniform::new(0i64, 100_000_000).unwrap(); +fn bool_array(rng: &mut StdRng) -> ArrayRef { + BoolArray::from_iter((0..ARRAY_SIZE).map(|_| rng.random_bool(0.5))).into_array() +} + +fn bool_array_nullable(rng: &mut StdRng) -> ArrayRef { + BoolArray::new( + (0..ARRAY_SIZE).map(|_| rng.random_bool(0.5)).collect(), + Validity::from_iter((0..ARRAY_SIZE).map(|_| rng.random_bool(0.9))), + ) + .into_array() +} - let arr1 = (0..ARRAY_SIZE) +fn int_array(rng: &mut StdRng) -> ArrayRef { + let range = Uniform::new(0i64, 100_000_000).unwrap(); + (0..ARRAY_SIZE) .map(|_| rng.sample(range)) .collect::>() - .into_array(); + .into_array() +} - let arr2 = (0..ARRAY_SIZE) - .map(|_| rng.sample(range)) +fn int_array_nullable(rng: &mut StdRng) -> ArrayRef { + let range = Uniform::new(0i64, 100_000_000).unwrap(); + PrimitiveArray::new( + (0..ARRAY_SIZE) + .map(|_| rng.sample(range)) + .collect::>(), + Validity::from_iter((0..ARRAY_SIZE).map(|_| rng.random_bool(0.9))), + ) + .into_array() +} + +fn float_array(rng: &mut StdRng) -> ArrayRef { + (0..ARRAY_SIZE) + .map(|_| rng.random_range(0.0f64..1.0)) .collect::>() - .into_array(); - let session = vortex_array::array_session(); + .into_array() +} - bencher - .with_inputs(|| (&arr1, &arr2, session.create_execution_ctx())) - .bench_refs(|input| { - input - .0 - .clone() - .binary(input.1.clone(), Operator::Gte) - .unwrap() - .execute::(&mut input.2) - }); +fn string_array(rng: &mut StdRng) -> ArrayRef { + VarBinViewArray::from_iter_str((0..ARRAY_SIZE).map(|_| { + let len = rng.random_range(1usize..24); + (0..len) + .map(|_| char::from(rng.random_range(b'a'..=b'z'))) + .collect::() + })) + .into_array() +} + +fn decimal_array(rng: &mut StdRng) -> ArrayRef { + DecimalArray::from_iter::( + (0..ARRAY_SIZE).map(|_| rng.random_range(0i128..100_000_000)), + DecimalDType::new(38, 2), + ) + .into_array() +} + +#[divan::bench] +fn compare_bool(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(0); + let arr1 = bool_array(&mut rng); + let arr2 = bool_array(&mut rng); + bench_compare(bencher, arr1, arr2, Operator::Gte); +} + +#[divan::bench] +fn compare_bool_nullable(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(0); + let arr1 = bool_array_nullable(&mut rng); + let arr2 = bool_array_nullable(&mut rng); + bench_compare(bencher, arr1, arr2, Operator::Gte); +} + +#[divan::bench] +fn compare_bool_constant(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(0); + let arr = bool_array(&mut rng); + let constant = ConstantArray::new(true, ARRAY_SIZE).into_array(); + bench_compare(bencher, arr, constant, Operator::Eq); +} + +#[divan::bench] +fn compare_int(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(0); + let arr1 = int_array(&mut rng); + let arr2 = int_array(&mut rng); + bench_compare(bencher, arr1, arr2, Operator::Gte); +} + +#[divan::bench] +fn compare_int_nullable(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(0); + let arr1 = int_array_nullable(&mut rng); + let arr2 = int_array_nullable(&mut rng); + bench_compare(bencher, arr1, arr2, Operator::Gte); +} + +#[divan::bench] +fn compare_int_constant(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(0); + let arr = int_array(&mut rng); + let constant = ConstantArray::new(50_000_000i64, ARRAY_SIZE).into_array(); + bench_compare(bencher, arr, constant, Operator::Gte); +} + +#[divan::bench] +fn compare_int_eq(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(0); + let arr1 = int_array(&mut rng); + let arr2 = int_array(&mut rng); + bench_compare(bencher, arr1, arr2, Operator::Eq); +} + +#[divan::bench] +fn compare_float(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(0); + let arr1 = float_array(&mut rng); + let arr2 = float_array(&mut rng); + bench_compare(bencher, arr1, arr2, Operator::Gte); +} + +#[divan::bench] +fn compare_decimal(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(0); + let arr1 = decimal_array(&mut rng); + let arr2 = decimal_array(&mut rng); + bench_compare(bencher, arr1, arr2, Operator::Gte); +} + +#[divan::bench] +fn compare_string_eq(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(0); + let arr1 = string_array(&mut rng); + let arr2 = string_array(&mut rng); + bench_compare(bencher, arr1, arr2, Operator::Eq); +} + +#[divan::bench] +fn compare_string_lt(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(0); + let arr1 = string_array(&mut rng); + let arr2 = string_array(&mut rng); + bench_compare(bencher, arr1, arr2, Operator::Lt); +} + +#[divan::bench] +fn compare_string_constant(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(0); + let arr = string_array(&mut rng); + let constant = ConstantArray::new(Scalar::from("mmmmmmmmmmmm"), ARRAY_SIZE).into_array(); + bench_compare(bencher, arr, constant, Operator::Lt); +} + +fn struct_array(rng: &mut StdRng) -> ArrayRef { + StructArray::from_fields(&[("a", int_array(rng)), ("b", int_array(rng))]) + .unwrap() + .into_array() +} + +#[divan::bench] +fn compare_struct_lt(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(0); + let arr1 = struct_array(&mut rng); + let arr2 = struct_array(&mut rng); + bench_compare(bencher, arr1, arr2, Operator::Lt); +} + +#[divan::bench] +fn compare_struct_eq(bencher: Bencher) { + let mut rng = StdRng::seed_from_u64(0); + let arr1 = struct_array(&mut rng); + let arr2 = struct_array(&mut rng); + bench_compare(bencher, arr1, arr2, Operator::Eq); } diff --git a/vortex-array/src/arrays/extension/compute/compare.rs b/vortex-array/src/arrays/extension/compute/compare.rs index ecd6bbd5614..b3f978ef503 100644 --- a/vortex-array/src/arrays/extension/compute/compare.rs +++ b/vortex-array/src/arrays/extension/compute/compare.rs @@ -22,6 +22,12 @@ impl CompareKernel for Extension { operator: CompareOperator, _ctx: &mut ExecutionCtx, ) -> VortexResult> { + // Storage values are only comparable when both sides share the same extension dtype + // (e.g. timestamps in different units must not compare their raw storage). + if !lhs.dtype().eq_ignore_nullability(rhs.dtype()) { + return Ok(None); + } + // If the RHS is a constant, we can extract the storage scalar. if let Some(const_ext) = rhs.as_constant() { let storage_scalar = const_ext.as_extension().to_storage_scalar(); diff --git a/vortex-array/src/scalar_fn/fns/binary/compare.rs b/vortex-array/src/scalar_fn/fns/binary/compare.rs deleted file mode 100644 index b7a53c21d62..00000000000 --- a/vortex-array/src/scalar_fn/fns/binary/compare.rs +++ /dev/null @@ -1,590 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -use std::cmp::Ordering; - -use arrow_array::BooleanArray; -use arrow_buffer::NullBuffer; -use arrow_ord::cmp; -use arrow_ord::ord::make_comparator; -use arrow_schema::Field; -use arrow_schema::SortOptions; -use vortex_error::VortexResult; -use vortex_error::vortex_err; - -use crate::ArrayRef; -use crate::Canonical; -use crate::ExecutionCtx; -use crate::IntoArray; -use crate::array::ArrayView; -use crate::array::VTable; -use crate::arrays::Constant; -use crate::arrays::ConstantArray; -use crate::arrays::ScalarFn; -use crate::arrays::scalar_fn::ExactScalarFn; -use crate::arrays::scalar_fn::ScalarFnArrayExt; -use crate::arrays::scalar_fn::ScalarFnArrayView; -use crate::arrow::ArrowSessionExt; -use crate::arrow::Datum; -use crate::arrow::from_arrow_columnar; -use crate::dtype::DType; -use crate::dtype::Nullability; -use crate::kernel::ExecuteParentKernel; -use crate::scalar::Scalar; -use crate::scalar_fn::fns::binary::Binary; -use crate::scalar_fn::fns::operators::CompareOperator; - -/// Trait for encoding-specific comparison kernels that operate in encoded space. -/// -/// Implementations can compare an encoded array against another array (typically a constant) -/// without first decompressing. The adaptor normalizes operand order so `array` is always -/// the left-hand side, swapping the operator when necessary. -pub trait CompareKernel: VTable { - fn compare( - lhs: ArrayView<'_, Self>, - rhs: &ArrayRef, - operator: CompareOperator, - ctx: &mut ExecutionCtx, - ) -> VortexResult>; -} - -/// Adaptor that bridges [`CompareKernel`] implementations to [`ExecuteParentKernel`]. -/// -/// When a `ScalarFnArray(Binary, cmp_op)` wraps a child that implements `CompareKernel`, -/// this adaptor extracts the comparison operator and other operand, normalizes operand order -/// (swapping the operator if the encoded array is on the RHS), and delegates to the kernel. -#[derive(Default, Debug)] -pub struct CompareExecuteAdaptor(pub V); - -impl ExecuteParentKernel for CompareExecuteAdaptor -where - V: CompareKernel, -{ - type Parent = ExactScalarFn; - - fn execute_parent( - &self, - array: ArrayView<'_, V>, - parent: ScalarFnArrayView<'_, Binary>, - child_idx: usize, - ctx: &mut ExecutionCtx, - ) -> VortexResult> { - // Only handle comparison operators - let Ok(cmp_op) = CompareOperator::try_from(*parent.options) else { - return Ok(None); - }; - - // Get the ScalarFnArray to access children - let Some(scalar_fn_array) = parent.as_opt::() else { - return Ok(None); - }; - // Normalize so `array` is always LHS, swapping the operator if needed - // TODO(joe): should be go this here or in the Rule/Kernel - let (cmp_op, other) = match child_idx { - 0 => (cmp_op, scalar_fn_array.get_child(1)), - 1 => (cmp_op.swap(), scalar_fn_array.get_child(0)), - _ => return Ok(None), - }; - - let len = array.len(); - let nullable = array.dtype().is_nullable() || other.dtype().is_nullable(); - - // Empty array → empty bool result - if len == 0 { - return Ok(Some( - Canonical::empty(&DType::Bool(nullable.into())).into_array(), - )); - } - - // Null constant on either side → all-null bool result - if other.as_constant().is_some_and(|s| s.is_null()) { - return Ok(Some( - ConstantArray::new(Scalar::null(DType::Bool(nullable.into())), len).into_array(), - )); - } - - V::compare(array, other, cmp_op, ctx) - } -} - -/// Execute a compare operation between two arrays. -/// -/// This is the entry point for compare operations from the binary expression. -/// Handles empty, constant-null, and constant-constant directly, otherwise falls back to Arrow. -pub(crate) fn execute_compare( - lhs: &ArrayRef, - rhs: &ArrayRef, - op: CompareOperator, - ctx: &mut ExecutionCtx, -) -> VortexResult { - let nullable = lhs.dtype().is_nullable() || rhs.dtype().is_nullable(); - - if lhs.is_empty() { - return Ok(Canonical::empty(&DType::Bool(nullable.into())).into_array()); - } - - let left_constant_null = lhs.as_constant().map(|l| l.is_null()).unwrap_or(false); - let right_constant_null = rhs.as_constant().map(|r| r.is_null()).unwrap_or(false); - if left_constant_null || right_constant_null { - return Ok( - ConstantArray::new(Scalar::null(DType::Bool(nullable.into())), lhs.len()).into_array(), - ); - } - - // Constant-constant fast path - if let (Some(lhs_const), Some(rhs_const)) = (lhs.as_opt::(), rhs.as_opt::()) - { - let result = scalar_cmp(lhs_const.scalar(), rhs_const.scalar(), op)?; - return Ok(ConstantArray::new(result, lhs.len()).into_array()); - } - - arrow_compare_arrays(lhs, rhs, op, ctx) -} - -/// Fall back to Arrow for comparison. -fn arrow_compare_arrays( - left: &ArrayRef, - right: &ArrayRef, - operator: CompareOperator, - ctx: &mut ExecutionCtx, -) -> VortexResult { - assert_eq!(left.len(), right.len()); - - let nullable = left.dtype().is_nullable() || right.dtype().is_nullable(); - - // Arrow's vectorized comparison kernels don't support nested types. - // For nested types, fall back to `make_comparator` which does element-wise comparison. - let arrow_array: BooleanArray = if left.dtype().is_nested() || right.dtype().is_nested() { - let session = ctx.session().clone(); - let lhs = session.arrow().execute_arrow(left.clone(), None, ctx)?; - let target_field = Field::new("", lhs.data_type().clone(), right.dtype().is_nullable()); - let rhs = session - .arrow() - .execute_arrow(right.clone(), Some(&target_field), ctx)?; - - compare_nested_arrow_arrays(lhs.as_ref(), rhs.as_ref(), operator)? - } else { - // Fast path: use vectorized kernels for primitive types. - let lhs = Datum::try_new(left, ctx)?; - let rhs = Datum::try_new_with_target_datatype(right, lhs.data_type(), ctx)?; - - match operator { - CompareOperator::Eq => cmp::eq(&lhs, &rhs)?, - CompareOperator::NotEq => cmp::neq(&lhs, &rhs)?, - CompareOperator::Gt => cmp::gt(&lhs, &rhs)?, - CompareOperator::Gte => cmp::gt_eq(&lhs, &rhs)?, - CompareOperator::Lt => cmp::lt(&lhs, &rhs)?, - CompareOperator::Lte => cmp::lt_eq(&lhs, &rhs)?, - } - }; - - from_arrow_columnar(&arrow_array, left.len(), nullable, ctx) -} - -pub fn scalar_cmp(lhs: &Scalar, rhs: &Scalar, operator: CompareOperator) -> VortexResult { - if lhs.is_null() | rhs.is_null() { - return Ok(Scalar::null(DType::Bool(Nullability::Nullable))); - } - - let nullability = lhs.dtype().nullability() | rhs.dtype().nullability(); - - // We use `partial_cmp` to ensure we do not lose a type mismatch error. - let ordering = lhs.partial_cmp(rhs).ok_or_else(|| { - vortex_err!( - "Cannot compare scalars with incompatible types: {} and {}", - lhs.dtype(), - rhs.dtype() - ) - })?; - - let b = match operator { - CompareOperator::Eq => ordering.is_eq(), - CompareOperator::NotEq => ordering.is_ne(), - CompareOperator::Gt => ordering.is_gt(), - CompareOperator::Gte => ordering.is_ge(), - CompareOperator::Lt => ordering.is_lt(), - CompareOperator::Lte => ordering.is_le(), - }; - - Ok(Scalar::bool(b, nullability)) -} - -/// Compare two Arrow arrays element-wise using [`make_comparator`]. -/// -/// This function is required for nested types (Struct, List, FixedSizeList) because Arrow's -/// vectorized comparison kernels ([`cmp::eq`], [`cmp::neq`], etc.) do not support them. -/// -/// The vectorized kernels are faster but only work on primitive types, so for non-nested types, -/// prefer using the vectorized kernels directly for better performance. -pub fn compare_nested_arrow_arrays( - lhs: &dyn arrow_array::Array, - rhs: &dyn arrow_array::Array, - operator: CompareOperator, -) -> VortexResult { - let compare_arrays_at = make_comparator(lhs, rhs, SortOptions::default())?; - - let cmp_fn = match operator { - CompareOperator::Eq => Ordering::is_eq, - CompareOperator::NotEq => Ordering::is_ne, - CompareOperator::Gt => Ordering::is_gt, - CompareOperator::Gte => Ordering::is_ge, - CompareOperator::Lt => Ordering::is_lt, - CompareOperator::Lte => Ordering::is_le, - }; - - let values = (0..lhs.len()) - .map(|i| cmp_fn(compare_arrays_at(i, i))) - .collect(); - let nulls = NullBuffer::union(lhs.nulls(), rhs.nulls()); - - Ok(BooleanArray::new(values, nulls)) -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use rstest::rstest; - use vortex_buffer::BitBuffer; - use vortex_buffer::buffer; - use vortex_error::VortexExpect; - - use crate::ArrayRef; - use crate::IntoArray; - use crate::VortexSessionExecute; - use crate::array_session; - use crate::arrays::BoolArray; - use crate::arrays::ListArray; - use crate::arrays::ListViewArray; - use crate::arrays::PrimitiveArray; - use crate::arrays::StructArray; - use crate::arrays::VarBinArray; - use crate::arrays::VarBinViewArray; - use crate::assert_arrays_eq; - use crate::builtins::ArrayBuiltins; - use crate::dtype::DType; - use crate::dtype::FieldName; - use crate::dtype::FieldNames; - use crate::dtype::Nullability; - use crate::dtype::PType; - use crate::extension::datetime::TimeUnit; - use crate::extension::datetime::Timestamp; - use crate::extension::datetime::TimestampOptions; - use crate::scalar::Scalar; - use crate::scalar_fn::fns::binary::compare::ConstantArray; - use crate::scalar_fn::fns::binary::scalar_cmp; - use crate::scalar_fn::fns::operators::CompareOperator; - use crate::scalar_fn::fns::operators::Operator; - use crate::test_harness::to_int_indices; - use crate::validity::Validity; - - #[test] - fn test_bool_basic_comparisons() { - let ctx = &mut array_session().create_execution_ctx(); - let arr = BoolArray::new( - BitBuffer::from_iter([true, true, false, true, false]), - Validity::from_iter([false, true, true, true, true]), - ); - - let matches = arr - .clone() - .into_array() - .binary(arr.clone().into_array(), Operator::Eq) - .unwrap() - .execute::(ctx) - .vortex_expect("must be a bool array"); - assert_eq!(to_int_indices(matches, ctx).unwrap(), [1u64, 2, 3, 4]); - - let matches = arr - .clone() - .into_array() - .binary(arr.clone().into_array(), Operator::NotEq) - .unwrap() - .execute::(ctx) - .vortex_expect("must be a bool array"); - let empty: [u64; 0] = []; - assert_eq!(to_int_indices(matches, ctx).unwrap(), empty); - - let other = BoolArray::new( - BitBuffer::from_iter([false, false, false, true, true]), - Validity::from_iter([false, true, true, true, true]), - ); - - let matches = arr - .clone() - .into_array() - .binary(other.clone().into_array(), Operator::Lte) - .unwrap() - .execute::(ctx) - .vortex_expect("must be a bool array"); - assert_eq!(to_int_indices(matches, ctx).unwrap(), [2u64, 3, 4]); - - let matches = arr - .clone() - .into_array() - .binary(other.clone().into_array(), Operator::Lt) - .unwrap() - .execute::(ctx) - .vortex_expect("must be a bool array"); - assert_eq!(to_int_indices(matches, ctx).unwrap(), [4u64]); - - let matches = other - .clone() - .into_array() - .binary(arr.clone().into_array(), Operator::Gte) - .unwrap() - .execute::(ctx) - .vortex_expect("must be a bool array"); - assert_eq!(to_int_indices(matches, ctx).unwrap(), [2u64, 3, 4]); - - let matches = other - .into_array() - .binary(arr.into_array(), Operator::Gt) - .unwrap() - .execute::(ctx) - .vortex_expect("must be a bool array"); - assert_eq!(to_int_indices(matches, ctx).unwrap(), [4u64]); - } - - #[test] - fn constant_compare() { - let left = ConstantArray::new(Scalar::from(2u32), 10); - let right = ConstantArray::new(Scalar::from(10u32), 10); - - let result = left - .into_array() - .binary(right.into_array(), Operator::Gt) - .unwrap(); - assert_eq!(result.len(), 10); - let scalar = result - .execute_scalar(0, &mut array_session().create_execution_ctx()) - .unwrap(); - assert_eq!(scalar.as_bool().value(), Some(false)); - } - - #[rstest] - #[case(VarBinArray::from(vec!["a", "b"]).into_array(), VarBinViewArray::from_iter_str(["a", "b"]).into_array())] - #[case(VarBinViewArray::from_iter_str(["a", "b"]).into_array(), VarBinArray::from(vec!["a", "b"]).into_array())] - #[case(VarBinArray::from(vec!["a".as_bytes(), "b".as_bytes()]).into_array(), VarBinViewArray::from_iter_bin(["a".as_bytes(), "b".as_bytes()]).into_array())] - #[case(VarBinViewArray::from_iter_bin(["a".as_bytes(), "b".as_bytes()]).into_array(), VarBinArray::from(vec!["a".as_bytes(), "b".as_bytes()]).into_array())] - fn arrow_compare_different_encodings(#[case] left: ArrayRef, #[case] right: ArrayRef) { - let mut ctx = array_session().create_execution_ctx(); - let res = left.binary(right, Operator::Eq).unwrap(); - let expected = BoolArray::from_iter([true, true]); - assert_arrays_eq!(res, expected, &mut ctx); - } - - #[test] - fn test_list_array_comparison() { - let mut ctx = array_session().create_execution_ctx(); - let values1 = PrimitiveArray::from_iter([1i32, 2, 3, 4, 5, 6]); - let offsets1 = PrimitiveArray::from_iter([0i32, 2, 4, 6]); - let list1 = ListArray::try_new( - values1.into_array(), - offsets1.into_array(), - Validity::NonNullable, - ) - .unwrap(); - - let values2 = PrimitiveArray::from_iter([1i32, 2, 3, 4, 7, 8]); - let offsets2 = PrimitiveArray::from_iter([0i32, 2, 4, 6]); - let list2 = ListArray::try_new( - values2.into_array(), - offsets2.into_array(), - Validity::NonNullable, - ) - .unwrap(); - - let result = list1 - .clone() - .into_array() - .binary(list2.clone().into_array(), Operator::Eq) - .unwrap(); - let expected = BoolArray::from_iter([true, true, false]); - assert_arrays_eq!(result, expected, &mut ctx); - - let result = list1 - .clone() - .into_array() - .binary(list2.clone().into_array(), Operator::NotEq) - .unwrap(); - let expected = BoolArray::from_iter([false, false, true]); - assert_arrays_eq!(result, expected, &mut ctx); - - let result = list1 - .into_array() - .binary(list2.into_array(), Operator::Lt) - .unwrap(); - let expected = BoolArray::from_iter([false, false, true]); - assert_arrays_eq!(result, expected, &mut ctx); - } - - #[test] - fn test_list_array_constant_comparison() { - let mut ctx = array_session().create_execution_ctx(); - let values = PrimitiveArray::from_iter([1i32, 2, 3, 4, 5, 6]); - let offsets = PrimitiveArray::from_iter([0i32, 2, 4, 6]); - let list = ListArray::try_new( - values.into_array(), - offsets.into_array(), - Validity::NonNullable, - ) - .unwrap(); - - let list_scalar = Scalar::list( - Arc::new(DType::Primitive(PType::I32, Nullability::NonNullable)), - vec![3i32.into(), 4i32.into()], - Nullability::NonNullable, - ); - let constant = ConstantArray::new(list_scalar, 3); - - let result = list - .into_array() - .binary(constant.into_array(), Operator::Eq) - .unwrap(); - let expected = BoolArray::from_iter([false, true, false]); - assert_arrays_eq!(result, expected, &mut ctx); - } - - #[test] - fn test_struct_array_comparison() { - let mut ctx = array_session().create_execution_ctx(); - let bool_field1 = BoolArray::from_iter([Some(true), Some(false), Some(true)]); - let int_field1 = PrimitiveArray::from_iter([1i32, 2, 3]); - - let bool_field2 = BoolArray::from_iter([Some(true), Some(false), Some(false)]); - let int_field2 = PrimitiveArray::from_iter([1i32, 2, 4]); - - let struct1 = StructArray::from_fields(&[ - ("bool_col", bool_field1.into_array()), - ("int_col", int_field1.into_array()), - ]) - .unwrap(); - - let struct2 = StructArray::from_fields(&[ - ("bool_col", bool_field2.into_array()), - ("int_col", int_field2.into_array()), - ]) - .unwrap(); - - let result = struct1 - .clone() - .into_array() - .binary(struct2.clone().into_array(), Operator::Eq) - .unwrap(); - let expected = BoolArray::from_iter([true, true, false]); - assert_arrays_eq!(result, expected, &mut ctx); - - let result = struct1 - .into_array() - .binary(struct2.into_array(), Operator::Gt) - .unwrap(); - let expected = BoolArray::from_iter([false, false, true]); - assert_arrays_eq!(result, expected, &mut ctx); - } - - #[test] - fn test_empty_struct_compare() { - let mut ctx = array_session().create_execution_ctx(); - let empty1 = StructArray::try_new( - FieldNames::from(Vec::::new()), - Vec::new(), - 5, - Validity::NonNullable, - ) - .unwrap(); - - let empty2 = StructArray::try_new( - FieldNames::from(Vec::::new()), - Vec::new(), - 5, - Validity::NonNullable, - ) - .unwrap(); - - let result = empty1 - .into_array() - .binary(empty2.into_array(), Operator::Eq) - .unwrap(); - let expected = BoolArray::from_iter([true, true, true, true, true]); - assert_arrays_eq!(result, expected, &mut ctx); - } - - /// Regression test: comparing struct arrays where the same logical field is backed by - /// different Vortex encodings (VarBinArray vs VarBinViewArray) must not panic. - #[test] - fn struct_compare_mixed_binary_encodings() { - let mut ctx = array_session().create_execution_ctx(); - // LHS: struct with a VarBinArray (offset-based) binary field - let bin_field1 = VarBinArray::from(vec![ - "apple".as_bytes(), - "banana".as_bytes(), - "cherry".as_bytes(), - ]); - let struct1 = StructArray::from_fields(&[("data", bin_field1.into_array())]).unwrap(); - - // RHS: struct with a VarBinViewArray (view-based) binary field — same logical DType - let bin_field2 = VarBinViewArray::from_iter_bin([ - "apple".as_bytes(), - "banana".as_bytes(), - "durian".as_bytes(), - ]); - let struct2 = StructArray::from_fields(&[("data", bin_field2.into_array())]).unwrap(); - - let result = struct1 - .into_array() - .binary(struct2.into_array(), Operator::Eq) - .unwrap(); - let expected = BoolArray::from_iter([true, true, false]); - assert_arrays_eq!(result, expected, &mut ctx); - } - - /// Regression test: `scalar_cmp` must error when comparing scalars with incompatible - /// extension types (e.g., timestamps with different time units) rather than silently - /// returning a wrong result. - #[test] - fn scalar_cmp_incompatible_extension_types_errors() { - let ms_scalar = Scalar::extension::( - TimestampOptions { - unit: TimeUnit::Milliseconds, - tz: None, - }, - Scalar::from(1704067200000i64), - ); - let s_scalar = Scalar::extension::( - TimestampOptions { - unit: TimeUnit::Seconds, - tz: None, - }, - Scalar::from(1704067200i64), - ); - - // Ordering comparisons must error on incompatible types. - assert!(scalar_cmp(&ms_scalar, &s_scalar, CompareOperator::Gt).is_err()); - assert!(scalar_cmp(&ms_scalar, &s_scalar, CompareOperator::Lt).is_err()); - assert!(scalar_cmp(&ms_scalar, &s_scalar, CompareOperator::Gte).is_err()); - assert!(scalar_cmp(&ms_scalar, &s_scalar, CompareOperator::Lte).is_err()); - assert!(scalar_cmp(&ms_scalar, &s_scalar, CompareOperator::Eq).is_err()); - assert!(scalar_cmp(&ms_scalar, &s_scalar, CompareOperator::NotEq).is_err()); - } - - #[test] - fn test_empty_list() { - let ctx = &mut array_session().create_execution_ctx(); - let list = ListViewArray::new( - BoolArray::from_iter(Vec::::new()).into_array(), - buffer![0i32, 0i32, 0i32].into_array(), - buffer![0i32, 0i32, 0i32].into_array(), - Validity::AllValid, - ); - - let result = list - .clone() - .into_array() - .binary(list.into_array(), Operator::Eq) - .unwrap(); - assert!(result.execute_scalar(0, ctx).unwrap().is_valid()); - assert!(result.execute_scalar(1, ctx).unwrap().is_valid()); - assert!(result.execute_scalar(2, ctx).unwrap().is_valid()); - } -} diff --git a/vortex-array/src/scalar_fn/fns/binary/compare/boolean.rs b/vortex-array/src/scalar_fn/fns/binary/compare/boolean.rs new file mode 100644 index 00000000000..15bd486d341 --- /dev/null +++ b/vortex-array/src/scalar_fn/fns/binary/compare/boolean.rs @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Native comparison of boolean arrays using word-wise bit operations. + +use vortex_buffer::BitBuffer; +use vortex_error::VortexResult; +use vortex_error::vortex_err; + +use crate::ArrayRef; +use crate::ExecutionCtx; +use crate::IntoArray; +use crate::arrays::BoolArray; +use crate::arrays::Constant; +use crate::dtype::Nullability; +use crate::scalar_fn::fns::binary::compare::compare_validity; +use crate::scalar_fn::fns::operators::CompareOperator; +use crate::validity::Validity; + +enum BoolOperand { + Array { bits: BitBuffer, validity: Validity }, + Constant { value: bool, validity: Validity }, +} + +impl BoolOperand { + fn try_new(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + if let Some(constant) = array.as_opt::() { + let value = constant + .scalar() + .as_bool_opt() + .ok_or_else(|| vortex_err!("expected boolean scalar"))? + .value() + .ok_or_else(|| vortex_err!("null constant handled by execute_compare"))?; + return Ok(Self::Constant { + value, + validity: if constant.scalar().dtype().is_nullable() { + Validity::AllValid + } else { + Validity::NonNullable + }, + }); + } + + let array = array.clone().execute::(ctx)?; + let validity = array.validity()?; + Ok(Self::Array { + bits: array.into_bit_buffer(), + validity, + }) + } + + fn validity(&self) -> Validity { + match self { + Self::Array { validity, .. } | Self::Constant { validity, .. } => validity.clone(), + } + } +} + +/// Compare two boolean arrays. +/// +/// Values compare as `false < true`; every operator reduces to at most two word-wise passes over +/// the value bit buffers. +pub(super) fn compare_bool( + lhs: &ArrayRef, + rhs: &ArrayRef, + op: CompareOperator, + nullability: Nullability, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let len = lhs.len(); + let lhs = BoolOperand::try_new(lhs, ctx)?; + let rhs = BoolOperand::try_new(rhs, ctx)?; + let validity = compare_validity(lhs.validity(), rhs.validity(), nullability)?; + + let bits = match (lhs, rhs) { + (BoolOperand::Array { bits: l, .. }, BoolOperand::Array { bits: r, .. }) => { + compare_bits(l, r, op) + } + (BoolOperand::Array { bits, .. }, BoolOperand::Constant { value, .. }) => { + compare_bits_constant(bits, value, op) + } + (BoolOperand::Constant { value, .. }, BoolOperand::Array { bits, .. }) => { + compare_bits_constant(bits, value, op.swap()) + } + (BoolOperand::Constant { value: l, .. }, BoolOperand::Constant { value: r, .. }) => { + // Unreachable through `execute_compare` (constant-constant is folded there), but + // cheap to answer anyway. + let result = super::ordering_predicate(op)(l.cmp(&r)); + BitBuffer::full(result, len) + } + }; + + Ok(BoolArray::try_new(bits, validity)?.into_array()) +} + +fn compare_bits(lhs: BitBuffer, rhs: BitBuffer, op: CompareOperator) -> BitBuffer { + match op { + CompareOperator::Eq => !(lhs ^ &rhs), + CompareOperator::NotEq => lhs ^ &rhs, + // a < b ⟺ !a & b + CompareOperator::Lt => rhs.into_bitand_not(&lhs), + // a <= b ⟺ !(a & !b) + CompareOperator::Lte => !lhs.into_bitand_not(&rhs), + // a > b ⟺ a & !b + CompareOperator::Gt => lhs.into_bitand_not(&rhs), + // a >= b ⟺ !(!a & b) + CompareOperator::Gte => !rhs.into_bitand_not(&lhs), + } +} + +/// Compare array bits against a non-null constant: `bits value`. +fn compare_bits_constant(bits: BitBuffer, value: bool, op: CompareOperator) -> BitBuffer { + let len = bits.len(); + match (op, value) { + (CompareOperator::Eq, true) + | (CompareOperator::NotEq, false) + | (CompareOperator::Gt, false) + | (CompareOperator::Gte, true) => bits, + (CompareOperator::Eq, false) + | (CompareOperator::NotEq, true) + | (CompareOperator::Lt, true) + | (CompareOperator::Lte, false) => !bits, + (CompareOperator::Lt, false) | (CompareOperator::Gt, true) => BitBuffer::new_unset(len), + (CompareOperator::Lte, true) | (CompareOperator::Gte, false) => BitBuffer::new_set(len), + } +} diff --git a/vortex-array/src/scalar_fn/fns/binary/compare/bytes.rs b/vortex-array/src/scalar_fn/fns/binary/compare/bytes.rs new file mode 100644 index 00000000000..70c56411d08 --- /dev/null +++ b/vortex-array/src/scalar_fn/fns/binary/compare/bytes.rs @@ -0,0 +1,400 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Native comparison of UTF-8 and binary arrays over canonical [`VarBinViewArray`]s. +//! +//! Equality first compares the leading 8 bytes of each view (length plus 4-byte prefix), which +//! answers most lanes without touching the data buffers. Ordering compares the inline 4-byte +//! prefixes first and only dereferences the full value on a prefix tie. UTF-8 values compare by +//! their byte representation, which matches code-point order. + +use std::cmp::Ordering; + +use vortex_buffer::BitBuffer; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; +use vortex_error::vortex_err; + +use crate::ArrayRef; +use crate::ExecutionCtx; +use crate::IntoArray; +use crate::arrays::BoolArray; +use crate::arrays::Constant; +use crate::arrays::VarBinViewArray; +use crate::arrays::varbinview::BinaryView; +use crate::dtype::DType; +use crate::dtype::Nullability; +use crate::scalar::Scalar; +use crate::scalar_fn::fns::binary::compare::compare_validity; +use crate::scalar_fn::fns::binary::compare::ordering_predicate; +use crate::scalar_fn::fns::operators::CompareOperator; +use crate::validity::Validity; + +enum BytesOperand { + Array { + values: VarBinViewArray, + validity: Validity, + }, + Constant { + value: Vec, + validity: Validity, + }, +} + +impl BytesOperand { + fn try_new(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + if let Some(constant) = array.as_opt::() { + let value = constant_bytes(constant.scalar())?; + return Ok(Self::Constant { + value, + validity: if constant.scalar().dtype().is_nullable() { + Validity::AllValid + } else { + Validity::NonNullable + }, + }); + } + + let values = array.clone().execute::(ctx)?; + let validity = values.validity()?; + Ok(Self::Array { values, validity }) + } + + fn validity(&self) -> Validity { + match self { + Self::Array { validity, .. } | Self::Constant { validity, .. } => validity.clone(), + } + } +} + +fn constant_bytes(scalar: &Scalar) -> VortexResult> { + let value = match scalar.dtype() { + DType::Utf8(_) => scalar + .as_utf8() + .value() + .map(|s| s.as_str().as_bytes().to_vec()), + DType::Binary(_) => scalar.as_binary().value().map(|b| b.to_vec()), + _ => vortex_bail!("expected utf8 or binary scalar, got {}", scalar.dtype()), + }; + value.ok_or_else(|| vortex_err!("null constant handled by execute_compare")) +} + +/// A resolved view over a canonical [`VarBinViewArray`]: the view structs plus borrowed slices of +/// every data buffer, supporting cheap per-lane byte access. +struct ViewsSide<'a> { + views: &'a [BinaryView], + buffers: Vec<&'a [u8]>, +} + +impl<'a> ViewsSide<'a> { + fn new(array: &'a VarBinViewArray) -> Self { + Self { + views: array.views(), + buffers: (0..array.data_buffers().len()) + .map(|idx| array.buffer(idx).as_slice()) + .collect(), + } + } + + fn len(&self) -> usize { + self.views.len() + } + + /// The view at `index` without a bounds check. + /// + /// # Safety + /// + /// `index` must be strictly less than `self.len()`. + #[inline] + unsafe fn view_unchecked(&self, index: usize) -> &'a BinaryView { + // SAFETY: caller guarantees index < self.views.len(). + unsafe { self.views.get_unchecked(index) } + } + + /// The full bytes of `view`, which must belong to this side. + #[inline] + fn view_bytes(&self, view: &'a BinaryView) -> &'a [u8] { + if view.is_inlined() { + view.as_inlined().value() + } else { + let view = view.as_view(); + &self.buffers[view.buffer_index as usize][view.as_range()] + } + } +} + +/// The leading 8 bytes of a view: the `u32` length plus the first 4 bytes of the value +/// (zero-padded for values shorter than 4 bytes). +#[inline] +#[expect(clippy::cast_possible_truncation, reason = "intentional bit slicing")] +fn view_head(view: &BinaryView) -> u64 { + view.as_u128() as u64 +} + +/// The first 4 value bytes of a view as a big-endian `u32` (zero-padded for values shorter than +/// 4 bytes), so that numeric comparison matches lexicographic byte order. +/// +/// Zero padding preserves lexicographic order: a padded position differs from a real byte only +/// when one value is a strict prefix of the other within the first 4 bytes, and the shorter +/// value orders first exactly as `0` orders before any later real byte. A padded tie falls +/// through to the tail or full byte comparison. +#[inline] +#[expect(clippy::cast_possible_truncation, reason = "intentional bit slicing")] +fn view_prefix(view: &BinaryView) -> u32 { + ((view.as_u128() >> 32) as u32).swap_bytes() +} + +/// Value bytes 4..12 of an *inlined* view as a big-endian `u64` (zero-padded past the value's +/// length). Only meaningful for inlined views: reference views store the buffer index and offset +/// in these bytes. +#[inline] +fn view_tail(view: &BinaryView) -> u64 { + ((view.as_u128() >> 64) as u64).swap_bytes() +} + +/// Compare `lhs_view` from `lhs` against `rhs_view` from `rhs` for equality. +#[inline] +fn view_eq( + lhs: &ViewsSide<'_>, + lhs_view: &BinaryView, + rhs: &ViewsSide<'_>, + rhs_view: &BinaryView, +) -> bool { + if view_head(lhs_view) != view_head(rhs_view) { + return false; + } + if lhs_view.is_inlined() { + // Lengths are equal and at most 12: the whole value lives in the view. + return lhs_view.as_u128() == rhs_view.as_u128(); + } + // Equal lengths above 12 and equal prefixes: compare the out-of-line suffixes. + lhs.view_bytes(lhs_view)[4..] == rhs.view_bytes(rhs_view)[4..] +} + +/// Compare `lhs_view` from `lhs` against `rhs_view` from `rhs`. +#[inline] +fn view_cmp( + lhs: &ViewsSide<'_>, + lhs_view: &BinaryView, + rhs: &ViewsSide<'_>, + rhs_view: &BinaryView, +) -> Ordering { + let lhs_prefix = view_prefix(lhs_view); + let rhs_prefix = view_prefix(rhs_view); + if lhs_prefix != rhs_prefix { + return lhs_prefix.cmp(&rhs_prefix); + } + if lhs_view.is_inlined() && rhs_view.is_inlined() { + // Both values live entirely in their views: compare the remaining 8 (zero-padded) + // value bytes, then lengths. A tie on padded windows means the shorter value is a + // prefix of the longer one. + let lhs_tail = view_tail(lhs_view); + let rhs_tail = view_tail(rhs_view); + if lhs_tail != rhs_tail { + return lhs_tail.cmp(&rhs_tail); + } + return lhs_view.len().cmp(&rhs_view.len()); + } + lhs.view_bytes(lhs_view).cmp(rhs.view_bytes(rhs_view)) +} + +/// Compare two UTF-8 or binary arrays. +pub(super) fn compare_bytes( + lhs: &ArrayRef, + rhs: &ArrayRef, + op: CompareOperator, + nullability: Nullability, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let len = lhs.len(); + let lhs = BytesOperand::try_new(lhs, ctx)?; + let rhs = BytesOperand::try_new(rhs, ctx)?; + let validity = compare_validity(lhs.validity(), rhs.validity(), nullability)?; + + let bits = match (&lhs, &rhs) { + (BytesOperand::Array { values: l, .. }, BytesOperand::Array { values: r, .. }) => { + compare_views(&ViewsSide::new(l), &ViewsSide::new(r), op) + } + (BytesOperand::Array { values, .. }, BytesOperand::Constant { value, .. }) => { + compare_views_constant(&ViewsSide::new(values), value, op) + } + (BytesOperand::Constant { value, .. }, BytesOperand::Array { values, .. }) => { + compare_views_constant(&ViewsSide::new(values), value, op.swap()) + } + (BytesOperand::Constant { value: l, .. }, BytesOperand::Constant { value: r, .. }) => { + // Unreachable through `execute_compare` (constant-constant is folded there), but + // cheap to answer anyway. + BitBuffer::full(ordering_predicate(op)(l.as_slice().cmp(r.as_slice())), len) + } + }; + + Ok(BoolArray::try_new(bits, validity)?.into_array()) +} + +fn compare_views(lhs: &ViewsSide<'_>, rhs: &ViewsSide<'_>, op: CompareOperator) -> BitBuffer { + let len = lhs.len(); + // The unchecked view accesses below index both sides with `i < len`, so this must hold even + // in release builds. + assert_eq!(len, rhs.len(), "compared views must have equal lengths"); + // Dispatch the operator outside the lane loop so each predicate inlines into its own loop; + // a shared `fn(Ordering) -> bool` pointer would cost an indirect call per lane. + match op { + CompareOperator::Eq => BitBuffer::collect_bool(len, |i| { + // SAFETY: `collect_bool` yields i < len == views.len() for both sides. + unsafe { view_eq(lhs, lhs.view_unchecked(i), rhs, rhs.view_unchecked(i)) } + }), + CompareOperator::NotEq => BitBuffer::collect_bool(len, |i| { + // SAFETY: `collect_bool` yields i < len == views.len() for both sides. + unsafe { !view_eq(lhs, lhs.view_unchecked(i), rhs, rhs.view_unchecked(i)) } + }), + CompareOperator::Gt => collect_ordering_bits(lhs, rhs, Ordering::is_gt), + CompareOperator::Gte => collect_ordering_bits(lhs, rhs, Ordering::is_ge), + CompareOperator::Lt => collect_ordering_bits(lhs, rhs, Ordering::is_lt), + CompareOperator::Lte => collect_ordering_bits(lhs, rhs, Ordering::is_le), + } +} + +/// Bit-pack `predicate(view_cmp(lhs[i], rhs[i]))` over two equal-length view sides. +fn collect_ordering_bits( + lhs: &ViewsSide<'_>, + rhs: &ViewsSide<'_>, + predicate: impl Fn(Ordering) -> bool, +) -> BitBuffer { + let len = lhs.len(); + assert_eq!(len, rhs.len(), "compared views must have equal lengths"); + BitBuffer::collect_bool(len, |i| { + // SAFETY: `collect_bool` yields i < len == views.len() for both sides. + predicate(unsafe { view_cmp(lhs, lhs.view_unchecked(i), rhs, rhs.view_unchecked(i)) }) + }) +} + +fn compare_views_constant(lhs: &ViewsSide<'_>, constant: &[u8], op: CompareOperator) -> BitBuffer { + let len = lhs.len(); + // The same head/prefix/tail words a view stores, precomputed once for the constant. + let mut prefix_bytes = [0u8; 4]; + let prefix_len = constant.len().min(4); + prefix_bytes[..prefix_len].copy_from_slice(&constant[..prefix_len]); + let mut tail_bytes = [0u8; 8]; + if constant.len() > 4 { + let tail_len = (constant.len() - 4).min(8); + tail_bytes[..tail_len].copy_from_slice(&constant[4..4 + tail_len]); + } + let constant_head = + (constant.len() as u64) | (u64::from(u32::from_le_bytes(prefix_bytes)) << 32); + let constant_prefix = u32::from_be_bytes(prefix_bytes); + let constant_tail = u64::from_be_bytes(tail_bytes); + // The full 16-byte word an inlined view holding `constant` would carry; only meaningful when + // the constant is short enough to inline, and only reached in that case (a longer constant + // never head-matches an inlined view). + let constant_inlined = + u128::from(constant_head) | (u128::from(u64::from_le_bytes(tail_bytes)) << 64); + + match op { + CompareOperator::Eq => BitBuffer::collect_bool(len, |i| { + // SAFETY: `collect_bool` yields i < len == views.len(). + let view = unsafe { lhs.view_unchecked(i) }; + constant_eq(lhs, view, constant, constant_head, constant_inlined) + }), + CompareOperator::NotEq => BitBuffer::collect_bool(len, |i| { + // SAFETY: `collect_bool` yields i < len == views.len(). + let view = unsafe { lhs.view_unchecked(i) }; + !constant_eq(lhs, view, constant, constant_head, constant_inlined) + }), + CompareOperator::Gt => collect_constant_ordering_bits( + lhs, + constant, + constant_prefix, + constant_tail, + Ordering::is_gt, + ), + CompareOperator::Gte => collect_constant_ordering_bits( + lhs, + constant, + constant_prefix, + constant_tail, + Ordering::is_ge, + ), + CompareOperator::Lt => collect_constant_ordering_bits( + lhs, + constant, + constant_prefix, + constant_tail, + Ordering::is_lt, + ), + CompareOperator::Lte => collect_constant_ordering_bits( + lhs, + constant, + constant_prefix, + constant_tail, + Ordering::is_le, + ), + } +} + +/// Bit-pack `predicate(constant_cmp(lhs[i], constant))` over one view side. +fn collect_constant_ordering_bits( + lhs: &ViewsSide<'_>, + constant: &[u8], + constant_prefix: u32, + constant_tail: u64, + predicate: impl Fn(Ordering) -> bool, +) -> BitBuffer { + BitBuffer::collect_bool(lhs.len(), |i| { + // SAFETY: `collect_bool` yields i < len == views.len(). + let view = unsafe { lhs.view_unchecked(i) }; + predicate(constant_cmp( + lhs, + view, + constant, + constant_prefix, + constant_tail, + )) + }) +} + +/// Compare a view against a constant for equality using the constant's precomputed head and +/// inline words. +#[inline] +fn constant_eq( + side: &ViewsSide<'_>, + view: &BinaryView, + constant: &[u8], + constant_head: u64, + constant_inlined: u128, +) -> bool { + if view_head(view) != constant_head { + return false; + } + if view.is_inlined() { + // An equal head implies equal lengths, so the constant is also at most 12 bytes and + // `constant_inlined` holds its exact inlined representation. + return view.as_u128() == constant_inlined; + } + side.view_bytes(view)[4..] == constant[4..] +} + +/// Compare a view against a constant using the constant's precomputed prefix and tail words. +#[inline] +fn constant_cmp( + side: &ViewsSide<'_>, + view: &BinaryView, + constant: &[u8], + constant_prefix: u32, + constant_tail: u64, +) -> Ordering { + let prefix = view_prefix(view); + if prefix != constant_prefix { + return prefix.cmp(&constant_prefix); + } + if view.is_inlined() { + // The view's whole value lives in its first 12 (zero-padded) bytes, and the constant's + // first 12 (zero-padded) bytes are precomputed: compare tails, then lengths. A padded + // tie means one value is a prefix of the other within the first 12 bytes. + let tail = view_tail(view); + if tail != constant_tail { + return tail.cmp(&constant_tail); + } + return (view.len() as usize).cmp(&constant.len()); + } + side.view_bytes(view).cmp(constant) +} diff --git a/vortex-array/src/scalar_fn/fns/binary/compare/decimal.rs b/vortex-array/src/scalar_fn/fns/binary/compare/decimal.rs new file mode 100644 index 00000000000..0825d0674af --- /dev/null +++ b/vortex-array/src/scalar_fn/fns/binary/compare/decimal.rs @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Native comparison of decimal arrays. +//! +//! Both operands share a logical [`DecimalDType`] (equal precision and scale), so comparing the +//! unscaled integer values is sufficient. The physical storage width may differ per operand; when +//! it does, both sides are widened once to the larger storage type before the lane loop. +//! +//! [`DecimalDType`]: crate::dtype::DecimalDType + +use vortex_buffer::BitBuffer; +use vortex_buffer::Buffer; +use vortex_error::VortexExpect; +use vortex_error::VortexResult; +use vortex_error::vortex_err; + +use crate::ArrayRef; +use crate::ExecutionCtx; +use crate::IntoArray; +use crate::arrays::BoolArray; +use crate::arrays::Constant; +use crate::arrays::DecimalArray; +use crate::dtype::NativeDecimalType; +use crate::dtype::Nullability; +use crate::dtype::i256; +use crate::match_each_decimal_value_type; +use crate::scalar::DecimalValue; +use crate::scalar_fn::fns::binary::compare::collect_bits; +use crate::scalar_fn::fns::binary::compare::collect_zip_bits; +use crate::scalar_fn::fns::binary::compare::compare_validity; +use crate::scalar_fn::fns::operators::CompareOperator; +use crate::validity::Validity; + +enum DecimalOperand { + Array { + values: DecimalArray, + validity: Validity, + }, + Constant { + value: DecimalValue, + validity: Validity, + }, +} + +impl DecimalOperand { + fn try_new(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + if let Some(constant) = array.as_opt::() { + let value = constant + .scalar() + .as_decimal() + .decimal_value() + .ok_or_else(|| vortex_err!("null constant handled by execute_compare"))?; + return Ok(Self::Constant { + value, + validity: if constant.scalar().dtype().is_nullable() { + Validity::AllValid + } else { + Validity::NonNullable + }, + }); + } + + let values = array.clone().execute::(ctx)?; + let validity = values.validity()?; + Ok(Self::Array { values, validity }) + } + + fn validity(&self) -> Validity { + match self { + Self::Array { validity, .. } | Self::Constant { validity, .. } => validity.clone(), + } + } +} + +/// Compare two decimal arrays with the same logical decimal dtype. +pub(super) fn compare_decimal( + lhs: &ArrayRef, + rhs: &ArrayRef, + op: CompareOperator, + nullability: Nullability, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let len = lhs.len(); + let lhs = DecimalOperand::try_new(lhs, ctx)?; + let rhs = DecimalOperand::try_new(rhs, ctx)?; + let validity = compare_validity(lhs.validity(), rhs.validity(), nullability)?; + + let bits = match (lhs, rhs) { + (DecimalOperand::Array { values: l, .. }, DecimalOperand::Array { values: r, .. }) => { + compare_decimal_values(&l, &r, op) + } + (DecimalOperand::Array { values, .. }, DecimalOperand::Constant { value, .. }) => { + compare_decimal_constant(&values, value, op) + } + (DecimalOperand::Constant { value, .. }, DecimalOperand::Array { values, .. }) => { + compare_decimal_constant(&values, value, op.swap()) + } + (DecimalOperand::Constant { value: l, .. }, DecimalOperand::Constant { value: r, .. }) => { + // Unreachable through `execute_compare` (constant-constant is folded there), but + // cheap to answer anyway. + let ordering = l.as_i256().cmp(&r.as_i256()); + BitBuffer::full(super::ordering_predicate(op)(ordering), len) + } + }; + + Ok(BoolArray::try_new(bits, validity)?.into_array()) +} + +fn compare_decimal_values( + lhs: &DecimalArray, + rhs: &DecimalArray, + op: CompareOperator, +) -> BitBuffer { + let common = lhs.values_type().max(rhs.values_type()); + match_each_decimal_value_type!(common, |W| { + let lhs = widened_buffer::(lhs); + let rhs = widened_buffer::(rhs); + compare_slices::(&lhs, &rhs, op) + }) +} + +/// Return the array's unscaled values widened to `W`, which must be at least as wide as the +/// array's storage type. +pub(super) fn widened_buffer(array: &DecimalArray) -> Buffer { + if array.values_type() == W::DECIMAL_TYPE { + return array.buffer::(); + } + match_each_decimal_value_type!(array.values_type(), |T| { + array + .buffer::() + .iter() + .map(|v| W::from(*v).vortex_expect("widening decimal cast must succeed")) + .collect() + }) +} + +fn compare_decimal_constant( + array: &DecimalArray, + constant: DecimalValue, + op: CompareOperator, +) -> BitBuffer { + match_each_decimal_value_type!(array.values_type(), |T| { + match constant.cast::() { + Some(value) => compare_slice_constant::(&array.buffer::(), value, op), + None => { + // The constant does not fit the array's storage type, so it is either greater + // than every possible array value or less than every possible array value; the + // sign tells us which. + let constant_greater = constant.as_i256() > i256::ZERO; + let result = match op { + CompareOperator::Eq => false, + CompareOperator::NotEq => true, + // array constant + CompareOperator::Lt | CompareOperator::Lte => constant_greater, + CompareOperator::Gt | CompareOperator::Gte => !constant_greater, + }; + BitBuffer::full(result, array.len()) + } + } + }) +} + +fn compare_slices(lhs: &[T], rhs: &[T], op: CompareOperator) -> BitBuffer { + match op { + CompareOperator::Eq => collect_zip_bits(lhs, rhs, |a: T, b: T| a == b), + CompareOperator::NotEq => collect_zip_bits(lhs, rhs, |a: T, b: T| a != b), + CompareOperator::Gt => collect_zip_bits(lhs, rhs, |a: T, b: T| a > b), + CompareOperator::Gte => collect_zip_bits(lhs, rhs, |a: T, b: T| a >= b), + CompareOperator::Lt => collect_zip_bits(lhs, rhs, |a: T, b: T| a < b), + CompareOperator::Lte => collect_zip_bits(lhs, rhs, |a: T, b: T| a <= b), + } +} + +fn compare_slice_constant( + values: &[T], + constant: T, + op: CompareOperator, +) -> BitBuffer { + match op { + CompareOperator::Eq => collect_bits(values, |a: T| a == constant), + CompareOperator::NotEq => collect_bits(values, |a: T| a != constant), + CompareOperator::Gt => collect_bits(values, |a: T| a > constant), + CompareOperator::Gte => collect_bits(values, |a: T| a >= constant), + CompareOperator::Lt => collect_bits(values, |a: T| a < constant), + CompareOperator::Lte => collect_bits(values, |a: T| a <= constant), + } +} diff --git a/vortex-array/src/scalar_fn/fns/binary/compare/mod.rs b/vortex-array/src/scalar_fn/fns/binary/compare/mod.rs new file mode 100644 index 00000000000..c7d6c79c7ae --- /dev/null +++ b/vortex-array/src/scalar_fn/fns/binary/compare/mod.rs @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Native comparison kernels. +//! +//! [`execute_compare`] dispatches on the logical [`DType`] of its operands and evaluates every +//! comparison directly over Vortex canonical arrays — bit buffers for booleans, lane kernels from +//! `vortex-compute` for primitives and decimals, binary views for strings/bytes, and a row-wise +//! comparator for nested types. There is no Arrow fallback. +//! +//! Floating point values compare with Vortex's total ordering (`NaN` is the largest value, +//! `-0.0 < +0.0`, and equality is bitwise), matching [`Scalar`] comparison semantics. + +use std::cmp::Ordering; + +use vortex_buffer::BitBuffer; +use vortex_buffer::BufferMut; +use vortex_compute::lane_kernels::IndexedSourceExt; +use vortex_compute::lane_kernels::LaneZip; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; +use vortex_error::vortex_err; + +use crate::ArrayRef; +use crate::Canonical; +use crate::ExecutionCtx; +use crate::IntoArray; +use crate::array::ArrayView; +use crate::array::VTable; +use crate::arrays::Constant; +use crate::arrays::ConstantArray; +use crate::arrays::ExtensionArray; +use crate::arrays::ScalarFn; +use crate::arrays::extension::ExtensionArrayExt; +use crate::arrays::scalar_fn::ExactScalarFn; +use crate::arrays::scalar_fn::ScalarFnArrayExt; +use crate::arrays::scalar_fn::ScalarFnArrayView; +use crate::dtype::DType; +use crate::dtype::Nullability; +use crate::kernel::ExecuteParentKernel; +use crate::scalar::Scalar; +use crate::scalar_fn::fns::binary::Binary; +use crate::scalar_fn::fns::operators::CompareOperator; +use crate::validity::Validity; + +mod boolean; +mod bytes; +mod decimal; +mod nested; +mod primitive; +#[cfg(test)] +mod tests; + +/// Trait for encoding-specific comparison kernels that operate in encoded space. +/// +/// Implementations can compare an encoded array against another array (typically a constant) +/// without first decompressing. The adaptor normalizes operand order so `array` is always +/// the left-hand side, swapping the operator when necessary. +pub trait CompareKernel: VTable { + fn compare( + lhs: ArrayView<'_, Self>, + rhs: &ArrayRef, + operator: CompareOperator, + ctx: &mut ExecutionCtx, + ) -> VortexResult>; +} + +/// Adaptor that bridges [`CompareKernel`] implementations to [`ExecuteParentKernel`]. +/// +/// When a `ScalarFnArray(Binary, cmp_op)` wraps a child that implements `CompareKernel`, +/// this adaptor extracts the comparison operator and other operand, normalizes operand order +/// (swapping the operator if the encoded array is on the RHS), and delegates to the kernel. +#[derive(Default, Debug)] +pub struct CompareExecuteAdaptor(pub V); + +impl ExecuteParentKernel for CompareExecuteAdaptor +where + V: CompareKernel, +{ + type Parent = ExactScalarFn; + + fn execute_parent( + &self, + array: ArrayView<'_, V>, + parent: ScalarFnArrayView<'_, Binary>, + child_idx: usize, + ctx: &mut ExecutionCtx, + ) -> VortexResult> { + // Only handle comparison operators + let Ok(cmp_op) = CompareOperator::try_from(*parent.options) else { + return Ok(None); + }; + + // Get the ScalarFnArray to access children + let Some(scalar_fn_array) = parent.as_opt::() else { + return Ok(None); + }; + // Normalize so `array` is always LHS, swapping the operator if needed + // TODO(joe): should be go this here or in the Rule/Kernel + let (cmp_op, other) = match child_idx { + 0 => (cmp_op, scalar_fn_array.get_child(1)), + 1 => (cmp_op.swap(), scalar_fn_array.get_child(0)), + _ => return Ok(None), + }; + + let len = array.len(); + let nullable = array.dtype().is_nullable() || other.dtype().is_nullable(); + + // Empty array → empty bool result + if len == 0 { + return Ok(Some( + Canonical::empty(&DType::Bool(nullable.into())).into_array(), + )); + } + + // Null constant on either side → all-null bool result + if other.as_constant().is_some_and(|s| s.is_null()) { + return Ok(Some( + ConstantArray::new(Scalar::null(DType::Bool(nullable.into())), len).into_array(), + )); + } + + V::compare(array, other, cmp_op, ctx) + } +} + +/// Execute a compare operation between two arrays. +/// +/// This is the entry point for compare operations from the binary expression. +/// Handles empty, constant-null, and constant-constant directly, otherwise dispatches to a +/// native per-dtype kernel. +pub(crate) fn execute_compare( + lhs: &ArrayRef, + rhs: &ArrayRef, + op: CompareOperator, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let nullable = lhs.dtype().is_nullable() || rhs.dtype().is_nullable(); + + if lhs.len() != rhs.len() { + vortex_bail!( + "compare operator requires equal lengths, got {} and {}", + lhs.len(), + rhs.len() + ); + } + + if lhs.is_empty() { + return Ok(Canonical::empty(&DType::Bool(nullable.into())).into_array()); + } + + let left_constant_null = lhs.as_constant().map(|l| l.is_null()).unwrap_or(false); + let right_constant_null = rhs.as_constant().map(|r| r.is_null()).unwrap_or(false); + if left_constant_null || right_constant_null { + return Ok( + ConstantArray::new(Scalar::null(DType::Bool(nullable.into())), lhs.len()).into_array(), + ); + } + + // Constant-constant fast path + if let (Some(lhs_const), Some(rhs_const)) = (lhs.as_opt::(), rhs.as_opt::()) + { + let result = scalar_cmp(lhs_const.scalar(), rhs_const.scalar(), op)?; + return Ok(ConstantArray::new(result, lhs.len()).into_array()); + } + + compare_arrays(lhs, rhs, op, ctx) +} + +/// Dispatch a comparison to the native kernel for the operands' logical dtype. +fn compare_arrays( + lhs: &ArrayRef, + rhs: &ArrayRef, + op: CompareOperator, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let nullability = Nullability::from(lhs.dtype().is_nullable() || rhs.dtype().is_nullable()); + + // Extension arrays compare through their storage. When both sides are extensions they must + // agree on the full extension dtype (a timestamp in milliseconds must not compare its raw + // storage against one in seconds); a lone extension side may compare against its raw storage. + if lhs.dtype().is_extension() || rhs.dtype().is_extension() { + if lhs.dtype().is_extension() + && rhs.dtype().is_extension() + && !lhs.dtype().eq_ignore_nullability(rhs.dtype()) + { + vortex_bail!( + "Cannot compare extension dtypes {} and {}", + lhs.dtype(), + rhs.dtype() + ); + } + let lhs = extension_storage(lhs, ctx)?; + let rhs = extension_storage(rhs, ctx)?; + return compare_arrays(&lhs, &rhs, op, ctx); + } + + if !lhs.dtype().eq_ignore_nullability(rhs.dtype()) { + vortex_bail!( + "Cannot compare different DTypes {} and {}", + lhs.dtype(), + rhs.dtype() + ); + } + + match lhs.dtype() { + // Every value of the null dtype is null, so every comparison result is null. + DType::Null => Ok(ConstantArray::new( + Scalar::null(DType::Bool(Nullability::Nullable)), + lhs.len(), + ) + .into_array()), + DType::Bool(_) => boolean::compare_bool(lhs, rhs, op, nullability, ctx), + DType::Primitive(..) => primitive::compare_primitive(lhs, rhs, op, nullability, ctx), + DType::Decimal(..) => decimal::compare_decimal(lhs, rhs, op, nullability, ctx), + DType::Utf8(_) | DType::Binary(_) => bytes::compare_bytes(lhs, rhs, op, nullability, ctx), + DType::Struct(..) | DType::List(..) | DType::FixedSizeList(..) => { + nested::compare_nested(lhs, rhs, op, nullability, ctx) + } + DType::Union(_) | DType::Variant(_) | DType::Extension(_) => { + vortex_bail!("compare is not supported for dtype {}", lhs.dtype()) + } + } +} + +/// Replace an extension array (or extension constant) with its storage. Non-extension arrays are +/// returned unchanged. +fn extension_storage(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + if !array.dtype().is_extension() { + return Ok(array.clone()); + } + if let Some(constant) = array.as_opt::() { + return Ok(ConstantArray::new( + constant.scalar().as_extension().to_storage_scalar(), + constant.len(), + ) + .into_array()); + } + let ext = array.clone().execute::(ctx)?; + Ok(ext.storage_array().clone()) +} + +pub fn scalar_cmp(lhs: &Scalar, rhs: &Scalar, operator: CompareOperator) -> VortexResult { + if lhs.is_null() | rhs.is_null() { + return Ok(Scalar::null(DType::Bool(Nullability::Nullable))); + } + + let nullability = lhs.dtype().nullability() | rhs.dtype().nullability(); + + // We use `partial_cmp` to ensure we do not lose a type mismatch error. + let ordering = lhs.partial_cmp(rhs).ok_or_else(|| { + vortex_err!( + "Cannot compare scalars with incompatible types: {} and {}", + lhs.dtype(), + rhs.dtype() + ) + })?; + + let b = match operator { + CompareOperator::Eq => ordering.is_eq(), + CompareOperator::NotEq => ordering.is_ne(), + CompareOperator::Gt => ordering.is_gt(), + CompareOperator::Gte => ordering.is_ge(), + CompareOperator::Lt => ordering.is_lt(), + CompareOperator::Lte => ordering.is_le(), + }; + + Ok(Scalar::bool(b, nullability)) +} + +/// Returns the predicate `Ordering -> bool` for a comparison operator. +#[inline] +pub(super) fn ordering_predicate(op: CompareOperator) -> fn(Ordering) -> bool { + match op { + CompareOperator::Eq => Ordering::is_eq, + CompareOperator::NotEq => Ordering::is_ne, + CompareOperator::Gt => Ordering::is_gt, + CompareOperator::Gte => Ordering::is_ge, + CompareOperator::Lt => Ordering::is_lt, + CompareOperator::Lte => Ordering::is_le, + } +} + +/// Freeze `len` bits packed into `words` (LSB-first, 64 lanes per word) into a [`BitBuffer`]. +pub(super) fn bit_buffer_from_words(words: BufferMut, len: usize) -> BitBuffer { + debug_assert!(words.len() * 64 >= len); + let mut bytes = words.into_byte_buffer(); + bytes.truncate(len.div_ceil(8)); + BitBuffer::new(bytes.freeze(), len) +} + +/// Bit-pack the predicate `f(lhs[i], rhs[i])` over two equal-length slices into a [`BitBuffer`]. +pub(super) fn collect_zip_bits( + lhs: &[T], + rhs: &[T], + mut f: impl FnMut(T, T) -> bool, +) -> BitBuffer { + let len = lhs.len(); + let mut words = BufferMut::::zeroed(len.div_ceil(64)); + LaneZip::new(lhs, rhs).map_bits_into(words.as_mut_slice(), |(a, b)| f(a, b)); + bit_buffer_from_words(words, len) +} + +/// Bit-pack the predicate `f(values[i])` over a slice into a [`BitBuffer`]. +pub(super) fn collect_bits(values: &[T], f: impl FnMut(T) -> bool) -> BitBuffer { + let len = values.len(); + let mut words = BufferMut::::zeroed(len.div_ceil(64)); + values.map_bits_into(words.as_mut_slice(), f); + bit_buffer_from_words(words, len) +} + +/// Combine operand validities into the validity of a comparison result with the given result +/// nullability. +pub(super) fn compare_validity( + lhs: Validity, + rhs: Validity, + nullability: Nullability, +) -> VortexResult { + Ok(lhs.and(rhs)?.union_nullability(nullability)) +} diff --git a/vortex-array/src/scalar_fn/fns/binary/compare/nested.rs b/vortex-array/src/scalar_fn/fns/binary/compare/nested.rs new file mode 100644 index 00000000000..cbc38d6b23e --- /dev/null +++ b/vortex-array/src/scalar_fn/fns/binary/compare/nested.rs @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Row-wise comparison of nested (struct, list, fixed-size list) arrays. +//! +//! Nested comparisons canonicalize both operands recursively and build a tree of row +//! comparators, one per nested level. The ordering semantics match [`Scalar`] comparison: +//! structs compare field-by-field in declaration order, lists compare element-by-element and +//! then by length, and a null value (at any nesting depth) orders before every non-null value. +//! Only top-level nulls make the comparison result null. +//! +//! [`Scalar`]: crate::scalar::Scalar + +use std::cmp::Ordering; + +use num_traits::AsPrimitive; +use vortex_buffer::BitBuffer; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; +use vortex_mask::Mask; + +use crate::ArrayRef; +use crate::ExecutionCtx; +use crate::IntoArray; +use crate::RecursiveCanonical; +use crate::arrays::BoolArray; +use crate::arrays::DecimalArray; +use crate::arrays::ExtensionArray; +use crate::arrays::FixedSizeListArray; +use crate::arrays::ListViewArray; +use crate::arrays::PrimitiveArray; +use crate::arrays::StructArray; +use crate::arrays::VarBinViewArray; +use crate::arrays::extension::ExtensionArrayExt; +use crate::arrays::fixed_size_list::FixedSizeListArrayExt; +use crate::arrays::listview::ListViewArrayExt; +use crate::arrays::struct_::StructArrayExt; +use crate::dtype::DType; +use crate::dtype::NativePType; +use crate::dtype::Nullability; +use crate::match_each_integer_ptype; +use crate::match_each_native_ptype; +use crate::scalar_fn::fns::binary::compare::compare_validity; +use crate::scalar_fn::fns::operators::CompareOperator; + +/// A row comparator: compares row `i` of the left operand against row `j` of the right operand. +type RowComparator = Box Ordering>; + +/// Compare two nested arrays row by row. +pub(super) fn compare_nested( + lhs: &ArrayRef, + rhs: &ArrayRef, + op: CompareOperator, + nullability: Nullability, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let len = lhs.len(); + let lhs = lhs + .clone() + .execute::(ctx)? + .0 + .into_array(); + let rhs = rhs + .clone() + .execute::(ctx)? + .0 + .into_array(); + + let validity = compare_validity(lhs.validity()?, rhs.validity()?, nullability)?; + let comparator = build_comparator(&lhs, &rhs, ctx)?; + // Dispatch the operator outside the row loop so the predicate inlines into each loop; the + // comparator call itself stays virtual. + let bits = match op { + CompareOperator::Eq => collect_ordering_bits(len, &comparator, Ordering::is_eq), + CompareOperator::NotEq => collect_ordering_bits(len, &comparator, Ordering::is_ne), + CompareOperator::Gt => collect_ordering_bits(len, &comparator, Ordering::is_gt), + CompareOperator::Gte => collect_ordering_bits(len, &comparator, Ordering::is_ge), + CompareOperator::Lt => collect_ordering_bits(len, &comparator, Ordering::is_lt), + CompareOperator::Lte => collect_ordering_bits(len, &comparator, Ordering::is_le), + }; + + Ok(BoolArray::try_new(bits, validity)?.into_array()) +} + +/// Bit-pack `predicate(comparator(i, i))` over `len` rows. +fn collect_ordering_bits( + len: usize, + comparator: &RowComparator, + predicate: impl Fn(Ordering) -> bool, +) -> BitBuffer { + BitBuffer::collect_bool(len, |i| predicate(comparator(i, i))) +} + +/// The validity mask of a recursively canonical array. +fn validity_mask(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + array.validity()?.execute_mask(array.len(), ctx) +} + +/// Build a row comparator over two recursively canonical arrays of the same logical dtype +/// (ignoring nullability). Null values order before all non-null values at every level. +fn build_comparator( + lhs: &ArrayRef, + rhs: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let lhs_mask = validity_mask(lhs, ctx)?; + let rhs_mask = validity_mask(rhs, ctx)?; + let values = build_values_comparator(lhs, rhs, ctx)?; + + if lhs_mask.all_true() && rhs_mask.all_true() { + return Ok(values); + } + + Ok(Box::new(move |i, j| { + match (lhs_mask.value(i), rhs_mask.value(j)) { + (true, true) => values(i, j), + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + (false, false) => Ordering::Equal, + } + })) +} + +/// Build a comparator over the non-null values of two recursively canonical arrays. +fn build_values_comparator( + lhs: &ArrayRef, + rhs: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult { + if !lhs.dtype().eq_ignore_nullability(rhs.dtype()) { + vortex_bail!( + "Cannot compare different DTypes {} and {}", + lhs.dtype(), + rhs.dtype() + ); + } + + Ok(match lhs.dtype() { + // Null values compare through the validity wrapper; the value comparator is trivial. + DType::Null => Box::new(|_, _| Ordering::Equal), + DType::Bool(_) => { + let lhs = lhs.clone().execute::(ctx)?.into_bit_buffer(); + let rhs = rhs.clone().execute::(ctx)?.into_bit_buffer(); + Box::new(move |i, j| lhs.value(i).cmp(&rhs.value(j))) + } + DType::Primitive(ptype, _) => { + match_each_native_ptype!(*ptype, |T| { primitive_comparator::(lhs, rhs, ctx)? }) + } + DType::Decimal(..) => { + let lhs = lhs.clone().execute::(ctx)?; + let rhs = rhs.clone().execute::(ctx)?; + let common = lhs.values_type().max(rhs.values_type()); + crate::match_each_decimal_value_type!(common, |W| { + let lhs = super::decimal::widened_buffer::(&lhs); + let rhs = super::decimal::widened_buffer::(&rhs); + Box::new(move |i: usize, j: usize| lhs[i].cmp(&rhs[j])) as RowComparator + }) + } + DType::Utf8(_) | DType::Binary(_) => { + let lhs = lhs.clone().execute::(ctx)?; + let rhs = rhs.clone().execute::(ctx)?; + Box::new(move |i, j| view_bytes(&lhs, i).cmp(view_bytes(&rhs, j))) + } + DType::Struct(..) => { + let lhs = lhs.clone().execute::(ctx)?; + let rhs = rhs.clone().execute::(ctx)?; + let fields = lhs + .iter_unmasked_fields() + .zip(rhs.iter_unmasked_fields()) + .map(|(lhs_field, rhs_field)| build_comparator(lhs_field, rhs_field, ctx)) + .collect::>>()?; + Box::new(move |i, j| { + fields + .iter() + .map(|cmp| cmp(i, j)) + .find(|ordering| ordering.is_ne()) + .unwrap_or(Ordering::Equal) + }) + } + DType::List(..) => { + let lhs = lhs.clone().execute::(ctx)?; + let rhs = rhs.clone().execute::(ctx)?; + let elements = build_comparator(lhs.elements(), rhs.elements(), ctx)?; + // Materialize offsets and sizes once instead of paying `offset_at`/`size_at`'s + // per-call encoding dispatch inside the row loop. + let lhs_offsets = usize_values(lhs.offsets(), ctx)?; + let lhs_sizes = usize_values(lhs.sizes(), ctx)?; + let rhs_offsets = usize_values(rhs.offsets(), ctx)?; + let rhs_sizes = usize_values(rhs.sizes(), ctx)?; + Box::new(move |i, j| { + let lhs_len = lhs_sizes[i]; + let rhs_len = rhs_sizes[j]; + (0..lhs_len.min(rhs_len)) + .map(|el| elements(lhs_offsets[i] + el, rhs_offsets[j] + el)) + .find(|ordering| ordering.is_ne()) + .unwrap_or_else(|| lhs_len.cmp(&rhs_len)) + }) + } + DType::FixedSizeList(_, size, _) => { + let size = *size as usize; + let lhs = lhs.clone().execute::(ctx)?; + let rhs = rhs.clone().execute::(ctx)?; + let elements = build_comparator(lhs.elements(), rhs.elements(), ctx)?; + Box::new(move |i, j| { + (0..size) + .map(|el| elements(i * size + el, j * size + el)) + .find(|ordering| ordering.is_ne()) + .unwrap_or(Ordering::Equal) + }) + } + DType::Extension(_) => { + let lhs = lhs.clone().execute::(ctx)?; + let rhs = rhs.clone().execute::(ctx)?; + build_comparator(lhs.storage_array(), rhs.storage_array(), ctx)? + } + DType::Union(_) | DType::Variant(_) => { + vortex_bail!("compare is not supported for dtype {}", lhs.dtype()) + } + }) +} + +fn primitive_comparator( + lhs: &ArrayRef, + rhs: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let lhs = lhs + .clone() + .execute::(ctx)? + .into_buffer::(); + let rhs = rhs + .clone() + .execute::(ctx)? + .into_buffer::(); + Ok(Box::new(move |i, j| lhs[i].total_compare(rhs[j]))) +} + +/// Materialize an integer array as `usize` values for `O(1)` row access. +fn usize_values(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult> { + let primitive = array.clone().execute::(ctx)?; + match_each_integer_ptype!(primitive.ptype(), |P| { + Ok(primitive.as_slice::

().iter().map(|v| v.as_()).collect()) + }) +} + +/// The bytes of row `index`, resolved directly from the view without cloning buffer handles the +/// way `VarBinViewArray::bytes_at` does. +fn view_bytes(array: &VarBinViewArray, index: usize) -> &[u8] { + let view = &array.views()[index]; + if view.is_inlined() { + view.as_inlined().value() + } else { + let view = view.as_view(); + &array.buffer(view.buffer_index as usize).as_slice()[view.as_range()] + } +} diff --git a/vortex-array/src/scalar_fn/fns/binary/compare/primitive.rs b/vortex-array/src/scalar_fn/fns/binary/compare/primitive.rs new file mode 100644 index 00000000000..3bfb11a266e --- /dev/null +++ b/vortex-array/src/scalar_fn/fns/binary/compare/primitive.rs @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Native comparison of primitive arrays via bit-packing lane kernels. + +use vortex_buffer::BitBuffer; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; + +use crate::ArrayRef; +use crate::ExecutionCtx; +use crate::IntoArray; +use crate::arrays::BoolArray; +use crate::arrays::ConstantArray; +use crate::dtype::DType; +use crate::dtype::NativePType; +use crate::dtype::Nullability; +use crate::dtype::PType; +use crate::match_each_native_ptype; +use crate::scalar::Scalar; +use crate::scalar_fn::fns::binary::PrimitiveOperand; +use crate::scalar_fn::fns::binary::compare::collect_bits; +use crate::scalar_fn::fns::binary::compare::collect_zip_bits; +use crate::scalar_fn::fns::binary::compare::compare_validity; +use crate::scalar_fn::fns::operators::CompareOperator; + +/// Compare two primitive arrays of the same [`PType`]. +/// +/// Floats compare with Vortex's total ordering: `NaN` is the largest value, `-0.0 < +0.0`, and +/// equality is bitwise. +pub(super) fn compare_primitive( + lhs: &ArrayRef, + rhs: &ArrayRef, + op: CompareOperator, + nullability: Nullability, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let ptype = PType::try_from(lhs.dtype())?; + match_each_native_ptype!(ptype, |T| { + compare_primitive_typed::(lhs, rhs, op, nullability, ctx) + }) +} + +fn compare_primitive_typed( + lhs: &ArrayRef, + rhs: &ArrayRef, + op: CompareOperator, + nullability: Nullability, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let len = lhs.len(); + let lhs = PrimitiveOperand::::try_new(lhs, ctx)?; + let rhs = PrimitiveOperand::::try_new(rhs, ctx)?; + if lhs.len() != rhs.len() { + vortex_bail!( + "compare operator requires equal lengths, got {} and {}", + lhs.len(), + rhs.len() + ); + } + + let validity = compare_validity(lhs.validity(), rhs.validity(), nullability)?; + + let bits = match (&lhs, &rhs) { + ( + PrimitiveOperand::Array { values: lhs, .. }, + PrimitiveOperand::Array { values: rhs, .. }, + ) => compare_slices(lhs, rhs, op), + ( + PrimitiveOperand::Array { values: lhs, .. }, + PrimitiveOperand::Constant { value: rhs, .. }, + ) => compare_slice_constant(lhs, *rhs, op), + ( + PrimitiveOperand::Constant { value: lhs, .. }, + PrimitiveOperand::Array { values: rhs, .. }, + ) => compare_slice_constant(rhs, *lhs, op.swap()), + ( + PrimitiveOperand::Constant { value: lhs, .. }, + PrimitiveOperand::Constant { value: rhs, .. }, + ) => { + // Unreachable through `execute_compare` (constant-constant is folded there), but + // cheap to answer anyway. + BitBuffer::full(apply_op(*lhs, *rhs, op), len) + } + (PrimitiveOperand::Null(_), _) | (_, PrimitiveOperand::Null(_)) => { + return Ok( + ConstantArray::new(Scalar::null(DType::Bool(Nullability::Nullable)), len) + .into_array(), + ); + } + }; + + Ok(BoolArray::try_new(bits, validity)?.into_array()) +} + +#[inline(always)] +fn apply_op(lhs: T, rhs: T, op: CompareOperator) -> bool { + match op { + CompareOperator::Eq => lhs.is_eq(rhs), + CompareOperator::NotEq => !lhs.is_eq(rhs), + CompareOperator::Gt => lhs.is_gt(rhs), + CompareOperator::Gte => lhs.is_ge(rhs), + CompareOperator::Lt => lhs.is_lt(rhs), + CompareOperator::Lte => lhs.is_le(rhs), + } +} + +fn compare_slices(lhs: &[T], rhs: &[T], op: CompareOperator) -> BitBuffer { + // Dispatch the operator outside the lane loop so each instantiation vectorizes a single + // branch-free predicate. + match op { + CompareOperator::Eq => collect_zip_bits(lhs, rhs, |a: T, b: T| a.is_eq(b)), + CompareOperator::NotEq => collect_zip_bits(lhs, rhs, |a: T, b: T| !a.is_eq(b)), + CompareOperator::Gt => collect_zip_bits(lhs, rhs, T::is_gt), + CompareOperator::Gte => collect_zip_bits(lhs, rhs, T::is_ge), + CompareOperator::Lt => collect_zip_bits(lhs, rhs, T::is_lt), + CompareOperator::Lte => collect_zip_bits(lhs, rhs, T::is_le), + } +} + +fn compare_slice_constant(lhs: &[T], rhs: T, op: CompareOperator) -> BitBuffer { + match op { + CompareOperator::Eq => collect_bits(lhs, |a: T| a.is_eq(rhs)), + CompareOperator::NotEq => collect_bits(lhs, |a: T| !a.is_eq(rhs)), + CompareOperator::Gt => collect_bits(lhs, |a: T| a.is_gt(rhs)), + CompareOperator::Gte => collect_bits(lhs, |a: T| a.is_ge(rhs)), + CompareOperator::Lt => collect_bits(lhs, |a: T| a.is_lt(rhs)), + CompareOperator::Lte => collect_bits(lhs, |a: T| a.is_le(rhs)), + } +} diff --git a/vortex-array/src/scalar_fn/fns/binary/compare/tests.rs b/vortex-array/src/scalar_fn/fns/binary/compare/tests.rs new file mode 100644 index 00000000000..c2af83561a4 --- /dev/null +++ b/vortex-array/src/scalar_fn/fns/binary/compare/tests.rs @@ -0,0 +1,673 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use std::sync::Arc; + +use rstest::rstest; +use vortex_buffer::BitBuffer; +use vortex_buffer::buffer; +use vortex_error::VortexExpect; + +use crate::ArrayRef; +use crate::IntoArray; +use crate::VortexSessionExecute; +use crate::array_session; +use crate::arrays::BoolArray; +use crate::arrays::ConstantArray; +use crate::arrays::DecimalArray; +use crate::arrays::ExtensionArray; +use crate::arrays::FixedSizeListArray; +use crate::arrays::ListArray; +use crate::arrays::ListViewArray; +use crate::arrays::PrimitiveArray; +use crate::arrays::StructArray; +use crate::arrays::VarBinArray; +use crate::arrays::VarBinViewArray; +use crate::assert_arrays_eq; +use crate::builtins::ArrayBuiltins; +use crate::dtype::DType; +use crate::dtype::DecimalDType; +use crate::dtype::FieldName; +use crate::dtype::FieldNames; +use crate::dtype::Nullability; +use crate::dtype::PType; +use crate::extension::datetime::TimeUnit; +use crate::extension::datetime::Timestamp; +use crate::extension::datetime::TimestampOptions; +use crate::scalar::DecimalValue; +use crate::scalar::Scalar; +use crate::scalar_fn::fns::binary::scalar_cmp; +use crate::scalar_fn::fns::operators::CompareOperator; +use crate::scalar_fn::fns::operators::Operator; +use crate::test_harness::to_int_indices; +use crate::validity::Validity; + +#[test] +fn test_bool_basic_comparisons() { + let ctx = &mut array_session().create_execution_ctx(); + let arr = BoolArray::new( + BitBuffer::from_iter([true, true, false, true, false]), + Validity::from_iter([false, true, true, true, true]), + ); + + let matches = arr + .clone() + .into_array() + .binary(arr.clone().into_array(), Operator::Eq) + .unwrap() + .execute::(ctx) + .vortex_expect("must be a bool array"); + assert_eq!(to_int_indices(matches, ctx).unwrap(), [1u64, 2, 3, 4]); + + let matches = arr + .clone() + .into_array() + .binary(arr.clone().into_array(), Operator::NotEq) + .unwrap() + .execute::(ctx) + .vortex_expect("must be a bool array"); + let empty: [u64; 0] = []; + assert_eq!(to_int_indices(matches, ctx).unwrap(), empty); + + let other = BoolArray::new( + BitBuffer::from_iter([false, false, false, true, true]), + Validity::from_iter([false, true, true, true, true]), + ); + + let matches = arr + .clone() + .into_array() + .binary(other.clone().into_array(), Operator::Lte) + .unwrap() + .execute::(ctx) + .vortex_expect("must be a bool array"); + assert_eq!(to_int_indices(matches, ctx).unwrap(), [2u64, 3, 4]); + + let matches = arr + .clone() + .into_array() + .binary(other.clone().into_array(), Operator::Lt) + .unwrap() + .execute::(ctx) + .vortex_expect("must be a bool array"); + assert_eq!(to_int_indices(matches, ctx).unwrap(), [4u64]); + + let matches = other + .clone() + .into_array() + .binary(arr.clone().into_array(), Operator::Gte) + .unwrap() + .execute::(ctx) + .vortex_expect("must be a bool array"); + assert_eq!(to_int_indices(matches, ctx).unwrap(), [2u64, 3, 4]); + + let matches = other + .into_array() + .binary(arr.into_array(), Operator::Gt) + .unwrap() + .execute::(ctx) + .vortex_expect("must be a bool array"); + assert_eq!(to_int_indices(matches, ctx).unwrap(), [4u64]); +} + +#[test] +fn constant_compare() { + let left = ConstantArray::new(Scalar::from(2u32), 10); + let right = ConstantArray::new(Scalar::from(10u32), 10); + + let result = left + .into_array() + .binary(right.into_array(), Operator::Gt) + .unwrap(); + assert_eq!(result.len(), 10); + let scalar = result + .execute_scalar(0, &mut array_session().create_execution_ctx()) + .unwrap(); + assert_eq!(scalar.as_bool().value(), Some(false)); +} + +#[rstest] +#[case(VarBinArray::from(vec!["a", "b"]).into_array(), VarBinViewArray::from_iter_str(["a", "b"]).into_array())] +#[case(VarBinViewArray::from_iter_str(["a", "b"]).into_array(), VarBinArray::from(vec!["a", "b"]).into_array())] +#[case(VarBinArray::from(vec!["a".as_bytes(), "b".as_bytes()]).into_array(), VarBinViewArray::from_iter_bin(["a".as_bytes(), "b".as_bytes()]).into_array())] +#[case(VarBinViewArray::from_iter_bin(["a".as_bytes(), "b".as_bytes()]).into_array(), VarBinArray::from(vec!["a".as_bytes(), "b".as_bytes()]).into_array())] +fn compare_different_encodings(#[case] left: ArrayRef, #[case] right: ArrayRef) { + let mut ctx = array_session().create_execution_ctx(); + let res = left.binary(right, Operator::Eq).unwrap(); + let expected = BoolArray::from_iter([true, true]); + assert_arrays_eq!(res, expected, &mut ctx); +} + +#[test] +fn test_list_array_comparison() { + let mut ctx = array_session().create_execution_ctx(); + let values1 = PrimitiveArray::from_iter([1i32, 2, 3, 4, 5, 6]); + let offsets1 = PrimitiveArray::from_iter([0i32, 2, 4, 6]); + let list1 = ListArray::try_new( + values1.into_array(), + offsets1.into_array(), + Validity::NonNullable, + ) + .unwrap(); + + let values2 = PrimitiveArray::from_iter([1i32, 2, 3, 4, 7, 8]); + let offsets2 = PrimitiveArray::from_iter([0i32, 2, 4, 6]); + let list2 = ListArray::try_new( + values2.into_array(), + offsets2.into_array(), + Validity::NonNullable, + ) + .unwrap(); + + let result = list1 + .clone() + .into_array() + .binary(list2.clone().into_array(), Operator::Eq) + .unwrap(); + let expected = BoolArray::from_iter([true, true, false]); + assert_arrays_eq!(result, expected, &mut ctx); + + let result = list1 + .clone() + .into_array() + .binary(list2.clone().into_array(), Operator::NotEq) + .unwrap(); + let expected = BoolArray::from_iter([false, false, true]); + assert_arrays_eq!(result, expected, &mut ctx); + + let result = list1 + .into_array() + .binary(list2.into_array(), Operator::Lt) + .unwrap(); + let expected = BoolArray::from_iter([false, false, true]); + assert_arrays_eq!(result, expected, &mut ctx); +} + +#[test] +fn test_list_array_constant_comparison() { + let mut ctx = array_session().create_execution_ctx(); + let values = PrimitiveArray::from_iter([1i32, 2, 3, 4, 5, 6]); + let offsets = PrimitiveArray::from_iter([0i32, 2, 4, 6]); + let list = ListArray::try_new( + values.into_array(), + offsets.into_array(), + Validity::NonNullable, + ) + .unwrap(); + + let list_scalar = Scalar::list( + Arc::new(DType::Primitive(PType::I32, Nullability::NonNullable)), + vec![3i32.into(), 4i32.into()], + Nullability::NonNullable, + ); + let constant = ConstantArray::new(list_scalar, 3); + + let result = list + .into_array() + .binary(constant.into_array(), Operator::Eq) + .unwrap(); + let expected = BoolArray::from_iter([false, true, false]); + assert_arrays_eq!(result, expected, &mut ctx); +} + +#[test] +fn test_struct_array_comparison() { + let mut ctx = array_session().create_execution_ctx(); + let bool_field1 = BoolArray::from_iter([Some(true), Some(false), Some(true)]); + let int_field1 = PrimitiveArray::from_iter([1i32, 2, 3]); + + let bool_field2 = BoolArray::from_iter([Some(true), Some(false), Some(false)]); + let int_field2 = PrimitiveArray::from_iter([1i32, 2, 4]); + + let struct1 = StructArray::from_fields(&[ + ("bool_col", bool_field1.into_array()), + ("int_col", int_field1.into_array()), + ]) + .unwrap(); + + let struct2 = StructArray::from_fields(&[ + ("bool_col", bool_field2.into_array()), + ("int_col", int_field2.into_array()), + ]) + .unwrap(); + + let result = struct1 + .clone() + .into_array() + .binary(struct2.clone().into_array(), Operator::Eq) + .unwrap(); + let expected = BoolArray::from_iter([true, true, false]); + assert_arrays_eq!(result, expected, &mut ctx); + + let result = struct1 + .into_array() + .binary(struct2.into_array(), Operator::Gt) + .unwrap(); + let expected = BoolArray::from_iter([false, false, true]); + assert_arrays_eq!(result, expected, &mut ctx); +} + +#[test] +fn test_empty_struct_compare() { + let mut ctx = array_session().create_execution_ctx(); + let empty1 = StructArray::try_new( + FieldNames::from(Vec::::new()), + Vec::new(), + 5, + Validity::NonNullable, + ) + .unwrap(); + + let empty2 = StructArray::try_new( + FieldNames::from(Vec::::new()), + Vec::new(), + 5, + Validity::NonNullable, + ) + .unwrap(); + + let result = empty1 + .into_array() + .binary(empty2.into_array(), Operator::Eq) + .unwrap(); + let expected = BoolArray::from_iter([true, true, true, true, true]); + assert_arrays_eq!(result, expected, &mut ctx); +} + +/// Regression test: comparing struct arrays where the same logical field is backed by +/// different Vortex encodings (VarBinArray vs VarBinViewArray) must not panic. +#[test] +fn struct_compare_mixed_binary_encodings() { + let mut ctx = array_session().create_execution_ctx(); + // LHS: struct with a VarBinArray (offset-based) binary field + let bin_field1 = VarBinArray::from(vec![ + "apple".as_bytes(), + "banana".as_bytes(), + "cherry".as_bytes(), + ]); + let struct1 = StructArray::from_fields(&[("data", bin_field1.into_array())]).unwrap(); + + // RHS: struct with a VarBinViewArray (view-based) binary field — same logical DType + let bin_field2 = VarBinViewArray::from_iter_bin([ + "apple".as_bytes(), + "banana".as_bytes(), + "durian".as_bytes(), + ]); + let struct2 = StructArray::from_fields(&[("data", bin_field2.into_array())]).unwrap(); + + let result = struct1 + .into_array() + .binary(struct2.into_array(), Operator::Eq) + .unwrap(); + let expected = BoolArray::from_iter([true, true, false]); + assert_arrays_eq!(result, expected, &mut ctx); +} + +/// Regression test: `scalar_cmp` must error when comparing scalars with incompatible +/// extension types (e.g., timestamps with different time units) rather than silently +/// returning a wrong result. +#[test] +fn scalar_cmp_incompatible_extension_types_errors() { + let ms_scalar = Scalar::extension::( + TimestampOptions { + unit: TimeUnit::Milliseconds, + tz: None, + }, + Scalar::from(1704067200000i64), + ); + let s_scalar = Scalar::extension::( + TimestampOptions { + unit: TimeUnit::Seconds, + tz: None, + }, + Scalar::from(1704067200i64), + ); + + // Ordering comparisons must error on incompatible types. + assert!(scalar_cmp(&ms_scalar, &s_scalar, CompareOperator::Gt).is_err()); + assert!(scalar_cmp(&ms_scalar, &s_scalar, CompareOperator::Lt).is_err()); + assert!(scalar_cmp(&ms_scalar, &s_scalar, CompareOperator::Gte).is_err()); + assert!(scalar_cmp(&ms_scalar, &s_scalar, CompareOperator::Lte).is_err()); + assert!(scalar_cmp(&ms_scalar, &s_scalar, CompareOperator::Eq).is_err()); + assert!(scalar_cmp(&ms_scalar, &s_scalar, CompareOperator::NotEq).is_err()); +} + +#[test] +fn test_empty_list() { + let ctx = &mut array_session().create_execution_ctx(); + let list = ListViewArray::new( + BoolArray::from_iter(Vec::::new()).into_array(), + buffer![0i32, 0i32, 0i32].into_array(), + buffer![0i32, 0i32, 0i32].into_array(), + Validity::AllValid, + ); + + let result = list + .clone() + .into_array() + .binary(list.into_array(), Operator::Eq) + .unwrap(); + assert!(result.execute_scalar(0, ctx).unwrap().is_valid()); + assert!(result.execute_scalar(1, ctx).unwrap().is_valid()); + assert!(result.execute_scalar(2, ctx).unwrap().is_valid()); +} + +fn execute_compare_test(lhs: ArrayRef, rhs: ArrayRef, op: Operator) -> ArrayRef { + lhs.binary(rhs, op).unwrap() +} + +#[rstest] +#[case(Operator::Eq, [false, true, false, false])] +#[case(Operator::NotEq, [true, false, true, true])] +#[case(Operator::Lt, [true, false, false, true])] +#[case(Operator::Lte, [true, true, false, true])] +#[case(Operator::Gt, [false, false, true, false])] +#[case(Operator::Gte, [false, true, true, false])] +fn int_all_operators(#[case] op: Operator, #[case] expected: [bool; 4]) { + let mut ctx = array_session().create_execution_ctx(); + let lhs = buffer![1i32, 5, 9, 2].into_array(); + let rhs = buffer![3i32, 5, 7, 4].into_array(); + let result = execute_compare_test(lhs, rhs, op); + assert_arrays_eq!(result, BoolArray::from_iter(expected), &mut ctx); +} + +#[rstest] +#[case(Operator::Lt, [Some(true), None, Some(false), None])] +#[case(Operator::Eq, [Some(false), None, Some(false), None])] +fn int_nullable(#[case] op: Operator, #[case] expected: [Option; 4]) { + let mut ctx = array_session().create_execution_ctx(); + let lhs = PrimitiveArray::from_option_iter([Some(1i64), None, Some(9), Some(2)]).into_array(); + let rhs = PrimitiveArray::from_option_iter([Some(3i64), Some(5), Some(7), None]).into_array(); + let result = execute_compare_test(lhs, rhs, op); + assert_arrays_eq!(result, BoolArray::from_iter(expected), &mut ctx); +} + +#[rstest] +#[case(Operator::Gt, [false, false, true, false])] +#[case(Operator::Lte, [true, true, false, true])] +fn int_constant_lhs_and_rhs(#[case] op: Operator, #[case] expected: [bool; 4]) { + let mut ctx = array_session().create_execution_ctx(); + let array = buffer![1i32, 5, 9, 2].into_array(); + let constant = ConstantArray::new(5i32, 4).into_array(); + + let result = execute_compare_test(array.clone(), constant.clone(), op); + assert_arrays_eq!(result, BoolArray::from_iter(expected), &mut ctx); + + // The swapped form must produce the swapped result. + let swapped = execute_compare_test(constant, array, op); + let expected_swapped: Vec = match op { + Operator::Gt => vec![true, false, false, true], + Operator::Lte => vec![false, true, true, false], + _ => unreachable!(), + }; + assert_arrays_eq!(swapped, BoolArray::from_iter(expected_swapped), &mut ctx); +} + +/// Floats compare with Vortex's total ordering: NaN is the largest value, equality is bitwise, +/// and -0.0 < +0.0. This matches `Scalar` comparison semantics. +#[test] +fn float_total_order() { + let mut ctx = array_session().create_execution_ctx(); + let lhs = buffer![f64::NAN, f64::NAN, -0.0f64, 1.0].into_array(); + let rhs = buffer![f64::NAN, f64::INFINITY, 0.0f64, f64::NAN].into_array(); + + let result = execute_compare_test(lhs.clone(), rhs.clone(), Operator::Eq); + assert_arrays_eq!( + result, + BoolArray::from_iter([true, false, false, false]), + &mut ctx + ); + + let result = execute_compare_test(lhs, rhs, Operator::Lt); + assert_arrays_eq!( + result, + BoolArray::from_iter([false, false, true, true]), + &mut ctx + ); +} + +#[rstest] +#[case(Operator::Eq, [true, false, true, true])] +#[case(Operator::Lt, [false, true, false, false])] +#[case(Operator::Gte, [true, false, true, true])] +fn bool_constant(#[case] op: Operator, #[case] expected: [bool; 4]) { + let mut ctx = array_session().create_execution_ctx(); + let array = BoolArray::from_iter([true, false, true, true]).into_array(); + let constant = ConstantArray::new(true, 4).into_array(); + let result = execute_compare_test(array, constant, op); + assert_arrays_eq!(result, BoolArray::from_iter(expected), &mut ctx); +} + +#[rstest] +// Inlined vs inlined, decided by prefix. +#[case("bad", "bat", Operator::Lt, true)] +// Inlined prefix tie decided by the tail bytes. +#[case("abcdefgh", "abcdefgi", Operator::Lt, true)] +// Inlined prefix and tail tie decided by length. +#[case("abc", "abcd", Operator::Lt, true)] +// Out-of-line values with equal prefixes. +#[case("aaaaaaaaaaaaaaaaaaaab", "aaaaaaaaaaaaaaaaaaaac", Operator::Lt, true)] +// Inlined vs out-of-line where one is a prefix of the other. +#[case("aaaa", "aaaaaaaaaaaaaaaaaaaa", Operator::Lt, true)] +// Equality across the inlined/out-of-line boundary. +#[case("aaaaaaaaaaaaaaaaaaaa", "aaaaaaaaaaaaaaaaaaaa", Operator::Eq, true)] +#[case("aaaaaaaaaaaaaaaaaaaa", "aaaaaaaaaaaaaaaaaaab", Operator::Eq, false)] +// Embedded NUL: "a\0" > "a" even though the padded prefixes tie. +#[case("a\0", "a", Operator::Gt, true)] +fn string_compare_cases( + #[case] lhs: &str, + #[case] rhs: &str, + #[case] op: Operator, + #[case] expected: bool, +) { + let mut ctx = array_session().create_execution_ctx(); + let lhs = VarBinViewArray::from_iter_str([lhs]).into_array(); + let rhs = VarBinViewArray::from_iter_str([rhs]).into_array(); + let result = execute_compare_test(lhs, rhs, op); + assert_arrays_eq!(result, BoolArray::from_iter([expected]), &mut ctx); +} + +#[test] +fn string_constant_compare() { + let mut ctx = array_session().create_execution_ctx(); + let array = VarBinViewArray::from_iter_str([ + "apple", + "banana", + "banan", + "bananarama-bananarama", + "cherry", + ]) + .into_array(); + let constant = ConstantArray::new(Scalar::from("banana"), 5).into_array(); + + let result = execute_compare_test(array.clone(), constant.clone(), Operator::Eq); + assert_arrays_eq!( + result, + BoolArray::from_iter([false, true, false, false, false]), + &mut ctx + ); + + let result = execute_compare_test(array.clone(), constant.clone(), Operator::Lt); + assert_arrays_eq!( + result, + BoolArray::from_iter([true, false, true, false, false]), + &mut ctx + ); + + let result = execute_compare_test(constant, array, Operator::Lt); + assert_arrays_eq!( + result, + BoolArray::from_iter([false, false, false, true, true]), + &mut ctx + ); +} + +#[test] +fn string_constant_longer_than_inline() { + let mut ctx = array_session().create_execution_ctx(); + let array = VarBinViewArray::from_iter_str(["short", "averyveryverylongstring", "averyvery"]) + .into_array(); + let constant = ConstantArray::new(Scalar::from("averyveryverylongstring"), 3).into_array(); + + let result = execute_compare_test(array.clone(), constant.clone(), Operator::Eq); + assert_arrays_eq!(result, BoolArray::from_iter([false, true, false]), &mut ctx); + + let result = execute_compare_test(array, constant, Operator::Lte); + assert_arrays_eq!(result, BoolArray::from_iter([false, true, true]), &mut ctx); +} + +#[test] +fn decimal_compare() { + let mut ctx = array_session().create_execution_ctx(); + let dtype = DecimalDType::new(10, 2); + let lhs = DecimalArray::from_iter::([100, 250, 300], dtype).into_array(); + let rhs = DecimalArray::from_iter::([200, 250, 100], dtype).into_array(); + + let result = execute_compare_test(lhs, rhs, Operator::Lt); + assert_arrays_eq!(result, BoolArray::from_iter([true, false, false]), &mut ctx); +} + +/// Two decimal arrays with the same logical dtype but different storage widths compare through +/// the widened common storage type. +#[test] +fn decimal_compare_mixed_storage_widths() { + let mut ctx = array_session().create_execution_ctx(); + let dtype = DecimalDType::new(10, 2); + let lhs = DecimalArray::from_iter::([100, 250, 300], dtype).into_array(); + let rhs = DecimalArray::from_iter::([200, 250, 100], dtype).into_array(); + + let result = execute_compare_test(lhs, rhs, Operator::Lte); + assert_arrays_eq!(result, BoolArray::from_iter([true, true, false]), &mut ctx); +} + +/// A decimal constant that does not fit the array's narrow storage type still compares +/// correctly: it is greater than every representable array value. +#[test] +fn decimal_constant_out_of_storage_range() { + let mut ctx = array_session().create_execution_ctx(); + let dtype = DecimalDType::new(20, 2); + let array = DecimalArray::from_iter::([1, 50, 100], dtype).into_array(); + let constant = ConstantArray::new( + Scalar::decimal( + DecimalValue::from(10_000_000i64), + dtype, + Nullability::NonNullable, + ), + 3, + ) + .into_array(); + + let result = execute_compare_test(array.clone(), constant.clone(), Operator::Lt); + assert_arrays_eq!(result, BoolArray::from_iter([true, true, true]), &mut ctx); + + let result = execute_compare_test(array, constant, Operator::Gte); + assert_arrays_eq!( + result, + BoolArray::from_iter([false, false, false]), + &mut ctx + ); +} + +/// Extension arrays compare through their storage values. +#[test] +fn extension_timestamp_compare() { + let mut ctx = array_session().create_execution_ctx(); + let ext_dtype = Timestamp::new(TimeUnit::Milliseconds, Nullability::NonNullable).erased(); + let lhs = ExtensionArray::new(ext_dtype.clone(), buffer![1000i64, 2000, 3000].into_array()) + .into_array(); + let rhs = + ExtensionArray::new(ext_dtype, buffer![1500i64, 2000, 2500].into_array()).into_array(); + + let result = execute_compare_test(lhs.clone(), rhs.clone(), Operator::Lt); + assert_arrays_eq!(result, BoolArray::from_iter([true, false, false]), &mut ctx); + + let result = execute_compare_test(lhs, rhs, Operator::Eq); + assert_arrays_eq!(result, BoolArray::from_iter([false, true, false]), &mut ctx); +} + +/// Comparing extension arrays with different extension dtypes (e.g. timestamps in different +/// units) must error rather than silently comparing raw storage values. +#[test] +fn extension_mismatched_units_errors() { + let mut ctx = array_session().create_execution_ctx(); + let ms = ExtensionArray::new( + Timestamp::new(TimeUnit::Milliseconds, Nullability::NonNullable).erased(), + buffer![1000i64, 2000].into_array(), + ) + .into_array(); + let secs = ExtensionArray::new( + Timestamp::new(TimeUnit::Seconds, Nullability::NonNullable).erased(), + buffer![1i64, 3].into_array(), + ) + .into_array(); + + let result = ms + .binary(secs, Operator::Eq) + .and_then(|a| a.execute::(&mut ctx)); + assert!(result.is_err()); +} + +/// Struct fields containing nulls order null-first, matching `Scalar` comparison semantics; +/// only top-level nulls make the result null. +#[test] +fn struct_compare_null_fields_order_first() { + let mut ctx = array_session().create_execution_ctx(); + let lhs = StructArray::from_fields(&[( + "a", + PrimitiveArray::from_option_iter([None, Some(5i32), None]).into_array(), + )]) + .unwrap() + .into_array(); + let rhs = StructArray::from_fields(&[( + "a", + PrimitiveArray::from_option_iter([Some(1i32), Some(5), None]).into_array(), + )]) + .unwrap() + .into_array(); + + // null field < non-null field; null == null. + let result = execute_compare_test(lhs.clone(), rhs.clone(), Operator::Lt); + assert_arrays_eq!(result, BoolArray::from_iter([true, false, false]), &mut ctx); + + let result = execute_compare_test(lhs, rhs, Operator::Eq); + assert_arrays_eq!(result, BoolArray::from_iter([false, true, true]), &mut ctx); +} + +#[test] +fn fixed_size_list_compare() { + let mut ctx = array_session().create_execution_ctx(); + let lhs = FixedSizeListArray::new( + buffer![1i32, 2, 3, 4, 5, 6].into_array(), + 2, + Validity::NonNullable, + 3, + ) + .into_array(); + let rhs = FixedSizeListArray::new( + buffer![1i32, 2, 3, 5, 4, 6].into_array(), + 2, + Validity::NonNullable, + 3, + ) + .into_array(); + + let result = execute_compare_test(lhs.clone(), rhs.clone(), Operator::Eq); + assert_arrays_eq!(result, BoolArray::from_iter([true, false, false]), &mut ctx); + + let result = execute_compare_test(lhs, rhs, Operator::Lt); + assert_arrays_eq!(result, BoolArray::from_iter([false, true, false]), &mut ctx); +} + +/// Binary (non-utf8) comparison over VarBinView arrays. +#[test] +fn binary_compare() { + let mut ctx = array_session().create_execution_ctx(); + let lhs = VarBinViewArray::from_iter_bin([b"bad".as_slice(), b"\xff\x00", b""]).into_array(); + let rhs = VarBinViewArray::from_iter_bin([b"bat".as_slice(), b"\xff", b""]).into_array(); + + let result = execute_compare_test(lhs.clone(), rhs.clone(), Operator::Lt); + assert_arrays_eq!(result, BoolArray::from_iter([true, false, false]), &mut ctx); + + let result = execute_compare_test(lhs, rhs, Operator::Eq); + assert_arrays_eq!(result, BoolArray::from_iter([false, false, true]), &mut ctx); +} diff --git a/vortex-array/src/scalar_fn/fns/binary/numeric.rs b/vortex-array/src/scalar_fn/fns/binary/numeric.rs index e95627155e9..ac76d2e4934 100644 --- a/vortex-array/src/scalar_fn/fns/binary/numeric.rs +++ b/vortex-array/src/scalar_fn/fns/binary/numeric.rs @@ -212,7 +212,9 @@ where } } -enum PrimitiveOperand { +/// A primitive binary-operator operand: a materialized buffer, a non-null constant, or an +/// all-null constant. +pub(super) enum PrimitiveOperand { Array { values: Buffer, validity: Validity, @@ -226,7 +228,7 @@ enum PrimitiveOperand { } impl PrimitiveOperand { - fn try_new(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + pub(super) fn try_new(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { if let Some(constant) = array.as_opt::() { return Ok( match constant.scalar().as_primitive().try_typed_value::()? { @@ -250,14 +252,14 @@ impl PrimitiveOperand { Ok(Self::Array { values, validity }) } - fn len(&self) -> usize { + pub(super) fn len(&self) -> usize { match self { Self::Array { values, .. } => values.len(), Self::Constant { len, .. } | Self::Null(len) => *len, } } - fn validity(&self) -> Validity { + pub(super) fn validity(&self) -> Validity { match self { Self::Array { validity, .. } => validity.clone(), Self::Constant { validity, .. } => validity.clone(), diff --git a/vortex-compute/src/lane_kernels/map_into.rs b/vortex-compute/src/lane_kernels/map_into.rs index d20b261c274..258913e9fc7 100644 --- a/vortex-compute/src/lane_kernels/map_into.rs +++ b/vortex-compute/src/lane_kernels/map_into.rs @@ -156,6 +156,67 @@ pub trait IndexedSourceExt: IndexedSource + Sized { } } + /// Apply the predicate `f(value)` lane-by-lane and bit-pack the results into + /// `words`, LSB-first, 64 lanes per `u64`. + /// + /// This is the kernel shape behind comparison operators: each lane read is an + /// independent indexed load (drive two columns via [`LaneZip`]) and the 64 + /// per-lane booleans of a chunk reduce into a single word with `OR + shift`, + /// which the autovectorizer lowers to a vector compare plus movemask. + /// + /// Words are written with `=` (not `|=`), so `words` need not be + /// zero-initialised. Bits at positions `>= self.len()` in the last word are + /// written as zero. + /// + /// Like [`map_into`], this kernel has no validity awareness; pair the packed + /// bits with a separately computed validity mask. + /// + /// [`LaneZip`]: crate::lane_kernels::LaneZip + /// [`map_into`]: IndexedSourceExt::map_into + /// + /// # Panics + /// + /// Panics if `words.len() < self.len().div_ceil(64)`. + #[inline] + fn map_bits_into(self, words: &mut [u64], mut f: F) + where + F: FnMut(Self::Item) -> bool, + { + #[inline(always)] + fn chunk(values: &S, f: &mut F, base: usize, count: usize) -> u64 + where + S: IndexedSource, + F: FnMut(S::Item) -> bool, + { + let mut packed: u64 = 0; + for bit_idx in 0..count { + // SAFETY: caller guarantees base + count <= len. + let val = unsafe { values.get_unchecked(base + bit_idx) }; + packed |= (f(val) as u64) << bit_idx; + } + packed + } + + let values = self; + let len = values.len(); + let num_words = len.div_ceil(64); + assert!( + words.len() >= num_words, + "words slice has {} entries, need at least {num_words}", + words.len(), + ); + + let full = len / 64; + let remainder = len % 64; + + for word_idx in 0..full { + words[word_idx] = chunk(&values, &mut f, word_idx * 64, 64); + } + if remainder != 0 { + words[full] = chunk(&values, &mut f, full * 64, remainder); + } + } + /// Fallible map with **no validity awareness at all** — every `None` returned /// by the closure is treated as a failure, even at null lanes. /// @@ -485,6 +546,43 @@ mod tests { assert!(res.is_ok(), "null lane should bypass the range check"); } + #[test] + fn map_bits_into_packs_full_and_remainder_words() { + let values: Vec = (0..130).collect(); + let mut words = vec![u64::MAX; 3]; + values.as_slice().map_bits_into(&mut words, |v| v % 2 == 0); + + for idx in 0..130 { + let bit = (words[idx / 64] >> (idx % 64)) & 1; + assert_eq!(bit == 1, idx % 2 == 0, "lane {idx}"); + } + // Bits past `len` in the remainder word must be written as zero. + assert_eq!(words[2] >> 2, 0); + } + + #[test] + fn map_bits_into_lane_zip_compare() { + use crate::lane_kernels::LaneZip; + + let lhs: Vec = (0..100).collect(); + let rhs: Vec = (0..100).rev().collect(); + let mut words = vec![0u64; 2]; + LaneZip::new(lhs.as_slice(), rhs.as_slice()).map_bits_into(&mut words, |(a, b)| a >= b); + + for idx in 0..100 { + let bit = (words[idx / 64] >> (idx % 64)) & 1; + assert_eq!(bit == 1, lhs[idx] >= rhs[idx], "lane {idx}"); + } + } + + #[test] + #[should_panic(expected = "words slice has 1 entries")] + fn map_bits_into_words_too_short_panics() { + let values: Vec = (0..65).collect(); + let mut words = vec![0u64; 1]; + values.as_slice().map_bits_into(&mut words, |v| v > 0); + } + #[test] fn try_map_masked_into_overflow_in_remainder() { let mut values: Vec = (0..130).collect(); From a6ce8327f339e2f9a4cac8dfa963c103c61c8b70 Mon Sep 17 00:00:00 2001 From: myrrc Date: Thu, 9 Jul 2026 15:32:13 +0100 Subject: [PATCH 042/104] Ungrouped aggregate function pushdown for duckdb (#8645) This PR adds support for pushing down ungropped aggregates min,max,sum,avg,mean,first,any_value,count(col), and count(*). It consists of roughly 3 parts: 1. Optimizer pass which works same as scalar function pushdown, but without visitors, because we can't express child extract with a visitor. 2. General accumulator handling in table_function.rs. This includes computing partials in every thread (so that we read data in parallel) and merging them, and also synchonization so that only one thread would write the global aggregated row count 3. Handling of count_star() (count(*)) a.k.a row count. Row count is a special accumulator because it doesn't need the array, and there's no column it aggregates. I've tried the implementation with CountStart being a separate accumulator, and it is much more complex than current one, because you can't provide a real column to accumulate on in select(), and you need to create a dummy one. So, we pay the cost of a separate atomic and a count_star positions vector, but our expression projection handling stays simple. We also reject count(*) if it's the only aggregate because duckdb calculating it natively (just using chunk lengths) is faster than our accumulator pipeline On the API input note, nearly all aggregate functions except for count_star() in duckdb have one non-const argument so input is a pair of a column and an expression. Depends on #8649 Signed-off-by: Mikhail Kot --- vortex-duckdb/build.rs | 3 +- vortex-duckdb/cpp/aggregate_fn_pushdown.cpp | 140 +++++++++ vortex-duckdb/cpp/expr.cpp | 14 +- .../cpp/include/aggregate_fn_pushdown.hpp | 24 ++ vortex-duckdb/cpp/include/expr.h | 5 + vortex-duckdb/cpp/include/table_function.h | 4 + vortex-duckdb/cpp/include/table_function.hpp | 8 + vortex-duckdb/cpp/table_function.cpp | 43 ++- vortex-duckdb/cpp/vortex_duckdb.cpp | 2 + vortex-duckdb/include/vortex.h | 11 +- vortex-duckdb/src/convert/expr.rs | 87 +++++- vortex-duckdb/src/convert/mod.rs | 2 + .../src/duckdb/aggregate_pushdown.rs | 43 +++ vortex-duckdb/src/duckdb/expr.rs | 17 +- .../{scalar_function.rs => function.rs} | 13 + vortex-duckdb/src/duckdb/mod.rs | 6 +- vortex-duckdb/src/ffi.rs | 21 +- vortex-duckdb/src/projection.rs | 24 ++ vortex-duckdb/src/table_function.rs | 278 ++++++++++++++++-- vortex-sqllogictest/Cargo.toml | 2 +- .../slt/duckdb/aggregate_pushdown.slt | 66 +++++ 21 files changed, 775 insertions(+), 38 deletions(-) create mode 100644 vortex-duckdb/cpp/aggregate_fn_pushdown.cpp create mode 100644 vortex-duckdb/cpp/include/aggregate_fn_pushdown.hpp create mode 100644 vortex-duckdb/src/duckdb/aggregate_pushdown.rs rename vortex-duckdb/src/duckdb/{scalar_function.rs => function.rs} (58%) create mode 100644 vortex-sqllogictest/slt/duckdb/aggregate_pushdown.slt diff --git a/vortex-duckdb/build.rs b/vortex-duckdb/build.rs index 186dba9757b..c6bc02f95de 100644 --- a/vortex-duckdb/build.rs +++ b/vortex-duckdb/build.rs @@ -27,13 +27,14 @@ const DEFAULT_DUCKDB_VERSION: &str = "1.5.3"; const BUILD_ARTIFACTS: [&str; 3] = ["libduckdb.dylib", "libduckdb.so", "libduckdb_static.a"]; -const SOURCE_FILES: [&str; 9] = [ +const SOURCE_FILES: [&str; 10] = [ "cpp/vortex_duckdb.cpp", "cpp/copy_function.cpp", "cpp/expr.cpp", "cpp/optimizer.cpp", "cpp/scalar_fn_pushdown.cpp", "cpp/cast_pushdown.cpp", + "cpp/aggregate_fn_pushdown.cpp", "cpp/table_filter.cpp", "cpp/table_function.cpp", "cpp/vector.cpp", diff --git a/vortex-duckdb/cpp/aggregate_fn_pushdown.cpp b/vortex-duckdb/cpp/aggregate_fn_pushdown.cpp new file mode 100644 index 00000000000..2caa3463d53 --- /dev/null +++ b/vortex-duckdb/cpp/aggregate_fn_pushdown.cpp @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#include "aggregate_fn_pushdown.hpp" +#include "duckdb/planner/expression/bound_aggregate_expression.hpp" +#include "duckdb/planner/expression/bound_columnref_expression.hpp" +#include "duckdb/planner/operator/logical_aggregate.hpp" +#include "optimizer.hpp" +#include "table_function.hpp" + +using enum LogicalOperatorType; + +LogicalOperatorPtr TryPushdownAggregateFunctions(ClientContext &context, LogicalOperatorPtr plan) { + Analyses analyses; + Projections projections; + FindGetsAndProjections(*plan, analyses, projections); + if (analyses.empty()) { + return plan; + } + return RewriteAggregates(context, std::move(plan), analyses, projections); +} + +LogicalOperatorPtr RewriteAggregates(ClientContext &context, + LogicalOperatorPtr op, + Analyses &analyses, + const Projections &projections) { + for (auto &child : op->children) { + child = RewriteAggregates(context, std::move(child), analyses, projections); + } + if (op->type == LOGICAL_AGGREGATE_AND_GROUP_BY) { + return TryReplaceAggregate(context, std::move(op), analyses, projections); + } + return op; +} + +static bool IsUngrouped(const LogicalAggregate &agg) { + return agg.groups.empty() && agg.grouping_sets.empty() && agg.grouping_functions.empty() && + !agg.expressions.empty(); +} + +constexpr inline idx_t COUNT_STAR_PROJ_IDX = std::numeric_limits::max(); + +LogicalOperatorPtr TryReplaceAggregate(ClientContext &context, + LogicalOperatorPtr op, + Analyses &analyses, + const Projections &projections) { + LogicalAggregate &agg = op->Cast(); + if (!IsUngrouped(agg)) { + return op; + } + + LogicalGet *const get = GetChildGet(agg); + if (get == nullptr) { + return op; + } + + vector> input; + const idx_t aggregates_len = agg.expressions.size(); + input.reserve(aggregates_len); + + for (const auto &expr : agg.expressions) { + if (expr->GetExpressionClass() != ExpressionClass::BOUND_AGGREGATE) { + return op; + } + const auto &bound_aggr = expr->Cast(); + if (bound_aggr.IsDistinct() || bound_aggr.filter != nullptr || bound_aggr.order_bys != nullptr) { + return op; + } + + if (bound_aggr.function.name == "count_star") { + input.emplace_back(COUNT_STAR_PROJ_IDX, *expr); + continue; + } + + if (bound_aggr.children.size() != 1 || + bound_aggr.children[0]->GetExpressionType() != ExpressionType::BOUND_COLUMN_REF) { + return op; + } + const auto &bound_col = bound_aggr.children[0]->Cast(); + const auto binding = Resolve(bound_col.binding, analyses, projections); + if (!binding || &binding->analysis.get != get) { + return op; + } + input.emplace_back(binding->column_index, *expr); + } + + if (!aggregate_pushdown(context, {*get, input})) { + return op; + } + + // GET now returns one column per aggregate. Expand existing columns + auto &column_ids = get->GetMutableColumnIds(); + get->types.resize(aggregates_len); + get->returned_types.resize(aggregates_len); + column_ids.resize(aggregates_len); + + vector names(aggregates_len); // need a copy because we reference original names + + for (idx_t i = 0; i < aggregates_len; i++) { + const auto &[column_index, expr] = input[i]; + if (column_index == COUNT_STAR_PROJ_IDX) { + names[i] = "count_star()"; + } else { + const TableColumnStorageIndex storage_index = get->GetColumnIds()[column_index].GetPrimaryIndex(); + names[i] = get->names[storage_index]; + } + get->types[i] = expr.return_type; + get->returned_types[i] = expr.return_type; + column_ids[i] = ColumnIndex {i}; + } + get->names = std::move(names); + get->projection_ids.clear(); + get->table_index = agg.aggregate_index; + + unique_ptr &child = agg.children[0]; + if (child->type == LOGICAL_GET) { + return std::move(child); + } + D_ASSERT(child->type == LOGICAL_PROJECTION); + D_ASSERT(child->children.size() == 1); + D_ASSERT(child->children[0]->type == LOGICAL_GET); + return std::move(child->children[0]); +} + +LogicalGet *GetChildGet(const LogicalAggregate &agg) { + if (agg.children.size() != 1) { + return nullptr; + } + LogicalOperator &child = *agg.children[0]; + LogicalOperator *op; + if (child.type == LOGICAL_GET) { + op = &child; + } else if (child.type == LOGICAL_PROJECTION && child.children.size() == 1 && + child.children[0]->type == LOGICAL_GET) { + op = child.children[0].get(); + } else { + return nullptr; + } + LogicalGet &get = op->Cast(); + return get.function.bind == duckdb_vx_table_function_bind ? &get : nullptr; +} diff --git a/vortex-duckdb/cpp/expr.cpp b/vortex-duckdb/cpp/expr.cpp index 8a871e9d7ee..819133f7873 100644 --- a/vortex-duckdb/cpp/expr.cpp +++ b/vortex-duckdb/cpp/expr.cpp @@ -3,11 +3,13 @@ #include "expr.h" #include "duckdb/function/scalar_function.hpp" +#include "duckdb/function/aggregate_function.hpp" #include "duckdb/planner/expression/bound_between_expression.hpp" #include "duckdb/planner/expression/bound_cast_expression.hpp" #include "duckdb/planner/expression/bound_columnref_expression.hpp" #include "duckdb/planner/expression/bound_comparison_expression.hpp" #include "duckdb/planner/expression/bound_constant_expression.hpp" +#include "duckdb/planner/expression/bound_aggregate_expression.hpp" #include "duckdb/planner/expression/bound_function_expression.hpp" #include "duckdb/planner/expression/bound_operator_expression.hpp" #include "duckdb/planner/expression/bound_conjunction_expression.hpp" @@ -81,6 +83,11 @@ extern "C" duckdb_state duckdb_vx_register_st_dwithin_override(duckdb_database f return DuckDBSuccess; } +extern "C" const char *duckdb_vx_agg_func_name(duckdb_vx_agg_func ffi) { + D_ASSERT(ffi); + return reinterpret_cast(ffi)->name.c_str(); +} + extern "C" const char *duckdb_vx_expr_to_string(duckdb_vx_expr ffi_expr) { if (!ffi_expr) { return nullptr; @@ -92,7 +99,6 @@ extern "C" const char *duckdb_vx_expr_to_string(duckdb_vx_expr ffi_expr) { return result; } -//! Create a DuckDB vortex error. extern "C" void duckdb_vx_destroy_expr(duckdb_vx_expr *ffi_expr) { auto expr = reinterpret_cast(ffi_expr); delete expr; @@ -201,3 +207,9 @@ extern "C" bool duckdb_vx_expr_get_bound_cast_is_try(duckdb_vx_expr ffi_expr) { auto &expr = reinterpret_cast(ffi_expr)->Cast(); return expr.try_cast; } + +extern "C" duckdb_vx_agg_func duckdb_vx_expr_get_bound_aggregate_function(duckdb_vx_expr ffi_expr) { + D_ASSERT(ffi_expr); + auto &expr = reinterpret_cast(ffi_expr)->Cast(); + return reinterpret_cast(&expr.function); +} diff --git a/vortex-duckdb/cpp/include/aggregate_fn_pushdown.hpp b/vortex-duckdb/cpp/include/aggregate_fn_pushdown.hpp new file mode 100644 index 00000000000..4deb716b7a5 --- /dev/null +++ b/vortex-duckdb/cpp/include/aggregate_fn_pushdown.hpp @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once +#include "optimizer.hpp" +#include "duckdb/optimizer/optimizer_extension.hpp" + +using namespace duckdb; + +// Push UNGROUPED_AGGREGATE's of form agg(T) and count_star() into GET. +LogicalOperatorPtr TryPushdownAggregateFunctions(ClientContext &context, LogicalOperatorPtr plan); + +LogicalOperatorPtr RewriteAggregates(ClientContext &context, + LogicalOperatorPtr op, + Analyses &analyses, + const Projections &projections); + +LogicalOperatorPtr TryReplaceAggregate(ClientContext &context, + LogicalOperatorPtr op, + Analyses &analyses, + const Projections &projections); + +// return GET for UNGROUPED_AGGREGATE -> [GET] or for UNGROUPED_AGGREGATE -> +// PROJECTION -> [GET], nullptr if not found. +LogicalGet *GetChildGet(const LogicalAggregate &agg); diff --git a/vortex-duckdb/cpp/include/expr.h b/vortex-duckdb/cpp/include/expr.h index 20ae4300584..886c1d21170 100644 --- a/vortex-duckdb/cpp/include/expr.h +++ b/vortex-duckdb/cpp/include/expr.h @@ -10,6 +10,7 @@ extern "C" { #endif typedef struct duckdb_vx_sfunc_ *duckdb_vx_sfunc; +typedef struct duckdb_vx_agg_func_ *duckdb_vx_agg_func; const char *duckdb_vx_sfunc_name(duckdb_vx_sfunc ffi_func); @@ -20,6 +21,8 @@ duckdb_state duckdb_vx_register_st_dwithin_override(duckdb_database ffi_db); typedef struct duckdb_vx_expr_ *duckdb_vx_expr; +const char *duckdb_vx_agg_func_name(duckdb_vx_agg_func func); + /// Return the string representation of the expression. Must be freed with `duckdb_free`. const char *duckdb_vx_expr_to_string(duckdb_vx_expr expr); @@ -273,6 +276,8 @@ duckdb_vx_expr duckdb_vx_expr_get_bound_cast_child(duckdb_vx_expr expr); bool duckdb_vx_expr_get_bound_cast_is_try(duckdb_vx_expr expr); +duckdb_vx_agg_func duckdb_vx_expr_get_bound_aggregate_function(duckdb_vx_expr expr); + #ifdef __cplusplus /* End C ABI */ } #endif diff --git a/vortex-duckdb/cpp/include/table_function.h b/vortex-duckdb/cpp/include/table_function.h index f59c0330300..65a1a3f1dd3 100644 --- a/vortex-duckdb/cpp/include/table_function.h +++ b/vortex-duckdb/cpp/include/table_function.h @@ -88,6 +88,10 @@ typedef struct { duckdb_state duckdb_vx_register_table_functions(duckdb_database ffi_db); +typedef struct duckdb_vx_agg_input_ *duckdb_vx_agg_input; +idx_t duckdb_vx_aggregate_len(duckdb_vx_agg_input ffi); +duckdb_vx_expr duckdb_vx_aggregate_at(duckdb_vx_agg_input ffi, idx_t index, idx_t *proj_idx); + #ifdef __cplusplus } #endif diff --git a/vortex-duckdb/cpp/include/table_function.hpp b/vortex-duckdb/cpp/include/table_function.hpp index 8cdb813f4a7..07d035917be 100644 --- a/vortex-duckdb/cpp/include/table_function.hpp +++ b/vortex-duckdb/cpp/include/table_function.hpp @@ -25,3 +25,11 @@ struct TableFunctionProjectionExpressionInput { // true if we can push down the expression, false otherwise bool projection_expression_pushdown(duckdb::ClientContext &context, const TableFunctionProjectionExpressionInput &input); + +struct TableFunctionUngroupedAggregateInput { + const duckdb::LogicalGet &get; + // Column scan index -> aggregate expression + const duckdb::vector> &projections; +}; + +bool aggregate_pushdown(duckdb::ClientContext &context, const TableFunctionUngroupedAggregateInput &input); diff --git a/vortex-duckdb/cpp/table_function.cpp b/vortex-duckdb/cpp/table_function.cpp index 2b351593450..42c4285c383 100644 --- a/vortex-duckdb/cpp/table_function.cpp +++ b/vortex-duckdb/cpp/table_function.cpp @@ -4,6 +4,7 @@ #include "data.hpp" #include "error.hpp" #include "table_function.hpp" +#include "expr.h" #include "vortex_duckdb.h" #include "table_function.h" #include "vortex.h" @@ -171,12 +172,16 @@ struct CTableBindResult { vector &names; }; +// This is a flaw of Duckdb API which doesn't allow passing non-const +// expressions. We never modify the value on Rust side. +static duckdb_vx_expr get_ffi_expr(const Expression &expr) { + return reinterpret_cast(const_cast(&expr)); +} + bool projection_expression_pushdown(ClientContext &, const TableFunctionProjectionExpressionInput &input) { const auto &bind = input.get.bind_data->Cast(); - // This is a flaw of Duckdb API which doesn't allow passing non-const - // expressions. We never modify the value on Rust side. - auto ffi_expr = reinterpret_cast(const_cast(&input.expression)); + duckdb_vx_expr ffi_expr = get_ffi_expr(input.expression); void *const ffi_bind = bind.ffi_data->DataPtr(); duckdb_vx_error error_out = nullptr; @@ -191,6 +196,33 @@ bool projection_expression_pushdown(ClientContext &, const TableFunctionProjecti return ret; } +extern "C" { +idx_t duckdb_vx_aggregate_len(duckdb_vx_agg_input ffi_input) { + return reinterpret_cast(ffi_input)->projections.size(); +} + +duckdb_vx_expr duckdb_vx_aggregate_at(duckdb_vx_agg_input ffi_input, idx_t i, idx_t *proj_idx) { + const auto &input = *reinterpret_cast(ffi_input); + const auto &[scan_index, expr] = input.projections[i]; + *proj_idx = scan_index == COUNT_STAR_PROJ_IDX ? scan_index + : input.get.GetColumnIds()[scan_index].GetPrimaryIndex(); + return get_ffi_expr(expr); +} +} + +bool aggregate_pushdown(ClientContext &, const TableFunctionUngroupedAggregateInput &input) { + const auto &bind = input.get.bind_data->Cast(); + void *const ffi_bind = bind.ffi_data->DataPtr(); + duckdb_vx_error error_out = nullptr; + const auto ffi_input = + reinterpret_cast(const_cast(&input)); + const bool res = duckdb_table_function_pushdown_projection_aggregates(ffi_bind, ffi_input, &error_out); + if (error_out) { + throw BinderException(IntoErrString(error_out)); + } + return res; +} + /** * Called for every new query. For example, if there is a VIEW over *.vortex, * and after a query another file is added matching the glob, for second query @@ -238,10 +270,11 @@ unique_ptr c_init_global(ClientContext &context, Table } unique_ptr -init_local(ExecutionContext &, TableFunctionInitInput &, GlobalTableFunctionState *global_state) { +init_local(ExecutionContext &, TableFunctionInitInput &input, GlobalTableFunctionState *global_state) { + const void *const ffi_bind = input.bind_data->Cast().ffi_data->DataPtr(); void *const ffi_global = global_state->Cast().ffi_data->DataPtr(); - duckdb_vx_data ffi_local_data = duckdb_table_function_init_local(ffi_global); + duckdb_vx_data ffi_local_data = duckdb_table_function_init_local(ffi_bind, ffi_global); auto cdata = unique_ptr(reinterpret_cast(ffi_local_data)); return make_uniq(std::move(cdata)); } diff --git a/vortex-duckdb/cpp/vortex_duckdb.cpp b/vortex-duckdb/cpp/vortex_duckdb.cpp index b1241fc9435..8b023c960dd 100644 --- a/vortex-duckdb/cpp/vortex_duckdb.cpp +++ b/vortex-duckdb/cpp/vortex_duckdb.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +#include "aggregate_fn_pushdown.hpp" #include "data.hpp" #include "error.hpp" #include "scalar_fn_pushdown.hpp" @@ -271,6 +272,7 @@ extern "C" duckdb_blob duckdb_vx_value_get_geometry(duckdb_value value) { static void VortexOptimizeFunction(OptimizerExtensionInput &input, unique_ptr &plan) { plan = TryPushdown(input.context, std::move(plan)); plan = TryPushdown(input.context, std::move(plan)); + plan = TryPushdownAggregateFunctions(input.context, std::move(plan)); } static void VortexPreOptimizeFunction(OptimizerExtensionInput &input, unique_ptr &plan) { diff --git a/vortex-duckdb/include/vortex.h b/vortex-duckdb/include/vortex.h index c63b20cbbb0..e8e4550e108 100644 --- a/vortex-duckdb/include/vortex.h +++ b/vortex-duckdb/include/vortex.h @@ -11,6 +11,8 @@ #pragma once +#define COUNT_STAR_PROJ_IDX UINT64_MAX + #ifdef __cplusplus extern "C" { #endif // __cplusplus @@ -40,6 +42,11 @@ bool duckdb_table_function_pushdown_projection_expression(void *bind_data, size_t column_id, duckdb_vx_error *error_out); +extern +bool duckdb_table_function_pushdown_projection_aggregates(void *bind_data, + duckdb_vx_agg_input input, + duckdb_vx_error *error_out); + extern void duckdb_table_function_scan(void *global_init_data, void *local_init_data, @@ -56,7 +63,9 @@ extern duckdb_vx_data duckdb_table_function_init_global(const duckdb_vx_tfunc_init_input *init_input, duckdb_vx_error *error_out); -extern duckdb_vx_data duckdb_table_function_init_local(void *global_init_data); +extern +duckdb_vx_data duckdb_table_function_init_local(const void *bind_data, + void *global_init_data); extern duckdb_vx_data duckdb_table_function_bind(duckdb_vx_tfunc_bind_input bind_input, diff --git a/vortex-duckdb/src/convert/expr.rs b/vortex-duckdb/src/convert/expr.rs index b2c763e0613..9acd763bf30 100644 --- a/vortex-duckdb/src/convert/expr.rs +++ b/vortex-duckdb/src/convert/expr.rs @@ -1,9 +1,23 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +use std::fmt::Display; +use std::fmt::Formatter; +use std::fmt::Result; use std::sync::Arc; use tracing::debug; +use vortex::aggregate_fn::Accumulator; +use vortex::aggregate_fn::DynAccumulator; +use vortex::aggregate_fn::EmptyOptions as AggregateEmptyOptions; +use vortex::aggregate_fn::NumericalAggregateOpts; +use vortex::aggregate_fn::combined::PairOptions; +use vortex::aggregate_fn::fns::count::Count; +use vortex::aggregate_fn::fns::first::First; +use vortex::aggregate_fn::fns::max::Max; +use vortex::aggregate_fn::fns::mean::Mean; +use vortex::aggregate_fn::fns::min::Min; +use vortex::aggregate_fn::fns::sum::Sum; use vortex::dtype::DType; use vortex::dtype::Nullability; use vortex::dtype::PType; @@ -28,7 +42,7 @@ use vortex::expr::not; use vortex::expr::or_collect; use vortex::expr::root; use vortex::scalar::Scalar; -use vortex::scalar_fn::EmptyOptions; +use vortex::scalar_fn::EmptyOptions as ScalarEmptyOptions; use vortex::scalar_fn::ScalarFnVTableExt; use vortex::scalar_fn::fns::between::Between; use vortex::scalar_fn::fns::between::BetweenOptions; @@ -180,7 +194,7 @@ fn try_from_geo_function( let Some(distance) = from_bound_f64(children[2])? else { return Ok(None); }; - let geo_distance = GeoDistance.new_expr(EmptyOptions, [a, b]); + let geo_distance = GeoDistance.new_expr(ScalarEmptyOptions, [a, b]); Binary.new_expr(Operator::Lte, [geo_distance, lit(distance)]) } "st_distance" => { @@ -193,7 +207,7 @@ fn try_from_geo_function( let Some(b) = geo_operand(children[1], ctx)? else { return Ok(None); }; - GeoDistance.new_expr(EmptyOptions, [a, b]) + GeoDistance.new_expr(ScalarEmptyOptions, [a, b]) } _ => return Ok(None), }; @@ -410,6 +424,7 @@ pub fn can_push_expression(value: &duckdb::ExpressionRef) -> bool { } op.children().all(can_push_expression) } + ExpressionClass::BoundAggregate(_) => false, } } @@ -463,6 +478,71 @@ pub fn try_from_projection_expression( }) } +/// Aggregations we have pushed down in Vortex +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PushedAggregate { + Min, + Max, + Sum, + Mean, + // Also used for ANY_VALUE() which is allowed by definition + First, + // Valid values in column + Count, +} + +impl Display for PushedAggregate { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + match self { + PushedAggregate::Min => f.write_str("min"), + PushedAggregate::Max => f.write_str("max"), + PushedAggregate::Sum => f.write_str("sum"), + PushedAggregate::Mean => f.write_str("mean"), + PushedAggregate::First => f.write_str("first"), + PushedAggregate::Count => f.write_str("count"), + } + } +} + +impl PushedAggregate { + pub fn build(self, dtype: DType) -> VortexResult> { + let opts = NumericalAggregateOpts::default(); + Ok(match self { + Self::Min => Box::new(Accumulator::try_new(Min, opts, dtype)?), + Self::Max => Box::new(Accumulator::try_new(Max, opts, dtype)?), + Self::Sum => Box::new(Accumulator::try_new(Sum, opts, dtype)?), + Self::Mean => Box::new(Accumulator::try_new( + Mean::combined(), + PairOptions(opts, opts), + dtype, + )?), + Self::First => Box::new(Accumulator::try_new(First, AggregateEmptyOptions, dtype)?), + Self::Count => Box::new(Accumulator::try_new(Count, opts, dtype)?), + }) + } +} + +/// Check if this is an aggregate function we can handle in Vortex +pub fn try_from_projection_aggregate( + expr: &duckdb::ExpressionRef, +) -> VortexResult> { + let Some(expr) = expr.as_class() else { + return Ok(None); + }; + let ExpressionClass::BoundAggregate(agg) = expr else { + return Ok(None); + }; + Ok(Some(match agg.aggregate_function.name() { + "min" => PushedAggregate::Min, + "max" => PushedAggregate::Max, + "sum" | "sum_no_overflow" => PushedAggregate::Sum, + "avg" | "mean" => PushedAggregate::Mean, + "first" | "any_value" => PushedAggregate::First, + "count" => PushedAggregate::Count, + _ => return Ok(None), + })) +} + // If you want to add support for other expressions, also change // can_push_expression fn try_from_expression_inner( @@ -585,6 +665,7 @@ fn try_from_expression_inner( _ => vortex_bail!("unexpected operator {:?} in bound conjunction", conj.op), } } + ExpressionClass::BoundAggregate(_) => return Ok(None), })) } diff --git a/vortex-duckdb/src/convert/mod.rs b/vortex-duckdb/src/convert/mod.rs index 0d641a73457..f1b5ba5bb10 100644 --- a/vortex-duckdb/src/convert/mod.rs +++ b/vortex-duckdb/src/convert/mod.rs @@ -8,8 +8,10 @@ mod table_filter; mod vector; pub use dtype::FromLogicalType; +pub use expr::PushedAggregate; pub use expr::can_push_expression; pub use expr::try_from_bound_expression; +pub use expr::try_from_projection_aggregate; pub use expr::try_from_projection_expression; pub use scalar::*; pub use table_filter::try_from_table_filter; diff --git a/vortex-duckdb/src/duckdb/aggregate_pushdown.rs b/vortex-duckdb/src/duckdb/aggregate_pushdown.rs new file mode 100644 index 00000000000..2b245b2a614 --- /dev/null +++ b/vortex-duckdb/src/duckdb/aggregate_pushdown.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use num_traits::AsPrimitive; + +use crate::cpp; +use crate::duckdb::Expression; +use crate::duckdb::ExpressionRef; +use crate::lifetime_wrapper; + +lifetime_wrapper!( + /// Aggregates we want to push at Vortex at once + AggregatePushdownInput, + cpp::duckdb_vx_agg_input, |_| {}); + +pub struct AggregateExpression<'a> { + pub expr: &'a ExpressionRef, + /// Output column projection id after the pass has expanded columns with + /// multiple aggregations per column + pub projection_id: u64, +} + +impl AggregatePushdownInputRef { + pub fn len(&self) -> usize { + unsafe { cpp::duckdb_vx_aggregate_len(self.as_ptr()) }.as_() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub fn get(&'_ self, index: usize) -> AggregateExpression<'_> { + let mut projection_id = 0u64; + let expr = unsafe { + cpp::duckdb_vx_aggregate_at(self.as_ptr(), index as u64, &raw mut projection_id) + }; + let expr = unsafe { Expression::borrow(expr) }; + AggregateExpression { + expr, + projection_id, + } + } +} diff --git a/vortex-duckdb/src/duckdb/expr.rs b/vortex-duckdb/src/duckdb/expr.rs index 76f4f386d36..61dbbab5dcf 100644 --- a/vortex-duckdb/src/duckdb/expr.rs +++ b/vortex-duckdb/src/duckdb/expr.rs @@ -2,13 +2,14 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors use std::ffi::CStr; -use std::ffi::c_void; use std::fmt::Display; use std::fmt::Formatter; use std::ptr; use crate::cpp; use crate::cpp::duckdb_vx_expr_class; +use crate::duckdb::AggregateFunction; +use crate::duckdb::AggregateFunctionRef; use crate::duckdb::DDBString; use crate::duckdb::LogicalType; use crate::duckdb::LogicalTypeRef; @@ -151,9 +152,15 @@ impl ExpressionRef { ExpressionClass::BoundFunction(BoundFunction { children, scalar_function: unsafe { ScalarFunction::borrow(out.scalar_function) }, - bind_info: out.bind_info, }) } + cpp::DUCKDB_VX_EXPR_CLASS::DUCKDB_VX_EXPR_CLASS_BOUND_AGGREGATE => { + let aggregate_function = unsafe { + let ptr = cpp::duckdb_vx_expr_get_bound_aggregate_function(self.as_ptr()); + AggregateFunction::borrow(ptr) + }; + ExpressionClass::BoundAggregate(BoundAggregate { aggregate_function }) + } cpp::DUCKDB_VX_EXPR_CLASS::DUCKDB_VX_EXPR_CLASS_BOUND_REF => { ExpressionClass::BoundRef } @@ -174,6 +181,7 @@ pub enum ExpressionClass<'a> { BoundOperator(BoundOperator<'a>), BoundFunction(BoundFunction<'a>), BoundCast(BoundCast<'a>), + BoundAggregate(BoundAggregate<'a>), /// Column inside ExpressionFilter for expression pushed down to Vortex. BoundRef, } @@ -187,6 +195,10 @@ pub struct BoundColumnRef { pub name: DDBString, } +pub struct BoundAggregate<'a> { + pub aggregate_function: &'a AggregateFunctionRef, +} + pub struct BoundConstant<'a> { pub value: &'a ValueRef, } @@ -236,7 +248,6 @@ impl<'a> BoundOperator<'a> { pub struct BoundFunction<'a> { children: &'a [cpp::duckdb_vx_expr], pub scalar_function: &'a ScalarFunctionRef, - pub bind_info: *const c_void, } impl<'a> BoundFunction<'a> { diff --git a/vortex-duckdb/src/duckdb/scalar_function.rs b/vortex-duckdb/src/duckdb/function.rs similarity index 58% rename from vortex-duckdb/src/duckdb/scalar_function.rs rename to vortex-duckdb/src/duckdb/function.rs index 175b0e70d5c..0441fe43548 100644 --- a/vortex-duckdb/src/duckdb/scalar_function.rs +++ b/vortex-duckdb/src/duckdb/function.rs @@ -8,6 +8,7 @@ use crate::cpp; use crate::lifetime_wrapper; lifetime_wrapper!(ScalarFunction, cpp::duckdb_vx_sfunc, |_| {}); +lifetime_wrapper!(AggregateFunction, cpp::duckdb_vx_agg_func, |_| {}); impl ScalarFunctionRef { pub fn name(&self) -> &str { @@ -20,3 +21,15 @@ impl ScalarFunctionRef { } } } + +impl AggregateFunctionRef { + pub fn name(&self) -> &str { + unsafe { + let name_ptr = cpp::duckdb_vx_agg_func_name(self.as_ptr()); + std::ffi::CStr::from_ptr(name_ptr) + .to_str() + .map_err(|e| vortex_err!("invalid utf-8: {e}")) + .vortex_expect("aggregate function name should be valid UTF-8") + } + } +} diff --git a/vortex-duckdb/src/duckdb/mod.rs b/vortex-duckdb/src/duckdb/mod.rs index 27ea994b1e7..95dc8adc186 100644 --- a/vortex-duckdb/src/duckdb/mod.rs +++ b/vortex-duckdb/src/duckdb/mod.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +mod aggregate_pushdown; mod bind_input; mod connection; mod data; @@ -8,11 +9,11 @@ mod data_chunk; mod database; mod ddb_string; mod expr; +mod function; mod logical_type; mod macro_; mod query_result; mod reusable_dict; -mod scalar_function; mod selection_vector; mod string_map; mod table_filter; @@ -24,6 +25,7 @@ mod vector_buffer; use std::ffi::c_void; use std::ptr; +pub use aggregate_pushdown::*; pub use bind_input::*; pub use connection::*; pub use data::*; @@ -31,10 +33,10 @@ pub use data_chunk::*; pub use database::*; pub use ddb_string::*; pub use expr::*; +pub use function::*; pub use logical_type::*; pub use query_result::*; pub use reusable_dict::*; -pub use scalar_function::*; pub use selection_vector::*; pub use string_map::*; pub use table_filter::*; diff --git a/vortex-duckdb/src/ffi.rs b/vortex-duckdb/src/ffi.rs index 880c3f7f01c..1ed78c23494 100644 --- a/vortex-duckdb/src/ffi.rs +++ b/vortex-duckdb/src/ffi.rs @@ -17,6 +17,7 @@ use crate::copy::copy_to_finalize; use crate::copy::copy_to_initialize_global; use crate::copy::copy_to_sink; use crate::cpp; +use crate::duckdb::AggregatePushdownInput; use crate::duckdb::BindInput; use crate::duckdb::BindResult; use crate::duckdb::Data; @@ -38,6 +39,7 @@ use crate::table_function::get_partition_data; use crate::table_function::init_global; use crate::table_function::init_local; use crate::table_function::pushdown_complex_filter; +use crate::table_function::pushdown_projection_aggregates; use crate::table_function::pushdown_projection_expression; use crate::table_function::scan; use crate::table_function::statistics; @@ -126,6 +128,20 @@ unsafe extern "C-unwind" fn duckdb_table_function_pushdown_projection_expression }) } +#[unsafe(no_mangle)] +pub unsafe extern "C-unwind" fn duckdb_table_function_pushdown_projection_aggregates( + bind_data: *mut c_void, + input: cpp::duckdb_vx_agg_input, + error_out: *mut cpp::duckdb_vx_error, +) -> bool { + let bind_data = unsafe { bind_data.cast::().as_mut() } + .vortex_expect("bind_data null pointer"); + let input = unsafe { AggregatePushdownInput::borrow(input) }; + try_or(error_out, || { + pushdown_projection_aggregates(bind_data, input) + }) +} + #[unsafe(no_mangle)] unsafe extern "C-unwind" fn duckdb_table_function_scan( global_init_data: *mut c_void, @@ -207,12 +223,15 @@ pub unsafe extern "C-unwind" fn duckdb_table_function_init_global( #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn duckdb_table_function_init_local( + bind_data: *const c_void, global_init_data: *mut c_void, ) -> cpp::duckdb_vx_data { + let bind_data = unsafe { bind_data.cast::().as_ref() } + .vortex_expect("bind_data null pointer"); let global_init_data = unsafe { global_init_data.cast::().as_ref() } .vortex_expect("global_init_data null pointer"); - let init_data = init_local(global_init_data); + let init_data = init_local(bind_data, global_init_data); Data::from(Box::new(init_data)).as_ptr() } diff --git a/vortex-duckdb/src/projection.rs b/vortex-duckdb/src/projection.rs index 4521115666c..df0481250bd 100644 --- a/vortex-duckdb/src/projection.rs +++ b/vortex-duckdb/src/projection.rs @@ -17,12 +17,14 @@ use vortex::expr::root; use vortex::expr::select; use vortex::layout::layouts::row_idx::row_idx; use vortex::scan::selection::Selection; +use vortex_utils::aliases::hash_set::HashSet; use crate::convert::try_from_table_filter; use crate::convert::try_from_virtual_column_filter; use crate::duckdb::LogicalType; use crate::duckdb::TableFilterClass; use crate::duckdb::TableFilterSetRef; +use crate::table_function::ColumnAggregate; // See MultiFileReader for constants @@ -187,6 +189,28 @@ impl Projection { file_row_number_column_pos, } } + + // Create a projection for aggregate scan + pub fn new_aggregate(aggregates: &[ColumnAggregate], fields: &[DuckdbField]) -> Self { + let mut names = Vec::with_capacity(aggregates.len()); + let mut seen: HashSet = HashSet::with_capacity(aggregates.len()); + for aggregate in aggregates { + let ColumnAggregate::Real { projection_id, .. } = aggregate else { + continue; + }; + if seen.contains(projection_id) { + continue; + } + seen.insert(*projection_id); + let projection_id: usize = projection_id.as_(); + names.push(fields[projection_id].name.as_str()); + } + Projection { + projection: select(names, root()), + file_index_column_pos: None, + file_row_number_column_pos: None, + } + } } pub struct Filter { diff --git a/vortex-duckdb/src/table_function.rs b/vortex-duckdb/src/table_function.rs index 892a2ce58a9..5509f020ccd 100644 --- a/vortex-duckdb/src/table_function.rs +++ b/vortex-duckdb/src/table_function.rs @@ -19,15 +19,19 @@ use futures::StreamExt; use futures::future::BoxFuture; use itertools::Itertools; use num_traits::AsPrimitive; +use parking_lot::Mutex; use static_assertions::assert_impl_all; use tracing::debug; +use vortex::aggregate_fn::DynAccumulator; use vortex::array::ArrayRef; use vortex::array::Canonical; +use vortex::array::ExecutionCtx; use vortex::array::VortexSessionExecute as _; use vortex::array::arrays::ScalarFn; use vortex::array::arrays::Struct; use vortex::array::arrays::StructArray; use vortex::array::arrays::scalar_fn::ScalarFnArrayExt; +use vortex::array::arrays::struct_::StructArrayExt; use vortex::array::optimizer::ArrayOptimizer; use vortex::error::VortexExpect; use vortex::error::VortexResult; @@ -45,14 +49,19 @@ use vortex::scalar_fn::fns::operators::Operator; use vortex::scalar_fn::fns::pack::Pack; use vortex::scan::DataSource; use vortex::scan::ScanRequest; +use vortex_utils::aliases::hash_map::HashMap; use vortex_utils::parallelism::get_available_parallelism; use crate::RUNTIME; use crate::SESSION; use crate::column_statistics::ColumnStatistics; use crate::column_statistics::ColumnStatisticsAggregate; +use crate::convert::PushedAggregate; use crate::convert::try_from_bound_expression; +use crate::convert::try_from_projection_aggregate; use crate::convert::try_from_projection_expression; +use crate::duckdb::AggregateExpression; +use crate::duckdb::AggregatePushdownInputRef; use crate::duckdb::BindInputRef; use crate::duckdb::BindResultRef; use crate::duckdb::DataChunkRef; @@ -68,6 +77,9 @@ use crate::projection::Filter; use crate::projection::Projection; use crate::projection::extract_schema_from_dtype; +// Aggregate projection index for count(*). See cpp/aggregate_fn_pushdown.cpp +pub const COUNT_STAR_PROJ_IDX: u64 = u64::MAX; + pub struct TableFunctionBind { data_source: Arc, filter_exprs: Vec, @@ -75,6 +87,8 @@ pub struct TableFunctionBind { // There exists at least one non-optional table filter or at least one // complex filter is pushed down. has_non_optional_filter: AtomicBool, + // Non-empty iff this scan is aggregate + aggregates: Vec, } assert_impl_all!(TableFunctionBind: Send, Clone); @@ -88,6 +102,7 @@ impl Clone for TableFunctionBind { has_non_optional_filter: AtomicBool::new( self.has_non_optional_filter.load(Ordering::Relaxed), ), + aggregates: self.aggregates.clone(), } } } @@ -124,6 +139,16 @@ pub struct TableFunctionGlobal { bytes_read: AtomicU64, file_index_column_pos: Option, file_row_number_column_pos: Option, + + // Following 4 fields are used only in aggregate scans. + /// ArrayRef's scanned but not aggregated in "partials". + /// 0 means all arrays have been aggregated but output is not written. + /// u64::MAX means arrays have been aggregated and we've written output row + pending: Arc, + aggregates: Vec, + // Accumulated partials + partials: Mutex>>, + row_count: AtomicU64, } assert_impl_all!(TableFunctionGlobal: Send, Sync); @@ -133,6 +158,8 @@ pub struct TableFunctionLocal { exporter: Option, partition_index: u64, file_index: usize, + // Aggregate scan accumulated partials. Empty for non-aggregate scan + partials: Vec>, } pub struct PartitionData { @@ -141,6 +168,15 @@ pub struct PartitionData { pub file_index: usize, } +#[derive(Clone)] +pub(crate) enum ColumnAggregate { + Real { + projection_id: u64, + aggregate: PushedAggregate, + }, + CountStar, +} + #[derive(Debug)] pub enum Cardinality { /// Unknown number of rows @@ -162,6 +198,7 @@ pub fn bind(input: &BindInputRef, result: &mut BindResultRef) -> VortexResult VortexResult VortexResult VortexResult partition, @@ -261,6 +306,7 @@ pub fn init_global(init_input: &TableInitInput) -> VortexResult VortexResult VortexResult TableFunctionLocal { +fn build_partials( + aggregates: &[ColumnAggregate], + fields: &[DuckdbField], +) -> VortexResult>> { + aggregates + .iter() + .filter_map(|spec| match spec { + ColumnAggregate::Real { + projection_id, + aggregate, + } => { + let projection_id: usize = projection_id.as_(); + Some(aggregate.build(fields[projection_id].dtype.clone())) + } + ColumnAggregate::CountStar => None, + }) + .collect() +} + +pub fn init_local( + bind_data: &TableFunctionBind, + global: &TableFunctionGlobal, +) -> TableFunctionLocal { unsafe { use custom_labels::sys; @@ -336,11 +411,109 @@ pub fn init_local(global: &TableFunctionGlobal) -> TableFunctionLocal { CURRENT_LABELSET.set(key, value); } + let partials = build_partials(&global.aggregates, &bind_data.column_fields) + // if aggregate initialization produced an error, it would error in + // init_global, see "partials" initialization there + .vortex_expect("local state aggregate initialization failed"); + TableFunctionLocal { iterator: global.iterator.clone(), exporter: None, partition_index: 0, file_index: 0, + partials, + } +} + +fn convert_result(array: ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + let array_result = array.optimize_recursive(ctx.session())?; + Ok(if let Some(array) = array_result.as_opt::() { + array.into_owned() + } else if let Some(array) = array_result.as_opt::() + && let Some(pack_options) = array.scalar_fn().as_opt::() + { + StructArray::new( + pack_options.names.clone(), + array.children(), + array.len(), + pack_options.nullability.into(), + ) + } else { + array_result.execute::(ctx)?.into_struct() + }) +} + +fn scan_aggregate( + local_state: &mut TableFunctionLocal, + global_state: &TableFunctionGlobal, + chunk: &mut DataChunkRef, +) -> VortexResult<()> { + let aggregates_len = global_state.aggregates.len(); + // seen[k] = output column for requested column k. + // If min(x), max(x), avg(y) are requested, seen = { 0: 0, 1: 1} + let mut seen: HashMap = HashMap::with_capacity(aggregates_len); + // positions[k] = column id for accumulator k + // If min(x), max(x), avg(y) are requested, positions = [0, 0, 1] + let mut positions: Vec = Vec::with_capacity(aggregates_len); + + for aggregate in &global_state.aggregates { + let ColumnAggregate::Real { projection_id, .. } = aggregate else { + continue; + }; + let len = seen.len(); + let pos = seen.entry_ref(projection_id).or_insert(len); + positions.push(*pos); + } + let has_count_star = local_state.partials.len() < aggregates_len; + + let mut ctx = SESSION.create_execution_ctx(); + loop { + let Some(result) = local_state.iterator.next() else { + // 0 means we're the last thread, u64::MAX means output is written. + // is_err() means CAS didn't succeed + if global_state + .pending + .compare_exchange(0, u64::MAX, Ordering::AcqRel, Ordering::Relaxed) + .is_err() + { + return Ok(()); + } + + let mut accumulators = global_state.partials.lock(); + let row_count = global_state.row_count.load(Ordering::Acquire) as i64; + let mut accum_iter = accumulators.iter_mut(); + for (idx, aggregate) in global_state.aggregates.iter().enumerate() { + let value = match aggregate { + ColumnAggregate::Real { .. } => { + let accum = accum_iter.next().vortex_expect("partial for real agg"); + Value::try_from(accum.finish()?)? + } + ColumnAggregate::CountStar => Value::from(row_count), + }; + chunk.get_vector_mut(idx).reference_value(&value); + } + chunk.set_len(1); + return Ok(()); + }; + let array = convert_result(result?.0, &mut ctx)?; + + for (i, partial) in positions.iter().zip(local_state.partials.iter_mut()) { + partial.accumulate(array.unmasked_field(*i), &mut ctx)?; + } + + { + let mut partials = global_state.partials.lock(); + for (global, local) in partials.iter_mut().zip(&mut local_state.partials) { + global.combine_partials(local.flush()?)?; + } + } + + if has_count_star { + global_state + .row_count + .fetch_add(array.len() as u64, Ordering::Relaxed); + } + global_state.pending.fetch_sub(1, Ordering::Release); } } @@ -349,6 +522,10 @@ pub fn scan( global_state: &TableFunctionGlobal, chunk: &mut DataChunkRef, ) -> VortexResult<()> { + if !local_state.partials.is_empty() { + return scan_aggregate(local_state, global_state, chunk); + } + loop { if local_state.exporter.is_none() { let mut ctx = SESSION.create_execution_ctx(); @@ -356,23 +533,8 @@ pub fn scan( return Ok(()); }; let (array_result, conversion_cache) = result?; - let array_result = array_result.optimize_recursive(ctx.session())?; local_state.file_index = conversion_cache.file_index; - - let array_result: StructArray = if let Some(array) = array_result.as_opt::() { - array.into_owned() - } else if let Some(array) = array_result.as_opt::() - && let Some(pack_options) = array.scalar_fn().as_opt::() - { - StructArray::new( - pack_options.names.clone(), - array.children(), - array.len(), - pack_options.nullability.into(), - ) - } else { - array_result.execute::(&mut ctx)?.into_struct() - }; + let array_result = convert_result(array_result, &mut ctx)?; local_state.exporter = Some(ArrayExporter::try_new( &array_result, @@ -484,6 +646,46 @@ pub fn pushdown_projection_expression( } } +/// Turn a scan into an aggregate scan. Input is N aggregations, possibly over +/// same columns. If we return true, optimized pass expands output to N columns, +/// e.g. min(x), max(x) turns into min(x0), max(x1), 2 columns in output. +pub fn pushdown_projection_aggregates( + bind_data: &mut TableFunctionBind, + input: &AggregatePushdownInputRef, +) -> VortexResult { + let len = input.len(); + let mut aggregates = Vec::with_capacity(len); + let mut has_non_count_star = false; + + debug!(%len, "pushing down projection aggregates"); + for i in 0..len { + let AggregateExpression { + expr, + projection_id, + } = input.get(i); + if projection_id == COUNT_STAR_PROJ_IDX { + aggregates.push(ColumnAggregate::CountStar); + continue; + } + let Some(aggregate) = try_from_projection_aggregate(expr)? else { + debug!(%expr, %i, "failed to push down projection aggregate"); + return Ok(false); + }; + debug!(%expr, %projection_id, %i, "pushed down projection aggregate"); + aggregates.push(ColumnAggregate::Real { + projection_id, + aggregate, + }); + has_non_count_star = true; + } + // DuckDB computes just count(*) faster than us + if !has_non_count_star { + return Ok(false); + } + bind_data.aggregates = aggregates; + Ok(true) +} + /// Get column-wise statistics. Available only if we're reading a single file. pub fn statistics(bind_data: &TableFunctionBind, column_index: usize) -> Option { let children = bind_data.data_source.children(); @@ -501,8 +703,16 @@ pub fn statistics(bind_data: &TableFunctionBind, column_index: usize) -> Option< Some(inner) => inner.file_stats().stats_sets(), None => return None, }; - let stats_aggregate = ColumnStatisticsAggregate::new(&stats_sets[column_index]); - let dtype = bind_data.column_fields[column_index].dtype.clone(); + let source_id = if bind_data.aggregates.is_empty() { + column_index + } else { + match bind_data.aggregates[column_index] { + ColumnAggregate::Real { projection_id, .. } => projection_id.as_(), + ColumnAggregate::CountStar => return None, + } + }; + let dtype = bind_data.column_fields[source_id].dtype.clone(); + let stats_aggregate = ColumnStatisticsAggregate::new(&stats_sets[source_id]); Some(ColumnStatistics::from(&stats_aggregate, dtype)) } @@ -517,6 +727,9 @@ pub fn statistics(bind_data: &TableFunctionBind, column_index: usize) -> Option< /// here. const DEFAULT_SELECTIVITY: f64 = 0.2; pub fn cardinality(bind_data: &TableFunctionBind) -> Cardinality { + // If we're doing an aggregate scan, we don't change output cardinality to + // 1 as we want duckdb to do our aggregation in parallel. That may look + // counterintuitive in the plan, though. let has_non_optional_filter = bind_data.has_non_optional_filter.load(Ordering::Relaxed); match bind_data.data_source.row_count() { Precision::Exact(v) => { @@ -556,6 +769,31 @@ pub fn to_string(bind_data: &TableFunctionBind, map: &mut DuckdbStringMapRef) { let mut filters = bind_data.filter_exprs.iter().map(|f| format!("{f}")); map.push("Filters", &filters.join("\n")); } + + if !bind_data.aggregates.is_empty() { + let aggregations = bind_data + .aggregates + .iter() + .map(|agg| match agg { + ColumnAggregate::Real { + projection_id, + aggregate, + } => { + let projection_id: usize = projection_id.as_(); + format!( + "{aggregate}({})", + bind_data.column_fields[projection_id].name + ) + } + ColumnAggregate::CountStar => "count(*)".to_string(), + }) + .join("\n"); + if !aggregations.is_empty() { + map.push("Aggregations", &aggregations); + } + return; + } + let projections = bind_data .column_fields .iter() diff --git a/vortex-sqllogictest/Cargo.toml b/vortex-sqllogictest/Cargo.toml index 2da45eec01b..9f1d7ccf23b 100644 --- a/vortex-sqllogictest/Cargo.toml +++ b/vortex-sqllogictest/Cargo.toml @@ -18,7 +18,7 @@ anyhow = { workspace = true } async-trait = { workspace = true } bigdecimal = { workspace = true } datafusion = { workspace = true } -datafusion-functions-nested = { workspace = true } +datafusion-functions-nested.workspace = true datafusion-sqllogictest = { workspace = true } indicatif = { workspace = true } regex = { workspace = true } diff --git a/vortex-sqllogictest/slt/duckdb/aggregate_pushdown.slt b/vortex-sqllogictest/slt/duckdb/aggregate_pushdown.slt new file mode 100644 index 00000000000..2bf91d7f007 --- /dev/null +++ b/vortex-sqllogictest/slt/duckdb/aggregate_pushdown.slt @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + +include ../setup.slt.no + +statement ok +COPY ( +WITH cte AS (SELECT generate_series AS i FROM generate_series(100000)) +SELECT (CASE WHEN i > 0 THEN i ELSE NULL END) AS i FROM cte +) +TO '${WORK_DIR}/agg-pushdown.vortex'; + +query TT +EXPLAIN +SELECT sum(i), min(i), max(i), avg(i), mean(i), any_value(i), first(i), count(*), count(i) +FROM '${WORK_DIR}/agg-pushdown.vortex'; +---- +:.*UNGROUPED_AGGREGATE.* + +query I +SELECT sum(i) FROM '${WORK_DIR}/agg-pushdown.vortex'; +---- +5000050000 + +query II +SELECT min(i), max(i) FROM '${WORK_DIR}/agg-pushdown.vortex'; +---- +1 100000 + +query RR +SELECT avg(i), mean(i) FROM '${WORK_DIR}/agg-pushdown.vortex'; +---- +50000.5 50000.5 + +query II +SELECT count(*), count(i) FROM '${WORK_DIR}/agg-pushdown.vortex'; +---- +100001 100000 + +# inverse order to test count(*) not being first +query II +SELECT count(i), count(*) FROM '${WORK_DIR}/agg-pushdown.vortex'; +---- +100000 100001 + +# Just count() is rejected because not pushing it down is faster: +# we just set chunk length and don't do any aggregation ourselves +query TT +EXPLAIN SELECT count(*) FROM '${WORK_DIR}/agg-pushdown.vortex'; +---- +:.*UNGROUPED_AGGREGATE.* + +query TT +EXPLAIN SELECT count(), count(), count() FROM '${WORK_DIR}/agg-pushdown.vortex'; +---- +:.*UNGROUPED_AGGREGATE.* + +query III +SELECT count(), count(), count() FROM '${WORK_DIR}/agg-pushdown.vortex'; +---- +100001 100001 100001 + +# aggregate over scalar i.e. SELECT mean(strlen(str)) +# + cte +# + view + recursive view +# + cte referencing any_value() From 07e1b52d54e7b43baa6406ddabb8fa6ac9603eab Mon Sep 17 00:00:00 2001 From: Frederic Branczyk Date: Thu, 9 Jul 2026 17:41:04 +0200 Subject: [PATCH 043/104] fix: accept FixedSizeList dtype when deserializing ListValue scalars (#8696) Scalar serialization writes `ScalarValue::Tuple` as a protobuf `ListValue` for both `List` and `FixedSizeList` dtypes, but `list_from_proto` only accepted `List`. Since #8667 made constant detection unconditional, a constant fixed-size list column (e.g. UUIDs stored as `fixed_size_list(u8)[16]`) is compressed to a `ConstantArray` whose scalar metadata then fails to deserialize on read: expected List dtype for ListValue, got fixed_size_list(u8)[16] so files written this way come back unreadable. Scalar validation already handles `FixedSizeList` + `Tuple` (including the size check), so deserialization was the only gap. Includes a proto round-trip test that fails without the fix. --------- Signed-off-by: Frederic Branczyk Co-authored-by: Claude Fable 5 --- vortex-array/src/scalar/proto.rs | 10 +++++++--- vortex-array/src/scalar/tests/round_trip.rs | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/vortex-array/src/scalar/proto.rs b/vortex-array/src/scalar/proto.rs index ebb15809abc..f172d5491f3 100644 --- a/vortex-array/src/scalar/proto.rs +++ b/vortex-array/src/scalar/proto.rs @@ -441,9 +441,13 @@ fn list_from_proto( dtype: &DType, session: &VortexSession, ) -> VortexResult { - let element_dtype = dtype - .as_list_element_opt() - .ok_or_else(|| vortex_err!(Serde: "expected List dtype for ListValue, got {dtype}"))?; + let element_dtype = match dtype { + DType::List(edt, _) => edt, + DType::FixedSizeList(edt, ..) => edt, + _ => { + vortex_bail!(Serde: "expected List or FixedSizeList dtype for ListValue, got {dtype}") + } + }; let mut values = Vec::with_capacity(v.values.len()); for elem in v.values.iter() { diff --git a/vortex-array/src/scalar/tests/round_trip.rs b/vortex-array/src/scalar/tests/round_trip.rs index 7d434a95796..ab315483d72 100644 --- a/vortex-array/src/scalar/tests/round_trip.rs +++ b/vortex-array/src/scalar/tests/round_trip.rs @@ -254,6 +254,25 @@ mod tests { assert_eq!(extracted.as_slice(), &large_binary); } + // Test that fixed-size list scalars round-trip through protobuf. This is + // the path taken by a ConstantArray of a fixed-size list dtype (e.g. a + // UUID column stored as fixed_size_list(u8)[16]), whose scalar metadata + // is serialized as a protobuf ListValue. + #[test] + fn test_protobuf_fixed_size_list_round_trip() { + let fsl = Scalar::fixed_size_list( + Arc::new(DType::Primitive(PType::U8, Nullability::NonNullable)), + (0u8..16) + .map(|i| Scalar::primitive(i, Nullability::NonNullable)) + .collect(), + Nullability::NonNullable, + ); + + let pb_fsl = pb::Scalar::from(&fsl); + let round_tripped = Scalar::from_proto(&pb_fsl, &SESSION).unwrap(); + assert_eq!(fsl, round_tripped); + } + // Test that nullable and non-nullable types are preserved #[test] fn test_nullability_preservation() { From aff0bd380c702786e5231c66662e41279309e8d3 Mon Sep 17 00:00:00 2001 From: Matthew Katz <87445739+mhk197@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:45:05 -0700 Subject: [PATCH 044/104] Split `TableStrategy` dispatcher from `StructStrategy` (#8638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the `TableStrategy` writer into a dtype and override dependent dispatcher, and a struct writer. - **`StructStrategy`** — a *structural* writer that only shreds struct streams. It transposes a struct chunk stream into one ordered stream per field (plus validity when nullable) and writes each through a child strategy resolved **by direct field name** (`field_writers` entry, else `default`; validity through `validity`). It is intentionally dtype-unaware and knows nothing about nested dtypes or field-path overrides; it bails on non-struct input. - **`TableStrategy`** — now a *dispatcher*. `write_stream` routes on the stream's dtype and configured per-field overrides. `TableStrategy` continues to own the field-path overrides and resolves the per-field child strategies before handing them to `StructStrategy`. This lets the writer dispatch different layout kinds per dtype — e.g. a list layout for list columns when a list strategy is configured — instead of `TableStrategy` hard-coding struct shredding as its only nested behavior. Signed-off-by: Matt Katz --- Cargo.lock | 1 + vortex-layout/Cargo.toml | 1 + vortex-layout/src/layouts/struct_/mod.rs | 2 + vortex-layout/src/layouts/struct_/writer.rs | 261 +++++++++++++ vortex-layout/src/layouts/table.rs | 403 ++++++++++---------- 5 files changed, 465 insertions(+), 203 deletions(-) create mode 100644 vortex-layout/src/layouts/struct_/writer.rs diff --git a/Cargo.lock b/Cargo.lock index 5104dbe285f..16df2a942c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10503,6 +10503,7 @@ dependencies = [ "bit-vec", "flatbuffers", "futures", + "insta", "itertools 0.14.0", "kanal", "moka", diff --git a/vortex-layout/Cargo.toml b/vortex-layout/Cargo.toml index 61b1253ef43..daa96d4e138 100644 --- a/vortex-layout/Cargo.toml +++ b/vortex-layout/Cargo.toml @@ -55,6 +55,7 @@ vortex-utils = { workspace = true, features = ["dashmap"] } [dev-dependencies] futures = { workspace = true, features = ["executor"] } +insta = { workspace = true } rstest = { workspace = true } temp-env = { workspace = true } tokio = { workspace = true, features = ["rt", "macros"] } diff --git a/vortex-layout/src/layouts/struct_/mod.rs b/vortex-layout/src/layouts/struct_/mod.rs index db056f4c57b..853a6793f4c 100644 --- a/vortex-layout/src/layouts/struct_/mod.rs +++ b/vortex-layout/src/layouts/struct_/mod.rs @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors mod reader; +pub mod writer; use std::sync::Arc; @@ -21,6 +22,7 @@ use vortex_error::vortex_err; use vortex_session::SessionExt; use vortex_session::VortexSession; use vortex_session::registry::CachedId; +pub use writer::StructStrategy; use crate::LayoutBuildContext; use crate::LayoutChildType; diff --git a/vortex-layout/src/layouts/struct_/writer.rs b/vortex-layout/src/layouts/struct_/writer.rs new file mode 100644 index 00000000000..3076c3ed39c --- /dev/null +++ b/vortex-layout/src/layouts/struct_/writer.rs @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! A writer strategy for struct-typed arrays. +//! +//! [`StructStrategy`] transposes a stream of struct chunks into one ordered stream per field +//! (plus a validity stream when the struct is nullable) and writes each through a configurable +//! child strategy, producing a single [`StructLayout`]. It is a *structural* writer: it does not +//! inspect child dtypes or resolve field-path overrides itself. Dispatching a child to the right +//! layout kind is the job of the caller (see [`TableStrategy`]). +//! +//! [`TableStrategy`]: crate::layouts::table::TableStrategy + +use std::sync::Arc; + +use async_trait::async_trait; +use futures::StreamExt; +use futures::TryStreamExt; +use futures::future::try_join; +use futures::future::try_join_all; +use futures::pin_mut; +use itertools::Itertools; +use vortex_array::ArrayContext; +use vortex_array::ArrayRef; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::StructArray; +use vortex_array::arrays::struct_::StructArrayExt; +use vortex_array::dtype::DType; +use vortex_array::dtype::FieldName; +use vortex_array::dtype::Nullability; +use vortex_error::VortexError; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; +use vortex_io::kanal_ext::KanalExt; +use vortex_io::session::RuntimeSessionExt; +use vortex_session::VortexSession; +use vortex_utils::aliases::DefaultHashBuilder; +use vortex_utils::aliases::hash_map::HashMap; +use vortex_utils::aliases::hash_set::HashSet; + +use crate::IntoLayout; +use crate::LayoutRef; +use crate::LayoutStrategy; +use crate::layouts::struct_::StructLayout; +use crate::segments::SegmentSinkRef; +use crate::sequence::SendableSequentialStream; +use crate::sequence::SequenceId; +use crate::sequence::SequencePointer; +use crate::sequence::SequentialStreamAdapter; +use crate::sequence::SequentialStreamExt; + +/// Writes struct-typed arrays into a [`StructLayout`], one child layout per field. +/// +/// Each field is written through a strategy resolved by direct field name: an entry in +/// `field_writers` if present, otherwise `default`. When the struct is nullable, its validity +/// bitmap is written through `validity`. +/// +/// `StructStrategy` is intentionally unaware of nested dtypes and field-path overrides. To write +/// arbitrarily nested struct trees with per-path overrides, drive it from +/// [`TableStrategy`][crate::layouts::table::TableStrategy], which dispatches on dtype and resolves +/// the per-field child strategies before handing them here. +#[derive(Clone)] +pub struct StructStrategy { + /// Per-field child strategies, keyed by direct field name. Fields without an entry use + /// `default`. + field_writers: HashMap>, + /// Strategy for fields that have no entry in `field_writers`. + default: Arc, + /// Strategy for the struct's own validity bitmap, used only when the struct is nullable. + validity: Arc, +} + +impl StructStrategy { + /// Create a new struct writer that writes every field through `default` and the validity + /// bitmap (when present) through `validity`. + pub fn new(validity: Arc, default: Arc) -> Self { + Self { + field_writers: HashMap::default(), + default, + validity, + } + } + + /// Override the strategy for a single field by name. + pub fn with_field_writer( + mut self, + name: impl Into, + writer: Arc, + ) -> Self { + self.field_writers.insert(name.into(), writer); + self + } + + /// Override the strategy for several fields by name at once. + pub fn with_field_writers( + mut self, + writers: impl IntoIterator)>, + ) -> Self { + self.field_writers.extend(writers); + self + } +} + +#[async_trait] +impl LayoutStrategy for StructStrategy { + async fn write_stream( + &self, + ctx: ArrayContext, + segment_sink: SegmentSinkRef, + stream: SendableSequentialStream, + mut eof: SequencePointer, + session: &VortexSession, + ) -> VortexResult { + let dtype = stream.dtype().clone(); + + let Some(struct_dtype) = dtype.as_struct_fields_opt() else { + vortex_bail!("StructStrategy can only write struct-typed streams, got {dtype}"); + }; + + // Check for unique field names at write time. + if HashSet::<_, DefaultHashBuilder>::from_iter(struct_dtype.names().iter()).len() + != struct_dtype.names().len() + { + vortex_bail!("StructLayout must have unique field names"); + } + let is_nullable = dtype.is_nullable(); + + // Optimization: when there are no fields, don't spawn any work and just write a trivial + // StructLayout. + if struct_dtype.nfields() == 0 && !is_nullable { + let row_count = stream + .try_fold( + 0u64, + |acc, (_, arr)| async move { Ok(acc + arr.len() as u64) }, + ) + .await?; + return Ok(StructLayout::new(row_count, dtype, vec![]).into_layout()); + } + + // stream -> stream> + let columns_session = session.clone(); + let columns_vec_stream = stream.map(move |chunk| { + let (sequence_id, chunk) = chunk?; + let mut sequence_pointer = sequence_id.descend(); + let mut ctx = columns_session.create_execution_ctx(); + let struct_chunk = chunk.clone().execute::(&mut ctx)?; + let mut columns: Vec<(SequenceId, ArrayRef)> = Vec::new(); + if is_nullable { + columns.push(( + sequence_pointer.advance(), + chunk + .validity()? + .execute_mask(chunk.len(), &mut ctx)? + .into_array(), + )); + } + + columns.extend( + struct_chunk + .iter_unmasked_fields() + .map(|field| (sequence_pointer.advance(), field.clone())), + ); + + Ok(columns) + }); + + let mut stream_count = struct_dtype.nfields(); + if is_nullable { + stream_count += 1; + } + + let (column_streams_tx, column_streams_rx): (Vec<_>, Vec<_>) = + (0..stream_count).map(|_| kanal::bounded_async(1)).unzip(); + + // Fan out column chunks to their respective transposed streams. Keep this future joined + // with the column writers so producer panics/errors cannot be hidden as channel EOF. + let handle = session.handle(); + let fanout_fut = async move { + pin_mut!(columns_vec_stream); + while let Some(result) = columns_vec_stream.next().await { + match result { + Ok(columns) => { + for (tx, column) in column_streams_tx.iter().zip_eq(columns.into_iter()) { + if tx.send(Ok(column)).await.is_err() { + vortex_bail!( + "struct column writer finished before all chunks were sent" + ); + } + } + } + Err(e) => { + let e: Arc = Arc::new(e); + for tx in column_streams_tx.iter() { + let _ = tx.send(Err(VortexError::from(Arc::clone(&e)))).await; + } + return Err(VortexError::from(e)); + } + } + } + Ok(()) + }; + + // First child column is the validity, subsequent children are the individual struct fields + let column_dtypes: Vec = if is_nullable { + std::iter::once(DType::Bool(Nullability::NonNullable)) + .chain(struct_dtype.fields()) + .collect() + } else { + struct_dtype.fields().collect() + }; + + let column_names: Vec = if is_nullable { + std::iter::once(FieldName::from("__validity")) + .chain(struct_dtype.names().iter().cloned()) + .collect() + } else { + struct_dtype.names().iter().cloned().collect() + }; + + let layout_futures: Vec<_> = column_dtypes + .into_iter() + .zip_eq(column_streams_rx) + .zip_eq(column_names) + .enumerate() + .map(move |(index, ((dtype, recv), name))| { + let column_stream = + SequentialStreamAdapter::new(dtype, recv.into_stream().boxed()).sendable(); + let child_eof = eof.split_off(); + let session = session.clone(); + let ctx = ctx.clone(); + let segment_sink = Arc::clone(&segment_sink); + handle.spawn_nested(move |h| { + // Validity is written through the validity strategy; every other field + // resolves to its named override or the default strategy. + let writer = if index == 0 && is_nullable { + Arc::clone(&self.validity) + } else { + self.field_writers + .get(&name) + .cloned() + .unwrap_or_else(|| Arc::clone(&self.default)) + }; + let session = session.with_handle(h); + + async move { + writer + .write_stream(ctx, segment_sink, column_stream, child_eof, &session) + .await + } + }) + }) + .collect(); + + let (_success, column_layouts) = try_join(fanout_fut, try_join_all(layout_futures)).await?; + // TODO(os): transposed stream could count row counts as well, + // This must hold though, all columns must have the same row count of the struct layout + let row_count = column_layouts.first().map(|l| l.row_count()).unwrap_or(0); + Ok(StructLayout::new(row_count, dtype, column_layouts).into_layout()) + } +} diff --git a/vortex-layout/src/layouts/table.rs b/vortex-layout/src/layouts/table.rs index 04c47a9f732..8e7256d44aa 100644 --- a/vortex-layout/src/layouts/table.rs +++ b/vortex-layout/src/layouts/table.rs @@ -3,65 +3,58 @@ //! A configurable writer strategy for tabular data. //! -//! Allows the caller to override specific leaf fields with custom layout strategies. +//! [`TableStrategy`] is a *dispatcher*: it inspects the dtype of the stream it is handed and +//! routes struct columns to [`StructStrategy`] and everything else to the configured leaf +//! strategy. Because it hands *itself* (suitably descended) to [`StructStrategy`] as the strategy +//! for the struct's children, arbitrarily nested struct trees are written with no manual wiring. +//! +//! The dispatcher also owns field-path overrides, letting callers force a specific leaf field — +//! at any depth — onto a custom strategy. use std::sync::Arc; use async_trait::async_trait; -use futures::StreamExt; -use futures::TryStreamExt; -use futures::future::try_join; -use futures::future::try_join_all; -use futures::pin_mut; -use itertools::Itertools; use vortex_array::ArrayContext; -use vortex_array::ArrayRef; -use vortex_array::IntoArray; -use vortex_array::VortexSessionExecute; -use vortex_array::arrays::StructArray; -use vortex_array::arrays::struct_::StructArrayExt; -use vortex_array::dtype::DType; use vortex_array::dtype::Field; use vortex_array::dtype::FieldName; use vortex_array::dtype::FieldPath; -use vortex_array::dtype::Nullability; -use vortex_error::VortexError; use vortex_error::VortexResult; -use vortex_error::vortex_bail; -use vortex_io::kanal_ext::KanalExt; -use vortex_io::session::RuntimeSessionExt; use vortex_session::VortexSession; -use vortex_utils::aliases::DefaultHashBuilder; use vortex_utils::aliases::hash_map::HashMap; use vortex_utils::aliases::hash_set::HashSet; -use crate::IntoLayout; use crate::LayoutRef; use crate::LayoutStrategy; -use crate::layouts::struct_::StructLayout; +use crate::layouts::struct_::StructStrategy; use crate::segments::SegmentSinkRef; use crate::sequence::SendableSequentialStream; -use crate::sequence::SequenceId; use crate::sequence::SequencePointer; -use crate::sequence::SequentialStreamAdapter; -use crate::sequence::SequentialStreamExt; -/// A configurable strategy for writing tables with nested field columns, allowing -/// overrides for specific leaf columns. +/// A configurable strategy for writing nested tabular data, dispatching each (sub)stream to the +/// structural writer for its dtype. +/// +/// Dispatch rules, applied to the dtype of the stream handed to [`write_stream`]: +/// - **struct** → [`StructStrategy`], with each field written by its override (if any) or by a +/// descended copy of this dispatcher. +/// - **anything else** → the leaf strategy. +/// +/// [`write_stream`]: LayoutStrategy::write_stream pub struct TableStrategy { - /// A set of leaf field overrides, e.g. to force one column to be compact-compressed. + /// A set of field-path overrides, e.g. to force one column to be compact-compressed. Keys are + /// paths relative to the level this dispatcher sits at. leaf_writers: HashMap>, - /// The writer for any validity arrays that may be present + /// The writer for any validity arrays that may be present, at any level of the tree. validity: Arc, - /// The fallback writer for any fields that do not have an explicit writer set in `leaf_writers` - fallback: Arc, + /// The writer for leaf fields, i.e. anything that is not a struct. + leaf: Arc, } impl TableStrategy { - /// Create a new writer with the specified write strategies for validity, and for all leaf - /// fields, with no overrides. + /// Create a new dispatcher with the given `validity` strategy and `fallback` leaf strategy and + /// no overrides. /// - /// Additional overrides can be configured using the `with_leaf_strategy` method. + /// Additional per-field overrides can be configured with + /// [`with_field_writer`][Self::with_field_writer]. /// /// ## Example /// @@ -79,7 +72,7 @@ impl TableStrategy { Self { leaf_writers: Default::default(), validity, - fallback, + leaf: fallback, } } @@ -137,7 +130,7 @@ impl TableStrategy { /// Override the default strategy for leaf columns that don't have overrides. pub fn with_default_strategy(mut self, default: Arc) -> Self { - self.fallback = default; + self.leaf = default; self } @@ -149,15 +142,47 @@ impl TableStrategy { } impl TableStrategy { - /// Descend into a subfield for the writer. + /// Build the [`StructStrategy`] used to write a struct-typed stream at this level. + /// + /// Each field that has an override (or a deeper override beneath it) is resolved up front; + /// every other field falls through to a clean descended dispatcher. + fn struct_strategy(&self) -> StructStrategy { + let mut field_writers: HashMap> = HashMap::default(); + + // The distinct named first-segments of our override paths are the only fields that need + // anything other than the default dispatcher. + let mut named_first: HashSet = HashSet::default(); + for path in self.leaf_writers.keys() { + if let Some(Field::Name(name)) = path.parts().first() { + named_first.insert(name.clone()); + } + } + + for name in named_first { + // `validate_path` forbids overlapping overrides, so a name has *either* an exact + // single-segment override *or* deeper overrides, never both. + let writer = match self.leaf_writers.get(&FieldPath::from_name(name.clone())) { + Some(exact) => Arc::clone(exact), + None => { + Arc::new(self.descend(&Field::Name(name.clone()))) as Arc + } + }; + field_writers.insert(name, writer); + } + + StructStrategy::new(Arc::clone(&self.validity), Arc::new(self.descend_clean())) + .with_field_writers(field_writers) + } + + /// Descend into a subfield, retaining only the overrides that apply beneath it (rebased to be + /// relative to the child). fn descend(&self, field: &Field) -> Self { - // Start with the existing set of overrides, then only retain the ones that contain - // the current field let mut new_writers = HashMap::with_capacity(self.leaf_writers.len()); for (field_path, strategy) in &self.leaf_writers { - if field_path.starts_with_field(field) + if field_path.parts().first() == Some(field) && let Some(subpath) = field_path.clone().step_into() + && !subpath.is_root() { new_writers.insert(subpath, Arc::clone(strategy)); } @@ -166,7 +191,17 @@ impl TableStrategy { Self { leaf_writers: new_writers, validity: Arc::clone(&self.validity), - fallback: Arc::clone(&self.fallback), + leaf: Arc::clone(&self.leaf), + } + } + + /// A copy of this dispatcher with no overrides, used as the default child strategy for fields + /// that carry no override. + fn descend_clean(&self) -> Self { + Self { + leaf_writers: HashMap::default(), + validity: Arc::clone(&self.validity), + leaf: Arc::clone(&self.leaf), } } @@ -189,7 +224,7 @@ impl TableStrategy { } } -/// Specialized strategy for when we exactly know the input schema. +/// Dispatches each stream to the structural writer for its dtype. #[async_trait] impl LayoutStrategy for TableStrategy { async fn write_stream( @@ -197,177 +232,22 @@ impl LayoutStrategy for TableStrategy { ctx: ArrayContext, segment_sink: SegmentSinkRef, stream: SendableSequentialStream, - mut eof: SequencePointer, + eof: SequencePointer, session: &VortexSession, ) -> VortexResult { let dtype = stream.dtype().clone(); - // Fallback: if the array is not a struct, fallback to writing a single array. - if !dtype.is_struct() { + if dtype.is_struct() { return self - .fallback + .struct_strategy() .write_stream(ctx, segment_sink, stream, eof, session) .await; } - let struct_dtype = dtype.as_struct_fields(); - - // Check for unique field names at write time. - if HashSet::<_, DefaultHashBuilder>::from_iter(struct_dtype.names().iter()).len() - != struct_dtype.names().len() - { - vortex_bail!("StructLayout must have unique field names"); - } - let is_nullable = dtype.is_nullable(); - - // Optimization: when there are no fields, don't spawn any work and just write a trivial - // StructLayout. - if struct_dtype.nfields() == 0 && !is_nullable { - let row_count = stream - .try_fold( - 0u64, - |acc, (_, arr)| async move { Ok(acc + arr.len() as u64) }, - ) - .await?; - return Ok(StructLayout::new(row_count, dtype, vec![]).into_layout()); - } - - // stream -> stream> - let columns_session = session.clone(); - let columns_vec_stream = stream.map(move |chunk| { - let (sequence_id, chunk) = chunk?; - let mut sequence_pointer = sequence_id.descend(); - let mut ctx = columns_session.create_execution_ctx(); - let struct_chunk = chunk.clone().execute::(&mut ctx)?; - let mut columns: Vec<(SequenceId, ArrayRef)> = Vec::new(); - if is_nullable { - columns.push(( - sequence_pointer.advance(), - chunk - .validity()? - .execute_mask(chunk.len(), &mut ctx)? - .into_array(), - )); - } - - columns.extend( - struct_chunk - .iter_unmasked_fields() - .map(|field| (sequence_pointer.advance(), field.clone())), - ); - - Ok(columns) - }); - - let mut stream_count = struct_dtype.nfields(); - if is_nullable { - stream_count += 1; - } - - let (column_streams_tx, column_streams_rx): (Vec<_>, Vec<_>) = - (0..stream_count).map(|_| kanal::bounded_async(1)).unzip(); - - // Fan out column chunks to their respective transposed streams. Keep this future joined - // with the column writers so producer panics/errors cannot be hidden as channel EOF. - let handle = session.handle(); - let fanout_fut = async move { - pin_mut!(columns_vec_stream); - while let Some(result) = columns_vec_stream.next().await { - match result { - Ok(columns) => { - for (tx, column) in column_streams_tx.iter().zip_eq(columns.into_iter()) { - if tx.send(Ok(column)).await.is_err() { - vortex_bail!( - "table column writer finished before all chunks were sent" - ); - } - } - } - Err(e) => { - let e: Arc = Arc::new(e); - for tx in column_streams_tx.iter() { - let _ = tx.send(Err(VortexError::from(Arc::clone(&e)))).await; - } - return Err(VortexError::from(e)); - } - } - } - Ok(()) - }; - - // First child column is the validity, subsequence children are the individual struct fields - let column_dtypes: Vec = if is_nullable { - std::iter::once(DType::Bool(Nullability::NonNullable)) - .chain(struct_dtype.fields()) - .collect() - } else { - struct_dtype.fields().collect() - }; - - let column_names: Vec = if is_nullable { - std::iter::once(FieldName::from("__validity")) - .chain(struct_dtype.names().iter().cloned()) - .collect() - } else { - struct_dtype.names().iter().cloned().collect() - }; - - let layout_futures: Vec<_> = column_dtypes - .into_iter() - .zip_eq(column_streams_rx) - .zip_eq(column_names) - .enumerate() - .map(move |(index, ((dtype, recv), name))| { - let column_stream = - SequentialStreamAdapter::new(dtype.clone(), recv.into_stream().boxed()) - .sendable(); - let child_eof = eof.split_off(); - let field = Field::Name(name.clone()); - let session = session.clone(); - let ctx = ctx.clone(); - let segment_sink = Arc::clone(&segment_sink); - handle.spawn_nested(move |h| { - let validity = Arc::clone(&self.validity); - // descend further and try with new fields - let writer = self - .leaf_writers - .get(&FieldPath::from_name(name)) - .cloned() - .unwrap_or_else(|| { - if dtype.is_struct() { - // Step into the field path for struct columns - Arc::new(self.descend(&field)) - } else { - // Use fallback for leaf columns - Arc::clone(&self.fallback) - } - }); - let session = session.with_handle(h); - - async move { - // If we have a matching writer, we use it. - // Otherwise, we descend into a new modified one. - // Write validity stream - if index == 0 && is_nullable { - validity - .write_stream(ctx, segment_sink, column_stream, child_eof, &session) - .await - } else { - // Use the underlying writer, otherwise use the fallback writer. - writer - .write_stream(ctx, segment_sink, column_stream, child_eof, &session) - .await - } - } - }) - }) - .collect(); - - let (_success, column_layouts) = try_join(fanout_fut, try_join_all(layout_futures)).await?; - // TODO(os): transposed stream could count row counts as well, - // This must hold though, all columns must have the same row count of the struct layout - let row_count = column_layouts.first().map(|l| l.row_count()).unwrap_or(0); - Ok(StructLayout::new(row_count, dtype, column_layouts).into_layout()) + // Leaf: hand off to the leaf strategy. + self.leaf + .write_stream(ctx, segment_sink, stream, eof, session) + .await } } @@ -378,25 +258,142 @@ mod tests { use vortex_array::ArrayContext; use vortex_array::ArrayRef; + use vortex_array::IntoArray; + use vortex_array::arrays::ChunkedArray; + use vortex_array::arrays::PrimitiveArray; + use vortex_array::arrays::StructArray; use vortex_array::dtype::DType; use vortex_array::dtype::FieldPath; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; use vortex_array::dtype::StructFields; use vortex_array::field_path; + use vortex_buffer::buffer; use vortex_error::VortexResult; use vortex_io::runtime::single::block_on; use vortex_io::session::RuntimeSessionExt; + use crate::LayoutRef; use crate::LayoutStrategy; + use crate::layouts::chunked::writer::ChunkedLayoutStrategy; use crate::layouts::flat::writer::FlatLayoutStrategy; use crate::layouts::table::TableStrategy; use crate::segments::TestSegments; use crate::sequence::SequenceId; + use crate::sequence::SequentialArrayStreamExt; use crate::sequence::SequentialStreamAdapter; use crate::sequence::SequentialStreamExt; use crate::test::SESSION; + async fn write(strategy: &S, array: ArrayRef) -> VortexResult { + let segments = Arc::new(TestSegments::default()); + let (ptr, eof) = SequenceId::root().split(); + let stream = array.to_array_stream().sequenced(ptr); + strategy + .write_stream(ArrayContext::empty(), segments, stream, eof, &SESSION) + .await + } + + /// A plain table dispatcher with no overrides. `flat` here is both the validity and leaf + /// strategy. + fn flat_table() -> TableStrategy { + let flat: Arc = Arc::new(FlatLayoutStrategy::default()); + TableStrategy::new(Arc::clone(&flat), flat) + } + + /// The dispatcher shreds a top-level struct into one child per field. + #[tokio::test] + async fn dispatches_struct() -> VortexResult<()> { + let struct_array = StructArray::from_fields( + [ + ("a", buffer![1i32, 2, 3].into_array()), + ("b", buffer![10i32, 20, 30].into_array()), + ] + .as_slice(), + )? + .into_array(); + + let layout = write(&flat_table(), struct_array).await?; + insta::assert_snapshot!(layout.display_tree(), @r" + vortex.struct, dtype: {a=i32, b=i32}, children: 2 + ├── a: vortex.flat, dtype: i32, segment: 0 + └── b: vortex.flat, dtype: i32, segment: 1 + "); + Ok(()) + } + + /// A non-struct stream is not shredded; it is handed straight to the leaf strategy. + #[tokio::test] + async fn non_struct_input_uses_leaf() -> VortexResult<()> { + let primitive = PrimitiveArray::from_iter([1i32, 2, 3]).into_array(); + let layout = write(&flat_table(), primitive).await?; + insta::assert_snapshot!(layout.display_tree(), @"vortex.flat, dtype: i32, segment: 0"); + Ok(()) + } + + /// A multi-chunk struct is transposed per field; each column is written by a chunked leaf. + #[tokio::test] + async fn chunked_struct() -> VortexResult<()> { + let validity: Arc = Arc::new(FlatLayoutStrategy::default()); + let chunked_flat: Arc = + Arc::new(ChunkedLayoutStrategy::new(FlatLayoutStrategy::default())); + let dispatcher = TableStrategy::new(validity, chunked_flat); + + let c0 = StructArray::from_fields( + [ + ("a", buffer![1i32, 2].into_array()), + ("b", buffer![10i32, 20].into_array()), + ] + .as_slice(), + )? + .into_array(); + let c1 = StructArray::from_fields( + [ + ("a", buffer![3i32].into_array()), + ("b", buffer![30i32].into_array()), + ] + .as_slice(), + )? + .into_array(); + let dtype = c0.dtype().clone(); + let chunked = ChunkedArray::try_new(vec![c0, c1], dtype)?.into_array(); + + let layout = write(&dispatcher, chunked).await?; + insta::assert_snapshot!(layout.display_tree(), @r" + vortex.struct, dtype: {a=i32, b=i32}, children: 2 + ├── a: vortex.chunked, dtype: i32, children: 2 + │ ├── [0]: vortex.flat, dtype: i32, segment: 0 + │ └── [1]: vortex.flat, dtype: i32, segment: 1 + └── b: vortex.chunked, dtype: i32, children: 2 + ├── [0]: vortex.flat, dtype: i32, segment: 2 + └── [1]: vortex.flat, dtype: i32, segment: 3 + "); + Ok(()) + } + + /// A field override on a struct field is honored ahead of the default leaf strategy. + #[tokio::test] + async fn field_override_is_used() -> VortexResult<()> { + let struct_array = StructArray::from_fields( + [ + ("a", buffer![1i32, 2, 3].into_array()), + ("b", buffer![10i32, 20, 30].into_array()), + ] + .as_slice(), + )? + .into_array(); + + let strategy = + flat_table().with_field_writer(field_path!(a), Arc::new(FlatLayoutStrategy::default())); + let layout = write(&strategy, struct_array).await?; + insta::assert_snapshot!(layout.display_tree(), @r" + vortex.struct, dtype: {a=i32, b=i32}, children: 2 + ├── a: vortex.flat, dtype: i32, segment: 0 + └── b: vortex.flat, dtype: i32, segment: 1 + "); + Ok(()) + } + #[test] #[should_panic( expected = "Override for field_path $a.$b conflicts with existing override for $a.$b.$c" From 24ad8fd370836186142bd8e462cf2f334917780e Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Thu, 9 Jul 2026 18:27:23 +0100 Subject: [PATCH 045/104] Remove arrow usage from VarBin::compare, VarBinView::filter and BoolArray::from_iter (#8695) All of these operations don't need to depend on arrow --- vortex-array/src/arrays/bool/array.rs | 17 +- vortex-array/src/arrays/filter/execute/mod.rs | 10 +- .../src/arrays/filter/execute/varbinview.rs | 68 ++--- vortex-array/src/arrays/filter/vtable.rs | 2 +- .../src/arrays/varbin/compute/compare.rs | 240 +++++++++--------- 5 files changed, 162 insertions(+), 175 deletions(-) diff --git a/vortex-array/src/arrays/bool/array.rs b/vortex-array/src/arrays/bool/array.rs index 8b83d0f8128..2dd08c55832 100644 --- a/vortex-array/src/arrays/bool/array.rs +++ b/vortex-array/src/arrays/bool/array.rs @@ -4,7 +4,6 @@ use std::fmt::Display; use std::fmt::Formatter; -use arrow_array::BooleanArray; use smallvec::smallvec; use vortex_buffer::BitBuffer; use vortex_buffer::BitBufferMeta; @@ -338,14 +337,16 @@ impl FromIterator for BoolArray { impl FromIterator> for BoolArray { fn from_iter>>(iter: I) -> Self { - let (buffer, nulls) = BooleanArray::from_iter(iter).into_parts(); + let iter = iter.into_iter(); + let capacity = iter.size_hint().0; + let mut bits = BitBufferMut::with_capacity(capacity); + let mut validity = BitBufferMut::with_capacity(capacity); + for value in iter { + bits.append(value.unwrap_or_default()); + validity.append(value.is_some()); + } - BoolArray::new( - BitBuffer::from(buffer), - nulls - .map(|n| Validity::from(BitBuffer::from(n.into_inner()))) - .unwrap_or(Validity::AllValid), - ) + BoolArray::new(bits.freeze(), Validity::from(validity.freeze())) } } diff --git a/vortex-array/src/arrays/filter/execute/mod.rs b/vortex-array/src/arrays/filter/execute/mod.rs index f35f628c332..52b0373ac49 100644 --- a/vortex-array/src/arrays/filter/execute/mod.rs +++ b/vortex-array/src/arrays/filter/execute/mod.rs @@ -83,19 +83,13 @@ pub(super) fn execute_filter_fast_paths( } /// Filter a canonical array by a mask, returning a new canonical array. -pub(super) fn execute_filter( - canonical: Canonical, - mask: &Arc, - ctx: &mut ExecutionCtx, -) -> Canonical { +pub(super) fn execute_filter(canonical: Canonical, mask: &Arc) -> Canonical { match canonical { Canonical::Null(_) => Canonical::Null(NullArray::new(mask.true_count())), Canonical::Bool(a) => Canonical::Bool(bool::filter_bool(&a, mask)), Canonical::Primitive(a) => Canonical::Primitive(primitive::filter_primitive(&a, mask)), Canonical::Decimal(a) => Canonical::Decimal(decimal::filter_decimal(&a, mask)), - Canonical::VarBinView(a) => { - Canonical::VarBinView(varbinview::filter_varbinview(&a, mask, ctx)) - } + Canonical::VarBinView(a) => Canonical::VarBinView(varbinview::filter_varbinview(&a, mask)), Canonical::List(a) => Canonical::List(listview::filter_listview(&a, mask)), Canonical::FixedSizeList(a) => { Canonical::FixedSizeList(fixed_size_list::filter_fixed_size_list(&a, mask)) diff --git a/vortex-array/src/arrays/filter/execute/varbinview.rs b/vortex-array/src/arrays/filter/execute/varbinview.rs index 1f9df8f99cd..58a348b41b0 100644 --- a/vortex-array/src/arrays/filter/execute/varbinview.rs +++ b/vortex-array/src/arrays/filter/execute/varbinview.rs @@ -3,54 +3,32 @@ use std::sync::Arc; -use arrow_array::BooleanArray; -use vortex_error::VortexExpect; -use vortex_mask::Mask; +use vortex_buffer::Buffer; use vortex_mask::MaskValues; -use crate::ArrayRef; -use crate::ExecutionCtx; -use crate::IntoArray; -use crate::arrays::VarBinView; use crate::arrays::VarBinViewArray; -use crate::arrow::ArrowSessionExt; -use crate::arrow::FromArrowArray; - -pub fn filter_varbinview( - array: &VarBinViewArray, - mask: &Arc, - ctx: &mut ExecutionCtx, -) -> VarBinViewArray { - // Delegate to the Arrow implementation of filter over `VarBinView`. - arrow_filter_fn( - &array.clone().into_array(), - &Mask::Values(Arc::clone(mask)), - ctx, - ) - .vortex_expect("VarBinViewArray is Arrow-compatible and supports arrow_filter_fn") - .as_::() - .into_owned() -} - -fn arrow_filter_fn( - array: &ArrayRef, - mask: &Mask, - ctx: &mut ExecutionCtx, -) -> vortex_error::VortexResult { - let values = match &mask { - Mask::Values(values) => values, - Mask::AllTrue(_) | Mask::AllFalse(_) => unreachable!("check in filter invoke"), - }; - - let array_ref = ctx - .session() - .arrow() - .clone() - .execute_arrow(array.clone(), None, ctx)?; - let mask_array = BooleanArray::new(values.bit_buffer().clone().into(), None); - let filtered = arrow_select::filter::filter(array_ref.as_ref(), &mask_array)?; - - ArrayRef::from_arrow(filtered.as_ref(), array.dtype().is_nullable()) +use crate::arrays::filter::execute::buffer; +use crate::arrays::filter::execute::filter_validity; +use crate::arrays::varbinview::BinaryView; +use crate::arrays::varbinview::VarBinViewArrayExt; +use crate::buffer::BufferHandle; + +pub fn filter_varbinview(array: &VarBinViewArray, mask: &Arc) -> VarBinViewArray { + let filtered_validity = filter_validity(array.varbinview_validity(), mask); + + let views = Buffer::::from_byte_buffer(array.views_handle().as_host().clone()); + let filtered_views = buffer::filter_buffer(views, mask.as_ref()); + + // SAFETY: the filtered views are a subset of the original views and reference the same data + // buffers, and the validity is filtered by the same mask so lengths stay aligned. + unsafe { + VarBinViewArray::new_handle_unchecked( + BufferHandle::new_host(filtered_views.into_byte_buffer()), + Arc::clone(array.data_buffers()), + array.dtype().clone(), + filtered_validity, + ) + } } #[cfg(test)] diff --git a/vortex-array/src/arrays/filter/vtable.rs b/vortex-array/src/arrays/filter/vtable.rs index 4f556eff61b..0f54a97c574 100644 --- a/vortex-array/src/arrays/filter/vtable.rs +++ b/vortex-array/src/arrays/filter/vtable.rs @@ -168,7 +168,7 @@ impl VTable for Filter { // TODO(joe): fix the ownership of AnyCanonical let child = Canonical::from(array.child().as_::()); Ok(ExecutionResult::done( - execute_filter(child, &mask_values, ctx).into_array(), + execute_filter(child, &mask_values).into_array(), )) } diff --git a/vortex-array/src/arrays/varbin/compute/compare.rs b/vortex-array/src/arrays/varbin/compute/compare.rs index 42876a92aca..f6fff972c9b 100644 --- a/vortex-array/src/arrays/varbin/compute/compare.rs +++ b/vortex-array/src/arrays/varbin/compute/compare.rs @@ -1,17 +1,12 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -use arrow_array::BinaryArray; -use arrow_array::LargeBinaryArray; -use arrow_array::LargeStringArray; -use arrow_array::StringArray; -use arrow_ord::cmp; -use arrow_schema::DataType; +use std::cmp::Ordering; + use vortex_buffer::BitBuffer; use vortex_error::VortexExpect as _; use vortex_error::VortexResult; use vortex_error::vortex_bail; -use vortex_error::vortex_err; use crate::ArrayRef; use crate::ExecutionCtx; @@ -20,19 +15,15 @@ use crate::array::ArrayView; use crate::arrays::BoolArray; use crate::arrays::PrimitiveArray; use crate::arrays::VarBin; -use crate::arrays::VarBinViewArray; use crate::arrays::varbin::VarBinArrayExt; -use crate::arrow::Datum; -use crate::arrow::from_arrow_columnar; -use crate::builtins::ArrayBuiltins; use crate::dtype::DType; use crate::dtype::IntegerPType; use crate::match_each_integer_ptype; use crate::scalar_fn::fns::binary::CompareKernel; use crate::scalar_fn::fns::operators::CompareOperator; -use crate::scalar_fn::fns::operators::Operator; -// This implementation exists so we can have custom translation of RHS to arrow that's not the same as IntoCanonical +// This implementation exists so we can compare against a constant in encoded space, without +// canonicalizing the VarBin array to VarBinView. impl CompareKernel for VarBin { fn compare( lhs: ArrayView<'_, VarBin>, @@ -40,106 +31,66 @@ impl CompareKernel for VarBin { operator: CompareOperator, ctx: &mut ExecutionCtx, ) -> VortexResult> { - if let Some(rhs_const) = rhs.as_constant() { - let nullable = lhs.dtype().is_nullable() || rhs_const.dtype().is_nullable(); - let len = lhs.len(); - - let rhs_is_empty = match rhs_const.dtype() { - DType::Binary(_) => rhs_const - .as_binary() - .is_empty() - .vortex_expect("RHS should not be null"), - DType::Utf8(_) => rhs_const - .as_utf8() - .is_empty() - .vortex_expect("RHS should not be null"), - _ => vortex_bail!("VarBinArray can only have type of Binary or Utf8"), - }; - - if rhs_is_empty { - let buffer = match operator { - CompareOperator::Gte => BitBuffer::new_set(len), /* Every possible value is >= "" */ - CompareOperator::Lt => BitBuffer::new_unset(len), // No value is < "" - CompareOperator::Eq | CompareOperator::Lte => { - let lhs_offsets = lhs.offsets().clone().execute::(ctx)?; - match_each_integer_ptype!(lhs_offsets.ptype(), |P| { - compare_offsets_to_empty::

(lhs_offsets, true) - }) - } - CompareOperator::NotEq | CompareOperator::Gt => { - let lhs_offsets = lhs.offsets().clone().execute::(ctx)?; - match_each_integer_ptype!(lhs_offsets.ptype(), |P| { - compare_offsets_to_empty::

(lhs_offsets, false) - }) - } - }; + let Some(rhs_const) = rhs.as_constant() else { + return Ok(None); + }; - return Ok(Some( - BoolArray::new( - buffer, - lhs.validity()?.union_nullability(rhs.dtype().nullability()), - ) - .into_array(), - )); - } - - let lhs = Datum::try_new(lhs.array(), ctx)?; + let len = lhs.len(); - // The RHS scalar must match the LHS Arrow data type. VarBin with i64 offsets is - // converted to LargeBinary/LargeUtf8 (see `preferred_arrow_type`), and Arrow refuses to - // compare LargeBinary with Binary (or LargeUtf8 with Utf8). - let arrow_rhs: &dyn arrow_array::Datum = match (rhs_const.dtype(), lhs.data_type()) { - (DType::Utf8(_), DataType::LargeUtf8) => &rhs_const - .as_utf8() - .value() - .map(LargeStringArray::new_scalar) - .unwrap_or_else(|| arrow_array::Scalar::new(LargeStringArray::new_null(1))), - (DType::Utf8(_), _) => &rhs_const - .as_utf8() - .value() - .map(StringArray::new_scalar) - .unwrap_or_else(|| arrow_array::Scalar::new(StringArray::new_null(1))), - (DType::Binary(_), DataType::LargeBinary) => &rhs_const - .as_binary() - .value() - .map(LargeBinaryArray::new_scalar) - .unwrap_or_else(|| arrow_array::Scalar::new(LargeBinaryArray::new_null(1))), - (DType::Binary(_), _) => &rhs_const - .as_binary() - .value() - .map(BinaryArray::new_scalar) - .unwrap_or_else(|| arrow_array::Scalar::new(BinaryArray::new_null(1))), - _ => vortex_bail!( - "VarBin array RHS can only be Utf8 or Binary, given {}", - rhs_const.dtype() - ), - }; + // The compare adaptor resolves null constants before dispatching to this kernel, so + // the scalar always carries a value. + let rhs_bytes: &[u8] = match rhs_const.dtype() { + DType::Binary(_) => rhs_const + .as_binary() + .value() + .vortex_expect("RHS should not be null") + .as_slice(), + DType::Utf8(_) => rhs_const + .as_utf8() + .value() + .vortex_expect("RHS should not be null") + .as_str() + .as_bytes(), + _ => vortex_bail!("VarBinArray can only have type of Binary or Utf8"), + }; - let array = match operator { - CompareOperator::Eq => cmp::eq(&lhs, arrow_rhs), - CompareOperator::NotEq => cmp::neq(&lhs, arrow_rhs), - CompareOperator::Gt => cmp::gt(&lhs, arrow_rhs), - CompareOperator::Gte => cmp::gt_eq(&lhs, arrow_rhs), - CompareOperator::Lt => cmp::lt(&lhs, arrow_rhs), - CompareOperator::Lte => cmp::lt_eq(&lhs, arrow_rhs), + let buffer = if rhs_bytes.is_empty() { + // Comparisons against "" only need the value lengths, i.e. the offset deltas. + match operator { + CompareOperator::Gte => BitBuffer::new_set(len), /* Every possible value is >= "" */ + CompareOperator::Lt => BitBuffer::new_unset(len), // No value is < "" + CompareOperator::Eq | CompareOperator::Lte => { + let lhs_offsets = lhs.offsets().clone().execute::(ctx)?; + match_each_integer_ptype!(lhs_offsets.ptype(), |P| { + compare_offsets_to_empty::

(lhs_offsets, true) + }) + } + CompareOperator::NotEq | CompareOperator::Gt => { + let lhs_offsets = lhs.offsets().clone().execute::(ctx)?; + match_each_integer_ptype!(lhs_offsets.ptype(), |P| { + compare_offsets_to_empty::

(lhs_offsets, false) + }) + } } - .map_err(|err| vortex_err!("Failed to compare VarBin array: {}", err))?; - - Ok(Some(from_arrow_columnar(&array, len, nullable, ctx)?)) - } else if !rhs.is::() { - // NOTE: If the rhs is not a VarBin array it will be canonicalized to a VarBinView - // Arrow doesn't support comparing VarBin to VarBinView arrays, so we convert ourselves - // to VarBinView and re-invoke. - Ok(Some( - lhs.array() - .clone() - .execute::(ctx)? - .into_array() - .binary(rhs.clone(), Operator::from(operator))?, - )) } else { - Ok(None) - } + let lhs_offsets = lhs.offsets().clone().execute::(ctx)?; + match_each_integer_ptype!(lhs_offsets.ptype(), |P| { + compare_bytes_to_constant( + lhs_offsets.as_slice::

(), + lhs.bytes().as_slice(), + rhs_bytes, + operator, + ) + }) + }; + + Ok(Some( + BoolArray::new( + buffer, + lhs.validity()?.union_nullability(rhs.dtype().nullability()), + ) + .into_array(), + )) } } @@ -153,6 +104,71 @@ fn compare_offsets_to_empty(offsets: PrimitiveArray, eq: bool) }) } +/// Compare every value in a VarBin array against a constant, resolving values through the +/// offsets. Dispatches the operator outside the lane loop so each predicate inlines into its +/// own loop. +fn compare_bytes_to_constant( + offsets: &[P], + bytes: &[u8], + constant: &[u8], + operator: CompareOperator, +) -> BitBuffer { + match operator { + CompareOperator::Eq => { + collect_lane_bits(offsets, |start, end| value_eq(bytes, start, end, constant)) + } + CompareOperator::NotEq => { + collect_lane_bits(offsets, |start, end| !value_eq(bytes, start, end, constant)) + } + CompareOperator::Gt => collect_lane_bits(offsets, |start, end| { + value_cmp(bytes, start, end, constant).is_gt() + }), + CompareOperator::Gte => collect_lane_bits(offsets, |start, end| { + value_cmp(bytes, start, end, constant).is_ge() + }), + CompareOperator::Lt => collect_lane_bits(offsets, |start, end| { + value_cmp(bytes, start, end, constant).is_lt() + }), + CompareOperator::Lte => collect_lane_bits(offsets, |start, end| { + value_cmp(bytes, start, end, constant).is_le() + }), + } +} + +/// Bit-pack `predicate(offsets[i], offsets[i + 1])` over each lane of a VarBin array. +fn collect_lane_bits( + offsets: &[P], + predicate: impl Fn(usize, usize) -> bool, +) -> BitBuffer { + BitBuffer::collect_bool(offsets.len() - 1, |idx| { + // SAFETY: `collect_bool` yields idx < offsets.len() - 1. + let start = unsafe { offsets.get_unchecked(idx) }.as_(); + let end = unsafe { offsets.get_unchecked(idx + 1) }.as_(); + predicate(start, end) + }) +} + +/// Whether `bytes[start..end]` equals `constant`, comparing lengths first so lanes of a +/// different length never touch the value bytes. +/// +/// Offsets at null positions are not validated, so an out-of-bounds or inverted range is +/// possible there; such lanes answer `false`, and validity masks them out of the result anyway. +#[inline(always)] +fn value_eq(bytes: &[u8], start: usize, end: usize, constant: &[u8]) -> bool { + // A lane can only match when its length equals the constant's, so lanes of a different + // length answer without touching the value bytes. An inverted garbage range (start > end) + // wraps to a huge value that never equals `constant.len()`. + end.wrapping_sub(start) == constant.len() + && bytes.get(start..end).is_some_and(|value| value == constant) +} + +/// Order `bytes[start..end]` against `constant`, treating the unvalidated garbage ranges that +/// can appear at null positions as empty; validity masks those lanes out of the result anyway. +#[inline(always)] +fn value_cmp(bytes: &[u8], start: usize, end: usize, constant: &[u8]) -> Ordering { + bytes.get(start..end).unwrap_or_default().cmp(constant) +} + #[cfg(test)] mod test { use vortex_buffer::BitBuffer; @@ -277,11 +293,9 @@ mod tests { ); } - /// Regression: a [`VarBinArray`] built with `i64` offsets is canonicalised to - /// Arrow `LargeUtf8` / `LargeBinary` by `preferred_arrow_type`. Without an explicit - /// branch in [`CompareKernel`], the constant RHS is wrapped in a `StringArray` / - /// `BinaryArray` and Arrow rejects the `LargeUtf8 == Utf8` mismatch. Triggering - /// this only requires `i64` offsets, not large data. + /// Regression: [`CompareKernel`] must handle every offset width; a `VarBinArray` built with + /// `i64` offsets once failed the constant comparison. Triggering this only requires `i64` + /// offsets, not large data. /// /// [`CompareKernel`]: super::CompareKernel #[test] From 1bcaf3ff4f6e8ccbed65c6f7ae1da03b5ec60506 Mon Sep 17 00:00:00 2001 From: Matthew Katz <87445739+mhk197@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:04:23 -0700 Subject: [PATCH 046/104] Add `list_sum` expression (#8676) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `vortex.list.sum` scalar function: the sum of each row's list elements, one output row per input row — the equivalent of DuckDB's `list_sum()` and DataFusion's `array_sum()`. Note that list entries that are empty or all null need to be nullified. However, `GroupedAccumulator` returns 0 in these cases, so we need to either: 1. Change the behavior of `GroupedAccumulator` and grouped sum (or even sum) kernels or 2. Do a second pass of list element validity and sizes, construct a mask for the sums, and apply it. The first option is probably best but can be applied in a followup PR. --------- Signed-off-by: Matt Katz --- vortex-array/Cargo.toml | 4 + vortex-array/benches/list_sum.rs | 192 +++++++ vortex-array/src/expr/exprs.rs | 26 + vortex-array/src/scalar_fn/fns/list_sum.rs | 585 +++++++++++++++++++++ vortex-array/src/scalar_fn/fns/mod.rs | 1 + vortex-array/src/scalar_fn/session.rs | 2 + 6 files changed, 810 insertions(+) create mode 100644 vortex-array/benches/list_sum.rs create mode 100644 vortex-array/src/scalar_fn/fns/list_sum.rs diff --git a/vortex-array/Cargo.toml b/vortex-array/Cargo.toml index 4d8ef4ea2bb..53db75d46e7 100644 --- a/vortex-array/Cargo.toml +++ b/vortex-array/Cargo.toml @@ -248,6 +248,10 @@ harness = false name = "list_length" harness = false +[[bench]] +name = "list_sum" +harness = false + [[bench]] name = "listview_rebuild" harness = false diff --git a/vortex-array/benches/list_sum.rs b/vortex-array/benches/list_sum.rs new file mode 100644 index 00000000000..6092b73a807 --- /dev/null +++ b/vortex-array/benches/list_sum.rs @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Benchmarks for the `list_sum` scalar function over `List`, `ListView`, and +//! `FixedSizeList` inputs. + +#![expect(clippy::unwrap_used)] +#![expect(clippy::cast_possible_truncation)] + +use std::sync::LazyLock; + +use divan::Bencher; +use rand::RngExt; +use rand::SeedableRng; +use rand::distr::Uniform; +use rand::rngs::StdRng; +use vortex_array::ArrayRef; +use vortex_array::Canonical; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::FixedSizeListArray; +use vortex_array::arrays::ListArray; +use vortex_array::arrays::ListViewArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::expr::list_sum; +use vortex_array::expr::root; +use vortex_array::validity::Validity; +use vortex_buffer::Buffer; +use vortex_session::VortexSession; + +fn main() { + divan::main(); +} + +static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); + +const BASE_LIST_SIZE: usize = 8; + +const SMALL: usize = 100; +const MEDIUM: usize = 10_000; +const LARGE: usize = 1_000_000; + +/// A uniformly-random partition of `num_lists * BASE_LIST_SIZE` elements into `num_lists` +/// lists, plus a validity mask with ~1/8 of lists null at random positions. +fn random_lists(num_lists: usize) -> (Vec, Validity) { + let mut rng = StdRng::seed_from_u64(num_lists as u64); + let total = (num_lists * BASE_LIST_SIZE) as i32; + + let cut_dist = Uniform::new_inclusive(0i32, total).unwrap(); + let mut cuts: Vec = (0..num_lists - 1).map(|_| rng.sample(cut_dist)).collect(); + cuts.sort_unstable(); + let mut sizes = Vec::with_capacity(num_lists); + let mut prev = 0i32; + for cut in cuts { + sizes.push(cut - prev); + prev = cut; + } + sizes.push(total - prev); + + let null_dist = Uniform::new(0u32, 8).unwrap(); + let valid = (0..num_lists).map(|_| rng.sample(null_dist) != 0); + ( + sizes, + Validity::Array(BoolArray::from_iter(valid).into_array()), + ) +} + +/// `0..total` as `i32` elements, with ~1/8 nulled at random positions when `nullable`. +fn make_elements(total: i32, nullable: bool) -> ArrayRef { + if !nullable { + return PrimitiveArray::from_iter(0..total).into_array(); + } + let mut rng = StdRng::seed_from_u64(total as u64); + let null_dist = Uniform::new(0u32, 8).unwrap(); + PrimitiveArray::from_option_iter((0..total).map(|v| (rng.sample(null_dist) != 0).then_some(v))) + .into_array() +} + +/// A canonical `List` of `num_lists` variable-length lists, ~1/8 of them null. +fn make_list(num_lists: usize, nullable_elements: bool) -> ArrayRef { + let (sizes, validity) = random_lists(num_lists); + let total: i32 = sizes.iter().sum(); + let elements = make_elements(total, nullable_elements); + let offsets: Buffer = std::iter::once(0) + .chain(sizes.iter().scan(0i32, |acc, &s| { + *acc += s; + Some(*acc) + })) + .collect(); + ListArray::try_new(elements, offsets.into_array(), validity) + .unwrap() + .into_array() +} + +/// A gapless `ListView` of `num_lists` variable-length lists, ~1/8 of them null. +fn make_listview(num_lists: usize) -> ArrayRef { + let (sizes, validity) = random_lists(num_lists); + let total: i32 = sizes.iter().sum(); + let elements = make_elements(total, false); + let offsets: Buffer = sizes + .iter() + .scan(0i32, |acc, &s| { + let start = *acc; + *acc += s; + Some(start) + }) + .collect(); + let sizes: Buffer = sizes.into_iter().collect(); + ListViewArray::new(elements, offsets.into_array(), sizes.into_array(), validity).into_array() +} + +/// A `FixedSizeList` of `num_lists` lists, ~1/8 of them null. +fn make_fsl(num_lists: usize) -> ArrayRef { + let mut rng = StdRng::seed_from_u64(num_lists as u64); + let total = (num_lists * BASE_LIST_SIZE) as i32; + let elements = make_elements(total, false); + let null_dist = Uniform::new(0u32, 8).unwrap(); + let valid = (0..num_lists).map(|_| rng.sample(null_dist) != 0); + let validity = Validity::Array(BoolArray::from_iter(valid).into_array()); + FixedSizeListArray::new(elements, BASE_LIST_SIZE as u32, validity, num_lists).into_array() +} + +/// Apply `list_sum(root())` and materialize the result. +fn run(bencher: Bencher, array: ArrayRef) { + let expr = list_sum(root()); + bencher + .with_inputs(|| (&array, SESSION.create_execution_ctx())) + .bench_refs(|(array, ctx)| { + array + .clone() + .apply(&expr) + .unwrap() + .execute::(ctx) + .unwrap() + }); +} + +#[divan::bench] +fn list_sum_small(bencher: Bencher) { + run(bencher, make_list(SMALL, false)); +} + +#[divan::bench] +fn list_sum_medium(bencher: Bencher) { + run(bencher, make_list(MEDIUM, false)); +} + +#[divan::bench] +fn list_sum_large(bencher: Bencher) { + run(bencher, make_list(LARGE, false)); +} + +#[divan::bench] +fn list_sum_nullable_elements_medium(bencher: Bencher) { + run(bencher, make_list(MEDIUM, true)); +} + +#[divan::bench] +fn list_sum_nullable_elements_large(bencher: Bencher) { + run(bencher, make_list(LARGE, true)); +} + +#[divan::bench] +fn listview_sum_small(bencher: Bencher) { + run(bencher, make_listview(SMALL)); +} + +#[divan::bench] +fn listview_sum_medium(bencher: Bencher) { + run(bencher, make_listview(MEDIUM)); +} + +#[divan::bench] +fn listview_sum_large(bencher: Bencher) { + run(bencher, make_listview(LARGE)); +} + +#[divan::bench] +fn fsl_sum_small(bencher: Bencher) { + run(bencher, make_fsl(SMALL)); +} + +#[divan::bench] +fn fsl_sum_medium(bencher: Bencher) { + run(bencher, make_fsl(MEDIUM)); +} + +#[divan::bench] +fn fsl_sum_large(bencher: Bencher) { + run(bencher, make_fsl(LARGE)); +} diff --git a/vortex-array/src/expr/exprs.rs b/vortex-array/src/expr/exprs.rs index 2bdd5346e09..73fa76fce92 100644 --- a/vortex-array/src/expr/exprs.rs +++ b/vortex-array/src/expr/exprs.rs @@ -10,6 +10,7 @@ use vortex_error::VortexExpect; use vortex_error::vortex_panic; use vortex_utils::iter::ReduceBalancedIterExt; +use crate::aggregate_fn::NumericalAggregateOpts; use crate::dtype::DType; use crate::dtype::FieldName; use crate::dtype::FieldNames; @@ -38,6 +39,7 @@ use crate::scalar_fn::fns::like::Like; use crate::scalar_fn::fns::like::LikeOptions; use crate::scalar_fn::fns::list_contains::ListContains; use crate::scalar_fn::fns::list_length::ListLength; +use crate::scalar_fn::fns::list_sum::ListSum; use crate::scalar_fn::fns::literal::Literal; use crate::scalar_fn::fns::mask::Mask; use crate::scalar_fn::fns::merge::DuplicateHandling; @@ -781,3 +783,27 @@ pub fn ext_storage(input: Expression) -> Expression { pub fn list_length(input: Expression) -> Expression { ListLength.new_expr(EmptyOptions, [input]) } + +// ---- ListSum ---- + +/// Creates an expression that sums the elements of each list for `List` and +/// `FixedSizeList` inputs, akin to DuckDB's `list_sum()`. +/// +/// Follows SQL `SUM` semantics per list: null lists, empty lists, and lists whose elements are +/// all null yield null; null elements are skipped; integer and decimal overflow yields a null +/// value. The result dtype follows `sum`'s widening rules and is always nullable. NaN float +/// elements are skipped by default; see [`list_sum_opts`] for the NaN-including variant. +/// +/// ```rust +/// # use vortex_array::expr::{list_sum, root}; +/// let expr = list_sum(root()); +/// ``` +pub fn list_sum(input: Expression) -> Expression { + ListSum.new_expr(NumericalAggregateOpts::default(), [input]) +} + +/// Creates a [`list_sum`] expression with explicit [`NumericalAggregateOpts`], controlling +/// whether NaN float elements are skipped (the default) or poison the list's sum to NaN. +pub fn list_sum_opts(input: Expression, options: NumericalAggregateOpts) -> Expression { + ListSum.new_expr(options, [input]) +} diff --git a/vortex-array/src/scalar_fn/fns/list_sum.rs b/vortex-array/src/scalar_fn/fns/list_sum.rs new file mode 100644 index 00000000000..a5f8d219db6 --- /dev/null +++ b/vortex-array/src/scalar_fn/fns/list_sum.rs @@ -0,0 +1,585 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use vortex_buffer::BitBuffer; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; +use vortex_error::vortex_err; +use vortex_mask::AllOr; +use vortex_session::VortexSession; +use vortex_session::registry::CachedId; + +use crate::ArrayRef; +use crate::Canonical; +use crate::Columnar; +use crate::ExecutionCtx; +use crate::IntoArray; +use crate::aggregate_fn::AggregateFnVTable; +use crate::aggregate_fn::DynGroupedAccumulator; +use crate::aggregate_fn::GroupRanges; +use crate::aggregate_fn::GroupedAccumulator; +use crate::aggregate_fn::GroupedArray; +use crate::aggregate_fn::NumericalAggregateOpts; +use crate::aggregate_fn::fns::sum::Sum; +use crate::arrays::BoolArray; +use crate::arrays::ConstantArray; +use crate::arrays::FixedSizeList; +use crate::arrays::ListView; +use crate::builtins::ArrayBuiltins; +use crate::dtype::DType; +use crate::scalar_fn::Arity; +use crate::scalar_fn::ChildName; +use crate::scalar_fn::ExecutionArgs; +use crate::scalar_fn::ScalarFnId; +use crate::scalar_fn::ScalarFnVTable; +use crate::validity::Validity; + +/// Sum of the elements in each list of a `List` or `FixedSizeList` typed array. +/// +/// Follows SQL `SUM` semantics per list, matching DuckDB's `list_sum`: null lists, empty +/// lists, and lists whose elements are all null yield a null sum; null elements are skipped. +/// Integer and decimal overflow yields a null sum value, matching [`Sum`]. The result dtype +/// follows [`Sum`]'s widening rules and is always nullable. +/// +/// NaN handling for float elements is controlled by [`NumericalAggregateOpts`]: with +/// `skip_nans` (the default) NaN values contribute nothing, otherwise any NaN poisons the +/// list's sum to NaN. +#[derive(Clone)] +pub struct ListSum; + +impl ScalarFnVTable for ListSum { + type Options = NumericalAggregateOpts; + + fn id(&self) -> ScalarFnId { + static ID: CachedId = CachedId::new("vortex.list.sum"); + *ID + } + + fn serialize(&self, options: &Self::Options) -> VortexResult>> { + Ok(Some(options.serialize())) + } + + fn deserialize( + &self, + metadata: &[u8], + _session: &VortexSession, + ) -> VortexResult { + NumericalAggregateOpts::deserialize(metadata) + } + + fn arity(&self, _options: &Self::Options) -> Arity { + Arity::Exact(1) + } + + fn child_name(&self, _options: &Self::Options, child_idx: usize) -> ChildName { + match child_idx { + 0 => ChildName::from("input"), + _ => unreachable!("Invalid child index {child_idx} for list_sum()"), + } + } + + fn return_dtype(&self, options: &Self::Options, arg_dtypes: &[DType]) -> VortexResult { + let elem_dtype = match &arg_dtypes[0] { + DType::List(elem, _) | DType::FixedSizeList(elem, ..) => elem.as_ref(), + other => vortex_bail!("list_sum() requires List or FixedSizeList, got {other}"), + }; + Sum.return_dtype(options, elem_dtype) + .ok_or_else(|| vortex_err!("list_sum() cannot sum elements of type {elem_dtype}")) + } + + fn execute( + &self, + options: &Self::Options, + args: &dyn ExecutionArgs, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + let input = args.get(0)?; + + let elem_dtype = match input.dtype() { + DType::List(elem, _) | DType::FixedSizeList(elem, ..) => elem.as_ref().clone(), + other => vortex_bail!("list_sum() requires List or FixedSizeList, got {other}"), + }; + + // `mask_empty_lists` needs access to list elements validity and sizes + let columnar = input.execute::(ctx)?; + + match columnar { + Columnar::Constant(constant) => { + // Canonicalize one row of the constant and broadcast its sum. + let one_row = ConstantArray::new(constant.scalar().clone(), 1) + .into_array() + .execute::(ctx)? + .into_array(); + let sum = + list_sum_impl(one_row, elem_dtype, options, ctx)?.execute_scalar(0, ctx)?; + Ok(ConstantArray::new(sum, constant.len()).into_array()) + } + Columnar::Canonical(canonical) => { + list_sum_impl(canonical.into_array(), elem_dtype, options, ctx) + } + } + } + + fn is_null_sensitive(&self, _options: &Self::Options) -> bool { + false + } + + fn is_fallible(&self, _options: &Self::Options) -> bool { + false + } +} + +/// Sum each list of a canonical `array` into one value per list. +/// +/// Note that we need to nullify sums produced by empty or all-null lists, +/// since grouped sum kernels default to 0 for these. +fn list_sum_impl( + canonical: ArrayRef, + elem_dtype: DType, + options: &NumericalAggregateOpts, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let mut acc = GroupedAccumulator::try_new(Sum, *options, elem_dtype)?; + acc.accumulate_list(&canonical, ctx)?; + let sums = acc.finish()?; + + let grouped: GroupedArray = if let Some(fsl) = canonical.as_opt::() { + fsl.into_owned().into() + } else if let Some(lv) = canonical.as_opt::() { + lv.into_owned().into() + } else { + let dtype = canonical.dtype(); + vortex_bail!("list_sum() requires List or FixedSizeList but got {dtype}") + }; + + mask_empty_lists(grouped, sums, ctx) +} + +/// Applies a mask to `sums` that nullifies entries produced by lists without at least +/// one valid element. This is necessary because the grouped `Sum` aggregate only produces +/// nulls for null lists and sums that overflow. +fn mask_empty_lists( + grouped: GroupedArray, + sums: ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let elements = grouped.elements(); + let elem_mask = elements.validity()?.execute_mask(elements.len(), ctx)?; + let ranges = grouped.group_ranges(ctx)?; + + let has_valid_element: BitBuffer = match elem_mask.bit_buffer() { + AllOr::All => match &ranges { + // fixed-size lists of non-zero width cannot have empty lists. + GroupRanges::FixedSizeList { size, .. } if *size > 0 => return Ok(sums), + GroupRanges::FixedSizeList { len, .. } => BitBuffer::full(false, *len), + GroupRanges::ListView { ranges } => ranges.iter().map(|&(_, size)| size > 0).collect(), + }, + AllOr::None => BitBuffer::full(false, ranges.len()), + AllOr::Some(bits) => ranges + .iter() + .map(|(offset, size)| size > 0 && bits.count_range(offset, offset + size) > 0) + .collect(), + }; + if has_valid_element.true_count() == has_valid_element.len() { + return Ok(sums); + } + + sums.mask(BoolArray::new(has_valid_element, Validity::NonNullable).into_array()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use prost::Message; + use rstest::rstest; + use vortex_buffer::buffer; + use vortex_error::VortexResult; + use vortex_proto::expr as pb; + + use crate::ArrayRef; + use crate::IntoArray; + use crate::VortexSessionExecute; + use crate::aggregate_fn::NumericalAggregateOpts; + use crate::array_session; + use crate::arrays::BoolArray; + use crate::arrays::ConstantArray; + use crate::arrays::FixedSizeListArray; + use crate::arrays::ListArray; + use crate::arrays::ListViewArray; + use crate::arrays::PrimitiveArray; + use crate::assert_arrays_eq; + use crate::dtype::DType; + use crate::dtype::Nullability; + use crate::dtype::PType; + use crate::expr::Expression; + use crate::expr::list_sum; + use crate::expr::list_sum_opts; + use crate::expr::proto::ExprSerializeProtoExt; + use crate::expr::root; + use crate::scalar::Scalar; + use crate::scalar_fn::ScalarFnVTable; + use crate::scalar_fn::fns::list_sum::ListSum; + use crate::validity::Validity; + + fn create_list_elements() -> ArrayRef { + PrimitiveArray::from_option_iter::([ + Some(1), + Some(2), + Some(3), + Some(4), + Some(5), + Some(6), + None, + ]) + .into_array() + } + + #[rstest] + #[case(buffer![0u32, 2, 5, 5, 7].into_array())] + #[case(buffer![0u64, 2, 5, 5, 7].into_array())] + fn test_list_sum(#[case] offsets: ArrayRef) -> VortexResult<()> { + let elements = create_list_elements(); + let list = ListArray::try_new(elements, offsets, Validity::NonNullable)?.into_array(); + let result = list.apply(&list_sum(root()))?; + let mut ctx = array_session().create_execution_ctx(); + // [1, 2] = 3; [3, 4, 5] = 12; [] = null; [6, null] = 6 (nulls skipped). + let expected = + PrimitiveArray::from_option_iter::([Some(3), Some(12), None, Some(6)]); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + + #[test] + fn test_nullable_list_sum() -> VortexResult<()> { + let elements = create_list_elements(); + let list = ListArray::try_new( + elements, + buffer![0u32, 2, 5, 5, 7].into_array(), + Validity::Array(BoolArray::from_iter([true, false, true, false]).into_array()), + )? + .into_array(); + let result = list.apply(&list_sum(root()))?; + + let mut ctx = array_session().create_execution_ctx(); + // Row 1 and 3 are null lists; row 2 is a valid but empty list. + let expected = PrimitiveArray::from_option_iter::([Some(3), None, None, None]); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + + #[test] + fn test_all_null_elements_sum_to_null() -> VortexResult<()> { + let elements = PrimitiveArray::from_option_iter::([None, None, Some(1)]); + let list = ListArray::try_new( + elements.into_array(), + buffer![0u32, 2, 3].into_array(), + Validity::NonNullable, + )? + .into_array(); + let result = list.apply(&list_sum(root()))?; + + let mut ctx = array_session().create_execution_ctx(); + let expected = PrimitiveArray::from_option_iter::([None, Some(1)]); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + + #[test] + fn test_overflow_sums_to_null() -> VortexResult<()> { + let elements = PrimitiveArray::from_iter([i64::MAX, 1, 1, 2]); + let list = ListArray::try_new( + elements.into_array(), + buffer![0u32, 2, 4].into_array(), + Validity::NonNullable, + )? + .into_array(); + let result = list.apply(&list_sum(root()))?; + + let mut ctx = array_session().create_execution_ctx(); + let expected = PrimitiveArray::from_option_iter::([None, Some(3)]); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + + #[test] + fn test_unsigned_widening() -> VortexResult<()> { + let elements = PrimitiveArray::from_iter([1u8, 2, 3]); + let list = ListArray::try_new( + elements.into_array(), + buffer![0u32, 3].into_array(), + Validity::NonNullable, + )? + .into_array(); + let result = list.apply(&list_sum(root()))?; + + let mut ctx = array_session().create_execution_ctx(); + let expected = PrimitiveArray::from_option_iter::([Some(6)]); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + + #[test] + fn test_bool_elements() -> VortexResult<()> { + let elements = BoolArray::from_iter([true, true, false, true]); + let list = ListArray::try_new( + elements.into_array(), + buffer![0u32, 3, 4].into_array(), + Validity::NonNullable, + )? + .into_array(); + let result = list.apply(&list_sum(root()))?; + + let mut ctx = array_session().create_execution_ctx(); + let expected = PrimitiveArray::from_option_iter::([Some(2), Some(1)]); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + + #[test] + fn test_nan_skipped_by_default() -> VortexResult<()> { + let elements = PrimitiveArray::from_iter([1.0f64, f64::NAN, 2.0]); + let list = ListArray::try_new( + elements.into_array(), + buffer![0u32, 3].into_array(), + Validity::NonNullable, + )? + .into_array(); + let result = list.apply(&list_sum(root()))?; + + let mut ctx = array_session().create_execution_ctx(); + let expected = PrimitiveArray::from_option_iter::([Some(3.0)]); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + + #[test] + fn test_all_nan_list_sums_to_zero() -> VortexResult<()> { + // NaN elements are *valid*: with the default `skip_nans` they contribute nothing, but + // the list still has valid elements, so the sum is `0.0` rather than null (unlike an + // all-null list). + let elements = PrimitiveArray::from_iter([f64::NAN, f64::NAN]); + let list = ListArray::try_new( + elements.into_array(), + buffer![0u32, 2].into_array(), + Validity::NonNullable, + )? + .into_array(); + let result = list.apply(&list_sum(root()))?; + + let mut ctx = array_session().create_execution_ctx(); + let expected = PrimitiveArray::from_option_iter::([Some(0.0)]); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + + #[test] + fn test_nan_poisons_with_include_nans() -> VortexResult<()> { + let elements = PrimitiveArray::from_iter([1.0f64, f64::NAN, 2.0]); + let list = ListArray::try_new( + elements.into_array(), + buffer![0u32, 3].into_array(), + Validity::NonNullable, + )? + .into_array(); + let result = list.apply(&list_sum_opts( + root(), + NumericalAggregateOpts::include_nans(), + ))?; + + let mut ctx = array_session().create_execution_ctx(); + assert!(result.is_valid(0, &mut ctx)?); + let result = result.execute::(&mut ctx)?; + assert!(result.as_slice::()[0].is_nan()); + Ok(()) + } + + #[test] + fn test_listview_sum() -> VortexResult<()> { + let elements = create_list_elements(); + // Overlapping and out-of-order views over the shared elements. + let lv = ListViewArray::new( + elements, + buffer![5u32, 0, 4, 1].into_array(), + buffer![2u32, 3, 0, 2].into_array(), + Validity::NonNullable, + ) + .into_array(); + let result = lv.apply(&list_sum(root()))?; + + let mut ctx = array_session().create_execution_ctx(); + // [6, null] = 6; [1, 2, 3] = 6; [] = null; [2, 3] = 5. + let expected = + PrimitiveArray::from_option_iter::([Some(6), Some(6), None, Some(5)]); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + + fn create_fixed_size_list(validity: Validity) -> ArrayRef { + // 4 lists of size 2 over 8 primitive elements. + let elements = PrimitiveArray::from_iter([1i32, 2, 3, 4, 5, 6, 7, 8]).into_array(); + FixedSizeListArray::new(elements, 2, validity, 4).into_array() + } + + #[test] + fn test_fixed_size_list_sum() -> VortexResult<()> { + let fsl = create_fixed_size_list(Validity::NonNullable); + let result = fsl.apply(&list_sum(root()))?; + + let mut ctx = array_session().create_execution_ctx(); + let expected = + PrimitiveArray::from_option_iter::([Some(3), Some(7), Some(11), Some(15)]); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + + #[test] + fn test_fixed_size_list_sum_nullable() -> VortexResult<()> { + let fsl = create_fixed_size_list(Validity::Array( + BoolArray::from_iter([true, false, true, false]).into_array(), + )); + let result = fsl.apply(&list_sum(root()))?; + + let mut ctx = array_session().create_execution_ctx(); + let expected = PrimitiveArray::from_option_iter::([Some(3), None, Some(11), None]); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + + #[test] + fn test_list_sum_take() -> VortexResult<()> { + let elements = create_list_elements(); + let list = ListArray::try_new( + elements, + buffer![0u32, 2, 5, 5, 7].into_array(), + Validity::NonNullable, + )? + .into_array(); + let taken = list.take(buffer![3u64, 0, 2].into_array())?; + + let result = taken.apply(&list_sum(root()))?; + let mut ctx = array_session().create_execution_ctx(); + let expected = PrimitiveArray::from_option_iter::([Some(6), Some(3), None]); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + + #[test] + fn test_list_sum_slice() -> VortexResult<()> { + let elements = create_list_elements(); + let list = ListArray::try_new( + elements, + buffer![0u32, 2, 5, 5, 7].into_array(), + Validity::NonNullable, + )? + .into_array(); + let sliced = list.slice(1..4)?; + + let result = sliced.apply(&list_sum(root()))?; + let mut ctx = array_session().create_execution_ctx(); + let expected = PrimitiveArray::from_option_iter::([Some(12), None, Some(6)]); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + + #[test] + fn test_empty_array() -> VortexResult<()> { + let elements = PrimitiveArray::from_iter([0i32; 0]); + let list = ListArray::try_new( + elements.into_array(), + buffer![0u32].into_array(), + Validity::NonNullable, + )? + .into_array(); + let result = list.apply(&list_sum(root()))?; + + let mut ctx = array_session().create_execution_ctx(); + let result = result.execute::(&mut ctx)?; + assert_eq!(result.len(), 0); + Ok(()) + } + + #[test] + fn test_constant_list_sum() -> VortexResult<()> { + let elements = create_list_elements(); + let list = ListArray::try_new( + elements, + buffer![0u32, 2, 5, 5, 7].into_array(), + Validity::NonNullable, + )? + .into_array(); + let mut ctx = array_session().create_execution_ctx(); + let scalar = list.execute_scalar(1, &mut ctx)?; + + let constant = ConstantArray::new(scalar, 3).into_array(); + let result = constant.apply(&list_sum(root()))?; + let expected = PrimitiveArray::from_option_iter::([Some(12), Some(12), Some(12)]); + assert_arrays_eq!(result, expected, &mut ctx); + Ok(()) + } + + #[test] + fn test_null_scalar_list_sum() -> VortexResult<()> { + let null_scalar = Scalar::null(DType::List( + Arc::new(DType::Primitive(PType::I32, Nullability::NonNullable)), + Nullability::Nullable, + )); + let array = ConstantArray::new(null_scalar, 2).into_array(); + let result = array.apply(&list_sum(root()))?; + + let mut ctx = array_session().create_execution_ctx(); + assert!(!result.is_valid(0, &mut ctx)?); + assert!(!result.is_valid(1, &mut ctx)?); + Ok(()) + } + + #[test] + fn test_unsupported_dtypes() { + let opts = NumericalAggregateOpts::default(); + // Non-list inputs are rejected. + assert!( + ListSum + .return_dtype( + &opts, + &[DType::Primitive(PType::I32, Nullability::NonNullable)] + ) + .is_err() + ); + // Non-numeric element types are rejected. + assert!( + ListSum + .return_dtype( + &opts, + &[DType::List( + Arc::new(DType::Utf8(Nullability::NonNullable)), + Nullability::NonNullable, + )], + ) + .is_err() + ); + } + + #[test] + fn test_display() { + assert_eq!(list_sum(root()).to_string(), "vortex.list.sum($)"); + assert_eq!( + list_sum_opts(root(), NumericalAggregateOpts::include_nans()).to_string(), + "vortex.list.sum($, opts=skip_nans=false)" + ); + } + + #[test] + fn test_proto_round_trip() -> VortexResult<()> { + for expr in [ + list_sum(root()), + list_sum_opts(root(), NumericalAggregateOpts::include_nans()), + ] { + let proto = expr.serialize_proto()?; + let buf = proto.encode_to_vec(); + let decoded = pb::Expr::decode(buf.as_slice())?; + let deser = Expression::from_proto(&decoded, &array_session())?; + assert_eq!(expr, deser); + } + Ok(()) + } +} diff --git a/vortex-array/src/scalar_fn/fns/mod.rs b/vortex-array/src/scalar_fn/fns/mod.rs index f7e98e676b0..f4618154329 100644 --- a/vortex-array/src/scalar_fn/fns/mod.rs +++ b/vortex-array/src/scalar_fn/fns/mod.rs @@ -15,6 +15,7 @@ pub mod is_null; pub mod like; pub mod list_contains; pub mod list_length; +pub mod list_sum; pub mod literal; pub mod mask; pub mod merge; diff --git a/vortex-array/src/scalar_fn/session.rs b/vortex-array/src/scalar_fn/session.rs index 477d5758990..e937ac8d316 100644 --- a/vortex-array/src/scalar_fn/session.rs +++ b/vortex-array/src/scalar_fn/session.rs @@ -22,6 +22,7 @@ use crate::scalar_fn::fns::is_null::IsNull; use crate::scalar_fn::fns::like::Like; use crate::scalar_fn::fns::list_contains::ListContains; use crate::scalar_fn::fns::list_length::ListLength; +use crate::scalar_fn::fns::list_sum::ListSum; use crate::scalar_fn::fns::literal::Literal; use crate::scalar_fn::fns::merge::Merge; use crate::scalar_fn::fns::not::Not; @@ -70,6 +71,7 @@ impl Default for ScalarFnSession { this.register(Like); this.register(ListContains); this.register(ListLength); + this.register(ListSum); this.register(Literal); this.register(Merge); this.register(Not); From a649d752b94c48b4b2b9e1417bd6ff930da31fbf Mon Sep 17 00:00:00 2001 From: mag1c1an1 <103239869+mag1c1an1@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:54:28 +0800 Subject: [PATCH 047/104] cuda: build.rs add trigger (#8708) --- vortex-cuda/build.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/vortex-cuda/build.rs b/vortex-cuda/build.rs index e201345cd73..0621a2884b3 100644 --- a/vortex-cuda/build.rs +++ b/vortex-cuda/build.rs @@ -35,6 +35,13 @@ fn main() { println!("cargo:rerun-if-env-changed=PROFILE"); + // nvcc availability depends on PATH (e.g. entering/leaving a nix shell that + // provides cuda_nvcc). Without this, cargo caches a stale build.rs result + // from an environment without nvcc, and switching to one with nvcc does not + // trigger PTX recompilation — producing a binary with an empty embedded_ptx + // table that silently falls back to CPU at runtime. + println!("cargo:rerun-if-env-changed=PATH"); + // Regenerate bit_unpack kernels only when the generator changes println!( "cargo:rerun-if-changed={}", From ceff058e3129b7cc75460a92112fdf63aff26acd Mon Sep 17 00:00:00 2001 From: Frederic Branczyk Date: Fri, 10 Jul 2026 14:31:16 +0200 Subject: [PATCH 048/104] vortex-file: Spawn tasks via runtime instead of directly via tokio (#8711) ## Rationale for this change I upgraded to 0.78 and some automation we have that detects when dependencies use raw `tokio::spawn` got triggered. We need to control all spawn points, both for correctness testing and for instrumentation purposes. I realize that these spawns are in test code, but I thought since vortex already has the runtime abstractions, it wouldn't hurt to use them in the tests as well (admittedly our tooling is not crazily sophisticated so it can't distinguish whether it's testing code or not). ## What changes are included in this PR? Use `TokioRuntime` instead of raw `tokio::spawn` in tests. ## What APIs are changed? Are there any user-facing changes? n/a Signed-off-by: Frederic Branczyk --- vortex-file/src/segments/source.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/vortex-file/src/segments/source.rs b/vortex-file/src/segments/source.rs index f7ef77e4bd5..3af33362b05 100644 --- a/vortex-file/src/segments/source.rs +++ b/vortex-file/src/segments/source.rs @@ -468,12 +468,13 @@ mod tests { #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn file_segment_source_panic_propagates_to_all_concurrent_readers() { let source = Arc::new(panicking_source()); + let handle = TokioRuntime::current(); let reader_count = 8; let readers: Vec<_> = (0..reader_count) .map(|_| { let source = Arc::clone(&source); - tokio::spawn(async move { + handle.spawn(async move { AssertUnwindSafe(source.request(SegmentId::from(0))) .catch_unwind() .await @@ -488,7 +489,7 @@ mod tests { let mut original_panics = 0; for reader in joined { - match reader.expect("reader task panicked at the join boundary") { + match reader { // The first reader to observe completion re-raises the original panic. Err(payload) => { assert!( @@ -557,10 +558,11 @@ mod tests { RequestMetrics::new(&metrics, vec![]), )); + let handle = TokioRuntime::current(); let tasks: Vec<_> = (0..n) .map(|i| { let source = Arc::clone(&source); - tokio::spawn(async move { source.request(SegmentId::from(i)).await }) + handle.spawn(async move { source.request(SegmentId::from(i)).await }) }) .collect(); From a530b3b7e38c8c4040c68df8c759aae2f4d7d58d Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Fri, 10 Jul 2026 13:38:29 +0100 Subject: [PATCH 049/104] Add `UnionVariants` type and embed it in `DType::Union` (#8703) ## Rationale for this change Tracking Issue: https://github.com/vortex-data/vortex/issues/7882 We want to make `DType::Union` usable. This PR has been resurrected from https://github.com/vortex-data/vortex/pull/7902. ## What changes are included in this PR? Replaces `DType::Union(Nullability)` with `DType::Union(UnionVariants, Nullability)`. `UnionVariants` carries a `FieldNames` list, per-variant `FieldDType`s, and an `i8` type-id vector (supporting non-consecutive tags like `[0, 5, 7]` so children can be removed without renumbering). The flatbuffer and protobuf `Union` schemas are extended with the `names`/`dtypes`/`type_ids` fields, and serde, flatbuffers, and protobuf paths are filled in with round-trip tests and nullability-constraint checks. The flatbuffer `Union` table uses explicit field ids so that `nullable` keeps the id it had before this change, keeping the schema conformant with develop. Existing `DType::Union(..)` match arms that reject or skip unions are left in place, and concrete implementations will come later. ## What APIs are changed? Are there any user-facing changes? `DType::Union` will now have a type parameter `UnionVariants`. Signed-off-by: Connor Tsui --- .../src/aggregate_fn/fns/is_sorted/mod.rs | 4 +- .../fns/uncompressed_size_in_bytes/mod.rs | 4 +- vortex-array/src/dtype/dtype_impl.rs | 35 +- vortex-array/src/dtype/mod.rs | 15 +- vortex-array/src/dtype/serde/flatbuffers.rs | 247 ++++++- vortex-array/src/dtype/serde/proto.rs | 181 +++++- vortex-array/src/dtype/serde/serde.rs | 155 ++++- vortex-array/src/dtype/union.rs | 614 ++++++++++++++++++ .../src/scalar_fn/fns/binary/compare/mod.rs | 2 +- .../scalar_fn/fns/binary/compare/nested.rs | 2 +- vortex-datafusion/src/convert/scalars.rs | 10 +- .../flatbuffers/vortex-dtype/dtype.fbs | 7 +- vortex-flatbuffers/src/generated/dtype.rs | 57 +- vortex-flatbuffers/src/generated/message.rs | 2 +- vortex-proto/proto/dtype.proto | 3 + vortex-proto/src/generated/vortex.dtype.rs | 9 +- vortex-row/src/codec.rs | 2 +- 17 files changed, 1311 insertions(+), 38 deletions(-) create mode 100644 vortex-array/src/dtype/union.rs diff --git a/vortex-array/src/aggregate_fn/fns/is_sorted/mod.rs b/vortex-array/src/aggregate_fn/fns/is_sorted/mod.rs index c3ee7f5d4e7..6256dc22327 100644 --- a/vortex-array/src/aggregate_fn/fns/is_sorted/mod.rs +++ b/vortex-array/src/aggregate_fn/fns/is_sorted/mod.rs @@ -247,7 +247,7 @@ impl AggregateFnVTable for IsSorted { | DType::List(..) | DType::FixedSizeList(..) | DType::Struct(..) - | DType::Union(_) + | DType::Union(..) | DType::Variant(..) | DType::Extension(_) => None, DType::Bool(_) @@ -264,7 +264,7 @@ impl AggregateFnVTable for IsSorted { | DType::List(..) | DType::FixedSizeList(..) | DType::Struct(..) - | DType::Union(_) + | DType::Union(..) | DType::Variant(..) | DType::Extension(_) => None, DType::Bool(_) diff --git a/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs b/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs index eac5b3c33ce..1d04a074d6f 100644 --- a/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs +++ b/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs @@ -289,7 +289,9 @@ fn supports_uncompressed_size_in_bytes(dtype: &DType) -> bool { DType::Struct(fields, _) => fields .fields() .all(|field| supports_uncompressed_size_in_bytes(&field)), - DType::Union(_) => todo!("TODO(connor)[Union]: unimplemented"), + DType::Union(variants, _) => variants + .variants() + .all(|variant| supports_uncompressed_size_in_bytes(&variant)), DType::Variant(_) => false, DType::Extension(ext_dtype) => { supports_uncompressed_size_in_bytes(ext_dtype.storage_dtype()) diff --git a/vortex-array/src/dtype/dtype_impl.rs b/vortex-array/src/dtype/dtype_impl.rs index 87fff761d81..e13262fc168 100644 --- a/vortex-array/src/dtype/dtype_impl.rs +++ b/vortex-array/src/dtype/dtype_impl.rs @@ -15,6 +15,7 @@ use crate::dtype::FieldDType; use crate::dtype::FieldName; use crate::dtype::PType; use crate::dtype::StructFields; +use crate::dtype::UnionVariants; use crate::dtype::decimal::DecimalDType; use crate::dtype::decimal::DecimalType; use crate::dtype::extension::ExtDTypeRef; @@ -63,7 +64,7 @@ impl DType { | List(_, null) | FixedSizeList(_, _, null) | Struct(_, null) - | Union(null) + | Union(_, null) | Variant(null) => matches!(null, Nullability::Nullable), Extension(ext_dtype) => ext_dtype.storage_dtype().is_nullable(), } @@ -91,7 +92,7 @@ impl DType { List(edt, _) => List(Arc::clone(edt), nullability), FixedSizeList(edt, size, _) => FixedSizeList(Arc::clone(edt), *size, nullability), Struct(sf, _) => Struct(sf.clone(), nullability), - Union(_) => Union(nullability), + Union(vs, _) => Union(vs.clone(), nullability), Variant(_) => Variant(nullability), Extension(ext) => Extension(ext.with_nullability(nullability)), } @@ -123,7 +124,15 @@ impl DType { .zip_eq(rhs_dtype.fields()) .all(|(l, r)| l.eq_ignore_nullability(&r))) } - (Union(_), Union(_)) => true, + (Union(lhs, _), Union(rhs, _)) => { + // Equal `names` implies equal length by FieldNames equality. + lhs.names() == rhs.names() + && lhs.type_ids() == rhs.type_ids() + && lhs + .variants() + .zip_eq(rhs.variants()) + .all(|(l, r)| l.eq_ignore_nullability(&r)) + } (Variant(_), Variant(_)) => true, (Extension(lhs_extdtype), Extension(rhs_extdtype)) => { lhs_extdtype.eq_ignore_nullability(rhs_extdtype) @@ -425,6 +434,24 @@ impl DType { } } + /// Get the [`UnionVariants`] if `self` is a [`DType::Union`], otherwise `None`. + pub fn as_union_variants_opt(&self) -> Option<&UnionVariants> { + if let Union(uv, _) = self { + Some(uv) + } else { + None + } + } + + /// Owned version of [Self::as_union_variants_opt]. + pub fn into_union_variants_opt(self) -> Option { + if let Union(uv, _) = self { + Some(uv) + } else { + None + } + } + /// Downcast a `DType` to an `ExtDType` pub fn as_extension(&self) -> &ExtDTypeRef { let Extension(ext) = self else { @@ -476,7 +503,7 @@ impl Display for DType { .map(|(field_null, dt)| format!("{field_null}={dt}")) .join(", "), ), - Union(null) => write!(f, "union(){null}"), + Union(uv, null) => write!(f, "union({uv}){null}"), Variant(null) => write!(f, "variant{null}"), Extension(ext) => write!(f, "{}", ext), } diff --git a/vortex-array/src/dtype/mod.rs b/vortex-array/src/dtype/mod.rs index 009c2610c73..403531779aa 100644 --- a/vortex-array/src/dtype/mod.rs +++ b/vortex-array/src/dtype/mod.rs @@ -31,6 +31,7 @@ mod ptype; pub mod serde; pub mod session; mod struct_; +mod union; use std::sync::Arc; @@ -110,11 +111,11 @@ pub enum DType { /// A logical union (sum) type. /// - /// `Union` is reserved for values that are drawn from one of several possible child domains. - /// The exact child-type metadata and canonical storage are not yet part of the stable public - /// Rust API, so most callers should prefer [`DType::Variant`] for dynamically typed values or - /// [`DType::Struct`] for fixed schemas. - Union(Nullability), + /// A `Union` is composed of one or more **variants**, each with a name and a `DType`. A per-row + /// `i8` tag selects which variant is "live" at that row. + /// + /// See [`UnionVariants`] for the type-tag conventions and accessors. + Union(UnionVariants, Nullability), /// Dynamically typed values stored as Vortex scalars. /// @@ -146,7 +147,8 @@ impl PartialEq for DType { } // StructFields handles its own Arc::ptr_eq in its PartialEq impl. (Self::Struct(a, na), Self::Struct(b, nb)) => na == nb && a == b, - (Self::Union(a), Self::Union(b)) => a == b, + // UnionVariants handles its own Arc::ptr_eq in its PartialEq impl. + (Self::Union(a, na), Self::Union(b, nb)) => na == nb && a == b, (Self::Variant(a), Self::Variant(b)) => a == b, (Self::Extension(a), Self::Extension(b)) => a == b, // Every variant is listed in the first position so that adding a new @@ -178,6 +180,7 @@ pub use half; pub use nullability::*; pub use ptype::*; pub use struct_::*; +pub use union::*; use crate::dtype::extension::ExtDTypeRef; diff --git a/vortex-array/src/dtype/serde/flatbuffers.rs b/vortex-array/src/dtype/serde/flatbuffers.rs index a61fc8f5527..803f92b6795 100644 --- a/vortex-array/src/dtype/serde/flatbuffers.rs +++ b/vortex-array/src/dtype/serde/flatbuffers.rs @@ -11,6 +11,7 @@ use itertools::Itertools; use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_error::vortex_bail; +use vortex_error::vortex_ensure; use vortex_error::vortex_err; use vortex_flatbuffers::FlatBuffer; use vortex_flatbuffers::FlatBufferRoot; @@ -21,8 +22,10 @@ use vortex_session::VortexSession; use crate::dtype::DType; use crate::dtype::DecimalDType; use crate::dtype::FieldDType; +use crate::dtype::Nullability; use crate::dtype::PType; use crate::dtype::StructFields; +use crate::dtype::UnionVariants; use crate::dtype::extension::ExtId; use crate::dtype::extension::ForeignExtDType; use crate::dtype::flatbuffers as fb; @@ -90,6 +93,42 @@ impl StructFields { } } +impl UnionVariants { + /// Creates a new instance from a flatbuffer-defined object and its underlying buffer. + fn from_fb( + fb_union: fbd::Union<'_>, + buffer: FlatBuffer, + session: VortexSession, + ) -> VortexResult { + let names = fb_union + .names() + .ok_or_else(|| vortex_err!("failed to parse union names from flatbuffer"))? + .iter() + .collect(); + + let dtypes = fb_union + .dtypes() + .ok_or_else(|| vortex_err!("failed to parse union dtypes from flatbuffer"))? + .iter() + .map(|dt| { + FieldDType::from(ViewedDType::from_fb_loc( + dt._tab.loc(), + buffer.clone(), + session.clone(), + )) + }) + .collect::>(); + + let type_ids: Vec = fb_union + .type_ids() + .ok_or_else(|| vortex_err!("failed to parse union type_ids from flatbuffer"))? + .iter() + .collect(); + + UnionVariants::try_from_fields(names, dtypes, type_ids) + } +} + impl DType { /// Create a [`DType`] from a flatbuffer buffer. pub fn from_flatbuffer(buffer: FlatBuffer, session: &VortexSession) -> VortexResult { @@ -195,7 +234,17 @@ impl TryFrom for DType { let fb_union = fb .type__as_union() .ok_or_else(|| vortex_err!("failed to parse union from flatbuffer"))?; - Ok(Self::Union(fb_union.nullable().into())) + let variants = + UnionVariants::from_fb(fb_union, vfdt.buffer().clone(), vfdt.session.clone())?; + + let nullability: Nullability = fb_union.nullable().into(); + vortex_ensure!( + variants.nullability_constraints_satisfied(nullability), + "Union nullability constraint not satisfied: nullability={:?}", + nullability + ); + + Ok(Self::Union(variants, nullability)) } fb::Type::Variant => { let fb_variant = fb @@ -341,13 +390,33 @@ impl WriteFlatBuffer for DType { ) .as_union_value() } - Self::Union(n) => fb::Union::create( - fbb, - &fb::UnionArgs { - nullable: (*n).into(), - }, - ) - .as_union_value(), + Self::Union(uv, n) => { + let names = uv + .names() + .iter() + .map(|name| fbb.create_string(name.as_ref())) + .collect_vec(); + let names = Some(fbb.create_vector(&names)); + + let dtypes = uv + .variants() + .map(|dtype| dtype.write_flatbuffer(fbb)) + .collect::>>()?; + let dtypes = Some(fbb.create_vector(&dtypes)); + + let type_ids = Some(fbb.create_vector(uv.type_ids())); + + fb::Union::create( + fbb, + &fb::UnionArgs { + names, + dtypes, + type_ids, + nullable: (*n).into(), + }, + ) + .as_union_value() + } Self::Variant(n) => fb::Variant::create( fbb, &fb::VariantArgs { @@ -446,6 +515,7 @@ mod test { use crate::dtype::DType; use crate::dtype::PType; use crate::dtype::StructFields; + use crate::dtype::UnionVariants; use crate::dtype::flatbuffers as fb; use crate::dtype::nullability::Nullability; use crate::dtype::serde::flatbuffers::ViewedDType; @@ -502,4 +572,165 @@ mod test { )); roundtrip_dtype(DType::Variant(Nullability::Nullable)); } + + fn make_basic_union() -> DType { + DType::Union( + UnionVariants::new( + ["int", "str"].into(), + vec![ + DType::Primitive(PType::I32, Nullability::NonNullable), + DType::Utf8(Nullability::NonNullable), + ], + ) + .unwrap(), + Nullability::NonNullable, + ) + } + + #[test] + fn test_union_round_trip_flatbuffer() { + roundtrip_dtype(make_basic_union()); + } + + #[test] + fn test_union_round_trip_flatbuffer_with_nullability() { + let dtype = DType::Union( + UnionVariants::new( + ["null_variant", "str"].into(), + vec![DType::Null, DType::Utf8(Nullability::NonNullable)], + ) + .unwrap(), + Nullability::Nullable, + ); + roundtrip_dtype(dtype); + } + + #[test] + fn test_union_round_trip_flatbuffer_with_type_id_indirection() { + let dtype = DType::Union( + UnionVariants::try_new( + ["a", "b", "c"].into(), + vec![ + DType::Primitive(PType::I32, Nullability::NonNullable), + DType::Utf8(Nullability::NonNullable), + DType::Bool(Nullability::NonNullable), + ], + vec![0, 5, 7], + ) + .unwrap(), + Nullability::NonNullable, + ); + + let bytes = dtype.write_flatbuffer_bytes().unwrap(); + let root_fb = root::(&bytes).unwrap(); + let view = ViewedDType::from_fb_loc( + root_fb._tab.loc(), + FlatBuffer::from(bytes.clone()), + SESSION.clone(), + ); + + let deserialized = DType::try_from(view).unwrap(); + assert_eq!(dtype, deserialized); + let DType::Union(uv, _) = &deserialized else { + panic!("Expected Union"); + }; + assert_eq!(uv.type_ids(), &[0, 5, 7]); + } + + #[test] + fn test_nested_union_round_trip_flatbuffer() { + let inner_union = make_basic_union(); + let struct_with_union = DType::Struct( + StructFields::from_iter([ + ("id", DType::Primitive(PType::I64, Nullability::NonNullable)), + ("inner", inner_union), + ]), + Nullability::NonNullable, + ); + + let outer_union = DType::Union( + UnionVariants::new( + ["plain", "nested"].into(), + vec![DType::Utf8(Nullability::NonNullable), struct_with_union], + ) + .unwrap(), + Nullability::NonNullable, + ); + + roundtrip_dtype(outer_union); + } + + /// A `UnionVariants` parsed lazily from a flatbuffer must compare equal to its eager + /// counterpart. + #[test] + fn test_union_viewed_dtype_equality() { + let eager = make_basic_union(); + + let bytes = eager.write_flatbuffer_bytes().unwrap(); + let root_fb = root::(&bytes).unwrap(); + let view = ViewedDType::from_fb_loc( + root_fb._tab.loc(), + FlatBuffer::from(bytes.clone()), + SESSION.clone(), + ); + + let viewed = DType::try_from(view).unwrap(); + assert_eq!(eager, viewed); + assert_eq!(viewed, eager); + } + + /// A malformed flatbuffer (here, `dtypes.len() != type_ids.len()`) must round-trip + /// to `Err`, not panic. + #[test] + fn test_union_malformed_flatbuffer_errors() { + use flatbuffers::FlatBufferBuilder; + use vortex_buffer::ByteBuffer; + use vortex_flatbuffers::WriteFlatBuffer; + + let mut fbb = FlatBufferBuilder::new(); + + // Build a Union with 2 names + 2 dtypes but only 1 type_id. + let name_a = fbb.create_string("a"); + let name_b = fbb.create_string("b"); + let names = fbb.create_vector(&[name_a, name_b]); + + let dtype_a = DType::Primitive(PType::I32, Nullability::NonNullable) + .write_flatbuffer(&mut fbb) + .unwrap(); + let dtype_b = DType::Utf8(Nullability::NonNullable) + .write_flatbuffer(&mut fbb) + .unwrap(); + let dtypes = fbb.create_vector(&[dtype_a, dtype_b]); + + let type_ids = fbb.create_vector::(&[0]); + + let union_table = fb::Union::create( + &mut fbb, + &fb::UnionArgs { + names: Some(names), + dtypes: Some(dtypes), + type_ids: Some(type_ids), + nullable: false, + }, + ); + + let dtype = fb::DType::create( + &mut fbb, + &fb::DTypeArgs { + type_type: fb::Type::Union, + type_: Some(union_table.as_union_value()), + }, + ); + fbb.finish_minimal(dtype); + let (vec, start) = fbb.collapse(); + let end = vec.len(); + let buffer = FlatBuffer::align_from(ByteBuffer::from(vec).slice(start..end)); + + let root_fb = root::(&buffer).unwrap(); + let view = ViewedDType::from_fb_loc(root_fb._tab.loc(), buffer, SESSION.clone()); + + let result = DType::try_from(view); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("length mismatch")); + } } diff --git a/vortex-array/src/dtype/serde/proto.rs b/vortex-array/src/dtype/serde/proto.rs index 3147168de90..ba33259a3a5 100644 --- a/vortex-array/src/dtype/serde/proto.rs +++ b/vortex-array/src/dtype/serde/proto.rs @@ -5,13 +5,16 @@ use std::sync::Arc; use vortex_error::VortexError; use vortex_error::VortexResult; +use vortex_error::vortex_ensure; use vortex_error::vortex_err; use vortex_session::VortexSession; use crate::dtype::DType; use crate::dtype::DecimalDType; +use crate::dtype::Nullability; use crate::dtype::PType; use crate::dtype::StructFields; +use crate::dtype::UnionVariants; use crate::dtype::extension::ExtId; use crate::dtype::extension::ForeignExtDType; use crate::dtype::field::Field; @@ -86,7 +89,33 @@ impl DType { ), s.nullable.into(), )), - DtypeType::Union(u) => Ok(Self::Union(u.nullable.into())), + DtypeType::Union(u) => { + let names = u.names.iter().map(|s| s.as_str()).collect(); + let dtypes = u + .dtypes + .iter() + .map(|dt| DType::from_proto(dt, session)) + .collect::>>()?; + let type_ids = u + .type_ids + .iter() + .map(|t| { + i8::try_from(*t).map_err(|_| { + vortex_err!("Union type_id {t} somehow does not fit in i8") + }) + }) + .collect::>>()?; + let variants = UnionVariants::try_new(names, dtypes, type_ids)?; + + let nullability: Nullability = u.nullable.into(); + vortex_ensure!( + variants.nullability_constraints_satisfied(nullability), + "Union nullability constraint not satisfied: nullability={:?}", + nullability + ); + + Ok(Self::Union(variants, nullability)) + } DtypeType::Variant(v) => Ok(Self::Variant(v.nullable.into())), DtypeType::Extension(e) => { #[expect(clippy::disallowed_methods, reason = "interning a dynamic id")] @@ -154,7 +183,13 @@ impl TryFrom<&DType> for pb::DType { .collect::>>()?, nullable: (*null).into(), }), - DType::Union(null) => DtypeType::Union(pb::Union { + DType::Union(uv, null) => DtypeType::Union(pb::Union { + names: uv.names().iter().map(|n| n.as_ref().to_string()).collect(), + dtypes: uv + .variants() + .map(|d| Self::try_from(&d)) + .collect::>>()?, + type_ids: uv.type_ids().iter().map(|t| *t as i32).collect(), nullable: (*null).into(), }), DType::Variant(null) => DtypeType::Variant(pb::Variant { @@ -239,6 +274,7 @@ mod tests { use crate::dtype::Nullability; use crate::dtype::PType; use crate::dtype::StructFields; + use crate::dtype::UnionVariants; use crate::dtype::proto::dtype::d_type::DtypeType; use crate::dtype::proto::dtype::field::FieldType; use crate::dtype::test::SESSION; @@ -381,6 +417,147 @@ mod tests { assert_eq!(DType::Variant(Nullability::Nullable), converted); } + #[test] + fn test_union_round_trip_proto() { + let variants = UnionVariants::new( + ["int", "str"].into(), + vec![ + DType::Primitive(PType::I32, Nullability::NonNullable), + DType::Utf8(Nullability::NonNullable), + ], + ) + .unwrap(); + let dtype = DType::Union(variants, Nullability::NonNullable); + let converted = round_trip_dtype(&dtype); + assert_eq!(dtype, converted); + } + + #[test] + fn test_union_round_trip_proto_with_nullability() { + let variants = UnionVariants::new( + ["null_variant", "str"].into(), + vec![DType::Null, DType::Utf8(Nullability::NonNullable)], + ) + .unwrap(); + + assert!(variants.nullability_constraints_satisfied(Nullability::Nullable)); + assert!(!variants.nullability_constraints_satisfied(Nullability::NonNullable)); + + let dtype = DType::Union(variants, Nullability::Nullable); + let converted = round_trip_dtype(&dtype); + assert_eq!(dtype, converted); + } + + #[test] + fn test_union_round_trip_proto_with_type_id_indirection() { + let variants = UnionVariants::try_new( + ["a", "b", "c"].into(), + vec![ + DType::Primitive(PType::I32, Nullability::NonNullable), + DType::Utf8(Nullability::NonNullable), + DType::Bool(Nullability::NonNullable), + ], + vec![0, 5, 7], + ) + .unwrap(); + + let dtype = DType::Union(variants, Nullability::NonNullable); + let converted = round_trip_dtype(&dtype); + assert_eq!(dtype, converted); + + let DType::Union(uv, _) = &converted else { + panic!("Expected Union"); + }; + assert_eq!(uv.type_ids(), &[0, 5, 7]); + assert!(!uv.is_consecutive()); + } + + #[test] + fn test_union_proto_rejects_violating_nullability_constraint() { + // Nullable Union with no nullable or Null children must be rejected. + let proto = pb::DType { + dtype_type: Some(DtypeType::Union(pb::Union { + names: vec!["a".to_string(), "b".to_string()], + dtypes: vec![ + pb::DType::try_from(&DType::Primitive(PType::I32, Nullability::NonNullable)) + .unwrap(), + pb::DType::try_from(&DType::Utf8(Nullability::NonNullable)).unwrap(), + ], + type_ids: vec![0, 1], + nullable: true, + })), + }; + + let result = DType::from_proto(&proto, &SESSION); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Union nullability constraint not satisfied") + ); + } + + #[test] + fn test_union_proto_rejects_out_of_range_type_id() { + let proto = pb::DType { + dtype_type: Some(DtypeType::Union(pb::Union { + names: vec!["a".to_string()], + dtypes: vec![ + pb::DType::try_from(&DType::Primitive(PType::I32, Nullability::NonNullable)) + .unwrap(), + ], + // 200 does not fit in i8. + type_ids: vec![200], + nullable: false, + })), + }; + + let result = DType::from_proto(&proto, &SESSION); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("does not fit in i8") + ); + } + + #[test] + fn test_nested_union_round_trip_proto() { + let inner_union = DType::Union( + UnionVariants::new( + ["a", "b"].into(), + vec![ + DType::Primitive(PType::I32, Nullability::NonNullable), + DType::Utf8(Nullability::NonNullable), + ], + ) + .unwrap(), + Nullability::NonNullable, + ); + + let struct_with_union = DType::Struct( + StructFields::from_iter([ + ("id", DType::Primitive(PType::I64, Nullability::NonNullable)), + ("inner", inner_union), + ]), + Nullability::NonNullable, + ); + + let outer_union = DType::Union( + UnionVariants::new( + ["plain", "nested"].into(), + vec![DType::Utf8(Nullability::NonNullable), struct_with_union], + ) + .unwrap(), + Nullability::NonNullable, + ); + + let converted = round_trip_dtype(&outer_union); + assert_eq!(outer_union, converted); + } + #[test] fn test_field_path_round_trip() { let test_paths = vec![ diff --git a/vortex-array/src/dtype/serde/serde.rs b/vortex-array/src/dtype/serde/serde.rs index 789fd17306b..9d127047b40 100644 --- a/vortex-array/src/dtype/serde/serde.rs +++ b/vortex-array/src/dtype/serde/serde.rs @@ -27,6 +27,7 @@ use crate::dtype::FieldNames; use crate::dtype::Nullability; use crate::dtype::PType; use crate::dtype::StructFields; +use crate::dtype::UnionVariants; use crate::dtype::decimal::DecimalDType; use crate::dtype::extension::ExtDTypeRef; use crate::dtype::extension::ExtId; @@ -115,7 +116,12 @@ impl Serialize for DType { state.serialize_field(n)?; state.end() } - DType::Union(n) => serializer.serialize_newtype_variant("DType", 11, "Union", n), + DType::Union(uv, n) => { + let mut state = serializer.serialize_tuple_variant("DType", 11, "Union", 2)?; + state.serialize_field(&uv)?; + state.serialize_field(n)?; + state.end() + } DType::Variant(n) => serializer.serialize_newtype_variant("DType", 10, "Variant", n), DType::Extension(ext) => { serializer.serialize_newtype_variant("DType", 9, "Extension", ext) @@ -137,6 +143,20 @@ impl Serialize for StructFields { } } +impl Serialize for UnionVariants { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut state = serializer.serialize_struct("UnionVariants", 3)?; + state.serialize_field("names", self.names())?; + let dtypes: Vec = self.variants().collect(); + state.serialize_field("dtypes", &dtypes)?; + state.serialize_field("type_ids", self.type_ids())?; + state.end() + } +} + // ============================================================================ // DType Deserialization with session context (DeserializeSeed) // ============================================================================ @@ -217,10 +237,9 @@ impl<'de> DeserializeSeed<'de> for DTypeSerde<'_, DType> { "Struct" => access.newtype_variant_seed(StructFieldsSeed { session: self.session, }), - "Union" => { - let n = access.newtype_variant()?; - Ok(DType::Union(n)) - } + "Union" => access.newtype_variant_seed(UnionVariantsSeed { + session: self.session, + }), "Variant" => { let n = access.newtype_variant()?; Ok(DType::Variant(n)) @@ -391,6 +410,132 @@ impl<'de> DeserializeSeed<'de> for StructFieldsSeed<'_> { } } +struct UnionVariantsSeed<'a> { + session: &'a VortexSession, +} + +impl<'de> DeserializeSeed<'de> for UnionVariantsSeed<'_> { + type Value = DType; + + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct UnionVisitor<'a> { + session: &'a VortexSession, + } + + impl<'de> Visitor<'de> for UnionVisitor<'_> { + type Value = DType; + + fn expecting(&self, f: &mut Formatter) -> fmt::Result { + f.write_str("Union tuple (variants, nullability)") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let variants = seq + .next_element_seed(DTypeSerde::::new(self.session))? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + let nullability: Nullability = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + if !variants.nullability_constraints_satisfied(nullability) { + return Err(de::Error::custom(format!( + "Union nullability constraint not satisfied: nullability={:?}", + nullability + ))); + } + Ok(DType::Union(variants, nullability)) + } + } + + deserializer.deserialize_tuple( + 2, + UnionVisitor { + session: self.session, + }, + ) + } +} + +impl<'de> DeserializeSeed<'de> for DTypeSerde<'_, UnionVariants> { + type Value = UnionVariants; + + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + const FIELDS: &[&str] = &["names", "dtypes", "type_ids"]; + + struct UnionVariantsInnerVisitor<'a> { + session: &'a VortexSession, + } + + impl<'de> Visitor<'de> for UnionVariantsInnerVisitor<'_> { + type Value = UnionVariants; + + fn expecting(&self, f: &mut Formatter) -> fmt::Result { + f.write_str("struct UnionVariants") + } + + fn visit_map(self, mut map: V) -> Result + where + V: MapAccess<'de>, + { + let mut names: Option = None; + let mut dtypes: Option> = None; + let mut type_ids: Option> = None; + + while let Some(key) = map.next_key::>()? { + match key.as_ref() { + "names" => { + if names.is_some() { + return Err(de::Error::duplicate_field("names")); + } + names = Some(map.next_value()?); + } + "dtypes" => { + if dtypes.is_some() { + return Err(de::Error::duplicate_field("dtypes")); + } + dtypes = Some( + map.next_value_seed(DTypeSerde::>::new(self.session))?, + ); + } + "type_ids" => { + if type_ids.is_some() { + return Err(de::Error::duplicate_field("type_ids")); + } + type_ids = Some(map.next_value()?); + } + _ => { + let _ = map.next_value::()?; + } + } + } + + let names = names.ok_or_else(|| de::Error::missing_field("names"))?; + let dtypes = dtypes.ok_or_else(|| de::Error::missing_field("dtypes"))?; + let type_ids = type_ids.ok_or_else(|| de::Error::missing_field("type_ids"))?; + + UnionVariants::try_new(names, dtypes, type_ids) + .map_err(|e| de::Error::custom(e.to_string())) + } + } + + deserializer.deserialize_struct( + "UnionVariants", + FIELDS, + UnionVariantsInnerVisitor { + session: self.session, + }, + ) + } +} + impl<'de> DeserializeSeed<'de> for DTypeSerde<'_, StructFields> { type Value = StructFields; diff --git a/vortex-array/src/dtype/union.rs b/vortex-array/src/dtype/union.rs new file mode 100644 index 00000000000..cb443e87d36 --- /dev/null +++ b/vortex-array/src/dtype/union.rs @@ -0,0 +1,614 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use std::fmt; +use std::hash::Hash; +use std::sync::Arc; + +use itertools::Itertools; +use vortex_error::VortexExpect; +use vortex_error::VortexResult; +use vortex_error::vortex_ensure; +use vortex_error::vortex_ensure_eq; + +use crate::dtype::DType; +use crate::dtype::FieldDType; +use crate::dtype::FieldNames; +use crate::dtype::Nullability; + +/// Type information for a union array. +/// +/// A `UnionVariants` describes the possible alternative types for each row of a union, along with a +/// per-variant `i8` type tag. We use the term **variants** (rather than "fields") because a union +/// is a sum type: each row chooses exactly one alternative. +/// +/// Per Arrow's spec, the per-row type tag is an `int8`. By default, tag `i` selects the child at +/// offset `i` (`type_ids = [0, 1, ..., N-1]`). +/// +/// Schemas may also use non-consecutive tags (e.g. `[0, 5, 7]`), in which case the value of +/// `type_ids[i]` is the tag used in the data to select the child at offset `i`. Supporting +/// non-consecutive tags lets the schema remove children without renumbering the remaining tags. +/// +/// Variant names must be distinct. Unlike [`StructFields`](crate::dtype::StructFields), which +/// permits duplicate field names for Arrow/Parquet round-trip fidelity, duplicates have no +/// meaningful semantics in a sum type and are rejected at construction. +/// +/// Note that the Arrow spec does not require distinct names (children are selected by the type-id +/// buffer, never by name), but DuckDB requires distinct member names for its `UNION` type and +/// resolves members by name (e.g. in `union_extract`), so permitting duplicates would break DuckDB +/// round-trips while enabling no useful semantics. +/// +/// ``` +/// use vortex_array::dtype::{DType, Nullability, PType, UnionVariants}; +/// +/// let variants = UnionVariants::new( +/// ["a", "b"].into(), +/// vec![ +/// DType::Primitive(PType::I32, Nullability::NonNullable), +/// DType::Utf8(Nullability::NonNullable), +/// ], +/// ) +/// .unwrap(); +/// +/// assert_eq!(variants.len(), 2); +/// assert_eq!(variants.type_ids(), &[0, 1]); +/// ``` +#[allow( + clippy::derived_hash_with_manual_eq, + reason = "manual PartialEq adds Arc::ptr_eq fast path only" +)] +#[derive(Clone, Eq, Hash)] +pub struct UnionVariants(Arc); + +impl PartialEq for UnionVariants { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) || self.0 == other.0 + } +} + +impl fmt::Debug for UnionVariants { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("UnionVariants") + .field("names", &self.0.names) + .field("dtypes", &self.0.dtypes) + .field("type_ids", &self.0.type_ids) + .finish() + } +} + +impl fmt::Display for UnionVariants { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Surface non-consecutive type tags so they aren't hidden by the format. Consecutive + // `[0, 1, ..., N-1]` is the common case and is left implicit. + let show_tags = !self.is_consecutive(); + write!( + f, + "{}", + self.names() + .iter() + .zip(self.variants()) + .zip(self.type_ids().iter()) + .map(|((name, dt), tag)| { + if show_tags { + format!("{name}@{tag}={dt}") + } else { + format!("{name}={dt}") + } + }) + .join(", ") + ) + } +} + +struct UnionVariantsInner { + /// The names of the variants. This is called `FieldNames` because it is shared with the + /// [`StructFields`](crate::dtype::StructFields) implementation. + names: FieldNames, + + /// The types of each of the variants. + dtypes: Arc<[FieldDType]>, + + /// One tag per variant, in variant order. The common case where children are referenced by + /// consecutive offsets is `[0, 1, ..., N-1]`. + /// + /// For schemas with explicit `typeIds` indirection (e.g. `[0, 5, 7]`), this stores those tags. + type_ids: Arc<[i8]>, +} + +impl UnionVariantsInner { + fn from_fields(names: FieldNames, dtypes: Arc<[FieldDType]>, type_ids: Arc<[i8]>) -> Self { + Self { + names, + dtypes, + type_ids, + } + } +} + +impl PartialEq for UnionVariantsInner { + fn eq(&self, other: &Self) -> bool { + self.names == other.names && self.dtypes == other.dtypes && self.type_ids == other.type_ids + } +} + +impl Eq for UnionVariantsInner {} + +impl Hash for UnionVariantsInner { + fn hash(&self, state: &mut H) { + self.names.hash(state); + self.dtypes.hash(state); + self.type_ids.hash(state); + } +} + +impl Default for UnionVariants { + fn default() -> Self { + Self::empty() + } +} + +impl UnionVariants { + /// The variants of the empty union. + pub fn empty() -> Self { + Self(Arc::new(UnionVariantsInner::from_fields( + FieldNames::default(), + Arc::from([]), + Arc::from([]), + ))) + } + + /// Validate that `names`, `dtypes`, and `type_ids` are mutually consistent. + fn validate_shape(names: &FieldNames, n_dtypes: usize, type_ids: &[i8]) -> VortexResult<()> { + vortex_ensure_eq!( + names.len(), + n_dtypes, + "length mismatch between names ({}) and dtypes ({})", + names.len(), + n_dtypes + ); + vortex_ensure_eq!( + names.len(), + type_ids.len(), + "length mismatch between names ({}) and type_ids ({})", + names.len(), + type_ids.len() + ); + + vortex_ensure!( + type_ids.iter().all_unique(), + "type_ids must be distinct, got {:?}", + type_ids + ); + vortex_ensure!( + names.iter().all_unique(), + "union variant names must be distinct, got {:?}", + names + ); + + Ok(()) + } + + /// Create a new [`UnionVariants`] with explicit `type_ids`. + /// + /// # Errors + /// + /// Returns an error if names, dtypes, or type IDs do not all have the same length, or if there + /// are any duplicate names or type ids. + pub fn try_new(names: FieldNames, dtypes: Vec, type_ids: Vec) -> VortexResult { + Self::validate_shape(&names, dtypes.len(), &type_ids)?; + + let dtypes: Arc<[FieldDType]> = dtypes.into_iter().map(FieldDType::from).collect(); + let type_ids: Arc<[i8]> = Arc::from(type_ids); + + Ok(Self(Arc::new(UnionVariantsInner::from_fields( + names, dtypes, type_ids, + )))) + } + + /// Create a new [`UnionVariants`] with consecutive `type_ids = [0, 1, ..., N-1]`. + /// + /// # Errors + /// + /// `names` and `dtypes` must have the same length, and `names.len()` cannot be more than + /// `i8::MAX as usize + 1` (128). + pub fn new(names: FieldNames, dtypes: Vec) -> VortexResult { + const MAX_CONSECUTIVE: usize = i8::MAX as usize + 1; + vortex_ensure!( + names.len() <= MAX_CONSECUTIVE, + "union supports at most {} consecutive variants, got {}", + MAX_CONSECUTIVE, + names.len() + ); + + #[expect( + clippy::cast_possible_truncation, + reason = "the MAX_CONSECUTIVE bound above guarantees `i as i8` is in range" + )] + let type_ids: Vec = (0..names.len()).map(|i| i as i8).collect(); + + Self::try_new(names, dtypes, type_ids) + } + + /// Create a new [`UnionVariants`] from pre-constructed [`FieldDType`]s, which may be owned or + /// backed by a flatbuffer view. + /// + /// Used by deserialization paths where the children may be lazily backed by a flatbuffer. + /// + /// # Errors + /// + /// Returns an error if names, dtypes, or type IDs do not all have the same length, or if there + /// are any duplicate names or type ids. + pub(crate) fn try_from_fields( + names: FieldNames, + dtypes: Vec, + type_ids: Vec, + ) -> VortexResult { + Self::validate_shape(&names, dtypes.len(), &type_ids)?; + + Ok(Self(Arc::new(UnionVariantsInner::from_fields( + names, + dtypes.into(), + Arc::from(type_ids), + )))) + } + + /// Get the names of the variants in the union. + pub fn names(&self) -> &FieldNames { + &self.0.names + } + + /// Returns the number of variants in the union. + pub fn len(&self) -> usize { + self.0.names.len() + } + + /// Returns true if the union has no variants. + pub fn is_empty(&self) -> bool { + self.0.names.is_empty() + } + + /// Returns the per-variant type tag vector. Entry `i` is the tag that the data uses to + /// select the variant at offset `i`. + pub fn type_ids(&self) -> &[i8] { + &self.0.type_ids + } + + /// Returns `true` if the type tags are the consecutive sequence `[0, 1, ..., N-1]`. + pub fn is_consecutive(&self) -> bool { + self.0 + .type_ids + .iter() + .enumerate() + .all(|(i, &tag)| i8::try_from(i).is_ok_and(|i| i == tag)) + } + + /// Find the offset of a variant by name. Returns `None` if no variant has the name. + /// + /// This is a linear scan over [`Self::names`]. A union has at most 128 variants (and usually + /// far fewer), so a linear scan beats building and probing a `HashMap` in practice. + pub fn find(&self, name: impl AsRef) -> Option { + let name = name.as_ref(); + self.0.names.iter().position(|n| n.as_ref() == name) + } + + /// Get the [`DType`] of a variant by name. Returns `None` if no variant has the name. + pub fn variant(&self, name: impl AsRef) -> Option { + let index = self.find(name)?; + Some( + self.0.dtypes[index] + .value() + .vortex_expect("variant DType must be valid"), + ) + } + + /// Get the [`DType`] of a variant by offset. + pub fn variant_by_index(&self, index: usize) -> Option { + Some( + self.0 + .dtypes + .get(index)? + .value() + .vortex_expect("variant DType must be valid"), + ) + } + + /// Returns an ordered iterator over the variants. + pub fn variants(&self) -> impl ExactSizeIterator + '_ { + self.0 + .dtypes + .iter() + .map(|dt| dt.value().vortex_expect("variant DType must be valid")) + } + + /// Convert a data-level type tag to a child offset. Returns `None` if the tag is not in + /// `type_ids`. + /// + /// This is a linear scan over [`Self::type_ids`]. The number of variants is bounded by + /// `i8::MAX + 1 = 128`, which fits in two cache lines, so a linear scan is faster than a + /// `HashMap` lookup in practice. + pub fn tag_to_child_index(&self, tag: i8) -> Option { + self.0.type_ids.iter().position(|&t| t == tag) + } + + /// Convert a child offset to its data-level type tag. + /// + /// # Panics + /// + /// Panics if `index >= self.len()`. + pub fn child_index_to_tag(&self, index: usize) -> i8 { + self.0.type_ids[index] + } + + /// Checks whether this set of variants satisfies the constraint imposed by a union-level + /// `Nullability`: + /// + /// - `Nullable`: at least one variant is `DType::Null` or has nullable nullability. + /// - `NonNullable`: no `DType::Null` variants and no nullable variants. + /// + /// The check materializes each [`FieldDType`] (so it may be expensive if some are still + /// flatbuffer-backed views). + /// + /// It is intentionally not part of `UnionVariants` construction since `Nullability` lives on + /// the `DType::Union(_, Nullability)` enum variant, not on `UnionVariants` itself. + pub fn nullability_constraints_satisfied(&self, union_nullability: Nullability) -> bool { + let has_null_or_nullable = self + .variants() + .any(|dt| matches!(dt, DType::Null) || dt.is_nullable()); + + match union_nullability { + Nullability::Nullable => has_null_or_nullable, + Nullability::NonNullable => !has_null_or_nullable, + } + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use crate::dtype::DType; + use crate::dtype::FieldNames; + use crate::dtype::Nullability; + use crate::dtype::PType; + use crate::dtype::UnionVariants; + + fn i32_variants() -> UnionVariants { + UnionVariants::new( + ["int", "str"].into(), + vec![ + DType::Primitive(PType::I32, Nullability::NonNullable), + DType::Utf8(Nullability::NonNullable), + ], + ) + .unwrap() + } + + #[test] + fn test_consecutive_type_ids() { + let variants = i32_variants(); + assert_eq!(variants.type_ids(), &[0, 1]); + assert!(variants.is_consecutive()); + assert_eq!(variants.tag_to_child_index(0), Some(0)); + assert_eq!(variants.tag_to_child_index(1), Some(1)); + assert_eq!(variants.tag_to_child_index(2), None); + assert_eq!(variants.child_index_to_tag(0), 0); + assert_eq!(variants.child_index_to_tag(1), 1); + } + + #[test] + fn test_type_id_indirection() { + let variants = UnionVariants::try_new( + ["a", "b", "c"].into(), + vec![ + DType::Primitive(PType::I32, Nullability::NonNullable), + DType::Utf8(Nullability::NonNullable), + DType::Bool(Nullability::NonNullable), + ], + vec![0, 5, 7], + ) + .unwrap(); + + assert_eq!(variants.type_ids(), &[0, 5, 7]); + assert!(!variants.is_consecutive()); + assert_eq!(variants.tag_to_child_index(0), Some(0)); + assert_eq!(variants.tag_to_child_index(5), Some(1)); + assert_eq!(variants.tag_to_child_index(7), Some(2)); + assert_eq!(variants.tag_to_child_index(1), None); + } + + #[test] + fn test_find() { + let variants = i32_variants(); + assert_eq!(variants.find("int"), Some(0)); + assert_eq!(variants.find("str"), Some(1)); + assert!(variants.find("missing").is_none()); + + let value = variants.variant("int").unwrap(); + assert_eq!( + value, + DType::Primitive(PType::I32, Nullability::NonNullable) + ); + } + + #[test] + fn test_duplicate_names_rejected() { + let result = UnionVariants::new( + ["dup", "dup"].into(), + vec![ + DType::Primitive(PType::I32, Nullability::NonNullable), + DType::Primitive(PType::I64, Nullability::NonNullable), + ], + ); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("distinct")); + } + + #[test] + fn test_length_mismatch_rejected() { + let result = UnionVariants::try_new( + ["a", "b"].into(), + vec![DType::Primitive(PType::I32, Nullability::NonNullable)], + vec![0, 1], + ); + assert!(result.is_err()); + + let result = UnionVariants::try_new( + ["a"].into(), + vec![DType::Primitive(PType::I32, Nullability::NonNullable)], + vec![0, 1], + ); + assert!(result.is_err()); + } + + #[test] + fn test_duplicate_type_ids_rejected() { + let result = UnionVariants::try_new( + ["a", "b"].into(), + vec![ + DType::Primitive(PType::I32, Nullability::NonNullable), + DType::Utf8(Nullability::NonNullable), + ], + vec![3, 3], + ); + assert!(result.is_err()); + } + + #[rstest] + #[case::nullable_with_null_child( + vec![ + DType::Null, + DType::Primitive(PType::I32, Nullability::NonNullable), + ], + Nullability::Nullable, + true, + )] + #[case::nullable_with_nullable_child( + vec![ + DType::Primitive(PType::I32, Nullability::NonNullable), + DType::Utf8(Nullability::Nullable), + ], + Nullability::Nullable, + true, + )] + #[case::nullable_with_no_null_or_nullable( + vec![ + DType::Primitive(PType::I32, Nullability::NonNullable), + DType::Utf8(Nullability::NonNullable), + ], + Nullability::Nullable, + false, + )] + #[case::nonnullable_with_null_child( + vec![ + DType::Null, + DType::Primitive(PType::I32, Nullability::NonNullable), + ], + Nullability::NonNullable, + false, + )] + #[case::nonnullable_with_nullable_child( + vec![ + DType::Primitive(PType::I32, Nullability::NonNullable), + DType::Utf8(Nullability::Nullable), + ], + Nullability::NonNullable, + false, + )] + #[case::nonnullable_clean( + vec![ + DType::Primitive(PType::I32, Nullability::NonNullable), + DType::Utf8(Nullability::NonNullable), + ], + Nullability::NonNullable, + true, + )] + fn test_nullability_constraints( + #[case] dtypes: Vec, + #[case] nullability: Nullability, + #[case] expected: bool, + ) { + let names: Vec<&str> = (0..dtypes.len()).map(|i| ["a", "b", "c", "d"][i]).collect(); + let variants = UnionVariants::new(names.as_slice().into(), dtypes).unwrap(); + assert_eq!( + variants.nullability_constraints_satisfied(nullability), + expected + ); + } + + #[test] + fn test_display() { + let variants = i32_variants(); + let dtype = DType::Union(variants, Nullability::NonNullable); + assert_eq!(dtype.to_string(), "union(int=i32, str=utf8)"); + + let nullable = DType::Union( + UnionVariants::new( + ["int", "maybe_str"].into(), + vec![ + DType::Primitive(PType::I32, Nullability::NonNullable), + DType::Utf8(Nullability::Nullable), + ], + ) + .unwrap(), + Nullability::Nullable, + ); + assert_eq!(nullable.to_string(), "union(int=i32, maybe_str=utf8?)?"); + } + + #[test] + fn test_display_with_type_id_indirection() { + let variants = UnionVariants::try_new( + ["a", "b", "c"].into(), + vec![ + DType::Primitive(PType::I32, Nullability::NonNullable), + DType::Utf8(Nullability::NonNullable), + DType::Bool(Nullability::NonNullable), + ], + vec![0, 5, 7], + ) + .unwrap(); + let dtype = DType::Union(variants, Nullability::NonNullable); + assert_eq!(dtype.to_string(), "union(a@0=i32, b@5=utf8, c@7=bool)"); + } + + #[test] + fn test_new_max_size() { + // 128 variants is the maximum for consecutive type_ids: tags 0..=127 all fit in i8. + let names: Vec = (0..128).map(|i| format!("v{i}")).collect(); + let dtypes: Vec = (0..128) + .map(|_| DType::Primitive(PType::I32, Nullability::NonNullable)) + .collect(); + let names: FieldNames = names.into_iter().collect(); + let variants = UnionVariants::new(names, dtypes).unwrap(); + assert_eq!(variants.len(), 128); + assert_eq!(variants.type_ids()[127], 127); + assert!(variants.is_consecutive()); + } + + #[test] + fn test_new_too_large_rejected() { + // 129 variants exceeds i8::MAX + 1 = 128. + let names: Vec = (0..129).map(|i| format!("v{i}")).collect(); + let dtypes: Vec = (0..129) + .map(|_| DType::Primitive(PType::I32, Nullability::NonNullable)) + .collect(); + let names: FieldNames = names.into_iter().collect(); + let result = UnionVariants::new(names, dtypes); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("at most 128 consecutive variants") + ); + } + + #[test] + fn test_empty() { + let v = UnionVariants::empty(); + assert!(v.is_empty()); + assert_eq!(v.len(), 0); + assert_eq!(v.type_ids(), &[] as &[i8]); + assert!(v.is_consecutive()); + } +} diff --git a/vortex-array/src/scalar_fn/fns/binary/compare/mod.rs b/vortex-array/src/scalar_fn/fns/binary/compare/mod.rs index c7d6c79c7ae..70e98639b54 100644 --- a/vortex-array/src/scalar_fn/fns/binary/compare/mod.rs +++ b/vortex-array/src/scalar_fn/fns/binary/compare/mod.rs @@ -217,7 +217,7 @@ fn compare_arrays( DType::Struct(..) | DType::List(..) | DType::FixedSizeList(..) => { nested::compare_nested(lhs, rhs, op, nullability, ctx) } - DType::Union(_) | DType::Variant(_) | DType::Extension(_) => { + DType::Union(..) | DType::Variant(_) | DType::Extension(_) => { vortex_bail!("compare is not supported for dtype {}", lhs.dtype()) } } diff --git a/vortex-array/src/scalar_fn/fns/binary/compare/nested.rs b/vortex-array/src/scalar_fn/fns/binary/compare/nested.rs index cbc38d6b23e..56d8cbf0496 100644 --- a/vortex-array/src/scalar_fn/fns/binary/compare/nested.rs +++ b/vortex-array/src/scalar_fn/fns/binary/compare/nested.rs @@ -213,7 +213,7 @@ fn build_values_comparator( let rhs = rhs.clone().execute::(ctx)?; build_comparator(lhs.storage_array(), rhs.storage_array(), ctx)? } - DType::Union(_) | DType::Variant(_) => { + DType::Union(..) | DType::Variant(_) => { vortex_bail!("compare is not supported for dtype {}", lhs.dtype()) } }) diff --git a/vortex-datafusion/src/convert/scalars.rs b/vortex-datafusion/src/convert/scalars.rs index 86c1309df57..1e11dd6eede 100644 --- a/vortex-datafusion/src/convert/scalars.rs +++ b/vortex-datafusion/src/convert/scalars.rs @@ -382,6 +382,7 @@ mod tests { use vortex::dtype::Nullability; use vortex::dtype::PType; use vortex::dtype::StructFields; + use vortex::dtype::UnionVariants; use vortex::dtype::i256; use vortex::scalar::DecimalValue; use vortex::scalar::Scalar; @@ -826,7 +827,14 @@ mod tests { 2, Nullability::Nullable )))] - #[case::union(Scalar::null(DType::Union(Nullability::Nullable)))] + #[case::union(Scalar::null(DType::Union( + UnionVariants::new( + ["a"].into(), + vec![DType::Primitive(PType::I32, Nullability::Nullable)], + ) + .unwrap(), + Nullability::Nullable + )))] fn unsupported_vortex_scalars_return_errors(#[case] scalar: Scalar) { let err = scalar.try_to_df().unwrap_err(); diff --git a/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs b/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs index 7eb6cc9fb39..fe737eafe0f 100644 --- a/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs +++ b/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs @@ -67,8 +67,13 @@ table Variant { nullable: bool; } +// Explicit ids keep `nullable` at id 0, the id it had before the variant metadata fields were +// added, so schemas written in between remain readable. table Union { - nullable: bool; + names: [string] (id: 1); + dtypes: [DType] (id: 2); + type_ids: [byte] (id: 3); // length must equal dtypes.len() + nullable: bool (id: 0); } union Type { diff --git a/vortex-flatbuffers/src/generated/dtype.rs b/vortex-flatbuffers/src/generated/dtype.rs index e58679fb816..8b26f68dc34 100644 --- a/vortex-flatbuffers/src/generated/dtype.rs +++ b/vortex-flatbuffers/src/generated/dtype.rs @@ -1478,6 +1478,9 @@ impl<'a> ::flatbuffers::Follow<'a> for Union<'a> { impl<'a> Union<'a> { pub const VT_NULLABLE: ::flatbuffers::VOffsetT = 4; + pub const VT_NAMES: ::flatbuffers::VOffsetT = 6; + pub const VT_DTYPES: ::flatbuffers::VOffsetT = 8; + pub const VT_TYPE_IDS: ::flatbuffers::VOffsetT = 10; #[inline] pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { @@ -1486,9 +1489,12 @@ impl<'a> Union<'a> { #[allow(unused_mut)] pub fn create<'bldr: 'args, 'args: 'mut_bldr, 'mut_bldr, A: ::flatbuffers::Allocator + 'bldr>( _fbb: &'mut_bldr mut ::flatbuffers::FlatBufferBuilder<'bldr, A>, - args: &'args UnionArgs + args: &'args UnionArgs<'args> ) -> ::flatbuffers::WIPOffset> { let mut builder = UnionBuilder::new(_fbb); + if let Some(x) = args.type_ids { builder.add_type_ids(x); } + if let Some(x) = args.dtypes { builder.add_dtypes(x); } + if let Some(x) = args.names { builder.add_names(x); } builder.add_nullable(args.nullable); builder.finish() } @@ -1501,6 +1507,27 @@ impl<'a> Union<'a> { // which contains a valid value in this slot unsafe { self._tab.get::(Union::VT_NULLABLE, Some(false)).unwrap()} } + #[inline] + pub fn names(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>>>(Union::VT_NAMES, None)} + } + #[inline] + pub fn dtypes(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>(Union::VT_DTYPES, None)} + } + #[inline] + pub fn type_ids(&self) -> Option<::flatbuffers::Vector<'a, i8>> { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, i8>>>(Union::VT_TYPE_IDS, None)} + } } impl ::flatbuffers::Verifiable for Union<'_> { @@ -1510,18 +1537,27 @@ impl ::flatbuffers::Verifiable for Union<'_> { ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { v.visit_table(pos)? .visit_field::("nullable", Self::VT_NULLABLE, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset<&'_ str>>>>("names", Self::VT_NAMES, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>>>("dtypes", Self::VT_DTYPES, false)? + .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, i8>>>("type_ids", Self::VT_TYPE_IDS, false)? .finish(); Ok(()) } } -pub struct UnionArgs { +pub struct UnionArgs<'a> { pub nullable: bool, + pub names: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>>>, + pub dtypes: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>>, + pub type_ids: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, i8>>>, } -impl<'a> Default for UnionArgs { +impl<'a> Default for UnionArgs<'a> { #[inline] fn default() -> Self { UnionArgs { nullable: false, + names: None, + dtypes: None, + type_ids: None, } } } @@ -1536,6 +1572,18 @@ impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> UnionBuilder<'a, 'b, A> { self.fbb_.push_slot::(Union::VT_NULLABLE, nullable, false); } #[inline] + pub fn add_names(&mut self, names: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset<&'b str>>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Union::VT_NAMES, names); + } + #[inline] + pub fn add_dtypes(&mut self, dtypes: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset>>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Union::VT_DTYPES, dtypes); + } + #[inline] + pub fn add_type_ids(&mut self, type_ids: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , i8>>) { + self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Union::VT_TYPE_IDS, type_ids); + } + #[inline] pub fn new(_fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>) -> UnionBuilder<'a, 'b, A> { let start = _fbb.start_table(); UnionBuilder { @@ -1554,6 +1602,9 @@ impl ::core::fmt::Debug for Union<'_> { fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { let mut ds = f.debug_struct("Union"); ds.field("nullable", &self.nullable()); + ds.field("names", &self.names()); + ds.field("dtypes", &self.dtypes()); + ds.field("type_ids", &self.type_ids()); ds.finish() } } diff --git a/vortex-flatbuffers/src/generated/message.rs b/vortex-flatbuffers/src/generated/message.rs index b3e4be76ef7..9cc48fe6be6 100644 --- a/vortex-flatbuffers/src/generated/message.rs +++ b/vortex-flatbuffers/src/generated/message.rs @@ -2,8 +2,8 @@ // @generated extern crate alloc; -use crate::array::*; use crate::dtype::*; +use crate::array::*; #[deprecated(since = "2.0.0", note = "Use associated constants instead. This will no longer be generated in 2021.")] pub const ENUM_MIN_MESSAGE_VERSION: u8 = 0; diff --git a/vortex-proto/proto/dtype.proto b/vortex-proto/proto/dtype.proto index 51b99217efb..4a221cef043 100644 --- a/vortex-proto/proto/dtype.proto +++ b/vortex-proto/proto/dtype.proto @@ -75,6 +75,9 @@ message Variant { } message Union { + repeated string names = 1; + repeated DType dtypes = 2; + repeated int32 type_ids = 3; // length must equal dtypes.len(); each value must fit in int8 bool nullable = 4; } diff --git a/vortex-proto/src/generated/vortex.dtype.rs b/vortex-proto/src/generated/vortex.dtype.rs index ffbac88cea4..27e44089253 100644 --- a/vortex-proto/src/generated/vortex.dtype.rs +++ b/vortex-proto/src/generated/vortex.dtype.rs @@ -71,8 +71,15 @@ pub struct Variant { #[prost(bool, tag = "1")] pub nullable: bool, } -#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct Union { + #[prost(string, repeated, tag = "1")] + pub names: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(message, repeated, tag = "2")] + pub dtypes: ::prost::alloc::vec::Vec, + /// length must equal dtypes.len(); each value must fit in int8 + #[prost(int32, repeated, tag = "3")] + pub type_ids: ::prost::alloc::vec::Vec, #[prost(bool, tag = "4")] pub nullable: bool, } diff --git a/vortex-row/src/codec.rs b/vortex-row/src/codec.rs index fa0959943db..fd14bb254b8 100644 --- a/vortex-row/src/codec.rs +++ b/vortex-row/src/codec.rs @@ -228,7 +228,7 @@ pub(crate) fn row_width_for_dtype(dtype: &DType) -> VortexResult { DType::Variant(_) => { vortex_bail!("row encoding does not support Variant arrays (no well-defined ordering)") } - DType::Union(_) => vortex_bail!("row encoding does not support Union arrays"), + DType::Union(..) => vortex_bail!("row encoding does not support Union arrays"), dtype => vortex_bail!("row encoding does not support dtype: {dtype:?}"), } } From 42bea5dea147663723120e4cbb5b66ac1af6397a Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Fri, 10 Jul 2026 13:42:16 +0100 Subject: [PATCH 050/104] Add golden-corpus determinism tests for the default compressor (#8697) Tracking Issue: https://github.com/vortex-data/vortex/issues/8701 Closes: https://github.com/vortex-data/vortex/issues/6171 ## Rationale for this change Groundwork for making the compressor's selection policy pluggable (#7697): before any refactoring of scheme selection, pin the compressor's current decisions with reviewable golden snapshots, so each subsequent PR can demonstrate unchanged selection behavior by showing zero snapshot churn. Test-only, and lands first so later PRs diff against a committed baseline. This also closes #6171 by adding dedicated regression coverage for whether the compressor produces the expected array tree, across a representative corpus rather than one-off encoding assertions. ## What changes are included in this PR? A golden test suite in `vortex-btrblocks`: 20 seed-generated corpus entries spanning the registered schemes' habitats, each compressed twice per run (direct determinism check) and snapshotted as its full encoding tree with exact array-level byte counts. Three feature variants cover the default scheme pool, `unstable_encodings`, and `with_compact` (+`zstd`+`pco`). A dedicated `btrblocks-golden` CI job runs all three feature combinations explicitly. The variants are mutually exclusive at compile time (`unstable_encodings` changes `ALL_SCHEMES`), so the existing `--all-features` workspace test jobs compile the `default` variant out, and it would otherwise only run incidentally in the default-features musl job. OnPair is excluded from the golden compressors: its dictionary training is nondeterministic run-to-run (randomly-seeded hash maps in the upstream crate), so it cannot serve as a baseline. That's a pre-existing bug worth its own issue. ## What APIs are changed? Are there any user-facing changes? None (tests and CI only). --------- Signed-off-by: Connor Tsui Co-authored-by: Claude --- .github/workflows/ci.yml | 32 ++ Cargo.lock | 1 + vortex-btrblocks/Cargo.toml | 1 + vortex-btrblocks/REUSE.toml | 7 + vortex-btrblocks/tests/golden.rs | 415 ++++++++++++++++++ ...lden__compact__binary_low_cardinality.snap | 11 + .../golden__compact__bool_random.snap | 7 + .../golden__compact__decimal_prices.snap | 9 + .../golden__compact__float_alp_prices.snap | 9 + ...golden__compact__float_full_precision.snap | 7 + ...olden__compact__float_low_cardinality.snap | 13 + .../golden__compact__float_mostly_null.snap | 13 + ...den__compact__int_arithmetic_sequence.snap | 7 + .../golden__compact__int_low_cardinality.snap | 11 + .../golden__compact__int_monotone_jitter.snap | 7 + .../golden__compact__int_mostly_null.snap | 9 + .../golden__compact__int_negatives.snap | 9 + .../snapshots/golden__compact__int_runs.snap | 9 + .../golden__compact__int_sparse_outliers.snap | 7 + .../golden__compact__int_wide_random.snap | 7 + .../golden__compact__list_of_int_runs.snap | 13 + ...lden__compact__string_fsst_structured.snap | 7 + ...lden__compact__string_low_cardinality.snap | 11 + .../golden__compact__struct_mixed.snap | 19 + ...n__compact__temporal_timestamp_micros.snap | 9 + ...lden__default__binary_low_cardinality.snap | 11 + .../golden__default__bool_random.snap | 7 + .../golden__default__decimal_prices.snap | 9 + .../golden__default__float_alp_prices.snap | 9 + ...golden__default__float_full_precision.snap | 15 + ...olden__default__float_low_cardinality.snap | 19 + .../golden__default__float_mostly_null.snap | 13 + ...den__default__int_arithmetic_sequence.snap | 7 + .../golden__default__int_low_cardinality.snap | 11 + .../golden__default__int_monotone_jitter.snap | 9 + .../golden__default__int_mostly_null.snap | 13 + .../golden__default__int_negatives.snap | 9 + .../snapshots/golden__default__int_runs.snap | 15 + .../golden__default__int_sparse_outliers.snap | 13 + .../golden__default__int_wide_random.snap | 7 + .../golden__default__list_of_int_runs.snap | 25 ++ ...lden__default__string_fsst_structured.snap | 15 + ...lden__default__string_low_cardinality.snap | 15 + .../golden__default__struct_mixed.snap | 23 + ...n__default__temporal_timestamp_micros.snap | 11 + ...den__unstable__binary_low_cardinality.snap | 11 + .../golden__unstable__bool_random.snap | 7 + .../golden__unstable__decimal_prices.snap | 9 + .../golden__unstable__float_alp_prices.snap | 9 + ...olden__unstable__float_full_precision.snap | 15 + ...lden__unstable__float_low_cardinality.snap | 19 + .../golden__unstable__float_mostly_null.snap | 13 + ...en__unstable__int_arithmetic_sequence.snap | 7 + ...golden__unstable__int_low_cardinality.snap | 11 + ...golden__unstable__int_monotone_jitter.snap | 15 + .../golden__unstable__int_mostly_null.snap | 13 + .../golden__unstable__int_negatives.snap | 9 + .../snapshots/golden__unstable__int_runs.snap | 15 + ...golden__unstable__int_sparse_outliers.snap | 13 + .../golden__unstable__int_wide_random.snap | 7 + .../golden__unstable__list_of_int_runs.snap | 25 ++ ...den__unstable__string_fsst_structured.snap | 15 + ...den__unstable__string_low_cardinality.snap | 15 + .../golden__unstable__struct_mixed.snap | 23 + ...__unstable__temporal_timestamp_micros.snap | 13 + 65 files changed, 1170 insertions(+) create mode 100644 vortex-btrblocks/REUSE.toml create mode 100644 vortex-btrblocks/tests/golden.rs create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__binary_low_cardinality.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__bool_random.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__decimal_prices.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__float_alp_prices.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__float_full_precision.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__float_low_cardinality.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__float_mostly_null.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__int_arithmetic_sequence.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__int_low_cardinality.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__int_monotone_jitter.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__int_mostly_null.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__int_negatives.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__int_runs.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__int_sparse_outliers.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__int_wide_random.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__list_of_int_runs.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__string_fsst_structured.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__string_low_cardinality.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__struct_mixed.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__compact__temporal_timestamp_micros.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__binary_low_cardinality.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__bool_random.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__decimal_prices.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__float_alp_prices.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__float_full_precision.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__float_low_cardinality.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__float_mostly_null.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__int_arithmetic_sequence.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__int_low_cardinality.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__int_monotone_jitter.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__int_mostly_null.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__int_negatives.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__int_runs.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__int_sparse_outliers.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__int_wide_random.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__list_of_int_runs.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__string_fsst_structured.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__string_low_cardinality.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__struct_mixed.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__default__temporal_timestamp_micros.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__binary_low_cardinality.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__bool_random.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__decimal_prices.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__float_alp_prices.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__float_full_precision.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__float_low_cardinality.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__float_mostly_null.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__int_arithmetic_sequence.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__int_low_cardinality.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__int_monotone_jitter.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__int_mostly_null.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__int_negatives.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__int_runs.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__int_sparse_outliers.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__int_wide_random.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__list_of_int_runs.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__string_fsst_structured.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__string_low_cardinality.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__struct_mixed.snap create mode 100644 vortex-btrblocks/tests/snapshots/golden__unstable__temporal_timestamp_micros.snap diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 372e2639bb0..23af8e2c5ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -452,6 +452,38 @@ jobs: alert-title: "Rust tests (linux-arm64) failed on develop" deduplication-key: ci-rust-test-linux-arm64-failure + btrblocks-golden: + name: "Rust (btrblocks golden corpus)" + timeout-minutes: 10 + runs-on: >- + ${{ github.repository == 'vortex-data/vortex' + && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=btrblocks-golden', github.run_id) + || 'ubuntu-latest' }} + steps: + - uses: runs-on/action@v2 + if: github.repository == 'vortex-data/vortex' + with: + sccache: s3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" + # The golden suite pins the compressor's decisions per feature variant, and the + # variants are mutually exclusive at compile time: the `default` variant only exists + # without `unstable_encodings` (which changes ALL_SCHEMES), so the `--all-features` + # workspace test jobs cannot run it. Run each feature combination explicitly. + - name: Golden corpus (default features) + run: | + cargo nextest run --cargo-profile ci --locked --no-fail-fast -p vortex-btrblocks --test golden + - name: Golden corpus (unstable_encodings) + run: | + cargo nextest run --cargo-profile ci --locked --no-fail-fast -p vortex-btrblocks --test golden \ + --features unstable_encodings + - name: Golden corpus (compact, unstable_encodings + zstd + pco) + run: | + cargo nextest run --cargo-profile ci --locked --no-fail-fast -p vortex-btrblocks --test golden \ + --features unstable_encodings,zstd,pco + build-java: name: "Java" runs-on: >- diff --git a/Cargo.lock b/Cargo.lock index 16df2a942c1..7a3b82f3dce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9928,6 +9928,7 @@ name = "vortex-btrblocks" version = "0.1.0" dependencies = [ "codspeed-divan-compat", + "insta", "itertools 0.14.0", "num-traits", "pco", diff --git a/vortex-btrblocks/Cargo.toml b/vortex-btrblocks/Cargo.toml index 5ee2bad5402..529c6d90bad 100644 --- a/vortex-btrblocks/Cargo.toml +++ b/vortex-btrblocks/Cargo.toml @@ -41,6 +41,7 @@ vortex-zstd = { workspace = true, optional = true } [dev-dependencies] divan = { workspace = true } +insta = { workspace = true } rstest = { workspace = true } test-with = { workspace = true } vortex-array = { workspace = true, features = ["_test-harness"] } diff --git a/vortex-btrblocks/REUSE.toml b/vortex-btrblocks/REUSE.toml new file mode 100644 index 00000000000..6bee2b6904f --- /dev/null +++ b/vortex-btrblocks/REUSE.toml @@ -0,0 +1,7 @@ +version = 1 + +# `insta` snapshot files do not allow leading comment lines. +[[annotations]] +path = "tests/snapshots/**.snap" +SPDX-FileCopyrightText = "Copyright the Vortex contributors" +SPDX-License-Identifier = "Apache-2.0" diff --git a/vortex-btrblocks/tests/golden.rs b/vortex-btrblocks/tests/golden.rs new file mode 100644 index 00000000000..c60752b064a --- /dev/null +++ b/vortex-btrblocks/tests/golden.rs @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Golden-corpus determinism tests for the default compressor. +//! +//! Compresses a fixed, seed-generated corpus and snapshots each entry's full encoding tree +//! and exact byte counts. These snapshots pin the default compressor's *decisions*: any +//! refactor of scheme selection (see the compressor cost-model track) must leave every +//! snapshot untouched, so snapshot churn in a later change is the reviewable signal of a +//! behavior change. +//! +//! Three variants cover the feature matrix: +//! +//! - `default`: the default feature set and [`BtrBlocksCompressor::default`]. +//! - `unstable`: `unstable_encodings` enabled, default builder — pins Delta / OnPair +//! selection (compiled out of `ALL_SCHEMES` otherwise). +//! - `compact`: `unstable_encodings` + `zstd` + `pco`, with +//! [`BtrBlocksCompressorBuilder::with_compact`] — pins Zstd / Pco selection. +//! +//! Every corpus entry is longer than 1024 values so the sampling-based estimation path is +//! exercised, and each entry is compressed twice per run to assert determinism directly. + +#![allow(clippy::cast_possible_truncation, clippy::tests_outside_test_module)] + +use std::fmt; +use std::sync::Arc; +use std::sync::LazyLock; + +use rand::RngExt; +use rand::SeedableRng; +use rand::rngs::StdRng; +use vortex_array::ArrayRef; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::DecimalArray; +use vortex_array::arrays::ListArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::arrays::StructArray; +use vortex_array::arrays::TemporalArray; +use vortex_array::arrays::VarBinViewArray; +use vortex_array::display::EncodingSummaryExtractor; +use vortex_array::display::MetadataExtractor; +use vortex_array::display::TreeContext; +use vortex_array::display::TreeExtractor; +use vortex_array::dtype::DType; +use vortex_array::dtype::DecimalDType; +use vortex_array::dtype::Nullability; +use vortex_array::extension::datetime::TimeUnit; +use vortex_array::validity::Validity; +use vortex_btrblocks::BtrBlocksCompressor; +use vortex_buffer::Buffer; +use vortex_error::VortexResult; +use vortex_session::VortexSession; + +static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); + +/// Number of values in each numeric corpus entry: comfortably above the 1024-value sampling +/// threshold so scheme selection runs on sampled estimates, as it does for real file chunks. +const N: usize = 16_384; + +/// Header extractor printing exact byte counts (the built-in [`NbytesExtractor`] rounds +/// through `humansize`, which could mask small size regressions). +/// +/// [`NbytesExtractor`]: vortex_array::display::NbytesExtractor +struct ExactNbytesExtractor; + +impl TreeExtractor for ExactNbytesExtractor { + fn write_header( + &self, + array: &ArrayRef, + _ctx: &TreeContext, + f: &mut fmt::Formatter<'_>, + ) -> fmt::Result { + write!(f, " nbytes={}", array.nbytes()) + } +} + +/// Renders the full snapshot content for one compressed corpus entry. +fn render(input: &ArrayRef, compressed: &ArrayRef) -> String { + format!( + "input: {}, len={}, nbytes={}\n{}", + input.dtype(), + input.len(), + input.nbytes(), + compressed + .tree_display_builder() + .with(EncodingSummaryExtractor) + .with(ExactNbytesExtractor) + .with(MetadataExtractor) + ) +} + +/// Compresses every corpus entry twice (direct determinism check) and snapshots the result +/// under `{variant}__{entry}`. +fn golden_corpus_snapshots(variant: &str, compressor: &BtrBlocksCompressor) -> VortexResult<()> { + for (name, array) in corpus()? { + let rendered = { + let mut exec_ctx = SESSION.create_execution_ctx(); + render(&array, &compressor.compress(&array, &mut exec_ctx)?) + }; + let rendered_again = { + let mut exec_ctx = SESSION.create_execution_ctx(); + render(&array, &compressor.compress(&array, &mut exec_ctx)?) + }; + assert_eq!( + rendered, rendered_again, + "compressing corpus entry {name} twice produced different results" + ); + + insta::assert_snapshot!(format!("{variant}__{name}"), rendered); + } + Ok(()) +} + +/// The fixed corpus: deterministic synthetic arrays covering each scheme's habitat. +fn corpus() -> VortexResult> { + Ok(vec![ + ("int_monotone_jitter", int_monotone_jitter()), + ("int_arithmetic_sequence", int_arithmetic_sequence()), + ("int_low_cardinality", int_low_cardinality()), + ("int_runs", int_runs()), + ("int_sparse_outliers", int_sparse_outliers()), + ("int_mostly_null", int_mostly_null()), + ("int_negatives", int_negatives()), + ("int_wide_random", int_wide_random()), + ("float_alp_prices", float_alp_prices()), + ("float_low_cardinality", float_low_cardinality()), + ("float_full_precision", float_full_precision()), + ("float_mostly_null", float_mostly_null()), + ("string_fsst_structured", string_fsst_structured()), + ("string_low_cardinality", string_low_cardinality()), + ("binary_low_cardinality", binary_low_cardinality()), + ("decimal_prices", decimal_prices()), + ("temporal_timestamp_micros", temporal_timestamp_micros()), + ("bool_random", bool_random()), + ("struct_mixed", struct_mixed()?), + ("list_of_int_runs", list_of_int_runs()?), + ]) +} + +/// Near-monotone u64 (timestamp-like): FoR/BitPacking habitat, Delta habitat when enabled. +fn int_monotone_jitter() -> ArrayRef { + let mut rng = StdRng::seed_from_u64(101); + let mut value = 1_700_000_000_000u64; + let values: Buffer = (0..N) + .map(|_| { + value += 900 + rng.random_range(0..200); + value + }) + .collect(); + PrimitiveArray::new(values, Validity::NonNullable).into_array() +} + +/// Exact arithmetic sequence: Sequence habitat (distinct == len, no nulls). +fn int_arithmetic_sequence() -> ArrayRef { + let values: Buffer = (0..N as i64).map(|i| 10_000 + 7 * i).collect(); + PrimitiveArray::new(values, Validity::NonNullable).into_array() +} + +/// A handful of widely-spaced distinct values: IntDict habitat. +fn int_low_cardinality() -> ArrayRef { + const DISTINCT: [i64; 6] = [0, 123_400, 617_000, 1_234_000, 12_340_000, 37_020_000]; + let mut rng = StdRng::seed_from_u64(102); + let values: Buffer = (0..N).map(|_| DISTINCT[rng.random_range(0..6)]).collect(); + PrimitiveArray::new(values, Validity::NonNullable).into_array() +} + +/// Long runs over a moderate value set: RunEnd/RLE habitat. +fn int_runs() -> ArrayRef { + let mut rng = StdRng::seed_from_u64(103); + let mut values: Vec = Vec::with_capacity(N); + while values.len() < N { + let value = rng.random_range(-50_000..50_000i32); + let run = rng.random_range(8..25); + values.extend(std::iter::repeat_n(value, run)); + } + values.truncate(N); + PrimitiveArray::new(Buffer::copy_from(&values), Validity::NonNullable).into_array() +} + +/// One dominant value with rare large outliers: Sparse habitat. +fn int_sparse_outliers() -> ArrayRef { + let mut rng = StdRng::seed_from_u64(104); + let values: Buffer = (0..N) + .map(|_| { + if rng.random_range(0..100) < 5 { + rng.random_range(1_000_000_000..2_000_000_000i64) + } else { + 1_000_000 + } + }) + .collect(); + PrimitiveArray::new(values, Validity::NonNullable).into_array() +} + +/// 95% nulls over small values: null-dominated integer habitat. +fn int_mostly_null() -> ArrayRef { + let mut rng = StdRng::seed_from_u64(105); + let mut validity: Vec = Vec::with_capacity(N); + let values: Buffer = (0..N) + .map(|_| { + let valid = rng.random_range(0..100) < 5; + validity.push(valid); + if valid { rng.random_range(0..1000) } else { 0 } + }) + .collect(); + PrimitiveArray::new( + values, + Validity::Array(BoolArray::from_iter(validity).into_array()), + ) + .into_array() +} + +/// Small values of mixed sign: ZigZag habitat. +fn int_negatives() -> ArrayRef { + let mut rng = StdRng::seed_from_u64(106); + let values: Buffer = (0..N).map(|_| rng.random_range(-128..128i64)).collect(); + PrimitiveArray::new(values, Validity::NonNullable).into_array() +} + +/// Full-width random u64: essentially incompressible; pins the "no scheme wins" path. +fn int_wide_random() -> ArrayRef { + let mut rng = StdRng::seed_from_u64(107); + let values: Buffer = (0..N).map(|_| rng.random::()).collect(); + PrimitiveArray::new(values, Validity::NonNullable).into_array() +} + +/// Two-decimal-digit "prices": ALP habitat. +fn float_alp_prices() -> ArrayRef { + let mut rng = StdRng::seed_from_u64(108); + let values: Buffer = (0..N) + .map(|_| rng.random_range(0..10_000_000i64) as f64 / 100.0) + .collect(); + PrimitiveArray::new(values, Validity::NonNullable).into_array() +} + +/// A handful of distinct floats: FloatDict habitat. +fn float_low_cardinality() -> ArrayRef { + const DISTINCT: [f64; 8] = [0.0, 0.5, 1.25, 2.75, 3.5, 10.125, 100.0625, 1000.03125]; + let mut rng = StdRng::seed_from_u64(109); + let values: Buffer = (0..N).map(|_| DISTINCT[rng.random_range(0..8)]).collect(); + PrimitiveArray::new(values, Validity::NonNullable).into_array() +} + +/// Full-precision uniform floats: ALP-RD habitat. +fn float_full_precision() -> ArrayRef { + let mut rng = StdRng::seed_from_u64(110); + let values: Buffer = (0..N).map(|_| rng.random::()).collect(); + PrimitiveArray::new(values, Validity::NonNullable).into_array() +} + +/// 95% nulls over floats: null-dominated sparse float habitat. +fn float_mostly_null() -> ArrayRef { + let mut rng = StdRng::seed_from_u64(111); + let mut validity: Vec = Vec::with_capacity(N); + let values: Buffer = (0..N) + .map(|_| { + let valid = rng.random_range(0..100) < 5; + validity.push(valid); + if valid { + rng.random::() * 100.0 + } else { + 0.0 + } + }) + .collect(); + PrimitiveArray::new( + values, + Validity::Array(BoolArray::from_iter(validity).into_array()), + ) + .into_array() +} + +/// High-cardinality strings with shared substructure (emails): FSST habitat. +fn string_fsst_structured() -> ArrayRef { + let mut rng = StdRng::seed_from_u64(112); + let strings: Vec = (0..N) + .map(|_| { + format!( + "user{:06}@example{}.com", + rng.random_range(0..1_000_000), + rng.random_range(0..100) + ) + }) + .collect(); + VarBinViewArray::from_iter_str(strings.iter().map(String::as_str)).into_array() +} + +/// A dozen distinct strings: StringDict habitat. +fn string_low_cardinality() -> ArrayRef { + const DISTINCT: [&str; 12] = [ + "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", + "juliett", "kilo", "lima", + ]; + let mut rng = StdRng::seed_from_u64(113); + let strings: Vec> = (0..N) + .map(|_| Some(DISTINCT[rng.random_range(0..12)])) + .collect(); + VarBinViewArray::from_iter(strings, DType::Utf8(Nullability::NonNullable)).into_array() +} + +/// Low-cardinality binary blobs: BinaryDict habitat (Zstd habitat under `compact`). +fn binary_low_cardinality() -> ArrayRef { + const DISTINCT: [&[u8]; 5] = [ + &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08], + &[0xDE, 0xAD, 0xBE, 0xEF], + &[0x00; 16], + &[0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00], + &[0x42; 12], + ]; + let mut rng = StdRng::seed_from_u64(114); + let blobs: Vec> = (0..N) + .map(|_| Some(DISTINCT[rng.random_range(0..5)])) + .collect(); + VarBinViewArray::from_iter(blobs, DType::Binary(Nullability::NonNullable)).into_array() +} + +/// Two-decimal-place decimals: DecimalScheme (byte-parts) habitat. +fn decimal_prices() -> ArrayRef { + let mut rng = StdRng::seed_from_u64(115); + let values: Buffer = (0..N).map(|_| rng.random_range(0..10_000_000i64)).collect(); + DecimalArray::new(values, DecimalDType::new(12, 2), Validity::NonNullable).into_array() +} + +/// Near-monotone microsecond timestamps: TemporalScheme (datetime-parts) habitat. +fn temporal_timestamp_micros() -> ArrayRef { + let mut rng = StdRng::seed_from_u64(116); + let mut value = 1_700_000_000_000_000i64; + let values: Buffer = (0..N) + .map(|_| { + value += rng.random_range(1_000..1_000_000); + value + }) + .collect(); + TemporalArray::new_timestamp( + PrimitiveArray::new(values, Validity::NonNullable).into_array(), + TimeUnit::Microseconds, + Some(Arc::from("UTC")), + ) + .into_array() +} + +/// Random booleans: no bool scheme is registered, pinning the "stays canonical" path. +fn bool_random() -> ArrayRef { + let mut rng = StdRng::seed_from_u64(117); + BoolArray::from_iter((0..N).map(|_| rng.random::())).into_array() +} + +/// Struct of int/string/float fields: pins the structural recursion path. +fn struct_mixed() -> VortexResult { + Ok(StructArray::from_fields(&[ + ("id", int_arithmetic_sequence()), + ("category", string_low_cardinality()), + ("value", float_alp_prices()), + ])? + .into_array()) +} + +/// Variable-length lists of run-heavy ints: pins the list offsets/elements path. +fn list_of_int_runs() -> VortexResult { + let mut rng = StdRng::seed_from_u64(118); + let elements = int_runs(); + let mut offsets: Vec = Vec::with_capacity(N / 4 + 1); + let mut offset = 0i32; + offsets.push(offset); + while (offset as usize) < N { + offset = (offset + rng.random_range(1..8)).min(N as i32); + offsets.push(offset); + } + let offsets = PrimitiveArray::new(Buffer::copy_from(&offsets), Validity::NonNullable); + Ok(ListArray::try_new(elements, offsets.into_array(), Validity::NonNullable)?.into_array()) +} + +/// Excludes OnPair from the golden compressors: its dictionary training (upstream `onpair` +/// crate) iterates randomly-seeded `hashbrown` maps, so its compressed output — and therefore +/// its sampled estimate — differs run-to-run. A nondeterministic scheme cannot serve as a +/// golden baseline; excluding it keeps the remaining unstable schemes pinned. +#[cfg(feature = "unstable_encodings")] +fn without_onpair( + builder: vortex_btrblocks::BtrBlocksCompressorBuilder, +) -> vortex_btrblocks::BtrBlocksCompressorBuilder { + use vortex_btrblocks::SchemeExt; + use vortex_btrblocks::schemes::string::OnPairScheme; + + builder.exclude_schemes([OnPairScheme.id()]) +} + +#[cfg(not(feature = "unstable_encodings"))] +#[test] +fn golden_default() -> VortexResult<()> { + golden_corpus_snapshots("default", &BtrBlocksCompressor::default()) +} + +#[cfg(feature = "unstable_encodings")] +#[test] +fn golden_unstable() -> VortexResult<()> { + use vortex_btrblocks::BtrBlocksCompressorBuilder; + + golden_corpus_snapshots( + "unstable", + &without_onpair(BtrBlocksCompressorBuilder::default()).build(), + ) +} + +#[cfg(all(feature = "unstable_encodings", feature = "zstd", feature = "pco"))] +#[test] +fn golden_compact() -> VortexResult<()> { + use vortex_btrblocks::BtrBlocksCompressorBuilder; + + golden_corpus_snapshots( + "compact", + &without_onpair(BtrBlocksCompressorBuilder::default().with_compact()).build(), + ) +} diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__binary_low_cardinality.snap b/vortex-btrblocks/tests/snapshots/golden__compact__binary_low_cardinality.snap new file mode 100644 index 00000000000..d68474ce61c --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__binary_low_cardinality.snap @@ -0,0 +1,11 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: binary, len=16384, nbytes=315856 +root: vortex.dict(binary, len=16384) nbytes=6199 + metadata: all_values_referenced: true + codes: fastlanes.bitpacked(u8, len=16384) nbytes=6144 + metadata: bit_width: 3, offset: 0 + values: vortex.zstd(binary, len=5) nbytes=55 + metadata: nrows: 5, slice: 0..5 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__bool_random.snap b/vortex-btrblocks/tests/snapshots/golden__compact__bool_random.snap new file mode 100644 index 00000000000..d80e2fa4464 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__bool_random.snap @@ -0,0 +1,7 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: bool, len=16384, nbytes=2048 +root: vortex.bool(bool, len=16384) nbytes=2048 + metadata: offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__decimal_prices.snap b/vortex-btrblocks/tests/snapshots/golden__compact__decimal_prices.snap new file mode 100644 index 00000000000..780ef30a6b0 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__decimal_prices.snap @@ -0,0 +1,9 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: decimal(12,2), len=16384, nbytes=131072 +root: vortex.decimal_byte_parts(decimal(12,2), len=16384) nbytes=47666 + metadata: + msp: vortex.pco(i32, len=16384) nbytes=47666 + metadata: ptype: i32, nrows: 16384, slice: 0..16384 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__float_alp_prices.snap b/vortex-btrblocks/tests/snapshots/golden__compact__float_alp_prices.snap new file mode 100644 index 00000000000..b1a3b41c4e7 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__float_alp_prices.snap @@ -0,0 +1,9 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: f64, len=16384, nbytes=131072 +root: vortex.alp(f64, len=16384) nbytes=47699 + metadata: exponents: e: 14, f: 12 + encoded: vortex.pco(i64, len=16384) nbytes=47699 + metadata: ptype: i64, nrows: 16384, slice: 0..16384 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__float_full_precision.snap b/vortex-btrblocks/tests/snapshots/golden__compact__float_full_precision.snap new file mode 100644 index 00000000000..20fecf31e22 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__float_full_precision.snap @@ -0,0 +1,7 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: f64, len=16384, nbytes=131072 +root: vortex.pco(f64, len=16384) nbytes=110692 + metadata: ptype: f64, nrows: 16384, slice: 0..16384 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__float_low_cardinality.snap b/vortex-btrblocks/tests/snapshots/golden__compact__float_low_cardinality.snap new file mode 100644 index 00000000000..006f5a93b69 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__float_low_cardinality.snap @@ -0,0 +1,13 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: f64, len=16384, nbytes=131072 +root: vortex.dict(f64, len=16384) nbytes=6184 + metadata: all_values_referenced: true + codes: fastlanes.bitpacked(u8, len=16384) nbytes=6144 + metadata: bit_width: 3, offset: 0 + values: vortex.alp(f64, len=8) nbytes=40 + metadata: exponents: e: 16, f: 11 + encoded: vortex.pco(i64, len=8) nbytes=40 + metadata: ptype: i64, nrows: 8, slice: 0..8 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__float_mostly_null.snap b/vortex-btrblocks/tests/snapshots/golden__compact__float_mostly_null.snap new file mode 100644 index 00000000000..134acccdc1c --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__float_mostly_null.snap @@ -0,0 +1,13 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: f64?, len=16384, nbytes=133120 +root: vortex.sparse(f64?, len=16384) nbytes=7545 + metadata: fill_value: null + patch_indices: vortex.pco(u16, len=850) nbytes=636 + metadata: ptype: u16, nrows: 850, slice: 0..850 + patch_values: vortex.primitive(f64?, len=850) nbytes=6907 + metadata: ptype: f64 + validity: vortex.bool(bool, len=850) nbytes=107 + metadata: offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__int_arithmetic_sequence.snap b/vortex-btrblocks/tests/snapshots/golden__compact__int_arithmetic_sequence.snap new file mode 100644 index 00000000000..873f3655a1f --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__int_arithmetic_sequence.snap @@ -0,0 +1,7 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i64, len=16384, nbytes=131072 +root: vortex.sequence(i64, len=16384) nbytes=0 + metadata: base: 10000i64, multiplier: 7i64 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__int_low_cardinality.snap b/vortex-btrblocks/tests/snapshots/golden__compact__int_low_cardinality.snap new file mode 100644 index 00000000000..0cbe2e06814 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__int_low_cardinality.snap @@ -0,0 +1,11 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i64, len=16384, nbytes=131072 +root: vortex.dict(i64, len=16384) nbytes=6177 + metadata: all_values_referenced: true + codes: fastlanes.bitpacked(u8, len=16384) nbytes=6144 + metadata: bit_width: 3, offset: 0 + values: vortex.pco(i64, len=6) nbytes=33 + metadata: ptype: i64, nrows: 6, slice: 0..6 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__int_monotone_jitter.snap b/vortex-btrblocks/tests/snapshots/golden__compact__int_monotone_jitter.snap new file mode 100644 index 00000000000..4a63aa0da71 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__int_monotone_jitter.snap @@ -0,0 +1,7 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: u64, len=16384, nbytes=131072 +root: vortex.pco(u64, len=16384) nbytes=15715 + metadata: ptype: u64, nrows: 16384, slice: 0..16384 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__int_mostly_null.snap b/vortex-btrblocks/tests/snapshots/golden__compact__int_mostly_null.snap new file mode 100644 index 00000000000..71e1c671a03 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__int_mostly_null.snap @@ -0,0 +1,9 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i32?, len=16384, nbytes=67584 +root: vortex.pco(i32?, len=16384) nbytes=3086 + metadata: ptype: i32, nrows: 16384, slice: 0..16384 + validity: vortex.bool(bool, len=16384) nbytes=2048 + metadata: offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__int_negatives.snap b/vortex-btrblocks/tests/snapshots/golden__compact__int_negatives.snap new file mode 100644 index 00000000000..24949893190 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__int_negatives.snap @@ -0,0 +1,9 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i64, len=16384, nbytes=131072 +root: fastlanes.for(i64, len=16384) nbytes=16384 + metadata: reference: -128i64 + encoded: fastlanes.bitpacked(i64, len=16384) nbytes=16384 + metadata: bit_width: 8, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__int_runs.snap b/vortex-btrblocks/tests/snapshots/golden__compact__int_runs.snap new file mode 100644 index 00000000000..4ab2e46cead --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__int_runs.snap @@ -0,0 +1,9 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i32, len=16384, nbytes=65536 +root: vortex.zigzag(i32, len=16384) nbytes=2969 + metadata: + encoded: vortex.pco(u32, len=16384) nbytes=2969 + metadata: ptype: u32, nrows: 16384, slice: 0..16384 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__int_sparse_outliers.snap b/vortex-btrblocks/tests/snapshots/golden__compact__int_sparse_outliers.snap new file mode 100644 index 00000000000..0303e0e4ef1 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__int_sparse_outliers.snap @@ -0,0 +1,7 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i64, len=16384, nbytes=131072 +root: vortex.pco(i64, len=16384) nbytes=3817 + metadata: ptype: i64, nrows: 16384, slice: 0..16384 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__int_wide_random.snap b/vortex-btrblocks/tests/snapshots/golden__compact__int_wide_random.snap new file mode 100644 index 00000000000..90e1894f910 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__int_wide_random.snap @@ -0,0 +1,7 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: u64, len=16384, nbytes=131072 +root: vortex.primitive(u64, len=16384) nbytes=131072 + metadata: ptype: u64 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__list_of_int_runs.snap b/vortex-btrblocks/tests/snapshots/golden__compact__list_of_int_runs.snap new file mode 100644 index 00000000000..88274daf4b1 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__list_of_int_runs.snap @@ -0,0 +1,13 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: list(i32), len=4066, nbytes=81804 +root: vortex.list(list(i32), len=4066) nbytes=4434 + metadata: + elements: vortex.zigzag(i32, len=16384) nbytes=2969 + metadata: + encoded: vortex.pco(u32, len=16384) nbytes=2969 + metadata: ptype: u32, nrows: 16384, slice: 0..16384 + offsets: vortex.pco(u16, len=4067) nbytes=1465 + metadata: ptype: u16, nrows: 4067, slice: 0..4067 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__string_fsst_structured.snap b/vortex-btrblocks/tests/snapshots/golden__compact__string_fsst_structured.snap new file mode 100644 index 00000000000..49ad248087b --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__string_fsst_structured.snap @@ -0,0 +1,7 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: utf8, len=16384, nbytes=653785 +root: vortex.zstd(utf8, len=16384) nbytes=109119 + metadata: nrows: 16384, slice: 0..16384 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__string_low_cardinality.snap b/vortex-btrblocks/tests/snapshots/golden__compact__string_low_cardinality.snap new file mode 100644 index 00000000000..a65054ac178 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__string_low_cardinality.snap @@ -0,0 +1,11 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: utf8, len=16384, nbytes=262144 +root: vortex.dict(utf8, len=16384) nbytes=8287 + metadata: all_values_referenced: true + codes: fastlanes.bitpacked(u8, len=16384) nbytes=8192 + metadata: bit_width: 4, offset: 0 + values: vortex.zstd(utf8, len=12) nbytes=95 + metadata: nrows: 12, slice: 0..12 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__struct_mixed.snap b/vortex-btrblocks/tests/snapshots/golden__compact__struct_mixed.snap new file mode 100644 index 00000000000..83e34c583dc --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__struct_mixed.snap @@ -0,0 +1,19 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: {id=i64, category=utf8, value=f64}, len=16384, nbytes=524288 +root: vortex.struct({id=i64, category=utf8, value=f64}, len=16384) nbytes=55986 + metadata: + id: vortex.sequence(i64, len=16384) nbytes=0 + metadata: base: 10000i64, multiplier: 7i64 + category: vortex.dict(utf8, len=16384) nbytes=8287 + metadata: all_values_referenced: true + codes: fastlanes.bitpacked(u8, len=16384) nbytes=8192 + metadata: bit_width: 4, offset: 0 + values: vortex.zstd(utf8, len=12) nbytes=95 + metadata: nrows: 12, slice: 0..12 + value: vortex.alp(f64, len=16384) nbytes=47699 + metadata: exponents: e: 14, f: 12 + encoded: vortex.pco(i64, len=16384) nbytes=47699 + metadata: ptype: i64, nrows: 16384, slice: 0..16384 diff --git a/vortex-btrblocks/tests/snapshots/golden__compact__temporal_timestamp_micros.snap b/vortex-btrblocks/tests/snapshots/golden__compact__temporal_timestamp_micros.snap new file mode 100644 index 00000000000..0422808ce29 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__compact__temporal_timestamp_micros.snap @@ -0,0 +1,9 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: vortex.timestamp[µs, tz=UTC](i64), len=16384, nbytes=131072 +root: vortex.ext(vortex.timestamp[µs, tz=UTC](i64), len=16384) nbytes=40985 + metadata: + storage: vortex.pco(i64, len=16384) nbytes=40985 + metadata: ptype: i64, nrows: 16384, slice: 0..16384 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__binary_low_cardinality.snap b/vortex-btrblocks/tests/snapshots/golden__default__binary_low_cardinality.snap new file mode 100644 index 00000000000..56918c0d096 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__binary_low_cardinality.snap @@ -0,0 +1,11 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: binary, len=16384, nbytes=315856 +root: vortex.dict(binary, len=16384) nbytes=6240 + metadata: all_values_referenced: true + codes: fastlanes.bitpacked(u8, len=16384) nbytes=6144 + metadata: bit_width: 3, offset: 0 + values: vortex.varbinview(binary, len=5) nbytes=96 + metadata: diff --git a/vortex-btrblocks/tests/snapshots/golden__default__bool_random.snap b/vortex-btrblocks/tests/snapshots/golden__default__bool_random.snap new file mode 100644 index 00000000000..d80e2fa4464 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__bool_random.snap @@ -0,0 +1,7 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: bool, len=16384, nbytes=2048 +root: vortex.bool(bool, len=16384) nbytes=2048 + metadata: offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__decimal_prices.snap b/vortex-btrblocks/tests/snapshots/golden__default__decimal_prices.snap new file mode 100644 index 00000000000..f669755e4b1 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__decimal_prices.snap @@ -0,0 +1,9 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: decimal(12,2), len=16384, nbytes=131072 +root: vortex.decimal_byte_parts(decimal(12,2), len=16384) nbytes=49152 + metadata: + msp: fastlanes.bitpacked(i32, len=16384) nbytes=49152 + metadata: bit_width: 24, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__float_alp_prices.snap b/vortex-btrblocks/tests/snapshots/golden__default__float_alp_prices.snap new file mode 100644 index 00000000000..f70d90a301d --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__float_alp_prices.snap @@ -0,0 +1,9 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: f64, len=16384, nbytes=131072 +root: vortex.alp(f64, len=16384) nbytes=49152 + metadata: exponents: e: 14, f: 12 + encoded: fastlanes.bitpacked(i64, len=16384) nbytes=49152 + metadata: bit_width: 24, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__float_full_precision.snap b/vortex-btrblocks/tests/snapshots/golden__default__float_full_precision.snap new file mode 100644 index 00000000000..487e7c7900b --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__float_full_precision.snap @@ -0,0 +1,15 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: f64, len=16384, nbytes=131072 +root: vortex.alprd(f64, len=16384) nbytes=112880 + metadata: right_bit_width: 52, patch_offset: 0 + left_parts: fastlanes.bitpacked(u16, len=16384) nbytes=6144 + metadata: bit_width: 3, offset: 0 + right_parts: fastlanes.bitpacked(u64, len=16384) nbytes=106496 + metadata: bit_width: 52, offset: 0 + patch_indices: vortex.primitive(u16, len=60) nbytes=120 + metadata: ptype: u16 + patch_values: vortex.primitive(u16, len=60) nbytes=120 + metadata: ptype: u16 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__float_low_cardinality.snap b/vortex-btrblocks/tests/snapshots/golden__default__float_low_cardinality.snap new file mode 100644 index 00000000000..38d952262d6 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__float_low_cardinality.snap @@ -0,0 +1,19 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: f64, len=16384, nbytes=131072 +root: vortex.alp(f64, len=16384) nbytes=10233 + metadata: exponents: e: 16, f: 12, patch_offset: 0 + encoded: vortex.dict(i64, len=16384) nbytes=6200 + metadata: all_values_referenced: true + codes: fastlanes.bitpacked(u8, len=16384) nbytes=6144 + metadata: bit_width: 3, offset: 0 + values: vortex.primitive(i64, len=7) nbytes=56 + metadata: ptype: i64 + patch_indices: vortex.primitive(u16, len=1996) nbytes=3992 + metadata: ptype: u16 + patch_values: vortex.constant(f64, len=1996) nbytes=9 + metadata: scalar: 1000.03125f64 + patch_chunk_offsets: vortex.primitive(u16, len=16) nbytes=32 + metadata: ptype: u16 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__float_mostly_null.snap b/vortex-btrblocks/tests/snapshots/golden__default__float_mostly_null.snap new file mode 100644 index 00000000000..7b88d819472 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__float_mostly_null.snap @@ -0,0 +1,13 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: f64?, len=16384, nbytes=133120 +root: vortex.sparse(f64?, len=16384) nbytes=8609 + metadata: fill_value: null + patch_indices: vortex.primitive(u16, len=850) nbytes=1700 + metadata: ptype: u16 + patch_values: vortex.primitive(f64?, len=850) nbytes=6907 + metadata: ptype: f64 + validity: vortex.bool(bool, len=850) nbytes=107 + metadata: offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__int_arithmetic_sequence.snap b/vortex-btrblocks/tests/snapshots/golden__default__int_arithmetic_sequence.snap new file mode 100644 index 00000000000..873f3655a1f --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__int_arithmetic_sequence.snap @@ -0,0 +1,7 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i64, len=16384, nbytes=131072 +root: vortex.sequence(i64, len=16384) nbytes=0 + metadata: base: 10000i64, multiplier: 7i64 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__int_low_cardinality.snap b/vortex-btrblocks/tests/snapshots/golden__default__int_low_cardinality.snap new file mode 100644 index 00000000000..8648200a138 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__int_low_cardinality.snap @@ -0,0 +1,11 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i64, len=16384, nbytes=131072 +root: vortex.dict(i64, len=16384) nbytes=6192 + metadata: all_values_referenced: true + codes: fastlanes.bitpacked(u8, len=16384) nbytes=6144 + metadata: bit_width: 3, offset: 0 + values: vortex.primitive(i64, len=6) nbytes=48 + metadata: ptype: i64 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__int_monotone_jitter.snap b/vortex-btrblocks/tests/snapshots/golden__default__int_monotone_jitter.snap new file mode 100644 index 00000000000..4800aae289d --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__int_monotone_jitter.snap @@ -0,0 +1,9 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: u64, len=16384, nbytes=131072 +root: fastlanes.for(u64, len=16384) nbytes=49152 + metadata: reference: 1700000001036u64 + encoded: fastlanes.bitpacked(u64, len=16384) nbytes=49152 + metadata: bit_width: 24, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__int_mostly_null.snap b/vortex-btrblocks/tests/snapshots/golden__default__int_mostly_null.snap new file mode 100644 index 00000000000..73566369279 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__int_mostly_null.snap @@ -0,0 +1,13 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i32?, len=16384, nbytes=67584 +root: vortex.sparse(i32?, len=16384) nbytes=3031 + metadata: fill_value: null + patch_indices: vortex.primitive(u16, len=823) nbytes=1646 + metadata: ptype: u16 + patch_values: fastlanes.bitpacked(i32?, len=823) nbytes=1383 + metadata: bit_width: 10, offset: 0 + validity_child: vortex.bool(bool, len=823) nbytes=103 + metadata: offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__int_negatives.snap b/vortex-btrblocks/tests/snapshots/golden__default__int_negatives.snap new file mode 100644 index 00000000000..24949893190 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__int_negatives.snap @@ -0,0 +1,9 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i64, len=16384, nbytes=131072 +root: fastlanes.for(i64, len=16384) nbytes=16384 + metadata: reference: -128i64 + encoded: fastlanes.bitpacked(i64, len=16384) nbytes=16384 + metadata: bit_width: 8, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__int_runs.snap b/vortex-btrblocks/tests/snapshots/golden__default__int_runs.snap new file mode 100644 index 00000000000..271de8faf93 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__int_runs.snap @@ -0,0 +1,15 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i32, len=16384, nbytes=65536 +root: vortex.runend(i32, len=16384) nbytes=3968 + metadata: offset: 0 + ends: fastlanes.for(u16, len=1020) nbytes=1792 + metadata: reference: 13u16 + encoded: fastlanes.bitpacked(u16, len=1020) nbytes=1792 + metadata: bit_width: 14, offset: 0 + values: fastlanes.for(i32, len=1020) nbytes=2176 + metadata: reference: -49931i32 + encoded: fastlanes.bitpacked(i32, len=1020) nbytes=2176 + metadata: bit_width: 17, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__int_sparse_outliers.snap b/vortex-btrblocks/tests/snapshots/golden__default__int_sparse_outliers.snap new file mode 100644 index 00000000000..53a5d1e023c --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__int_sparse_outliers.snap @@ -0,0 +1,13 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i64, len=16384, nbytes=131072 +root: vortex.sparse(i64, len=16384) nbytes=5540 + metadata: fill_value: 1000000i64 + patch_indices: vortex.primitive(u16, len=848) nbytes=1696 + metadata: ptype: u16 + patch_values: fastlanes.for(i64, len=848) nbytes=3840 + metadata: reference: 1000830099i64 + encoded: fastlanes.bitpacked(i64, len=848) nbytes=3840 + metadata: bit_width: 30, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__int_wide_random.snap b/vortex-btrblocks/tests/snapshots/golden__default__int_wide_random.snap new file mode 100644 index 00000000000..90e1894f910 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__int_wide_random.snap @@ -0,0 +1,7 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: u64, len=16384, nbytes=131072 +root: vortex.primitive(u64, len=16384) nbytes=131072 + metadata: ptype: u64 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__list_of_int_runs.snap b/vortex-btrblocks/tests/snapshots/golden__default__list_of_int_runs.snap new file mode 100644 index 00000000000..c9554add05a --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__list_of_int_runs.snap @@ -0,0 +1,25 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: list(i32), len=4066, nbytes=81804 +root: vortex.list(list(i32), len=4066) nbytes=11146 + metadata: + elements: vortex.runend(i32, len=16384) nbytes=3968 + metadata: offset: 0 + ends: fastlanes.for(u16, len=1020) nbytes=1792 + metadata: reference: 13u16 + encoded: fastlanes.bitpacked(u16, len=1020) nbytes=1792 + metadata: bit_width: 14, offset: 0 + values: fastlanes.for(i32, len=1020) nbytes=2176 + metadata: reference: -49931i32 + encoded: fastlanes.bitpacked(i32, len=1020) nbytes=2176 + metadata: bit_width: 17, offset: 0 + offsets: fastlanes.bitpacked(u16, len=4067) nbytes=7178 + metadata: bit_width: 14, offset: 0 + patch_indices: vortex.primitive(u16, len=1) nbytes=2 + metadata: ptype: u16 + patch_values: vortex.constant(u16, len=1) nbytes=4 + metadata: scalar: 16384u16 + patch_chunk_offsets: vortex.primitive(u8, len=4) nbytes=4 + metadata: ptype: u8 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__string_fsst_structured.snap b/vortex-btrblocks/tests/snapshots/golden__default__string_fsst_structured.snap new file mode 100644 index 00000000000..327f050b0d7 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__string_fsst_structured.snap @@ -0,0 +1,15 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: utf8, len=16384, nbytes=653785 +root: vortex.fsst(utf8, len=16384) nbytes=151382 + metadata: len: 16384, nsymbols: 223 + uncompressed_lengths: vortex.sparse(u8, len=16384) nbytes=3154 + metadata: fill_value: 24u8 + patch_indices: vortex.primitive(u16, len=1575) nbytes=3150 + metadata: ptype: u16 + patch_values: vortex.constant(u8, len=1575) nbytes=2 + metadata: scalar: 23u8 + codes_offsets: fastlanes.bitpacked(u32, len=16385) nbytes=36992 + metadata: bit_width: 17, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__string_low_cardinality.snap b/vortex-btrblocks/tests/snapshots/golden__default__string_low_cardinality.snap new file mode 100644 index 00000000000..ec79989d46a --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__string_low_cardinality.snap @@ -0,0 +1,15 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: utf8, len=16384, nbytes=262144 +root: vortex.dict(utf8, len=16384) nbytes=8373 + metadata: all_values_referenced: true + codes: fastlanes.bitpacked(u8, len=16384) nbytes=8192 + metadata: bit_width: 4, offset: 0 + values: vortex.fsst(utf8, len=12) nbytes=181 + metadata: len: 12, nsymbols: 9 + uncompressed_lengths: vortex.primitive(u8, len=12) nbytes=12 + metadata: ptype: u8 + codes_offsets: vortex.primitive(u8, len=13) nbytes=13 + metadata: ptype: u8 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__struct_mixed.snap b/vortex-btrblocks/tests/snapshots/golden__default__struct_mixed.snap new file mode 100644 index 00000000000..be8f48b779b --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__struct_mixed.snap @@ -0,0 +1,23 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: {id=i64, category=utf8, value=f64}, len=16384, nbytes=524288 +root: vortex.struct({id=i64, category=utf8, value=f64}, len=16384) nbytes=57525 + metadata: + id: vortex.sequence(i64, len=16384) nbytes=0 + metadata: base: 10000i64, multiplier: 7i64 + category: vortex.dict(utf8, len=16384) nbytes=8373 + metadata: all_values_referenced: true + codes: fastlanes.bitpacked(u8, len=16384) nbytes=8192 + metadata: bit_width: 4, offset: 0 + values: vortex.fsst(utf8, len=12) nbytes=181 + metadata: len: 12, nsymbols: 9 + uncompressed_lengths: vortex.primitive(u8, len=12) nbytes=12 + metadata: ptype: u8 + codes_offsets: vortex.primitive(u8, len=13) nbytes=13 + metadata: ptype: u8 + value: vortex.alp(f64, len=16384) nbytes=49152 + metadata: exponents: e: 14, f: 12 + encoded: fastlanes.bitpacked(i64, len=16384) nbytes=49152 + metadata: bit_width: 24, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__default__temporal_timestamp_micros.snap b/vortex-btrblocks/tests/snapshots/golden__default__temporal_timestamp_micros.snap new file mode 100644 index 00000000000..6f38e8e6f5d --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__default__temporal_timestamp_micros.snap @@ -0,0 +1,11 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: vortex.timestamp[µs, tz=UTC](i64), len=16384, nbytes=131072 +root: vortex.ext(vortex.timestamp[µs, tz=UTC](i64), len=16384) nbytes=67584 + metadata: + storage: fastlanes.for(i64, len=16384) nbytes=67584 + metadata: reference: 1700000000891673i64 + encoded: fastlanes.bitpacked(i64, len=16384) nbytes=67584 + metadata: bit_width: 33, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__binary_low_cardinality.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__binary_low_cardinality.snap new file mode 100644 index 00000000000..56918c0d096 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__binary_low_cardinality.snap @@ -0,0 +1,11 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: binary, len=16384, nbytes=315856 +root: vortex.dict(binary, len=16384) nbytes=6240 + metadata: all_values_referenced: true + codes: fastlanes.bitpacked(u8, len=16384) nbytes=6144 + metadata: bit_width: 3, offset: 0 + values: vortex.varbinview(binary, len=5) nbytes=96 + metadata: diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__bool_random.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__bool_random.snap new file mode 100644 index 00000000000..d80e2fa4464 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__bool_random.snap @@ -0,0 +1,7 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: bool, len=16384, nbytes=2048 +root: vortex.bool(bool, len=16384) nbytes=2048 + metadata: offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__decimal_prices.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__decimal_prices.snap new file mode 100644 index 00000000000..f669755e4b1 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__decimal_prices.snap @@ -0,0 +1,9 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: decimal(12,2), len=16384, nbytes=131072 +root: vortex.decimal_byte_parts(decimal(12,2), len=16384) nbytes=49152 + metadata: + msp: fastlanes.bitpacked(i32, len=16384) nbytes=49152 + metadata: bit_width: 24, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__float_alp_prices.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__float_alp_prices.snap new file mode 100644 index 00000000000..f70d90a301d --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__float_alp_prices.snap @@ -0,0 +1,9 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: f64, len=16384, nbytes=131072 +root: vortex.alp(f64, len=16384) nbytes=49152 + metadata: exponents: e: 14, f: 12 + encoded: fastlanes.bitpacked(i64, len=16384) nbytes=49152 + metadata: bit_width: 24, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__float_full_precision.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__float_full_precision.snap new file mode 100644 index 00000000000..487e7c7900b --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__float_full_precision.snap @@ -0,0 +1,15 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: f64, len=16384, nbytes=131072 +root: vortex.alprd(f64, len=16384) nbytes=112880 + metadata: right_bit_width: 52, patch_offset: 0 + left_parts: fastlanes.bitpacked(u16, len=16384) nbytes=6144 + metadata: bit_width: 3, offset: 0 + right_parts: fastlanes.bitpacked(u64, len=16384) nbytes=106496 + metadata: bit_width: 52, offset: 0 + patch_indices: vortex.primitive(u16, len=60) nbytes=120 + metadata: ptype: u16 + patch_values: vortex.primitive(u16, len=60) nbytes=120 + metadata: ptype: u16 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__float_low_cardinality.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__float_low_cardinality.snap new file mode 100644 index 00000000000..38d952262d6 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__float_low_cardinality.snap @@ -0,0 +1,19 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: f64, len=16384, nbytes=131072 +root: vortex.alp(f64, len=16384) nbytes=10233 + metadata: exponents: e: 16, f: 12, patch_offset: 0 + encoded: vortex.dict(i64, len=16384) nbytes=6200 + metadata: all_values_referenced: true + codes: fastlanes.bitpacked(u8, len=16384) nbytes=6144 + metadata: bit_width: 3, offset: 0 + values: vortex.primitive(i64, len=7) nbytes=56 + metadata: ptype: i64 + patch_indices: vortex.primitive(u16, len=1996) nbytes=3992 + metadata: ptype: u16 + patch_values: vortex.constant(f64, len=1996) nbytes=9 + metadata: scalar: 1000.03125f64 + patch_chunk_offsets: vortex.primitive(u16, len=16) nbytes=32 + metadata: ptype: u16 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__float_mostly_null.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__float_mostly_null.snap new file mode 100644 index 00000000000..7b88d819472 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__float_mostly_null.snap @@ -0,0 +1,13 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: f64?, len=16384, nbytes=133120 +root: vortex.sparse(f64?, len=16384) nbytes=8609 + metadata: fill_value: null + patch_indices: vortex.primitive(u16, len=850) nbytes=1700 + metadata: ptype: u16 + patch_values: vortex.primitive(f64?, len=850) nbytes=6907 + metadata: ptype: f64 + validity: vortex.bool(bool, len=850) nbytes=107 + metadata: offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__int_arithmetic_sequence.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__int_arithmetic_sequence.snap new file mode 100644 index 00000000000..873f3655a1f --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__int_arithmetic_sequence.snap @@ -0,0 +1,7 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i64, len=16384, nbytes=131072 +root: vortex.sequence(i64, len=16384) nbytes=0 + metadata: base: 10000i64, multiplier: 7i64 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__int_low_cardinality.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__int_low_cardinality.snap new file mode 100644 index 00000000000..8648200a138 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__int_low_cardinality.snap @@ -0,0 +1,11 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i64, len=16384, nbytes=131072 +root: vortex.dict(i64, len=16384) nbytes=6192 + metadata: all_values_referenced: true + codes: fastlanes.bitpacked(u8, len=16384) nbytes=6144 + metadata: bit_width: 3, offset: 0 + values: vortex.primitive(i64, len=6) nbytes=48 + metadata: ptype: i64 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__int_monotone_jitter.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__int_monotone_jitter.snap new file mode 100644 index 00000000000..f1ded4c3e59 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__int_monotone_jitter.snap @@ -0,0 +1,15 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: u64, len=16384, nbytes=131072 +root: fastlanes.delta(u64, len=16384) nbytes=19840 + metadata: offset: 0 + bases: vortex.primitive(u64, len=256) nbytes=2048 + metadata: ptype: u64 + deltas: vortex.dict(u64, len=16384) nbytes=17792 + metadata: all_values_referenced: true + codes: vortex.primitive(u8, len=16384) nbytes=16384 + metadata: ptype: u8 + values: fastlanes.bitpacked(u64, len=201) nbytes=1408 + metadata: bit_width: 11, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__int_mostly_null.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__int_mostly_null.snap new file mode 100644 index 00000000000..73566369279 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__int_mostly_null.snap @@ -0,0 +1,13 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i32?, len=16384, nbytes=67584 +root: vortex.sparse(i32?, len=16384) nbytes=3031 + metadata: fill_value: null + patch_indices: vortex.primitive(u16, len=823) nbytes=1646 + metadata: ptype: u16 + patch_values: fastlanes.bitpacked(i32?, len=823) nbytes=1383 + metadata: bit_width: 10, offset: 0 + validity_child: vortex.bool(bool, len=823) nbytes=103 + metadata: offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__int_negatives.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__int_negatives.snap new file mode 100644 index 00000000000..24949893190 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__int_negatives.snap @@ -0,0 +1,9 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i64, len=16384, nbytes=131072 +root: fastlanes.for(i64, len=16384) nbytes=16384 + metadata: reference: -128i64 + encoded: fastlanes.bitpacked(i64, len=16384) nbytes=16384 + metadata: bit_width: 8, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__int_runs.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__int_runs.snap new file mode 100644 index 00000000000..271de8faf93 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__int_runs.snap @@ -0,0 +1,15 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i32, len=16384, nbytes=65536 +root: vortex.runend(i32, len=16384) nbytes=3968 + metadata: offset: 0 + ends: fastlanes.for(u16, len=1020) nbytes=1792 + metadata: reference: 13u16 + encoded: fastlanes.bitpacked(u16, len=1020) nbytes=1792 + metadata: bit_width: 14, offset: 0 + values: fastlanes.for(i32, len=1020) nbytes=2176 + metadata: reference: -49931i32 + encoded: fastlanes.bitpacked(i32, len=1020) nbytes=2176 + metadata: bit_width: 17, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__int_sparse_outliers.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__int_sparse_outliers.snap new file mode 100644 index 00000000000..53a5d1e023c --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__int_sparse_outliers.snap @@ -0,0 +1,13 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: i64, len=16384, nbytes=131072 +root: vortex.sparse(i64, len=16384) nbytes=5540 + metadata: fill_value: 1000000i64 + patch_indices: vortex.primitive(u16, len=848) nbytes=1696 + metadata: ptype: u16 + patch_values: fastlanes.for(i64, len=848) nbytes=3840 + metadata: reference: 1000830099i64 + encoded: fastlanes.bitpacked(i64, len=848) nbytes=3840 + metadata: bit_width: 30, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__int_wide_random.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__int_wide_random.snap new file mode 100644 index 00000000000..90e1894f910 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__int_wide_random.snap @@ -0,0 +1,7 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: u64, len=16384, nbytes=131072 +root: vortex.primitive(u64, len=16384) nbytes=131072 + metadata: ptype: u64 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__list_of_int_runs.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__list_of_int_runs.snap new file mode 100644 index 00000000000..c9554add05a --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__list_of_int_runs.snap @@ -0,0 +1,25 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: list(i32), len=4066, nbytes=81804 +root: vortex.list(list(i32), len=4066) nbytes=11146 + metadata: + elements: vortex.runend(i32, len=16384) nbytes=3968 + metadata: offset: 0 + ends: fastlanes.for(u16, len=1020) nbytes=1792 + metadata: reference: 13u16 + encoded: fastlanes.bitpacked(u16, len=1020) nbytes=1792 + metadata: bit_width: 14, offset: 0 + values: fastlanes.for(i32, len=1020) nbytes=2176 + metadata: reference: -49931i32 + encoded: fastlanes.bitpacked(i32, len=1020) nbytes=2176 + metadata: bit_width: 17, offset: 0 + offsets: fastlanes.bitpacked(u16, len=4067) nbytes=7178 + metadata: bit_width: 14, offset: 0 + patch_indices: vortex.primitive(u16, len=1) nbytes=2 + metadata: ptype: u16 + patch_values: vortex.constant(u16, len=1) nbytes=4 + metadata: scalar: 16384u16 + patch_chunk_offsets: vortex.primitive(u8, len=4) nbytes=4 + metadata: ptype: u8 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__string_fsst_structured.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__string_fsst_structured.snap new file mode 100644 index 00000000000..327f050b0d7 --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__string_fsst_structured.snap @@ -0,0 +1,15 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: utf8, len=16384, nbytes=653785 +root: vortex.fsst(utf8, len=16384) nbytes=151382 + metadata: len: 16384, nsymbols: 223 + uncompressed_lengths: vortex.sparse(u8, len=16384) nbytes=3154 + metadata: fill_value: 24u8 + patch_indices: vortex.primitive(u16, len=1575) nbytes=3150 + metadata: ptype: u16 + patch_values: vortex.constant(u8, len=1575) nbytes=2 + metadata: scalar: 23u8 + codes_offsets: fastlanes.bitpacked(u32, len=16385) nbytes=36992 + metadata: bit_width: 17, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__string_low_cardinality.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__string_low_cardinality.snap new file mode 100644 index 00000000000..ec79989d46a --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__string_low_cardinality.snap @@ -0,0 +1,15 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: utf8, len=16384, nbytes=262144 +root: vortex.dict(utf8, len=16384) nbytes=8373 + metadata: all_values_referenced: true + codes: fastlanes.bitpacked(u8, len=16384) nbytes=8192 + metadata: bit_width: 4, offset: 0 + values: vortex.fsst(utf8, len=12) nbytes=181 + metadata: len: 12, nsymbols: 9 + uncompressed_lengths: vortex.primitive(u8, len=12) nbytes=12 + metadata: ptype: u8 + codes_offsets: vortex.primitive(u8, len=13) nbytes=13 + metadata: ptype: u8 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__struct_mixed.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__struct_mixed.snap new file mode 100644 index 00000000000..be8f48b779b --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__struct_mixed.snap @@ -0,0 +1,23 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: {id=i64, category=utf8, value=f64}, len=16384, nbytes=524288 +root: vortex.struct({id=i64, category=utf8, value=f64}, len=16384) nbytes=57525 + metadata: + id: vortex.sequence(i64, len=16384) nbytes=0 + metadata: base: 10000i64, multiplier: 7i64 + category: vortex.dict(utf8, len=16384) nbytes=8373 + metadata: all_values_referenced: true + codes: fastlanes.bitpacked(u8, len=16384) nbytes=8192 + metadata: bit_width: 4, offset: 0 + values: vortex.fsst(utf8, len=12) nbytes=181 + metadata: len: 12, nsymbols: 9 + uncompressed_lengths: vortex.primitive(u8, len=12) nbytes=12 + metadata: ptype: u8 + codes_offsets: vortex.primitive(u8, len=13) nbytes=13 + metadata: ptype: u8 + value: vortex.alp(f64, len=16384) nbytes=49152 + metadata: exponents: e: 14, f: 12 + encoded: fastlanes.bitpacked(i64, len=16384) nbytes=49152 + metadata: bit_width: 24, offset: 0 diff --git a/vortex-btrblocks/tests/snapshots/golden__unstable__temporal_timestamp_micros.snap b/vortex-btrblocks/tests/snapshots/golden__unstable__temporal_timestamp_micros.snap new file mode 100644 index 00000000000..a763fa28f4b --- /dev/null +++ b/vortex-btrblocks/tests/snapshots/golden__unstable__temporal_timestamp_micros.snap @@ -0,0 +1,13 @@ +--- +source: vortex-btrblocks/tests/golden.rs +expression: rendered +--- +input: vortex.timestamp[µs, tz=UTC](i64), len=16384, nbytes=131072 +root: vortex.ext(vortex.timestamp[µs, tz=UTC](i64), len=16384) nbytes=43008 + metadata: + storage: fastlanes.delta(i64, len=16384) nbytes=43008 + metadata: offset: 0 + bases: vortex.primitive(i64, len=256) nbytes=2048 + metadata: ptype: i64 + deltas: fastlanes.bitpacked(i64, len=16384) nbytes=40960 + metadata: bit_width: 20, offset: 0 From dd4ab8936c5a6676f4f321efabf5d5fe7eef4f96 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Fri, 10 Jul 2026 14:43:28 +0100 Subject: [PATCH 051/104] Ignore quick-xml rustsec since we cannot update it ourselves (#8712) --- deny.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deny.toml b/deny.toml index b9f9c79a796..9c0c3eb3986 100644 --- a/deny.toml +++ b/deny.toml @@ -15,8 +15,8 @@ feature-depth = 1 ignore = [ # Paste is no longer maintained because its essentially "finished". "RUSTSEC-2024-0436", - # proc-macro-error-2 is unmaintained, only used by the `test_with` test dependency - "RUSTSEC-2026-0173" + # quick-xml is a transitive dependency that we cannot upgrade ourselves + "RUSTSEC-2026-0195" ] [licenses] From d96e4ad3516a1e41ac4b4bad2fb8f796cdbb87a5 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Fri, 10 Jul 2026 15:53:45 +0100 Subject: [PATCH 052/104] Ignore one more quick-xml rustsec advisory (#8716) I missed it in the previous pr --- deny.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deny.toml b/deny.toml index 9c0c3eb3986..21240f1f7e7 100644 --- a/deny.toml +++ b/deny.toml @@ -16,7 +16,8 @@ ignore = [ # Paste is no longer maintained because its essentially "finished". "RUSTSEC-2024-0436", # quick-xml is a transitive dependency that we cannot upgrade ourselves - "RUSTSEC-2026-0195" + "RUSTSEC-2026-0195", + "RUSTSEC-2026-0194" ] [licenses] From 357a4ba2752773bc7f0e00c8c143dcb71d8015c2 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Fri, 10 Jul 2026 16:21:42 +0100 Subject: [PATCH 053/104] Remove stored `Union` `Nullability` (#8714) ## Summary Tracking issue: #7882 A union has no independent parent validity. A row is null when its selected child is null, so the union's logical nullability is determined by its variants. Storing the same information on `DType::Union` created two sources of truth and allowed the outer value to disagree with the variants. This change makes inconsistent union DTypes impossible. The remaining question is how an operation such as `mask` should change a union's variants when it introduces nulls. That remains deferred and does not block the canonical sparse `UnionArray`. The Arrow implementation notes and deferred operation scope are in [this comment on #7882](https://github.com/vortex-data/vortex/issues/7882#issuecomment-4935971614). ## Changes - change `DType::Union` to store only `UnionVariants` - derive union nullability at runtime from the variant DTypes - name the non-zero-cost scan `derived_nullability` - leave `with_nullability` unchanged for unions instead of rewriting variant schemas - restrict union least-supertype coercion to identical variants for now - remove Union nullability from Serde, protobuf, and FlatBuffers Supersedes #8713. --------- Signed-off-by: Connor Tsui --- .../fns/uncompressed_size_in_bytes/mod.rs | 2 +- vortex-array/src/dtype/coercion.rs | 71 +++++++++++++ vortex-array/src/dtype/dtype_impl.rs | 25 ++--- vortex-array/src/dtype/mod.rs | 9 +- vortex-array/src/dtype/serde/flatbuffers.rs | 24 +---- vortex-array/src/dtype/serde/mod.rs | 21 ++++ vortex-array/src/dtype/serde/proto.rs | 57 ++-------- vortex-array/src/dtype/serde/serde.rs | 64 +---------- vortex-array/src/dtype/union.rs | 100 ++++++++---------- vortex-datafusion/src/convert/scalars.rs | 3 +- .../flatbuffers/vortex-dtype/dtype.fbs | 9 +- vortex-flatbuffers/src/generated/dtype.rs | 23 +--- vortex-flatbuffers/src/generated/message.rs | 2 +- vortex-proto/proto/dtype.proto | 1 - vortex-proto/src/generated/vortex.dtype.rs | 2 - 15 files changed, 176 insertions(+), 237 deletions(-) diff --git a/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs b/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs index 1d04a074d6f..6b2feb380a7 100644 --- a/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs +++ b/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs @@ -289,7 +289,7 @@ fn supports_uncompressed_size_in_bytes(dtype: &DType) -> bool { DType::Struct(fields, _) => fields .fields() .all(|field| supports_uncompressed_size_in_bytes(&field)), - DType::Union(variants, _) => variants + DType::Union(variants) => variants .variants() .all(|variant| supports_uncompressed_size_in_bytes(&variant)), DType::Variant(_) => false, diff --git a/vortex-array/src/dtype/coercion.rs b/vortex-array/src/dtype/coercion.rs index d57c0b9c1f4..3df356d93af 100644 --- a/vortex-array/src/dtype/coercion.rs +++ b/vortex-array/src/dtype/coercion.rs @@ -79,6 +79,25 @@ impl DType { /// The core primitive — what type can hold both `self` and `other`? /// Returns `None` if no common supertype exists. pub fn least_supertype(&self, other: &DType) -> Option { + match (self, other) { + (DType::Union(lhs), DType::Union(rhs)) => { + return (lhs == rhs).then(|| self.clone()); + } + (DType::Null, DType::Union(variants)) => { + return variants + .derived_nullability() + .is_nullable() + .then(|| other.clone()); + } + (DType::Union(variants), DType::Null) => { + return variants + .derived_nullability() + .is_nullable() + .then(|| self.clone()); + } + _ => {} + } + let union_null = self.nullability() | other.nullability(); if let ( @@ -308,6 +327,7 @@ mod tests { use crate::dtype::DType; use crate::dtype::PType; + use crate::dtype::UnionVariants; use crate::dtype::decimal::DecimalDType; use crate::dtype::nullability::Nullability::NonNullable; use crate::dtype::nullability::Nullability::Nullable; @@ -349,6 +369,57 @@ mod tests { ); } + #[test] + fn least_supertype_union_requires_identical_variants() { + let nonnullable = DType::Union( + UnionVariants::new( + ["value"].into(), + vec![DType::Primitive(PType::I32, NonNullable)], + ) + .unwrap(), + ); + let nullable = DType::Union( + UnionVariants::new( + ["value"].into(), + vec![DType::Primitive(PType::I32, Nullable)], + ) + .unwrap(), + ); + + assert_eq!( + nonnullable.least_supertype(&nonnullable), + Some(nonnullable.clone()) + ); + assert!(nonnullable.least_supertype(&nullable).is_none()); + assert!(nullable.least_supertype(&nonnullable).is_none()); + } + + #[test] + fn least_supertype_null_requires_nullable_union() { + let nonnullable = DType::Union( + UnionVariants::new( + ["value"].into(), + vec![DType::Primitive(PType::I32, NonNullable)], + ) + .unwrap(), + ); + let nullable = DType::Union( + UnionVariants::new( + ["value"].into(), + vec![DType::Primitive(PType::I32, Nullable)], + ) + .unwrap(), + ); + + assert!(DType::Null.least_supertype(&nonnullable).is_none()); + assert!(nonnullable.least_supertype(&DType::Null).is_none()); + assert_eq!( + DType::Null.least_supertype(&nullable), + Some(nullable.clone()) + ); + assert_eq!(nullable.least_supertype(&DType::Null), Some(nullable)); + } + #[test] fn least_supertype_unsigned_widening() { let u8_nn = DType::Primitive(PType::U8, NonNullable); diff --git a/vortex-array/src/dtype/dtype_impl.rs b/vortex-array/src/dtype/dtype_impl.rs index e13262fc168..5767a124667 100644 --- a/vortex-array/src/dtype/dtype_impl.rs +++ b/vortex-array/src/dtype/dtype_impl.rs @@ -64,8 +64,8 @@ impl DType { | List(_, null) | FixedSizeList(_, _, null) | Struct(_, null) - | Union(_, null) | Variant(null) => matches!(null, Nullability::Nullable), + Union(variants) => variants.derived_nullability().is_nullable(), Extension(ext_dtype) => ext_dtype.storage_dtype().is_nullable(), } } @@ -80,7 +80,10 @@ impl DType { self.with_nullability(Nullability::Nullable) } - /// Get a new DType with the given nullability (but otherwise the same as `self`) + /// Get a new DType with the given nullability (but otherwise the same as `self`). + /// + /// [`DType::Null`] and [`DType::Union`] have intrinsic nullability and are returned unchanged. + /// To change a union's nullability, construct different [`UnionVariants`]. pub fn with_nullability(&self, nullability: Nullability) -> Self { match self { Null => Null, @@ -92,7 +95,7 @@ impl DType { List(edt, _) => List(Arc::clone(edt), nullability), FixedSizeList(edt, size, _) => FixedSizeList(Arc::clone(edt), *size, nullability), Struct(sf, _) => Struct(sf.clone(), nullability), - Union(vs, _) => Union(vs.clone(), nullability), + Union(vs) => Union(vs.clone()), Variant(_) => Variant(nullability), Extension(ext) => Extension(ext.with_nullability(nullability)), } @@ -124,7 +127,7 @@ impl DType { .zip_eq(rhs_dtype.fields()) .all(|(l, r)| l.eq_ignore_nullability(&r))) } - (Union(lhs, _), Union(rhs, _)) => { + (Union(lhs), Union(rhs)) => { // Equal `names` implies equal length by FieldNames equality. lhs.names() == rhs.names() && lhs.type_ids() == rhs.type_ids() @@ -436,20 +439,12 @@ impl DType { /// Get the [`UnionVariants`] if `self` is a [`DType::Union`], otherwise `None`. pub fn as_union_variants_opt(&self) -> Option<&UnionVariants> { - if let Union(uv, _) = self { - Some(uv) - } else { - None - } + if let Union(uv) = self { Some(uv) } else { None } } /// Owned version of [Self::as_union_variants_opt]. pub fn into_union_variants_opt(self) -> Option { - if let Union(uv, _) = self { - Some(uv) - } else { - None - } + if let Union(uv) = self { Some(uv) } else { None } } /// Downcast a `DType` to an `ExtDType` @@ -503,7 +498,7 @@ impl Display for DType { .map(|(field_null, dt)| format!("{field_null}={dt}")) .join(", "), ), - Union(uv, null) => write!(f, "union({uv}){null}"), + Union(uv) => write!(f, "union({uv}){}", uv.derived_nullability()), Variant(null) => write!(f, "variant{null}"), Extension(ext) => write!(f, "{}", ext), } diff --git a/vortex-array/src/dtype/mod.rs b/vortex-array/src/dtype/mod.rs index 403531779aa..6a166a868ff 100644 --- a/vortex-array/src/dtype/mod.rs +++ b/vortex-array/src/dtype/mod.rs @@ -114,8 +114,13 @@ pub enum DType { /// A `Union` is composed of one or more **variants**, each with a name and a `DType`. A per-row /// `i8` tag selects which variant is "live" at that row. /// + /// Unlike other nested types, a union has no independent outer nullability. Its nullability is + /// derived at runtime from its variants: a union is nullable when any variant can contain null. + /// A concrete union array has no parent validity bitmap; a row's validity is the validity of + /// its selected child. + /// /// See [`UnionVariants`] for the type-tag conventions and accessors. - Union(UnionVariants, Nullability), + Union(UnionVariants), /// Dynamically typed values stored as Vortex scalars. /// @@ -148,7 +153,7 @@ impl PartialEq for DType { // StructFields handles its own Arc::ptr_eq in its PartialEq impl. (Self::Struct(a, na), Self::Struct(b, nb)) => na == nb && a == b, // UnionVariants handles its own Arc::ptr_eq in its PartialEq impl. - (Self::Union(a, na), Self::Union(b, nb)) => na == nb && a == b, + (Self::Union(a), Self::Union(b)) => a == b, (Self::Variant(a), Self::Variant(b)) => a == b, (Self::Extension(a), Self::Extension(b)) => a == b, // Every variant is listed in the first position so that adding a new diff --git a/vortex-array/src/dtype/serde/flatbuffers.rs b/vortex-array/src/dtype/serde/flatbuffers.rs index 803f92b6795..eef15d59c57 100644 --- a/vortex-array/src/dtype/serde/flatbuffers.rs +++ b/vortex-array/src/dtype/serde/flatbuffers.rs @@ -11,7 +11,6 @@ use itertools::Itertools; use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_error::vortex_bail; -use vortex_error::vortex_ensure; use vortex_error::vortex_err; use vortex_flatbuffers::FlatBuffer; use vortex_flatbuffers::FlatBufferRoot; @@ -22,7 +21,6 @@ use vortex_session::VortexSession; use crate::dtype::DType; use crate::dtype::DecimalDType; use crate::dtype::FieldDType; -use crate::dtype::Nullability; use crate::dtype::PType; use crate::dtype::StructFields; use crate::dtype::UnionVariants; @@ -236,15 +234,7 @@ impl TryFrom for DType { .ok_or_else(|| vortex_err!("failed to parse union from flatbuffer"))?; let variants = UnionVariants::from_fb(fb_union, vfdt.buffer().clone(), vfdt.session.clone())?; - - let nullability: Nullability = fb_union.nullable().into(); - vortex_ensure!( - variants.nullability_constraints_satisfied(nullability), - "Union nullability constraint not satisfied: nullability={:?}", - nullability - ); - - Ok(Self::Union(variants, nullability)) + Ok(Self::Union(variants)) } fb::Type::Variant => { let fb_variant = fb @@ -390,7 +380,7 @@ impl WriteFlatBuffer for DType { ) .as_union_value() } - Self::Union(uv, n) => { + Self::Union(uv) => { let names = uv .names() .iter() @@ -412,7 +402,6 @@ impl WriteFlatBuffer for DType { names, dtypes, type_ids, - nullable: (*n).into(), }, ) .as_union_value() @@ -583,7 +572,6 @@ mod test { ], ) .unwrap(), - Nullability::NonNullable, ) } @@ -593,14 +581,13 @@ mod test { } #[test] - fn test_union_round_trip_flatbuffer_with_nullability() { + fn test_union_round_trip_flatbuffer_with_nullable_variant() { let dtype = DType::Union( UnionVariants::new( ["null_variant", "str"].into(), vec![DType::Null, DType::Utf8(Nullability::NonNullable)], ) .unwrap(), - Nullability::Nullable, ); roundtrip_dtype(dtype); } @@ -618,7 +605,6 @@ mod test { vec![0, 5, 7], ) .unwrap(), - Nullability::NonNullable, ); let bytes = dtype.write_flatbuffer_bytes().unwrap(); @@ -631,7 +617,7 @@ mod test { let deserialized = DType::try_from(view).unwrap(); assert_eq!(dtype, deserialized); - let DType::Union(uv, _) = &deserialized else { + let DType::Union(uv) = &deserialized else { panic!("Expected Union"); }; assert_eq!(uv.type_ids(), &[0, 5, 7]); @@ -654,7 +640,6 @@ mod test { vec![DType::Utf8(Nullability::NonNullable), struct_with_union], ) .unwrap(), - Nullability::NonNullable, ); roundtrip_dtype(outer_union); @@ -710,7 +695,6 @@ mod test { names: Some(names), dtypes: Some(dtypes), type_ids: Some(type_ids), - nullable: false, }, ); diff --git a/vortex-array/src/dtype/serde/mod.rs b/vortex-array/src/dtype/serde/mod.rs index d0f43f0c4ac..17e07c244cb 100644 --- a/vortex-array/src/dtype/serde/mod.rs +++ b/vortex-array/src/dtype/serde/mod.rs @@ -24,6 +24,7 @@ mod test { use crate::dtype::DType; use crate::dtype::Nullability; use crate::dtype::PType; + use crate::dtype::UnionVariants; use crate::dtype::serde::DTypeSerde; use crate::dtype::test::SESSION; @@ -181,4 +182,24 @@ mod test { .unwrap(); assert_eq!(DType::Variant(Nullability::Nullable), deserialized); } + + #[test] + fn test_serde_union_dtype_json_roundtrip() { + let dtype = DType::Union( + UnionVariants::new(["value"].into(), vec![DType::Utf8(Nullability::Nullable)]).unwrap(), + ); + + let json = serde_json::to_string(&dtype).unwrap(); + assert_eq!( + json, + r#"{"Union":{"names":["value"],"dtypes":[{"Utf8":true}],"type_ids":[0]}}"# + ); + let mut deserializer = serde_json::Deserializer::from_str(&json); + let deserialized: DType = DTypeSerde::::new(&SESSION) + .deserialize(&mut deserializer) + .unwrap(); + + assert_eq!(deserialized, dtype); + assert_eq!(deserialized.nullability(), Nullability::Nullable); + } } diff --git a/vortex-array/src/dtype/serde/proto.rs b/vortex-array/src/dtype/serde/proto.rs index ba33259a3a5..e3dfb90557a 100644 --- a/vortex-array/src/dtype/serde/proto.rs +++ b/vortex-array/src/dtype/serde/proto.rs @@ -5,13 +5,11 @@ use std::sync::Arc; use vortex_error::VortexError; use vortex_error::VortexResult; -use vortex_error::vortex_ensure; use vortex_error::vortex_err; use vortex_session::VortexSession; use crate::dtype::DType; use crate::dtype::DecimalDType; -use crate::dtype::Nullability; use crate::dtype::PType; use crate::dtype::StructFields; use crate::dtype::UnionVariants; @@ -106,15 +104,7 @@ impl DType { }) .collect::>>()?; let variants = UnionVariants::try_new(names, dtypes, type_ids)?; - - let nullability: Nullability = u.nullable.into(); - vortex_ensure!( - variants.nullability_constraints_satisfied(nullability), - "Union nullability constraint not satisfied: nullability={:?}", - nullability - ); - - Ok(Self::Union(variants, nullability)) + Ok(Self::Union(variants)) } DtypeType::Variant(v) => Ok(Self::Variant(v.nullable.into())), DtypeType::Extension(e) => { @@ -183,14 +173,13 @@ impl TryFrom<&DType> for pb::DType { .collect::>>()?, nullable: (*null).into(), }), - DType::Union(uv, null) => DtypeType::Union(pb::Union { + DType::Union(uv) => DtypeType::Union(pb::Union { names: uv.names().iter().map(|n| n.as_ref().to_string()).collect(), dtypes: uv .variants() .map(|d| Self::try_from(&d)) .collect::>>()?, type_ids: uv.type_ids().iter().map(|t| *t as i32).collect(), - nullable: (*null).into(), }), DType::Variant(null) => DtypeType::Variant(pb::Variant { nullable: (*null).into(), @@ -427,23 +416,22 @@ mod tests { ], ) .unwrap(); - let dtype = DType::Union(variants, Nullability::NonNullable); + let dtype = DType::Union(variants); let converted = round_trip_dtype(&dtype); assert_eq!(dtype, converted); } #[test] - fn test_union_round_trip_proto_with_nullability() { + fn test_union_round_trip_proto_with_nullable_variant() { let variants = UnionVariants::new( ["null_variant", "str"].into(), vec![DType::Null, DType::Utf8(Nullability::NonNullable)], ) .unwrap(); - assert!(variants.nullability_constraints_satisfied(Nullability::Nullable)); - assert!(!variants.nullability_constraints_satisfied(Nullability::NonNullable)); + assert_eq!(variants.derived_nullability(), Nullability::Nullable); - let dtype = DType::Union(variants, Nullability::Nullable); + let dtype = DType::Union(variants); let converted = round_trip_dtype(&dtype); assert_eq!(dtype, converted); } @@ -461,43 +449,17 @@ mod tests { ) .unwrap(); - let dtype = DType::Union(variants, Nullability::NonNullable); + let dtype = DType::Union(variants); let converted = round_trip_dtype(&dtype); assert_eq!(dtype, converted); - let DType::Union(uv, _) = &converted else { + let DType::Union(uv) = &converted else { panic!("Expected Union"); }; assert_eq!(uv.type_ids(), &[0, 5, 7]); assert!(!uv.is_consecutive()); } - #[test] - fn test_union_proto_rejects_violating_nullability_constraint() { - // Nullable Union with no nullable or Null children must be rejected. - let proto = pb::DType { - dtype_type: Some(DtypeType::Union(pb::Union { - names: vec!["a".to_string(), "b".to_string()], - dtypes: vec![ - pb::DType::try_from(&DType::Primitive(PType::I32, Nullability::NonNullable)) - .unwrap(), - pb::DType::try_from(&DType::Utf8(Nullability::NonNullable)).unwrap(), - ], - type_ids: vec![0, 1], - nullable: true, - })), - }; - - let result = DType::from_proto(&proto, &SESSION); - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("Union nullability constraint not satisfied") - ); - } - #[test] fn test_union_proto_rejects_out_of_range_type_id() { let proto = pb::DType { @@ -509,7 +471,6 @@ mod tests { ], // 200 does not fit in i8. type_ids: vec![200], - nullable: false, })), }; @@ -534,7 +495,6 @@ mod tests { ], ) .unwrap(), - Nullability::NonNullable, ); let struct_with_union = DType::Struct( @@ -551,7 +511,6 @@ mod tests { vec![DType::Utf8(Nullability::NonNullable), struct_with_union], ) .unwrap(), - Nullability::NonNullable, ); let converted = round_trip_dtype(&outer_union); diff --git a/vortex-array/src/dtype/serde/serde.rs b/vortex-array/src/dtype/serde/serde.rs index 9d127047b40..1d386a34cbf 100644 --- a/vortex-array/src/dtype/serde/serde.rs +++ b/vortex-array/src/dtype/serde/serde.rs @@ -116,12 +116,7 @@ impl Serialize for DType { state.serialize_field(n)?; state.end() } - DType::Union(uv, n) => { - let mut state = serializer.serialize_tuple_variant("DType", 11, "Union", 2)?; - state.serialize_field(&uv)?; - state.serialize_field(n)?; - state.end() - } + DType::Union(uv) => serializer.serialize_newtype_variant("DType", 11, "Union", uv), DType::Variant(n) => serializer.serialize_newtype_variant("DType", 10, "Variant", n), DType::Extension(ext) => { serializer.serialize_newtype_variant("DType", 9, "Extension", ext) @@ -237,9 +232,9 @@ impl<'de> DeserializeSeed<'de> for DTypeSerde<'_, DType> { "Struct" => access.newtype_variant_seed(StructFieldsSeed { session: self.session, }), - "Union" => access.newtype_variant_seed(UnionVariantsSeed { - session: self.session, - }), + "Union" => access + .newtype_variant_seed(DTypeSerde::::new(self.session)) + .map(DType::Union), "Variant" => { let n = access.newtype_variant()?; Ok(DType::Variant(n)) @@ -410,57 +405,6 @@ impl<'de> DeserializeSeed<'de> for StructFieldsSeed<'_> { } } -struct UnionVariantsSeed<'a> { - session: &'a VortexSession, -} - -impl<'de> DeserializeSeed<'de> for UnionVariantsSeed<'_> { - type Value = DType; - - fn deserialize(self, deserializer: D) -> Result - where - D: Deserializer<'de>, - { - struct UnionVisitor<'a> { - session: &'a VortexSession, - } - - impl<'de> Visitor<'de> for UnionVisitor<'_> { - type Value = DType; - - fn expecting(&self, f: &mut Formatter) -> fmt::Result { - f.write_str("Union tuple (variants, nullability)") - } - - fn visit_seq(self, mut seq: A) -> Result - where - A: SeqAccess<'de>, - { - let variants = seq - .next_element_seed(DTypeSerde::::new(self.session))? - .ok_or_else(|| de::Error::invalid_length(0, &self))?; - let nullability: Nullability = seq - .next_element()? - .ok_or_else(|| de::Error::invalid_length(1, &self))?; - if !variants.nullability_constraints_satisfied(nullability) { - return Err(de::Error::custom(format!( - "Union nullability constraint not satisfied: nullability={:?}", - nullability - ))); - } - Ok(DType::Union(variants, nullability)) - } - } - - deserializer.deserialize_tuple( - 2, - UnionVisitor { - session: self.session, - }, - ) - } -} - impl<'de> DeserializeSeed<'de> for DTypeSerde<'_, UnionVariants> { type Value = UnionVariants; diff --git a/vortex-array/src/dtype/union.rs b/vortex-array/src/dtype/union.rs index cb443e87d36..c2097d3994c 100644 --- a/vortex-array/src/dtype/union.rs +++ b/vortex-array/src/dtype/union.rs @@ -339,26 +339,12 @@ impl UnionVariants { self.0.type_ids[index] } - /// Checks whether this set of variants satisfies the constraint imposed by a union-level - /// `Nullability`: + /// Returns the runtime-derived nullability of the union. /// - /// - `Nullable`: at least one variant is `DType::Null` or has nullable nullability. - /// - `NonNullable`: no `DType::Null` variants and no nullable variants. - /// - /// The check materializes each [`FieldDType`] (so it may be expensive if some are still - /// flatbuffer-backed views). - /// - /// It is intentionally not part of `UnionVariants` construction since `Nullability` lives on - /// the `DType::Union(_, Nullability)` enum variant, not on `UnionVariants` itself. - pub fn nullability_constraints_satisfied(&self, union_nullability: Nullability) -> bool { - let has_null_or_nullable = self - .variants() - .any(|dt| matches!(dt, DType::Null) || dt.is_nullable()); - - match union_nullability { - Nullability::Nullable => has_null_or_nullable, - Nullability::NonNullable => !has_null_or_nullable, - } + /// This is not a zero-cost accessor: it scans every variant and materializes each + /// [`FieldDType`], which may be expensive when variants are still flatbuffer-backed views. + pub fn derived_nullability(&self) -> Nullability { + self.variants().any(|dtype| dtype.is_nullable()).into() } } @@ -480,7 +466,6 @@ mod tests { DType::Primitive(PType::I32, Nullability::NonNullable), ], Nullability::Nullable, - true, )] #[case::nullable_with_nullable_child( vec![ @@ -488,57 +473,57 @@ mod tests { DType::Utf8(Nullability::Nullable), ], Nullability::Nullable, - true, )] - #[case::nullable_with_no_null_or_nullable( + #[case::nonnullable( vec![ DType::Primitive(PType::I32, Nullability::NonNullable), DType::Utf8(Nullability::NonNullable), ], - Nullability::Nullable, - false, - )] - #[case::nonnullable_with_null_child( - vec![ - DType::Null, - DType::Primitive(PType::I32, Nullability::NonNullable), - ], - Nullability::NonNullable, - false, - )] - #[case::nonnullable_with_nullable_child( - vec![ - DType::Primitive(PType::I32, Nullability::NonNullable), - DType::Utf8(Nullability::Nullable), - ], Nullability::NonNullable, - false, )] - #[case::nonnullable_clean( - vec![ - DType::Primitive(PType::I32, Nullability::NonNullable), - DType::Utf8(Nullability::NonNullable), - ], - Nullability::NonNullable, - true, - )] - fn test_nullability_constraints( - #[case] dtypes: Vec, - #[case] nullability: Nullability, - #[case] expected: bool, - ) { + #[case::empty(vec![], Nullability::NonNullable)] + fn test_derived_nullability(#[case] dtypes: Vec, #[case] expected: Nullability) { let names: Vec<&str> = (0..dtypes.len()).map(|i| ["a", "b", "c", "d"][i]).collect(); let variants = UnionVariants::new(names.as_slice().into(), dtypes).unwrap(); - assert_eq!( - variants.nullability_constraints_satisfied(nullability), - expected + assert_eq!(variants.derived_nullability(), expected); + } + + #[test] + fn test_nested_union_nullability() { + let inner = DType::Union( + UnionVariants::new(["value"].into(), vec![DType::Utf8(Nullability::Nullable)]).unwrap(), + ); + let outer = DType::Union( + UnionVariants::new( + ["nested", "number"].into(), + vec![ + inner, + DType::Primitive(PType::I32, Nullability::NonNullable), + ], + ) + .unwrap(), + ); + + assert_eq!(outer.nullability(), Nullability::Nullable); + } + + #[test] + fn test_with_nullability_does_not_change_union_variants() { + let nonnullable = DType::Union(i32_variants()); + assert_eq!(nonnullable.as_nullable(), nonnullable); + assert_eq!(nonnullable.nullability(), Nullability::NonNullable); + + let nullable = DType::Union( + UnionVariants::new(["value"].into(), vec![DType::Utf8(Nullability::Nullable)]).unwrap(), ); + assert_eq!(nullable.as_nonnullable(), nullable); + assert_eq!(nullable.nullability(), Nullability::Nullable); } #[test] fn test_display() { let variants = i32_variants(); - let dtype = DType::Union(variants, Nullability::NonNullable); + let dtype = DType::Union(variants); assert_eq!(dtype.to_string(), "union(int=i32, str=utf8)"); let nullable = DType::Union( @@ -550,7 +535,6 @@ mod tests { ], ) .unwrap(), - Nullability::Nullable, ); assert_eq!(nullable.to_string(), "union(int=i32, maybe_str=utf8?)?"); } @@ -567,7 +551,7 @@ mod tests { vec![0, 5, 7], ) .unwrap(); - let dtype = DType::Union(variants, Nullability::NonNullable); + let dtype = DType::Union(variants); assert_eq!(dtype.to_string(), "union(a@0=i32, b@5=utf8, c@7=bool)"); } diff --git a/vortex-datafusion/src/convert/scalars.rs b/vortex-datafusion/src/convert/scalars.rs index 1e11dd6eede..e26aa005adc 100644 --- a/vortex-datafusion/src/convert/scalars.rs +++ b/vortex-datafusion/src/convert/scalars.rs @@ -832,8 +832,7 @@ mod tests { ["a"].into(), vec![DType::Primitive(PType::I32, Nullability::Nullable)], ) - .unwrap(), - Nullability::Nullable + .unwrap() )))] fn unsupported_vortex_scalars_return_errors(#[case] scalar: Scalar) { let err = scalar.try_to_df().unwrap_err(); diff --git a/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs b/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs index fe737eafe0f..add1ae63e2e 100644 --- a/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs +++ b/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs @@ -67,13 +67,10 @@ table Variant { nullable: bool; } -// Explicit ids keep `nullable` at id 0, the id it had before the variant metadata fields were -// added, so schemas written in between remain readable. table Union { - names: [string] (id: 1); - dtypes: [DType] (id: 2); - type_ids: [byte] (id: 3); // length must equal dtypes.len() - nullable: bool (id: 0); + names: [string]; + dtypes: [DType]; + type_ids: [byte]; // length must equal dtypes.len() } union Type { diff --git a/vortex-flatbuffers/src/generated/dtype.rs b/vortex-flatbuffers/src/generated/dtype.rs index 8b26f68dc34..953e826da82 100644 --- a/vortex-flatbuffers/src/generated/dtype.rs +++ b/vortex-flatbuffers/src/generated/dtype.rs @@ -1477,10 +1477,9 @@ impl<'a> ::flatbuffers::Follow<'a> for Union<'a> { } impl<'a> Union<'a> { - pub const VT_NULLABLE: ::flatbuffers::VOffsetT = 4; - pub const VT_NAMES: ::flatbuffers::VOffsetT = 6; - pub const VT_DTYPES: ::flatbuffers::VOffsetT = 8; - pub const VT_TYPE_IDS: ::flatbuffers::VOffsetT = 10; + pub const VT_NAMES: ::flatbuffers::VOffsetT = 4; + pub const VT_DTYPES: ::flatbuffers::VOffsetT = 6; + pub const VT_TYPE_IDS: ::flatbuffers::VOffsetT = 8; #[inline] pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { @@ -1495,18 +1494,10 @@ impl<'a> Union<'a> { if let Some(x) = args.type_ids { builder.add_type_ids(x); } if let Some(x) = args.dtypes { builder.add_dtypes(x); } if let Some(x) = args.names { builder.add_names(x); } - builder.add_nullable(args.nullable); builder.finish() } - #[inline] - pub fn nullable(&self) -> bool { - // Safety: - // Created from valid Table for this object - // which contains a valid value in this slot - unsafe { self._tab.get::(Union::VT_NULLABLE, Some(false)).unwrap()} - } #[inline] pub fn names(&self) -> Option<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>> { // Safety: @@ -1536,7 +1527,6 @@ impl ::flatbuffers::Verifiable for Union<'_> { v: &mut ::flatbuffers::Verifier, pos: usize ) -> Result<(), ::flatbuffers::InvalidFlatbuffer> { v.visit_table(pos)? - .visit_field::("nullable", Self::VT_NULLABLE, false)? .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset<&'_ str>>>>("names", Self::VT_NAMES, false)? .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>>>("dtypes", Self::VT_DTYPES, false)? .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, i8>>>("type_ids", Self::VT_TYPE_IDS, false)? @@ -1545,7 +1535,6 @@ impl ::flatbuffers::Verifiable for Union<'_> { } } pub struct UnionArgs<'a> { - pub nullable: bool, pub names: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>>>, pub dtypes: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>>, pub type_ids: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, i8>>>, @@ -1554,7 +1543,6 @@ impl<'a> Default for UnionArgs<'a> { #[inline] fn default() -> Self { UnionArgs { - nullable: false, names: None, dtypes: None, type_ids: None, @@ -1567,10 +1555,6 @@ pub struct UnionBuilder<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> { start_: ::flatbuffers::WIPOffset<::flatbuffers::TableUnfinishedWIPOffset>, } impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> UnionBuilder<'a, 'b, A> { - #[inline] - pub fn add_nullable(&mut self, nullable: bool) { - self.fbb_.push_slot::(Union::VT_NULLABLE, nullable, false); - } #[inline] pub fn add_names(&mut self, names: ::flatbuffers::WIPOffset<::flatbuffers::Vector<'b , ::flatbuffers::ForwardsUOffset<&'b str>>>) { self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Union::VT_NAMES, names); @@ -1601,7 +1585,6 @@ impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> UnionBuilder<'a, 'b, A> { impl ::core::fmt::Debug for Union<'_> { fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { let mut ds = f.debug_struct("Union"); - ds.field("nullable", &self.nullable()); ds.field("names", &self.names()); ds.field("dtypes", &self.dtypes()); ds.field("type_ids", &self.type_ids()); diff --git a/vortex-flatbuffers/src/generated/message.rs b/vortex-flatbuffers/src/generated/message.rs index 9cc48fe6be6..b3e4be76ef7 100644 --- a/vortex-flatbuffers/src/generated/message.rs +++ b/vortex-flatbuffers/src/generated/message.rs @@ -2,8 +2,8 @@ // @generated extern crate alloc; -use crate::dtype::*; use crate::array::*; +use crate::dtype::*; #[deprecated(since = "2.0.0", note = "Use associated constants instead. This will no longer be generated in 2021.")] pub const ENUM_MIN_MESSAGE_VERSION: u8 = 0; diff --git a/vortex-proto/proto/dtype.proto b/vortex-proto/proto/dtype.proto index 4a221cef043..decc2179080 100644 --- a/vortex-proto/proto/dtype.proto +++ b/vortex-proto/proto/dtype.proto @@ -78,7 +78,6 @@ message Union { repeated string names = 1; repeated DType dtypes = 2; repeated int32 type_ids = 3; // length must equal dtypes.len(); each value must fit in int8 - bool nullable = 4; } message DType { diff --git a/vortex-proto/src/generated/vortex.dtype.rs b/vortex-proto/src/generated/vortex.dtype.rs index 27e44089253..4bffca278e2 100644 --- a/vortex-proto/src/generated/vortex.dtype.rs +++ b/vortex-proto/src/generated/vortex.dtype.rs @@ -80,8 +80,6 @@ pub struct Union { /// length must equal dtypes.len(); each value must fit in int8 #[prost(int32, repeated, tag = "3")] pub type_ids: ::prost::alloc::vec::Vec, - #[prost(bool, tag = "4")] - pub nullable: bool, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct DType { From 2a58c67aa8ca629ce91720db55682a7124d6e938 Mon Sep 17 00:00:00 2001 From: Adam Gutglick Date: Fri, 10 Jul 2026 18:14:46 +0100 Subject: [PATCH 054/104] Optimize dependencies using cargo-shear (#8715) ## Rationale for this change Inspired by @0ax1, trying to see how many dependencies we can remove/simplify Signed-off-by: Adam Gutglick --- Cargo.lock | 67 ------------------- Cargo.toml | 11 --- benchmarks/datafusion-bench/Cargo.toml | 3 + benchmarks/duckdb-bench/Cargo.toml | 3 + encodings/bytebool/Cargo.toml | 1 - encodings/experimental/onpair/Cargo.toml | 1 - encodings/sequence/Cargo.toml | 1 - vortex-array/Cargo.toml | 2 - .../src/arrays/bool/vtable/canonical.rs | 2 - vortex-array/src/arrays/bool/vtable/mod.rs | 1 - vortex-btrblocks/Cargo.toml | 1 - vortex-buffer/Cargo.toml | 1 - vortex-compressor/Cargo.toml | 2 - vortex-cuda/Cargo.toml | 1 - vortex-cuda/cub/Cargo.toml | 3 + vortex-cuda/ffi/Cargo.toml | 1 - vortex-cuda/nvcomp/Cargo.toml | 3 + vortex-duckdb/Cargo.toml | 1 - vortex-ffi/Cargo.toml | 4 -- vortex-proto/Cargo.toml | 3 + vortex-row/Cargo.toml | 1 - vortex-sqllogictest/Cargo.toml | 12 ++-- vortex-tensor/Cargo.toml | 6 -- vortex-test/compat-gen/Cargo.toml | 1 - vortex-tui/Cargo.toml | 1 - vortex-utils/Cargo.toml | 1 - vortex/Cargo.toml | 7 +- xtask/Cargo.toml | 1 - 28 files changed, 25 insertions(+), 117 deletions(-) delete mode 100644 vortex-array/src/arrays/bool/vtable/canonical.rs diff --git a/Cargo.lock b/Cargo.lock index 7a3b82f3dce..c8ea96df402 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1197,39 +1197,6 @@ dependencies = [ "libbz2-rs-sys", ] -[[package]] -name = "camino" -version = "1.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" -dependencies = [ - "serde_core", -] - -[[package]] -name = "cargo-platform" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "cargo_metadata" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 2.0.18", -] - [[package]] name = "cast" version = "0.3.0" @@ -8305,10 +8272,6 @@ name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] [[package]] name = "seq-macro" @@ -9735,12 +9698,9 @@ dependencies = [ "arrow-array 58.3.0", "codspeed-divan-compat", "fastlanes", - "futures", "mimalloc", "parquet 58.3.0", - "paste", "rand 0.10.2", - "rand_distr 0.6.0", "serde_json", "tokio", "tracing", @@ -9803,12 +9763,10 @@ dependencies = [ "arbitrary", "arc-swap", "arcref", - "arrow-arith 58.3.0", "arrow-array 58.3.0", "arrow-buffer 58.3.0", "arrow-cast 58.3.0", "arrow-data 58.3.0", - "arrow-ord 58.3.0", "arrow-schema 58.3.0", "arrow-select 58.3.0", "arrow-string 58.3.0", @@ -9930,7 +9888,6 @@ dependencies = [ "codspeed-divan-compat", "insta", "itertools 0.14.0", - "num-traits", "pco", "rand 0.10.2", "rstest", @@ -9966,7 +9923,6 @@ dependencies = [ "itertools 0.14.0", "memmap2", "num-traits", - "rand 0.10.2", "rstest", "serde", "simdutf8", @@ -9983,7 +9939,6 @@ dependencies = [ "vortex-array", "vortex-buffer", "vortex-error", - "vortex-mask", "vortex-session", ] @@ -10010,7 +9965,6 @@ dependencies = [ "vortex-array", "vortex-buffer", "vortex-error", - "vortex-fsst", "vortex-session", ] @@ -10023,10 +9977,8 @@ dependencies = [ "num-traits", "parking_lot", "rand 0.10.2", - "rstest", "rustc-hash", "tracing", - "tracing-subscriber", "vortex-array", "vortex-buffer", "vortex-error", @@ -10069,7 +10021,6 @@ dependencies = [ "arrow-schema 58.3.0", "async-trait", "bindgen", - "bytes", "codspeed-criterion-compat-walltime", "cudarc", "fastlanes", @@ -10100,7 +10051,6 @@ dependencies = [ "arrow-schema 58.3.0", "futures", "vortex", - "vortex-array", "vortex-cuda", "vortex-cuda-macros", "vortex-ffi", @@ -10199,7 +10149,6 @@ version = "0.1.0" dependencies = [ "anyhow", "async-fs", - "async-trait", "bindgen", "bitvec", "cbindgen", @@ -10277,16 +10226,12 @@ dependencies = [ "bytes", "cbindgen", "futures", - "itertools 0.14.0", "mimalloc", - "object_store", "paste", - "prost 0.14.4", "rand 0.10.2", "tempfile", "tracing", "tracing-subscriber", - "url", "vortex", "vortex-array", ] @@ -10573,7 +10518,6 @@ name = "vortex-onpair" version = "0.1.0" dependencies = [ "codspeed-divan-compat", - "memchr", "num-traits", "onpair", "prost 0.14.4", @@ -10680,7 +10624,6 @@ dependencies = [ "arrow-array 58.3.0", "arrow-row 58.3.0", "arrow-schema 58.3.0", - "bytes", "codspeed-divan-compat", "mimalloc", "rand 0.10.2", @@ -10732,7 +10675,6 @@ dependencies = [ name = "vortex-sequence" version = "0.1.0" dependencies = [ - "itertools 0.14.0", "num-traits", "prost 0.14.4", "rstest", @@ -10800,22 +10742,16 @@ version = "0.1.0" dependencies = [ "arrow-array 58.3.0", "arrow-schema 58.3.0", - "codspeed-divan-compat", "half", "itertools 0.14.0", - "mimalloc", "num-traits", "prost 0.14.4", - "rand 0.10.2", - "rand_distr 0.6.0", "rstest", "vortex-array", - "vortex-btrblocks", "vortex-buffer", "vortex-compressor", "vortex-error", "vortex-session", - "vortex-utils", ] [[package]] @@ -10847,7 +10783,6 @@ dependencies = [ "humansize", "indicatif", "itertools 0.14.0", - "js-sys", "parquet 58.3.0", "ratatui", "ratzilla", @@ -10868,7 +10803,6 @@ version = "0.1.0" dependencies = [ "dashmap", "hashbrown 0.17.1", - "vortex-error", ] [[package]] @@ -11417,7 +11351,6 @@ name = "xtask" version = "0.1.0" dependencies = [ "anyhow", - "cargo_metadata", "clap", "prost-build", "xshell", diff --git a/Cargo.toml b/Cargo.toml index e781ec222d1..b3c1932809a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,7 +94,6 @@ rust-version = "1.91.0" version = "0.1.0" [workspace.dependencies] -aho-corasick = "1.1.3" anyhow = "1.0.97" arbitrary = "1.3.2" arc-swap = "1.9" @@ -105,7 +104,6 @@ arrow-buffer = "58.3" arrow-cast = "58.3" arrow-data = "58.3" arrow-ipc = "58.3" -arrow-ord = "58.3" arrow-row = "58.3" arrow-schema = "58.3" arrow-select = "58.3" @@ -121,7 +119,6 @@ bit-vec = "0.9.0" bitvec = "1.0.1" bytes = "1.11.1" bzip2 = "0.6.0" -cargo_metadata = "0.23.1" cbindgen = "0.29.0" cc = "1.2" cfg-if = "1.0.1" @@ -152,7 +149,6 @@ datafusion-physical-expr-common = { version = "54" } datafusion-physical-plan = { version = "54" } datafusion-pruning = { version = "54" } datafusion-sqllogictest = { version = "54" } -dirs = "6.0.0" divan = { package = "codspeed-divan-compat", version = "5.0.0" } enum-iterator = "2.0.0" env_logger = "0.11" @@ -176,7 +172,6 @@ indicatif = "0.18.0" insta = "1.43" inventory = "0.3.20" itertools = "0.14.0" -jetscii = "0.5.3" jiff = "0.2.0" jni = { version = "0.22.0" } kanal = "0.1.1" @@ -186,12 +181,9 @@ libfuzzer-sys = "0.4" libloading = "0.8" liblzma = "0.4" log = { version = "0.4.21" } -loom = { version = "0.7", features = ["checkpoint"] } -memchr = "2.8.0" memmap2 = "0.9.5" mimalloc = "0.1.42" moka = { version = "0.12.10", default-features = false } -multiversion = "0.8.0" noodles-bgzf = "0.47.0" noodles-vcf = { version = "0.88.0", features = ["async"] } num-traits = "0.2.19" @@ -224,7 +216,6 @@ rand = "0.10.1" rand_distr = "0.6" ratatui = { version = "0.30", default-features = false } regex = "1.11.0" -regex-automata = "0.4" reqwest = { version = "0.13.0", features = [ "blocking", "charset", @@ -330,11 +321,9 @@ vortex-zstd = { version = "0.1.0", path = "./encodings/zstd", default-features = # No version constraints for unpublished crates. vortex-bench = { path = "./vortex-bench", default-features = false } -vortex-compat = { path = "./vortex-test/compat-gen" } vortex-cuda = { path = "./vortex-cuda", default-features = false } vortex-cuda-macros = { path = "./vortex-cuda/macros" } vortex-duckdb = { path = "./vortex-duckdb", default-features = false } -vortex-test-e2e-cuda = { path = "./vortex-test/e2e-cuda", default-features = false } vortex-tui = { path = "./vortex-tui" } [workspace.lints.rust] diff --git a/benchmarks/datafusion-bench/Cargo.toml b/benchmarks/datafusion-bench/Cargo.toml index d0015dd2995..2f99d621a85 100644 --- a/benchmarks/datafusion-bench/Cargo.toml +++ b/benchmarks/datafusion-bench/Cargo.toml @@ -14,6 +14,9 @@ rust-version = { workspace = true } version = { workspace = true } publish = false +[package.metadata.cargo-shear] +ignored = ["vortex-cuda"] + [dependencies] anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } diff --git a/benchmarks/duckdb-bench/Cargo.toml b/benchmarks/duckdb-bench/Cargo.toml index b70375cf963..77c3d5280fc 100644 --- a/benchmarks/duckdb-bench/Cargo.toml +++ b/benchmarks/duckdb-bench/Cargo.toml @@ -14,6 +14,9 @@ rust-version.workspace = true version.workspace = true publish = false +[package.metadata.cargo-shear] +ignored = ["vortex-cuda"] + [dependencies] anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } diff --git a/encodings/bytebool/Cargo.toml b/encodings/bytebool/Cargo.toml index bca2ed71aa9..53197452ff5 100644 --- a/encodings/bytebool/Cargo.toml +++ b/encodings/bytebool/Cargo.toml @@ -21,7 +21,6 @@ num-traits = { workspace = true } vortex-array = { workspace = true } vortex-buffer = { workspace = true } vortex-error = { workspace = true } -vortex-mask = { workspace = true } vortex-session = { workspace = true } [dev-dependencies] diff --git a/encodings/experimental/onpair/Cargo.toml b/encodings/experimental/onpair/Cargo.toml index 258568cc75a..2f6b19147fb 100644 --- a/encodings/experimental/onpair/Cargo.toml +++ b/encodings/experimental/onpair/Cargo.toml @@ -17,7 +17,6 @@ version = { workspace = true } workspace = true [dependencies] -memchr = { workspace = true } num-traits = { workspace = true } onpair = { workspace = true } prost = { workspace = true } diff --git a/encodings/sequence/Cargo.toml b/encodings/sequence/Cargo.toml index 5dd9c5c187d..e60d6be29b7 100644 --- a/encodings/sequence/Cargo.toml +++ b/encodings/sequence/Cargo.toml @@ -25,7 +25,6 @@ vortex-proto = { workspace = true } vortex-session = { workspace = true } [dev-dependencies] -itertools = { workspace = true } rstest = { workspace = true } vortex-array = { path = "../../vortex-array", features = ["_test-harness"] } diff --git a/vortex-array/Cargo.toml b/vortex-array/Cargo.toml index 53db75d46e7..310c936a019 100644 --- a/vortex-array/Cargo.toml +++ b/vortex-array/Cargo.toml @@ -23,12 +23,10 @@ workspace = true arbitrary = { workspace = true, optional = true } arc-swap = { workspace = true } arcref = { workspace = true } -arrow-arith = { workspace = true } arrow-array = { workspace = true, features = ["ffi"] } arrow-buffer = { workspace = true } arrow-cast = { workspace = true } arrow-data = { workspace = true } -arrow-ord = { workspace = true } arrow-schema = { workspace = true, features = ["canonical_extension_types"] } arrow-select = { workspace = true } arrow-string = { workspace = true } diff --git a/vortex-array/src/arrays/bool/vtable/canonical.rs b/vortex-array/src/arrays/bool/vtable/canonical.rs deleted file mode 100644 index 0d735177e5d..00000000000 --- a/vortex-array/src/arrays/bool/vtable/canonical.rs +++ /dev/null @@ -1,2 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors diff --git a/vortex-array/src/arrays/bool/vtable/mod.rs b/vortex-array/src/arrays/bool/vtable/mod.rs index 3d01b3a44dc..8a9cd961da8 100644 --- a/vortex-array/src/arrays/bool/vtable/mod.rs +++ b/vortex-array/src/arrays/bool/vtable/mod.rs @@ -28,7 +28,6 @@ use crate::builders::BoolBuilder; use crate::dtype::DType; use crate::serde::ArrayChildren; use crate::validity::Validity; -mod canonical; mod kernel; mod operations; mod validity; diff --git a/vortex-btrblocks/Cargo.toml b/vortex-btrblocks/Cargo.toml index 529c6d90bad..255d2fbd075 100644 --- a/vortex-btrblocks/Cargo.toml +++ b/vortex-btrblocks/Cargo.toml @@ -18,7 +18,6 @@ all-features = true [dependencies] itertools = { workspace = true } -num-traits = { workspace = true } pco = { workspace = true, optional = true } rand = { workspace = true } vortex-alp = { workspace = true } diff --git a/vortex-buffer/Cargo.toml b/vortex-buffer/Cargo.toml index e0397bacc91..ae9d7e6cc05 100644 --- a/vortex-buffer/Cargo.toml +++ b/vortex-buffer/Cargo.toml @@ -39,7 +39,6 @@ workspace = true [dev-dependencies] divan = { workspace = true } num-traits = { workspace = true } -rand = { workspace = true } rstest = { workspace = true } [[bench]] diff --git a/vortex-compressor/Cargo.toml b/vortex-compressor/Cargo.toml index 2d28d86a5e6..24a2d8e40d5 100644 --- a/vortex-compressor/Cargo.toml +++ b/vortex-compressor/Cargo.toml @@ -28,8 +28,6 @@ vortex-utils = { workspace = true } [dev-dependencies] divan = { workspace = true } -rstest = { workspace = true } -tracing-subscriber = { workspace = true, features = ["env-filter"] } vortex-array = { workspace = true, features = ["_test-harness"] } vortex-session = { workspace = true } diff --git a/vortex-cuda/Cargo.toml b/vortex-cuda/Cargo.toml index cc34bee74fb..6ad2662e416 100644 --- a/vortex-cuda/Cargo.toml +++ b/vortex-cuda/Cargo.toml @@ -26,7 +26,6 @@ unstable_encodings = ["vortex/unstable_encodings"] arc-swap = { workspace = true } arrow-schema = { workspace = true, features = ["ffi"] } async-trait = { workspace = true } -bytes = { workspace = true } cudarc = { workspace = true, features = ["f16"] } futures = { workspace = true, features = ["executor"] } itertools = { workspace = true } diff --git a/vortex-cuda/cub/Cargo.toml b/vortex-cuda/cub/Cargo.toml index ed166318569..c37fd18ec83 100644 --- a/vortex-cuda/cub/Cargo.toml +++ b/vortex-cuda/cub/Cargo.toml @@ -17,6 +17,9 @@ rust-version = { workspace = true } version = { workspace = true } publish = false +[package.metadata.cargo-shear] +ignored = ["libloading"] + [lints] workspace = true diff --git a/vortex-cuda/ffi/Cargo.toml b/vortex-cuda/ffi/Cargo.toml index a8ca51ad954..c1e1efdb23c 100644 --- a/vortex-cuda/ffi/Cargo.toml +++ b/vortex-cuda/ffi/Cargo.toml @@ -22,7 +22,6 @@ vortex-cuda = { path = ".." } vortex-ffi = { path = "../../vortex-ffi" } [dev-dependencies] -vortex-array = { workspace = true, features = ["_test-harness"] } vortex-cuda-macros = { workspace = true } [lib] diff --git a/vortex-cuda/nvcomp/Cargo.toml b/vortex-cuda/nvcomp/Cargo.toml index 5fdd88d4369..4caeef8c7a2 100644 --- a/vortex-cuda/nvcomp/Cargo.toml +++ b/vortex-cuda/nvcomp/Cargo.toml @@ -14,6 +14,9 @@ rust-version = { workspace = true } version = { workspace = true } publish = false +[package.metadata.cargo-shear] +ignored = ["libloading"] + [lints] workspace = true diff --git a/vortex-duckdb/Cargo.toml b/vortex-duckdb/Cargo.toml index 01a0e114fde..59dbe8d4003 100644 --- a/vortex-duckdb/Cargo.toml +++ b/vortex-duckdb/Cargo.toml @@ -25,7 +25,6 @@ crate-type = ["rlib"] [dependencies] async-fs = { workspace = true } -async-trait = { workspace = true } bitvec = { workspace = true } custom-labels = { workspace = true } futures = { workspace = true } diff --git a/vortex-ffi/Cargo.toml b/vortex-ffi/Cargo.toml index 0fddf153d2b..61b1c4071b4 100644 --- a/vortex-ffi/Cargo.toml +++ b/vortex-ffi/Cargo.toml @@ -25,14 +25,10 @@ arrow-schema = { workspace = true } async-fs = { workspace = true } bytes = { workspace = true } futures = { workspace = true } -itertools = { workspace = true } mimalloc = { workspace = true, optional = true } -object_store = { workspace = true, features = ["aws", "azure", "gcp"] } paste = { workspace = true } -prost = { workspace = true } tracing = { workspace = true, features = ["std", "log"] } tracing-subscriber = { workspace = true, features = ["env-filter"] } -url = { workspace = true, features = [] } vortex = { workspace = true, features = ["object_store"] } [dev-dependencies] diff --git a/vortex-proto/Cargo.toml b/vortex-proto/Cargo.toml index e47865ef299..ba0202c2ea0 100644 --- a/vortex-proto/Cargo.toml +++ b/vortex-proto/Cargo.toml @@ -19,6 +19,9 @@ version = { workspace = true } [package.metadata.docs.rs] all-features = true +[package.metadata.cargo-shear] +ignored = ["prost-types"] + [dependencies] prost = { workspace = true } prost-types = { workspace = true } diff --git a/vortex-row/Cargo.toml b/vortex-row/Cargo.toml index 9222c7d6a43..bcb50bc1276 100644 --- a/vortex-row/Cargo.toml +++ b/vortex-row/Cargo.toml @@ -18,7 +18,6 @@ version = { workspace = true } workspace = true [dependencies] -bytes = { workspace = true } smallvec = { workspace = true } vortex-array = { workspace = true } vortex-buffer = { workspace = true } diff --git a/vortex-sqllogictest/Cargo.toml b/vortex-sqllogictest/Cargo.toml index 9f1d7ccf23b..cf7d3e56660 100644 --- a/vortex-sqllogictest/Cargo.toml +++ b/vortex-sqllogictest/Cargo.toml @@ -17,20 +17,22 @@ version = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } bigdecimal = { workspace = true } -datafusion = { workspace = true } -datafusion-functions-nested.workspace = true datafusion-sqllogictest = { workspace = true } -indicatif = { workspace = true } regex = { workspace = true } rstest = { workspace = true } sqllogictest = "0.29.1" thiserror = { workspace = true } tokio = { workspace = true, features = ["full"] } -tracing-subscriber = { workspace = true, features = ["env-filter"] } vortex = { workspace = true, features = ["tokio"] } -vortex-datafusion = { workspace = true } vortex-duckdb = { workspace = true } +[dev-dependencies] +datafusion = { workspace = true } +datafusion-functions-nested.workspace = true +indicatif = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +vortex-datafusion = { workspace = true } + [lints] workspace = true diff --git a/vortex-tensor/Cargo.toml b/vortex-tensor/Cargo.toml index a293e604b5b..7429d71521d 100644 --- a/vortex-tensor/Cargo.toml +++ b/vortex-tensor/Cargo.toml @@ -22,7 +22,6 @@ vortex-buffer = { workspace = true } vortex-compressor = { workspace = true } vortex-error = { workspace = true } vortex-session = { workspace = true } -vortex-utils = { workspace = true } arrow-array = { workspace = true } arrow-schema = { workspace = true } @@ -32,10 +31,5 @@ num-traits = { workspace = true } prost = { workspace = true } [dev-dependencies] -divan = { workspace = true } -mimalloc = { workspace = true } -rand = { workspace = true } -rand_distr = { workspace = true } rstest = { workspace = true } vortex-array = { workspace = true, features = ["_test-harness"] } -vortex-btrblocks = { path = "../vortex-btrblocks" } diff --git a/vortex-test/compat-gen/Cargo.toml b/vortex-test/compat-gen/Cargo.toml index b22c49112b8..dd8bec4703a 100644 --- a/vortex-test/compat-gen/Cargo.toml +++ b/vortex-test/compat-gen/Cargo.toml @@ -25,7 +25,6 @@ vortex = { workspace = true, features = ["files", "tokio", "zstd"] } vortex-array = { workspace = true, features = ["_test-harness"] } vortex-buffer = { workspace = true } vortex-error = { workspace = true } -vortex-fsst = { workspace = true, features = ["_test-harness"] } vortex-session = { workspace = true } # TPC-H generation diff --git a/vortex-tui/Cargo.toml b/vortex-tui/Cargo.toml index 33cd1958ba2..2c890758945 100644 --- a/vortex-tui/Cargo.toml +++ b/vortex-tui/Cargo.toml @@ -74,7 +74,6 @@ vortex-datafusion = { workspace = true, optional = true } # WASM-only dependencies [target.'cfg(target_arch = "wasm32")'.dependencies] console_error_panic_hook = "0.1.7" -js-sys = "0.3.81" ratzilla = "0.3" wasm-bindgen = "0.2.104" wasm-bindgen-futures = { workspace = true } diff --git a/vortex-utils/Cargo.toml b/vortex-utils/Cargo.toml index ca6c639ec0d..a9af93d998a 100644 --- a/vortex-utils/Cargo.toml +++ b/vortex-utils/Cargo.toml @@ -16,7 +16,6 @@ version = { workspace = true } [dependencies] dashmap = { workspace = true, optional = true } hashbrown = { workspace = true } -vortex-error = { workspace = true } [lints] workspace = true diff --git a/vortex/Cargo.toml b/vortex/Cargo.toml index 75fe5501199..510ec5747f7 100644 --- a/vortex/Cargo.toml +++ b/vortex/Cargo.toml @@ -16,6 +16,9 @@ version = { workspace = true } [package.metadata.docs.rs] all-features = true +[package.metadata.cargo-shear] +ignored = ["vortex-tensor"] + [lints] workspace = true @@ -54,18 +57,14 @@ anyhow = { workspace = true } arrow-array = { workspace = true } divan = { workspace = true } fastlanes = { workspace = true } -futures = { workspace = true } mimalloc = { workspace = true } parquet = { workspace = true } -paste = { workspace = true } rand = { workspace = true } -rand_distr = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } vortex = { path = ".", features = ["tokio"] } -vortex-tensor = { workspace = true } [features] default = ["files", "zstd"] diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 865d10ac809..ebbc6987d46 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -17,7 +17,6 @@ version = { workspace = true } [dependencies] anyhow = { workspace = true } -cargo_metadata = { workspace = true } clap = { workspace = true, features = ["derive"] } prost-build = { workspace = true } xshell = { workspace = true } From d2b2378aa8f0a50e70047bf62fefbdd0ceec7fa1 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Fri, 10 Jul 2026 18:50:00 +0100 Subject: [PATCH 055/104] Implement LIKE in vortex instead of falling back to arrow (#8709) One more operation where we fallback to arrow instead of using our own implementation. We can leverage view array layouts to speed up certain comparisons --- Cargo.lock | 4 +- Cargo.toml | 5 +- vortex-array/Cargo.toml | 8 +- vortex-array/benches/like.rs | 122 +++++ vortex-array/src/scalar_fn/fns/like/mod.rs | 476 +++++++++++++++++- .../src/scalar_fn/fns/like/pattern.rs | 279 ++++++++++ 6 files changed, 873 insertions(+), 21 deletions(-) create mode 100644 vortex-array/benches/like.rs create mode 100644 vortex-array/src/scalar_fn/fns/like/pattern.rs diff --git a/Cargo.lock b/Cargo.lock index c8ea96df402..e227ed34f88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9769,7 +9769,6 @@ dependencies = [ "arrow-data 58.3.0", "arrow-schema 58.3.0", "arrow-select 58.3.0", - "arrow-string 58.3.0", "async-lock", "bytes", "cfg-if", @@ -9785,6 +9784,7 @@ dependencies = [ "inventory", "itertools 0.14.0", "jiff", + "memchr", "num-traits", "num_enum", "parking_lot", @@ -9794,6 +9794,8 @@ dependencies = [ "prost 0.14.4", "rand 0.10.2", "rand_distr 0.6.0", + "regex", + "regex-syntax", "rstest", "rstest_reuse", "rustc-hash", diff --git a/Cargo.toml b/Cargo.toml index b3c1932809a..109f0a66f0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -181,6 +181,7 @@ libfuzzer-sys = "0.4" libloading = "0.8" liblzma = "0.4" log = { version = "0.4.21" } +memchr = "2.8.0" memmap2 = "0.9.5" mimalloc = "0.1.42" moka = { version = "0.12.10", default-features = false } @@ -215,7 +216,9 @@ quote = "1.0.44" rand = "0.10.1" rand_distr = "0.6" ratatui = { version = "0.30", default-features = false } -regex = "1.11.0" +regex = "1.12" +regex-automata = "0.4" +regex-syntax = "0.8.9" reqwest = { version = "0.13.0", features = [ "blocking", "charset", diff --git a/vortex-array/Cargo.toml b/vortex-array/Cargo.toml index 310c936a019..ecbcd8e456e 100644 --- a/vortex-array/Cargo.toml +++ b/vortex-array/Cargo.toml @@ -29,7 +29,6 @@ arrow-cast = { workspace = true } arrow-data = { workspace = true } arrow-schema = { workspace = true, features = ["canonical_extension_types"] } arrow-select = { workspace = true } -arrow-string = { workspace = true } async-lock = { workspace = true } bytes = { workspace = true } cfg-if = { workspace = true } @@ -43,6 +42,7 @@ humansize = { workspace = true } inventory = { workspace = true } itertools = { workspace = true } jiff = { workspace = true } +memchr = { workspace = true } num-traits = { workspace = true } num_enum = { workspace = true } parking_lot = { workspace = true } @@ -53,6 +53,8 @@ primitive-types = { workspace = true, optional = true, features = [ ] } prost = { workspace = true } rand = { workspace = true } +regex = { workspace = true } +regex-syntax = { workspace = true } rstest = { workspace = true, optional = true } rstest_reuse = { workspace = true, optional = true } rustc-hash = { workspace = true } @@ -136,6 +138,10 @@ harness = false name = "kleene_bool" harness = false +[[bench]] +name = "like" +harness = false + [[bench]] name = "interleave" harness = false diff --git a/vortex-array/benches/like.rs b/vortex-array/benches/like.rs new file mode 100644 index 00000000000..68219724717 --- /dev/null +++ b/vortex-array/benches/like.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#![expect(clippy::unwrap_used)] + +use divan::Bencher; +use rand::RngExt; +use rand::SeedableRng; +use rand::distr::Uniform; +use rand::prelude::StdRng; +use vortex_array::ArrayRef; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::VarBinViewArray; +use vortex_array::arrays::scalar_fn::ScalarFnFactoryExt; +use vortex_array::scalar_fn::fns::like::Like; +use vortex_array::scalar_fn::fns::like::LikeOptions; + +fn main() { + divan::main(); +} + +const ARRAY_SIZE: usize = 2_048; + +/// Random lowercase strings of 4..=24 bytes, some with a `hello` infix. +fn strings() -> ArrayRef { + let mut rng = StdRng::seed_from_u64(0); + let len_dist = Uniform::new_inclusive(4usize, 24).unwrap(); + VarBinViewArray::from_iter_str((0..ARRAY_SIZE).map(|i| { + let len = rng.sample(len_dist); + let mut s: String = (0..len) + .map(|_| char::from(rng.random_range(b'a'..=b'z'))) + .collect(); + if i % 7 == 0 { + s.insert_str(len / 2, "hello"); + } + s + })) + .into_array() +} + +fn bench_like(bencher: Bencher, pattern: &str, options: LikeOptions) { + let session = vortex_array::array_session(); + let array = strings(); + bencher + .with_inputs(|| { + ( + Like.try_new_array( + ARRAY_SIZE, + options, + [ + array.clone(), + ConstantArray::new(pattern, ARRAY_SIZE).into_array(), + ], + ) + .unwrap(), + session.create_execution_ctx(), + ) + }) + .bench_values(|(array, mut ctx)| array.execute::(&mut ctx).unwrap()); +} + +#[divan::bench] +fn like_exact(bencher: Bencher) { + bench_like(bencher, "hello", LikeOptions::default()); +} + +#[divan::bench] +fn like_prefix(bencher: Bencher) { + bench_like(bencher, "hello%", LikeOptions::default()); +} + +#[divan::bench] +fn like_suffix(bencher: Bencher) { + bench_like(bencher, "%hello", LikeOptions::default()); +} + +#[divan::bench] +fn like_contains(bencher: Bencher) { + bench_like(bencher, "%hello%", LikeOptions::default()); +} + +#[divan::bench] +fn like_regex(bencher: Bencher) { + bench_like(bencher, "h_llo%w%d", LikeOptions::default()); +} + +#[divan::bench] +fn like_per_row_patterns(bencher: Bencher) { + let session = vortex_array::array_session(); + let array = strings(); + // A non-constant pattern child takes the per-row path; repeated patterns hit the + // compile cache. + let patterns = VarBinViewArray::from_iter_str((0..ARRAY_SIZE).map(|_| "hello%")).into_array(); + bencher + .with_inputs(|| { + ( + Like.try_new_array( + ARRAY_SIZE, + LikeOptions::default(), + [array.clone(), patterns.clone()], + ) + .unwrap(), + session.create_execution_ctx(), + ) + }) + .bench_values(|(array, mut ctx)| array.execute::(&mut ctx).unwrap()); +} + +#[divan::bench] +fn ilike_contains(bencher: Bencher) { + bench_like( + bencher, + "%HELLO%", + LikeOptions { + negated: false, + case_insensitive: true, + }, + ); +} diff --git a/vortex-array/src/scalar_fn/fns/like/mod.rs b/vortex-array/src/scalar_fn/fns/like/mod.rs index 63ced07c8e7..bdedce7112b 100644 --- a/vortex-array/src/scalar_fn/fns/like/mod.rs +++ b/vortex-array/src/scalar_fn/fns/like/mod.rs @@ -2,26 +2,36 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors mod kernel; +mod pattern; use std::borrow::Cow; use std::fmt::Display; use std::fmt::Formatter; pub use kernel::*; +use pattern::LikePattern; use prost::Message; +use vortex_buffer::BitBuffer; use vortex_error::VortexResult; use vortex_error::vortex_bail; +use vortex_error::vortex_err; use vortex_proto::expr as pb; use vortex_session::VortexSession; use vortex_session::registry::CachedId; use crate::ArrayRef; +use crate::Canonical; use crate::ExecutionCtx; -use crate::arrow::Datum; -use crate::arrow::from_arrow_columnar; +use crate::IntoArray; +use crate::arrays::BoolArray; +use crate::arrays::ConstantArray; +use crate::arrays::VarBinViewArray; +use crate::arrays::varbinview::BinaryView; use crate::dtype::DType; +use crate::dtype::Nullability; use crate::expr::Expression; use crate::expr::and; +use crate::scalar::Scalar; use crate::scalar_fn::Arity; use crate::scalar_fn::ChildName; use crate::scalar_fn::ExecutionArgs; @@ -140,7 +150,7 @@ impl ScalarFnVTable for Like { let child = args.get(0)?; let pattern = args.get(1)?; - arrow_like(&child, &pattern, *options, ctx) + execute_like(&child, &pattern, *options, ctx) } fn validity( @@ -163,34 +173,261 @@ impl ScalarFnVTable for Like { } } -/// Implementation of LIKE using the Arrow crate. -pub(crate) fn arrow_like( +/// Native implementation of LIKE over canonical [`VarBinViewArray`]s. +/// +/// Patterns are compiled once per distinct pattern (see [`LikePattern`]) and evaluated per +/// value directly on the view bytes. +pub(crate) fn execute_like( array: &ArrayRef, pattern: &ArrayRef, options: LikeOptions, ctx: &mut ExecutionCtx, ) -> VortexResult { - let nullable = array.dtype().is_nullable() | pattern.dtype().is_nullable(); - let len = array.len(); assert_eq!( array.len(), pattern.len(), - "Arrow Like: length mismatch for {}", + "LIKE: length mismatch for {}", array.encoding_id() ); + let len = array.len(); + let nullability = + Nullability::from(array.dtype().is_nullable() || pattern.dtype().is_nullable()); - // convert the pattern to the preferred array datatype - let lhs = Datum::try_new(array, ctx)?; - let rhs = Datum::try_new_with_target_datatype(pattern, lhs.data_type(), ctx)?; + if len == 0 { + return Ok(Canonical::empty(&DType::Bool(nullability)).into_array()); + } - let result = match (options.negated, options.case_insensitive) { - (false, false) => arrow_string::like::like(&lhs, &rhs)?, - (true, false) => arrow_string::like::nlike(&lhs, &rhs)?, - (false, true) => arrow_string::like::ilike(&lhs, &rhs)?, - (true, true) => arrow_string::like::nilike(&lhs, &rhs)?, - }; + if let Some(pattern_const) = pattern.as_constant() { + let Some(pattern_str) = pattern_const.as_utf8().value() else { + // A null pattern makes every row null. + return Ok( + ConstantArray::new(Scalar::null(DType::Bool(Nullability::Nullable)), len) + .into_array(), + ); + }; + let values = array.clone().execute::(ctx)?; + let haystack = ResolvedViews::new(&values); + // The ASCII case-insensitive fast paths are only sound when the haystack is pure + // ASCII; see `LikePattern::compile`. + let ascii_haystack = + options.case_insensitive && pattern_str.as_str().is_ascii() && haystack.is_ascii(); + let compiled = LikePattern::compile( + pattern_str.as_str(), + options.case_insensitive, + ascii_haystack, + )?; + let bits = eval_pattern(&haystack, &compiled, options.negated); + let validity = values.validity()?.union_nullability(nullability); + return Ok(BoolArray::new(bits, validity).into_array()); + } - from_arrow_columnar(&result, len, nullable, ctx) + // Per-row patterns: compile each pattern individually. + let values = array.clone().execute::(ctx)?; + let patterns = pattern.clone().execute::(ctx)?; + let haystack = ResolvedViews::new(&values); + let pattern_views = ResolvedViews::new(&patterns); + let ascii_haystack = options.case_insensitive && haystack.is_ascii(); + + let mut bits = Vec::with_capacity(len); + // Reuse the previous row's compiled pattern while the pattern bytes repeat, so runs of + // identical patterns (the common case for non-constant pattern children) compile once. + let mut cached: Option<(&[u8], LikePattern)> = None; + for i in 0..len { + let pattern_bytes = pattern_views.bytes(i); + let compiled = match &cached { + Some((bytes, compiled)) if *bytes == pattern_bytes => compiled, + _ => { + let pattern_str = std::str::from_utf8(pattern_bytes) + .map_err(|e| vortex_err!("LIKE pattern is not valid UTF-8: {e}"))?; + let compiled = LikePattern::compile( + pattern_str, + options.case_insensitive, + ascii_haystack && pattern_str.is_ascii(), + )?; + &cached.insert((pattern_bytes, compiled)).1 + } + }; + bits.push(compiled.matches(haystack.bytes(i)) != options.negated); + } + let validity = values + .validity()? + .and(patterns.validity()?)? + .union_nullability(nullability); + Ok(BoolArray::new(BitBuffer::from_iter(bits), validity).into_array()) +} + +/// Resolved views over a canonical [`VarBinViewArray`]: the view structs plus borrowed slices +/// of every data buffer, supporting cheap per-element byte access. +struct ResolvedViews<'a> { + views: &'a [BinaryView], + buffers: Vec<&'a [u8]>, +} + +impl<'a> ResolvedViews<'a> { + fn new(array: &'a VarBinViewArray) -> Self { + Self { + views: array.views(), + buffers: (0..array.data_buffers().len()) + .map(|idx| array.buffer(idx).as_slice()) + .collect(), + } + } + + #[inline] + fn bytes(&self, index: usize) -> &'a [u8] { + let view = &self.views[index]; + if view.is_inlined() { + view.as_inlined().value() + } else { + let view = view.as_view(); + &self.buffers[view.buffer_index as usize][view.as_range()] + } + } + + /// Whether every value (including values under a null) is pure ASCII. + fn is_ascii(&self) -> bool { + (0..self.views.len()).all(|i| self.bytes(i).is_ascii()) + } + + /// The last `suffix_len` bytes of `view`, which must belong to this array. + /// + /// # Safety + /// + /// `suffix_len` must be at most `view.len()`. Buffer bounds are guaranteed by + /// [`VarBinViewArray::validate`], which checks every view against its data buffer at + /// construction. + #[inline] + unsafe fn suffix_bytes_unchecked(&self, view: &'a BinaryView, suffix_len: usize) -> &'a [u8] { + let len = view.len() as usize; + if view.is_inlined() { + // SAFETY: inlined values hold `len <= 12` value bytes, and the caller + // guarantees `suffix_len <= len`. + unsafe { view.as_inlined().value().get_unchecked(len - suffix_len..) } + } else { + let view = view.as_view(); + let end = view.offset as usize + len; + // SAFETY: validated views reference `buffer_index < buffers.len()` and bytes + // `offset..offset + len` within that buffer. + unsafe { + self.buffers + .get_unchecked(view.buffer_index as usize) + .get_unchecked(end - suffix_len..end) + } + } + } +} + +/// Evaluate `pattern` against every element of `haystack`. +/// +/// The equality, prefix, and suffix patterns exploit the view layout: a view stores the value +/// length and its first four bytes inline, which settles most elements without touching the +/// data buffers (values of up to 12 bytes are stored entirely inline). +fn eval_pattern(haystack: &ResolvedViews<'_>, pattern: &LikePattern, negated: bool) -> BitBuffer { + let len = haystack.views.len(); + match pattern { + LikePattern::Eq(needle) if needle.len() <= BinaryView::MAX_INLINED_SIZE => { + // The needle fits in a view, so equality is a single 16-byte comparison: a view + // of a different length or prefix can never share the same bit pattern. + let needle_view = BinaryView::new_inlined(needle).as_u128(); + BitBuffer::collect_bool(len, |i| { + (haystack.views[i].as_u128() == needle_view) != negated + }) + } + LikePattern::Eq(needle) => { + // Compare the view head (length plus 4-byte prefix) first; only views that agree + // on both dereference their data buffer for the remaining bytes. + let needle_head = needle_head(needle); + BitBuffer::collect_bool(len, |i| { + let view = &haystack.views[i]; + let matched = + view_head(view) == needle_head && haystack.bytes(i)[4..] == needle[4..]; + matched != negated + }) + } + LikePattern::StartsWith(needle) => { + // A branch-free masked comparison of the view's inline 4-byte prefix rejects + // almost every element without touching the data buffers; only elements whose + // prefix matches compare the remaining needle bytes. + let needle_len = needle.len(); + let prefix_len = needle_len.min(4); + let needle_prefix = u32::from_le_bytes({ + let mut padded = [0u8; 4]; + padded[..prefix_len].copy_from_slice(&needle[..prefix_len]); + padded + }); + let prefix_mask = if prefix_len == 4 { + u32::MAX + } else { + (1u32 << (8 * prefix_len)) - 1 + }; + BitBuffer::collect_bool(len, |i| { + let view = &haystack.views[i]; + let matched = view.len() as usize >= needle_len + && (view_prefix(view) & prefix_mask) == needle_prefix + && (needle_len <= 4 || haystack.bytes(i)[4..needle_len] == needle[4..]); + matched != negated + }) + } + LikePattern::EndsWith(needle) => { + // Inlined values compare their suffix inside the view struct without touching the + // data buffers; reference views slice exactly the suffix out of their buffer. + let needle_len = needle.len(); + BitBuffer::collect_bool(len, |i| { + // SAFETY: `i` is below the array length, and the suffix length is only read + // once the view is known to be at least `needle_len` long. + let matched = unsafe { + let view = haystack.views.get_unchecked(i); + view.len() as usize >= needle_len + && bytes_eq(haystack.suffix_bytes_unchecked(view, needle_len), needle) + }; + matched != negated + }) + } + LikePattern::IEqAscii(needle) => BitBuffer::collect_bool(len, |i| { + let view = &haystack.views[i]; + let matched = view.len() as usize == needle.len() + && haystack.bytes(i).eq_ignore_ascii_case(needle); + matched != negated + }), + LikePattern::Contains(finder, needle_len) => BitBuffer::collect_bool(len, |i| { + let view = &haystack.views[i]; + let matched = + view.len() as usize >= *needle_len && finder.find(haystack.bytes(i)).is_some(); + matched != negated + }), + _ => BitBuffer::collect_bool(len, |i| pattern.matches(haystack.bytes(i)) != negated), + } +} + +/// Byte equality as an inlined loop with early exit. +/// +/// Faster than slice `==` for the short, unpredictable-length comparisons in this module, +/// which otherwise lower to a `memcmp` libcall per element. +#[inline] +fn bytes_eq(lhs: &[u8], rhs: &[u8]) -> bool { + lhs.len() == rhs.len() && std::iter::zip(lhs, rhs).all(|(l, r)| l == r) +} + +/// The leading 8 bytes of a view: the `u32` length plus the first 4 bytes of the value +/// (zero-padded for values shorter than 4 bytes). +#[inline] +#[expect(clippy::cast_possible_truncation, reason = "intentional bit slicing")] +fn view_head(view: &BinaryView) -> u64 { + view.as_u128() as u64 +} + +/// The view head a needle of more than 4 bytes would have: its length plus first 4 bytes. +fn needle_head(needle: &[u8]) -> u64 { + let prefix: [u8; 4] = [needle[0], needle[1], needle[2], needle[3]]; + (needle.len() as u64) | (u64::from(u32::from_le_bytes(prefix)) << 32) +} + +/// The first 4 value bytes stored inline in any view (zero-padded for values shorter than +/// 4 bytes), as a raw little-endian `u32` in memory order. +#[inline] +#[expect(clippy::cast_possible_truncation, reason = "intentional bit slicing")] +fn view_prefix(view: &BinaryView) -> u32 { + (view.as_u128() >> 32) as u32 } /// Variants of the LIKE filter that we know how to turn into a stats pruning predicate. @@ -243,10 +480,16 @@ impl<'a> LikeVariant<'a> { mod tests { use std::borrow::Cow; + use rstest::rstest; + use crate::IntoArray; use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::BoolArray; + use crate::arrays::ConstantArray; + use crate::arrays::VarBinArray; + use crate::arrays::VarBinViewArray; + use crate::arrays::scalar_fn::ScalarFnFactoryExt; use crate::assert_arrays_eq; use crate::dtype::DType; use crate::dtype::Nullability; @@ -256,8 +499,205 @@ mod tests { use crate::expr::not; use crate::expr::not_ilike; use crate::expr::root; + use crate::scalar::Scalar; + use crate::scalar_fn::fns::like::Like; + use crate::scalar_fn::fns::like::LikeOptions; use crate::scalar_fn::fns::like::LikeVariant; + fn run_like( + array: crate::ArrayRef, + pattern: crate::ArrayRef, + options: LikeOptions, + ) -> crate::ArrayRef { + let len = array.len(); + Like.try_new_array(len, options, [array, pattern]).unwrap() + } + + #[rstest] + // Exact, prefix, suffix, contains fast paths. + #[case("hello", [true, false, false, false])] + #[case("he%", [true, false, true, false])] + #[case("%llo", [true, false, false, true])] + #[case("%ell%", [true, false, false, true])] + // Wildcards that require the regex path. + #[case("h_llo", [true, false, false, false])] + #[case("h%o", [true, false, false, false])] + #[case("%", [true, true, true, true])] + #[case("_____", [true, true, false, true])] + fn test_like_patterns(#[case] pattern: &str, #[case] expected: [bool; 4]) { + let mut ctx = array_session().create_execution_ctx(); + let array = + VarBinViewArray::from_iter_str(["hello", "world", "help", "jello"]).into_array(); + let result = run_like( + array, + ConstantArray::new(pattern, 4).into_array(), + LikeOptions::default(), + ); + assert_arrays_eq!(result, BoolArray::from_iter(expected), &mut ctx); + } + + #[test] + fn test_like_escapes() { + let mut ctx = array_session().create_execution_ctx(); + let array = VarBinViewArray::from_iter_str(["100%", "100x", "a_b", "axb"]).into_array(); + + let result = run_like( + array.clone(), + ConstantArray::new(r"100\%", 4).into_array(), + LikeOptions::default(), + ); + assert_arrays_eq!( + result, + BoolArray::from_iter([true, false, false, false]), + &mut ctx + ); + + let result = run_like( + array, + ConstantArray::new(r"a\_b", 4).into_array(), + LikeOptions::default(), + ); + assert_arrays_eq!( + result, + BoolArray::from_iter([false, false, true, false]), + &mut ctx + ); + } + + #[test] + fn test_like_regex_meta_characters_are_literal() { + let mut ctx = array_session().create_execution_ctx(); + let array = VarBinViewArray::from_iter_str(["a.c", "abc", "a$c"]).into_array(); + let result = run_like( + array, + ConstantArray::new("a.%", 3).into_array(), + LikeOptions::default(), + ); + assert_arrays_eq!(result, BoolArray::from_iter([true, false, false]), &mut ctx); + } + + #[test] + fn test_like_unicode() { + let mut ctx = array_session().create_execution_ctx(); + let array = + VarBinViewArray::from_iter_str(["h\u{00a3}llo", "hxllo", "h\u{00a3}xllo"]).into_array(); + // `_` matches exactly one character of any byte width. + let result = run_like( + array, + ConstantArray::new("h_llo", 3).into_array(), + LikeOptions::default(), + ); + assert_arrays_eq!(result, BoolArray::from_iter([true, true, false]), &mut ctx); + } + + #[test] + fn test_nlike() { + let mut ctx = array_session().create_execution_ctx(); + let array = VarBinViewArray::from_iter_str(["hello", "world"]).into_array(); + let result = run_like( + array, + ConstantArray::new("he%", 2).into_array(), + LikeOptions { + negated: true, + case_insensitive: false, + }, + ); + assert_arrays_eq!(result, BoolArray::from_iter([false, true]), &mut ctx); + } + + #[test] + fn test_ilike() { + let mut ctx = array_session().create_execution_ctx(); + let ilike = LikeOptions { + negated: false, + case_insensitive: true, + }; + + // Pure-ASCII data takes the ASCII fast paths. + let array = VarBinViewArray::from_iter_str(["HELLO", "world", "Help"]).into_array(); + let result = run_like(array, ConstantArray::new("he%", 3).into_array(), ilike); + assert_arrays_eq!(result, BoolArray::from_iter([true, false, true]), &mut ctx); + + // Non-ASCII data requires full case folding: `k` matches U+212A KELVIN SIGN. + let array = VarBinViewArray::from_iter_str(["\u{212a}", "k", "x"]).into_array(); + let result = run_like(array, ConstantArray::new("k", 3).into_array(), ilike); + assert_arrays_eq!(result, BoolArray::from_iter([true, true, false]), &mut ctx); + } + + #[test] + fn test_nilike() { + let mut ctx = array_session().create_execution_ctx(); + let array = VarBinViewArray::from_iter_str(["HELLO", "world"]).into_array(); + let result = run_like( + array, + ConstantArray::new("he%", 2).into_array(), + LikeOptions { + negated: true, + case_insensitive: true, + }, + ); + assert_arrays_eq!(result, BoolArray::from_iter([false, true]), &mut ctx); + } + + #[test] + fn test_like_nullable_input() { + let mut ctx = array_session().create_execution_ctx(); + let array = VarBinViewArray::from_iter_nullable_str([Some("hello"), None, Some("help")]) + .into_array(); + let result = run_like( + array, + ConstantArray::new("he%", 3).into_array(), + LikeOptions::default(), + ); + assert_arrays_eq!( + result, + BoolArray::from_iter([Some(true), None, Some(true)]), + &mut ctx + ); + } + + #[test] + fn test_like_null_pattern() { + let mut ctx = array_session().create_execution_ctx(); + let array = VarBinViewArray::from_iter_str(["hello", "world"]).into_array(); + let result = run_like( + array, + ConstantArray::new(Scalar::null(DType::Utf8(Nullability::Nullable)), 2).into_array(), + LikeOptions::default(), + ); + assert_arrays_eq!(result, BoolArray::from_iter([None, None]), &mut ctx); + } + + #[test] + fn test_like_per_row_patterns() { + let mut ctx = array_session().create_execution_ctx(); + let array = VarBinViewArray::from_iter_str(["hello", "hello", "hello"]).into_array(); + let patterns = VarBinViewArray::from_iter_str(["he%", "%world", "h_llo"]).into_array(); + let result = run_like(array, patterns, LikeOptions::default()); + assert_arrays_eq!(result, BoolArray::from_iter([true, false, true]), &mut ctx); + } + + #[test] + fn test_like_non_canonical_input() { + let mut ctx = array_session().create_execution_ctx(); + // VarBin (not the canonical VarBinView) is canonicalized before evaluation. + let array = VarBinArray::from_iter( + [Some("hello"), Some("world")], + DType::Utf8(Nullability::Nullable), + ) + .into_array(); + let result = run_like( + array, + ConstantArray::new("he%", 2).into_array(), + LikeOptions::default(), + ); + assert_arrays_eq!( + result, + BoolArray::from_iter([Some(true), Some(false)]), + &mut ctx + ); + } + #[test] fn invert_booleans() { let not_expr = not(root()); diff --git a/vortex-array/src/scalar_fn/fns/like/pattern.rs b/vortex-array/src/scalar_fn/fns/like/pattern.rs new file mode 100644 index 00000000000..8a667159ecb --- /dev/null +++ b/vortex-array/src/scalar_fn/fns/like/pattern.rs @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Compiled SQL LIKE patterns evaluated over UTF-8 byte slices. +//! +//! A [`LikePattern`] is compiled once per pattern and then evaluated per value. Patterns that +//! reduce to plain equality, prefix, suffix, or substring searches use direct byte comparisons +//! (with `memchr`'s SIMD substring search for the contains case); everything else falls back to +//! a regex translated from the LIKE pattern. +//! +//! The pattern grammar and its regex translation mirror the `arrow-string` LIKE kernels so that +//! results are identical to the previous Arrow-backed implementation: `%` matches any sequence +//! of characters (including across newlines), `_` matches exactly one character, and `\` escapes +//! the next character. + +use memchr::memchr3; +use memchr::memmem::Finder; +use regex::bytes::Regex; +use regex::bytes::RegexBuilder; +use vortex_error::VortexResult; +use vortex_error::vortex_err; + +/// A LIKE pattern compiled for repeated evaluation against UTF-8 haystacks. +pub(crate) enum LikePattern { + /// The pattern has no wildcards: plain byte equality. + Eq(Vec), + /// `prefix%`: byte prefix comparison. + StartsWith(Vec), + /// `%suffix`: byte suffix comparison. + EndsWith(Vec), + /// `%needle%`: SIMD substring search, along with the needle length. + /// + /// The searcher is boxed to keep this variant close in size to the others. + Contains(Box>, usize), + /// Equality ignoring ASCII case (ILIKE over ASCII-only data). + IEqAscii(Vec), + /// Prefix comparison ignoring ASCII case (ILIKE over ASCII-only data). + IStartsWithAscii(Vec), + /// Suffix comparison ignoring ASCII case (ILIKE over ASCII-only data). + IEndsWithAscii(Vec), + /// Everything else: the LIKE pattern translated to an anchored regex. + Regex(Regex), +} + +impl LikePattern { + /// Compile `pattern`, with `case_insensitive` selecting ILIKE semantics. + /// + /// `ascii_haystack` enables the ASCII-only case-insensitive fast paths and must only be + /// `true` when every haystack this pattern will be evaluated against is pure ASCII: + /// full case folding can match ASCII pattern characters against non-ASCII haystack + /// characters (e.g. `k` folds to U+212A KELVIN SIGN), which the ASCII fast paths would + /// miss. + pub(crate) fn compile( + pattern: &str, + case_insensitive: bool, + ascii_haystack: bool, + ) -> VortexResult { + if case_insensitive { + if ascii_haystack && pattern.is_ascii() { + if !contains_like_pattern(pattern) { + return Ok(Self::IEqAscii(pattern.as_bytes().to_vec())); + } else if pattern.ends_with('%') + && !contains_like_pattern(&pattern[..pattern.len() - 1]) + { + return Ok(Self::IStartsWithAscii( + pattern.as_bytes()[..pattern.len() - 1].to_vec(), + )); + } else if pattern.starts_with('%') && !contains_like_pattern(&pattern[1..]) { + return Ok(Self::IEndsWithAscii(pattern.as_bytes()[1..].to_vec())); + } + } + return Ok(Self::Regex(regex_like(pattern, true)?)); + } + + Ok(if !contains_like_pattern(pattern) { + Self::Eq(pattern.as_bytes().to_vec()) + } else if pattern.ends_with('%') && !contains_like_pattern(&pattern[..pattern.len() - 1]) { + Self::StartsWith(pattern.as_bytes()[..pattern.len() - 1].to_vec()) + } else if pattern.starts_with('%') && !contains_like_pattern(&pattern[1..]) { + Self::EndsWith(pattern.as_bytes()[1..].to_vec()) + } else if pattern.starts_with('%') + && pattern.ends_with('%') + && !contains_like_pattern(&pattern[1..pattern.len() - 1]) + { + Self::Contains( + Box::new(Finder::new(&pattern.as_bytes()[1..pattern.len() - 1]).into_owned()), + pattern.len() - 2, + ) + } else { + Self::Regex(regex_like(pattern, false)?) + }) + } + + /// Evaluate this pattern against `haystack`, which must be valid UTF-8. + #[inline] + pub(crate) fn matches(&self, haystack: &[u8]) -> bool { + match self { + Self::Eq(needle) => needle == haystack, + Self::StartsWith(needle) => { + needle.len() <= haystack.len() && &haystack[..needle.len()] == needle.as_slice() + } + Self::EndsWith(needle) => { + needle.len() <= haystack.len() + && &haystack[haystack.len() - needle.len()..] == needle.as_slice() + } + Self::Contains(finder, needle_len) => { + haystack.len() >= *needle_len && finder.find(haystack).is_some() + } + Self::IEqAscii(needle) => haystack.eq_ignore_ascii_case(needle), + Self::IStartsWithAscii(needle) => { + needle.len() <= haystack.len() + && haystack[..needle.len()].eq_ignore_ascii_case(needle) + } + Self::IEndsWithAscii(needle) => { + needle.len() <= haystack.len() + && haystack[haystack.len() - needle.len()..].eq_ignore_ascii_case(needle) + } + Self::Regex(regex) => regex.is_match(haystack), + } + } +} + +/// Returns whether `pattern` contains a wildcard (`%`, `_`) or an escape (`\`). +fn contains_like_pattern(pattern: &str) -> bool { + memchr3(b'%', b'_', b'\\', pattern.as_bytes()).is_some() +} + +/// Transforms a LIKE `pattern` into an equivalent anchored regex. +/// +/// This is a port of the `arrow-string` translation so that pattern semantics are unchanged: +/// +/// 1. `%` => `.*` (a leading/trailing `%` truncates the anchor instead, e.g. `%foo%` => `foo` +/// rather than `^.*foo.*$`); +/// 2. `_` => `.`; +/// 3. regex meta characters are escaped so they match literally; +/// 4. `\x` matches `x` literally (a trailing `\` matches a literal backslash). +/// +/// The regex runs over the haystack bytes directly (`regex::bytes`) in Unicode mode, which +/// matches identically to a `&str` regex on valid UTF-8 input. +fn regex_like(pattern: &str, case_insensitive: bool) -> VortexResult { + let mut result = String::with_capacity(pattern.len() * 2); + let mut chars_iter = pattern.chars().peekable(); + match chars_iter.peek() { + // If the pattern starts with `%`, avoid starting the regex with a slow but + // meaningless `^.*`. + Some('%') => { + chars_iter.next(); + } + _ => result.push('^'), + }; + + while let Some(c) = chars_iter.next() { + match c { + '\\' => { + match chars_iter.peek() { + Some(&next) => { + if regex_syntax::is_meta_character(next) { + result.push('\\'); + } + result.push(next); + // Skip the next char as it is already appended. + chars_iter.next(); + } + None => { + // A trailing backslash matches a literal backslash. + result.push('\\'); + result.push('\\'); + } + } + } + '%' => result.push_str(".*"), + '_' => result.push('.'), + c => { + if regex_syntax::is_meta_character(c) { + result.push('\\'); + } + result.push(c); + } + } + } + // Instead of ending the regex with `.*$` and making it needlessly slow, just end it. + if result.ends_with(".*") { + result.pop(); + result.pop(); + } else { + result.push('$'); + } + RegexBuilder::new(&result) + .case_insensitive(case_insensitive) + .dot_matches_new_line(true) + .build() + .map_err(|e| vortex_err!("Unable to build regex from LIKE pattern: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn like(pattern: &str) -> LikePattern { + LikePattern::compile(pattern, false, false).unwrap() + } + + #[test] + fn compile_fast_paths() { + assert!(matches!(like("foo"), LikePattern::Eq(_))); + assert!(matches!(like("foo%"), LikePattern::StartsWith(_))); + assert!(matches!(like("%foo"), LikePattern::EndsWith(_))); + assert!(matches!(like("%foo%"), LikePattern::Contains(..))); + assert!(matches!(like("%"), LikePattern::StartsWith(_))); + assert!(matches!(like("%%"), LikePattern::Contains(..))); + assert!(matches!(like("f%o"), LikePattern::Regex(_))); + assert!(matches!(like("f_o"), LikePattern::Regex(_))); + assert!(matches!(like(r"foo\%"), LikePattern::Regex(_))); + } + + #[test] + fn regex_translation() { + // (pattern, expected regex) pairs from the arrow-string translation. + let cases = [ + (r"%foobar%", r"foobar"), + (r"foo%bar", r"^foo.*bar$"), + (r"foo_bar", r"^foo.bar$"), + (r"\%\_", r"^%_$"), + (r"\a", r"^a$"), + (r"\\%", r"^\\"), + (r"\\a", r"^\\a$"), + (r".", r"^\.$"), + (r"$", r"^\$$"), + (r"\\", r"^\\$"), + ]; + for (pattern, expected) in cases { + assert_eq!(regex_like(pattern, false).unwrap().to_string(), expected); + } + } + + #[test] + fn matches_wildcards() { + assert!(like("h_llo w%d").matches(b"hello world")); + assert!(like("h_llo w%d").matches("h\u{00a3}llo wd".as_bytes())); + assert!(!like("h_llo w%d").matches(b"hxxllo world")); + // `%` crosses newlines. + assert!(like("hello%world").matches(b"hello\nworld")); + // `_` matches exactly one character, of any width. + assert!(like("_").matches("\u{00a3}".as_bytes())); + assert!(!like("_").matches("\u{00a3}x".as_bytes())); + } + + #[test] + fn matches_escapes() { + assert!(like(r"100\%").matches(b"100%")); + assert!(!like(r"100\%").matches(b"100x")); + assert!(like(r"a\_b").matches(b"a_b")); + assert!(!like(r"a\_b").matches(b"axb")); + // A trailing backslash matches a literal backslash. + assert!(like("trailing\\").matches(b"trailing\\")); + } + + #[test] + fn matches_case_insensitive() { + let ilike = |pattern: &str| LikePattern::compile(pattern, true, false).unwrap(); + assert!(ilike("hello%").matches(b"HELLO WORLD")); + assert!(ilike("%WORLD").matches(b"hello world")); + // Full case folding: the ASCII pattern `k` matches U+212A KELVIN SIGN. + assert!(ilike("k").matches("\u{212a}".as_bytes())); + // Greek sigma case folds across all three forms. + assert!(ilike("\u{03c3}").matches("\u{03a3}".as_bytes())); + assert!(ilike("\u{03c3}").matches("\u{03c2}".as_bytes())); + + // The ASCII fast paths agree with the regex on ASCII haystacks. + let ascii = |pattern: &str| LikePattern::compile(pattern, true, true).unwrap(); + assert!(matches!(ascii("abc"), LikePattern::IEqAscii(_))); + assert!(matches!(ascii("abc%"), LikePattern::IStartsWithAscii(_))); + assert!(matches!(ascii("%abc"), LikePattern::IEndsWithAscii(_))); + assert!(ascii("abc").matches(b"AbC")); + assert!(ascii("abc%").matches(b"ABCDEF")); + assert!(ascii("%def").matches(b"ABCDEF")); + assert!(!ascii("abc").matches(b"AbCd")); + } +} From d51841094b123d31294126042754bd36992fb09e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:36:31 +0000 Subject: [PATCH 056/104] Update actions/setup-java digest to 0f481fc (#8729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/setup-java](https://redirect.github.com/actions/setup-java) ([changelog](https://redirect.github.com/actions/setup-java/compare/ad2b38190b15e4d6bdf0c97fb4fca8412226d287..0f481fcb613427c0f801b606911222b5b6f3083a)) | action | digest | `ad2b381` → `0f481fc` | --- > [!WARNING] > Some dependencies could not be looked up. Check the [Dependency Dashboard](../issues/357) for more information. --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - Between 12:00 AM and 03:59 AM, on the second Monday and fourth Monday of the month (`* 0-3 * * 1#2,1#4`) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/vortex-data/vortex). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- .github/workflows/package.yml | 4 ++-- .github/workflows/publish-dry-runs.yml | 2 +- .github/workflows/publish.yml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index cee26f919d6..630383b0311 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -24,7 +24,7 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Set up JDK 17 - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: java-version: "17" distribution: "temurin" diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index bded979fdf8..f59bbf214af 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -116,7 +116,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 - - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: distribution: "corretto" java-version: "17" @@ -150,7 +150,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 - - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: distribution: "corretto" java-version: "17" diff --git a/.github/workflows/publish-dry-runs.yml b/.github/workflows/publish-dry-runs.yml index 77ed1a15526..e381b3d4d6f 100644 --- a/.github/workflows/publish-dry-runs.yml +++ b/.github/workflows/publish-dry-runs.yml @@ -81,7 +81,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 - - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: distribution: "corretto" java-version: "17" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fbc76722d1c..200e6f35a4f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -88,7 +88,7 @@ jobs: working-directory: ./java steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5 + - uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5 with: distribution: "corretto" java-version: "17" From efe52af14cc11a20b128bcff1259c7bd0034f1c1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:36:53 +0100 Subject: [PATCH 057/104] Update actions/setup-python digest to ece7cb0 (#8730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/setup-python](https://redirect.github.com/actions/setup-python) ([changelog](https://redirect.github.com/actions/setup-python/compare/a309ff8b426b58ec0e2a45f0f869d46889d02405..ece7cb06caefa5fff74198d8649806c4678c61a1)) | action | digest | `a309ff8` → `ece7cb0` | --- > [!WARNING] > Some dependencies could not be looked up. Check the [Dependency Dashboard](../issues/357) for more information. --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - Between 12:00 AM and 03:59 AM, on the second Monday and fourth Monday of the month (`* 0-3 * * 1#2,1#4`) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/vortex-data/vortex). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index f59bbf214af..4853e48e551 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -78,7 +78,7 @@ jobs: PY - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 # Latest macOS doesn't allow maturin to install stuff into the global Python interpreter if: "${{ matrix.target.runs-on == 'macos-latest' }}" with: From 8363595100dd483fc07200a09cdccb0a09698b76 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:37:22 +0000 Subject: [PATCH 058/104] Update actions/stale digest to 1e223db (#8731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/stale](https://redirect.github.com/actions/stale) ([changelog](https://redirect.github.com/actions/stale/compare/eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899..1e223db275d687790206a7acac4d1a11bd6fe629)) | action | digest | `eb5cf3a` → `1e223db` | --- > [!WARNING] > Some dependencies could not be looked up. Check the [Dependency Dashboard](../issues/357) for more information. --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - Between 12:00 AM and 03:59 AM, on the second Monday and fourth Monday of the month (`* 0-3 * * 1#2,1#4`) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/vortex-data/vortex). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 8acf85f33a1..7c26746415f 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -12,7 +12,7 @@ jobs: actions: write pull-requests: write steps: - - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10 + - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10 with: # After 30 days of no activity, a PR will be marked as stale days-before-pr-stale: 14 From 862f158da788eb27b7f30fe9782eea019f676daa Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Mon, 13 Jul 2026 09:39:46 +0100 Subject: [PATCH 059/104] Benchmarks for fixed-width array filtering (#8727) Benchmarks for filtering fixed width types --- vortex-array/Cargo.toml | 4 + vortex-array/benches/filter_fixed_width.rs | 158 +++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 vortex-array/benches/filter_fixed_width.rs diff --git a/vortex-array/Cargo.toml b/vortex-array/Cargo.toml index ecbcd8e456e..507c7345621 100644 --- a/vortex-array/Cargo.toml +++ b/vortex-array/Cargo.toml @@ -248,6 +248,10 @@ harness = false name = "filter_bool" harness = false +[[bench]] +name = "filter_fixed_width" +harness = false + [[bench]] name = "list_length" harness = false diff --git a/vortex-array/benches/filter_fixed_width.rs b/vortex-array/benches/filter_fixed_width.rs new file mode 100644 index 00000000000..01334108848 --- /dev/null +++ b/vortex-array/benches/filter_fixed_width.rs @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Compact fixed-width filter benchmarks. +//! +//! The cases cover the dispatch dimensions without a large Cartesian product so CodSpeed's +//! instruction-count simulation remains inexpensive. + +#![expect( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::unwrap_used +)] + +use std::sync::LazyLock; + +use divan::Bencher; +use vortex_array::ArrayRef; +use vortex_array::Canonical; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::array_session; +use vortex_array::arrays::DecimalArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::dtype::DecimalDType; +use vortex_array::dtype::i256; +use vortex_buffer::BitBuffer; +use vortex_mask::Mask; +use vortex_session::VortexSession; + +fn main() { + LazyLock::force(&SESSION); + divan::main(); +} + +static SESSION: LazyLock = LazyLock::new(array_session); + +// Keep each case small: the sweep has 24 cases and the targeted sections add only seven more. +const LEN: usize = 16_384; +const DENSITIES: &[f64] = &[0.01, 0.5, 0.8, 0.95]; +const CACHED_DENSITIES: &[f64] = &[0.01, 0.1]; + +#[derive(Clone, Copy, Debug)] +enum Pattern { + Random, + Runs, + Contiguous, +} + +const PATTERNS: &[Pattern] = &[Pattern::Random, Pattern::Runs, Pattern::Contiguous]; + +fn random_mask(density: f64) -> Mask { + let threshold = (density * u64::MAX as f64) as u64; + let mut state = 0x1234_5678_9abc_def0u64; + Mask::from_buffer(BitBuffer::from_iter((0..LEN).map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state <= threshold + }))) +} + +fn pattern_mask(pattern: Pattern) -> Mask { + match pattern { + Pattern::Random => random_mask(0.5), + Pattern::Runs => Mask::from_iter((0..LEN).map(|index| (index / 32).is_multiple_of(2))), + Pattern::Contiguous => Mask::from_slices(LEN, vec![(LEN / 4, LEN * 3 / 4)]), + } +} + +fn bench_filter( + bencher: Bencher, + array: ArrayRef, + make_mask: impl Fn() -> Mask + Sync, + cache_indices: bool, +) { + bencher + .with_inputs(|| { + let mask = make_mask(); + if cache_indices { + let _ = mask.values().unwrap().indices(); + } + (array.clone(), mask, SESSION.create_execution_ctx()) + }) + .bench_refs(|(array, mask, ctx)| { + divan::black_box( + array + .clone() + .filter(mask.clone()) + .unwrap() + .execute::(ctx) + .unwrap(), + ); + }); +} + +fn i8_array() -> ArrayRef { + PrimitiveArray::from_iter((0..LEN).map(|index| index as i8)).into_array() +} + +fn i16_array() -> ArrayRef { + PrimitiveArray::from_iter((0..LEN).map(|index| index as i16)).into_array() +} + +fn i32_array() -> ArrayRef { + PrimitiveArray::from_iter((0..LEN).map(|index| index as i32)).into_array() +} + +fn i64_array() -> ArrayRef { + PrimitiveArray::from_iter((0..LEN).map(|index| index as i64)).into_array() +} + +fn i128_array() -> ArrayRef { + DecimalArray::from_iter( + (0..LEN).map(|index| index as i128), + DecimalDType::new(19, 0), + ) + .into_array() +} + +fn i256_array() -> ArrayRef { + DecimalArray::from_iter( + (0..LEN).map(|index| i256::from_i128(index as i128)), + DecimalDType::new(39, 0), + ) + .into_array() +} + +macro_rules! random_density_benchmark { + ($name:ident, $array:ident) => { + #[divan::bench(args = DENSITIES)] + fn $name(bencher: Bencher, density: f64) { + bench_filter(bencher, $array(), || random_mask(density), false); + } + }; +} + +random_density_benchmark!(random_i8, i8_array); +random_density_benchmark!(random_i16, i16_array); +random_density_benchmark!(random_i32, i32_array); +random_density_benchmark!(random_i64, i64_array); +random_density_benchmark!(random_i128, i128_array); +random_density_benchmark!(random_i256, i256_array); + +#[divan::bench(args = PATTERNS)] +fn patterns_i128(bencher: Bencher, pattern: Pattern) { + bench_filter(bencher, i128_array(), || pattern_mask(pattern), false); +} + +#[divan::bench(args = CACHED_DENSITIES)] +fn cached_indices_i32(bencher: Bencher, density: f64) { + bench_filter(bencher, i32_array(), || random_mask(density), true); +} + +#[divan::bench(args = CACHED_DENSITIES)] +fn cached_indices_i128(bencher: Bencher, density: f64) { + bench_filter(bencher, i128_array(), || random_mask(density), true); +} From 0c7e0ff0104dba83b513c7f72f9c8c479980a747 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Mon, 13 Jul 2026 09:40:25 +0100 Subject: [PATCH 060/104] Add docs on how to use Spark vortex datasource (#8728) We have refactored how the spark datasource is laid out and some of the instructions were incorrect. At the same time we were not handling ALL of the spark usage patterns that others have grown accustomed to Co-authored-by: Claude --- README.md | 2 +- docs/user-guide/spark.md | 123 +++++++++++- java/README.md | 4 +- java/vortex-spark/README.md | 115 ++++++++++++ .../java/dev/vortex/spark/VortexCatalog.java | 119 ++++++++++++ .../dev/vortex/spark/VortexDataSourceV2.java | 21 ++- .../vortex/spark/VortexSessionCatalog.java | 79 ++++++++ .../spark/VortexSessionCatalogTest.java | 113 +++++++++++ .../java/dev/vortex/spark/VortexSqlTest.java | 176 ++++++++++++++++++ 9 files changed, 737 insertions(+), 15 deletions(-) create mode 100644 java/vortex-spark/README.md create mode 100644 java/vortex-spark/src/main/java/dev/vortex/spark/VortexCatalog.java create mode 100644 java/vortex-spark/src/main/java/dev/vortex/spark/VortexSessionCatalog.java create mode 100644 java/vortex-spark/src/test/java/dev/vortex/spark/VortexSessionCatalogTest.java create mode 100644 java/vortex-spark/src/test/java/dev/vortex/spark/VortexSqlTest.java diff --git a/README.md b/README.md index 8279e9651ca..893a6a1cb47 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![CodSpeed Badge](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/vortex-data/vortex) [![Crates.io](https://img.shields.io/crates/v/vortex.svg)](https://crates.io/crates/vortex) [![PyPI - Version](https://img.shields.io/pypi/v/vortex-data)](https://pypi.org/project/vortex-data/) -[![Maven - Version](https://img.shields.io/maven-central/v/dev.vortex/vortex-spark)](https://central.sonatype.com/artifact/dev.vortex/vortex-spark) +[![Maven - Version](https://img.shields.io/maven-central/v/dev.vortex/vortex-spark_2.13)](https://central.sonatype.com/artifact/dev.vortex/vortex-spark_2.13) [![codecov](https://codecov.io/github/vortex-data/vortex/graph/badge.svg)](https://codecov.io/github/vortex-data/vortex) [![Cite](https://img.shields.io/badge/cite-CITATION.cff-blue)](CITATION.cff) diff --git a/docs/user-guide/spark.md b/docs/user-guide/spark.md index 676c11f332b..5b1f9b44b8d 100644 --- a/docs/user-guide/spark.md +++ b/docs/user-guide/spark.md @@ -1,15 +1,52 @@ # Spark Vortex provides a Spark DataSource V2 connector for reading and writing Vortex files. The -connector is published to Maven Central as `dev.vortex:vortex-spark`. +connector is published to Maven Central in two flavors: -## Installation +- `dev.vortex:vortex-spark_2.13` for Spark 4.x (Scala 2.13) +- `dev.vortex:vortex-spark_2.12` for Spark 3.5.x (Scala 2.12) -Add the dependency to your build. The connector is built against Spark 4.x with Scala 2.13. +Use the `all` classifier JAR (e.g. `vortex-spark_2.13-0.78.0-all.jar`). It is self-contained: +it bundles the Vortex JNI bindings, native libraries for Linux (x86_64 and aarch64) and macOS +(aarch64), and relocates its Arrow, Guava, and Jackson dependencies to avoid classpath +conflicts with Spark. The thin (unclassified) JAR does not work on its own because it +references relocated classes that only ship in the `all` JAR. + +## Getting Vortex into Spark + +For `spark-shell`, `spark-submit`, or `pyspark`, pass the `all` JAR with `--jars`. Spark +accepts either a local path or a URL, so you can point directly at Maven Central: + +```shell +spark-shell --jars https://repo1.maven.org/maven2/dev/vortex/vortex-spark_2.13/0.78.0/vortex-spark_2.13-0.78.0-all.jar +``` + +Or equivalently when building a session programmatically, e.g. in PySpark: + +```python +spark = ( + SparkSession.builder + .config("spark.jars", "/path/to/vortex-spark_2.13-0.78.0-all.jar") + .getOrCreate() +) +``` + +```{note} +`--packages dev.vortex:vortex-spark_2.13:0.78.0` does not work: `--packages` cannot select +the `all` classifier and resolves the thin JAR, which fails at runtime with +`NoClassDefFoundError: dev/vortex/relocated/...`. +``` + +Once the JAR is on the classpath, the connector registers itself automatically under the +format name `vortex` — no session configuration is required. + +## Installation as a Build Dependency + +To depend on the connector from a JVM project, add the `all` classifier to the dependency: ````{tab} Gradle (Kotlin) ```kotlin -implementation("dev.vortex:vortex-spark:") +implementation("dev.vortex:vortex-spark_2.13:0.78.0:all") ``` ```` @@ -17,18 +54,18 @@ implementation("dev.vortex:vortex-spark:") ```xml dev.vortex - vortex-spark - ${vortex.version} + vortex-spark_2.13 + 0.78.0 + all ``` ```` -The connector ships as a shadow JAR that relocates its Arrow, Guava, and Protobuf dependencies -to avoid classpath conflicts with Spark. - ## Reading Vortex Files -Use the `vortex` format to read a single file or a directory of Vortex files: +Paths may be local filesystem paths (`/path/to/data`) or URLs (`file:///path/to/data`, +`s3://bucket/path/to/data`). Use the `vortex` format to read a single file or a directory of +Vortex files: ```java Dataset df = spark.read() @@ -65,6 +102,72 @@ Each Spark partition produces one output file named `part-{partitionId}-{taskId} The connector supports all standard Spark save modes: `Overwrite`, `Append`, `Ignore`, and `ErrorIfExists`. +## Spark SQL + +The connector can also be used from pure SQL. To query existing Vortex files, register them +as a temporary view: + +```sql +CREATE TEMPORARY VIEW people +USING vortex +OPTIONS (path '/path/to/data'); + +SELECT name, age FROM people WHERE age > 30; +``` + +Tables can be created with `USING vortex`, then written to and read back with plain SQL. +With a `LOCATION` clause the table is external, backed by the files at that path; without +one the table is managed, and Spark stores its data under the warehouse directory (and +deletes it on `DROP TABLE`): + +```sql +CREATE TABLE student (id INT, name STRING, age INT) +USING vortex; + +INSERT INTO student VALUES (1, 'Alice', 20), (2, 'Bob', 21); + +SELECT * FROM student; +``` + +`CREATE TABLE ... AS SELECT` works the same way: + +```sql +CREATE TABLE adults +USING vortex +AS SELECT * FROM people WHERE age >= 18; +``` + +```{note} +On Spark 3.5, `CREATE TABLE ... USING vortex` additionally requires replacing the session +catalog, because Spark 3.5's built-in catalog cannot read tables backed by a DataSource +V2-only connector: + + spark.sql.catalog.spark_catalog=dev.vortex.spark.VortexSessionCatalog + +The extension delegates everything to the built-in session catalog (including the Hive +metastore, if configured) and only changes how `vortex` tables are resolved; tables of other +providers are untouched. It is not needed on Spark 4, though setting it is harmless. +``` + +## Direct File Queries + +Spark's built-in ``SELECT * FROM format.`path` `` syntax only works for built-in file +formats, so the connector ships a path-based catalog that provides the equivalent for +Vortex. Register it in the session configuration under the name `vortex`: + +```shell +spark-sql --conf spark.sql.catalog.vortex=dev.vortex.spark.VortexCatalog +``` + +Then query a Vortex file, or a directory of Vortex files, directly by path — no view or +table required: + +```sql +SELECT * FROM vortex.`/path/to/data`; + +INSERT INTO vortex.`/path/to/data` VALUES (1, 'Alice', 20); +``` + ## Supported Types | Spark Type | Vortex Type | diff --git a/java/README.md b/java/README.md index d9554420998..f45f2474f11 100644 --- a/java/README.md +++ b/java/README.md @@ -3,7 +3,9 @@ We provide two interfaces for working with Vortex from Java: - `vortex-java` - a low-level interface JNI for working with Vortex files and arrays on cloud and local storage -- `vortex-spark` - A Spark connector for working with datasets of Vortex files +- `vortex-spark` - A Spark connector for working with datasets of Vortex files. See + [vortex-spark/README.md](vortex-spark/README.md) for how to load the connector into Spark + and query Vortex files from the DataFrame API or Spark SQL. ## Publishing diff --git a/java/vortex-spark/README.md b/java/vortex-spark/README.md new file mode 100644 index 00000000000..c6e8c9345bd --- /dev/null +++ b/java/vortex-spark/README.md @@ -0,0 +1,115 @@ +# vortex-spark + +A Spark DataSource V2 connector for reading and writing [Vortex](https://vortex.dev) files. +It registers itself under the format name `vortex` and supports both the DataFrame API and +Spark SQL. + +Two flavors are published to Maven Central: + +| Artifact | Spark | Scala | +|-------------------------------|-----------|-------| +| `dev.vortex:vortex-spark_2.13` | Spark 4.x | 2.13 | +| `dev.vortex:vortex-spark_2.12` | Spark 3.5.x | 2.12 | + +Use the `all` classifier JAR (e.g. `vortex-spark_2.13-0.78.0-all.jar`). It is self-contained: +it bundles the Vortex JNI bindings, native libraries for Linux (x86_64 and aarch64) and macOS +(aarch64), and relocates its Arrow, Guava, and Jackson dependencies to avoid classpath +conflicts with Spark. The thin (unclassified) JAR does not work on its own because it +references relocated classes that only ship in the `all` JAR. + +## Getting Vortex into Spark + +Pass the `all` JAR to `spark-shell`, `spark-submit`, or `pyspark` with `--jars`. Spark accepts +either a local path or a URL, so you can point directly at Maven Central: + +```shell +spark-shell --jars https://repo1.maven.org/maven2/dev/vortex/vortex-spark_2.13/0.78.0/vortex-spark_2.13-0.78.0-all.jar +``` + +Or configure it on the session builder, e.g. in PySpark: + +```python +spark = ( + SparkSession.builder + .config("spark.jars", "/path/to/vortex-spark_2.13-0.78.0-all.jar") + .getOrCreate() +) +``` + +Note that `--packages dev.vortex:vortex-spark_2.13:0.78.0` does not work: `--packages` cannot +select the `all` classifier and resolves the thin JAR, which fails at runtime with +`NoClassDefFoundError: dev/vortex/relocated/...`. + +To depend on the connector from a JVM project instead, add the `all` classifier to the +dependency: + +```kotlin +implementation("dev.vortex:vortex-spark_2.13:0.78.0:all") +``` + +## Usage + +Paths may be local filesystem paths (`/path/to/data`) or URLs (`file:///path/to/data`, +`s3://bucket/path/to/data`). + +### DataFrame API + +```java +// Write +df.write() + .format("vortex") + .option("path", "/path/to/output") + .mode(SaveMode.Overwrite) + .save(); + +// Read a single file or a directory of .vortex files +Dataset df = spark.read() + .format("vortex") + .option("path", "/path/to/output") + .load(); +``` + +### Spark SQL + +```sql +-- Query existing Vortex files through a temporary view +CREATE TEMPORARY VIEW people +USING vortex +OPTIONS (path '/path/to/data'); + +SELECT name, age FROM people WHERE age > 30; + +-- Create a table and write to it. With a LOCATION clause the table is external, +-- backed by the files at that path; without one it is managed by Spark. +CREATE TABLE student (id INT, name STRING, age INT) +USING vortex; + +INSERT INTO student VALUES (1, 'Alice', 20), (2, 'Bob', 21); + +SELECT * FROM student; +``` + +On Spark 3.5, `CREATE TABLE ... USING vortex` additionally requires replacing the session +catalog with `spark.sql.catalog.spark_catalog=dev.vortex.spark.VortexSessionCatalog`, +because Spark 3.5's built-in catalog cannot read tables backed by a DataSource V2-only +connector. The extension delegates everything else to the built-in session catalog and +leaves tables of other providers untouched; it is not needed on Spark 4. + +### Direct file queries + +Spark's built-in ``SELECT * FROM format.`path` `` syntax only works for built-in file +formats, so the connector ships a path-based catalog that provides the equivalent for +Vortex. Register it under the name `vortex` with this session config: + +``` +spark.sql.catalog.vortex=dev.vortex.spark.VortexCatalog +``` + +Then query (or insert into) Vortex files directly by path: + +```sql +SELECT * FROM vortex.`/path/to/data`; +``` + +See the [Spark user guide](https://docs.vortex.dev/user-guide/spark.html) for the full +documentation, including supported types, write options, and S3 configuration. diff --git a/java/vortex-spark/src/main/java/dev/vortex/spark/VortexCatalog.java b/java/vortex-spark/src/main/java/dev/vortex/spark/VortexCatalog.java new file mode 100644 index 00000000000..3d496a4cee0 --- /dev/null +++ b/java/vortex-spark/src/main/java/dev/vortex/spark/VortexCatalog.java @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +package dev.vortex.spark; + +import java.util.Map; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.Table; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.catalog.TableChange; +import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.util.CaseInsensitiveStringMap; + +/** + * A path-based Spark catalog for querying Vortex files directly from SQL. + * + *

Spark only supports {@code SELECT * FROM format.`path`} syntax for built-in file formats, so this catalog provides + * the equivalent for Vortex. Register it under the name {@code vortex}: + * + *

spark.sql.catalog.vortex=dev.vortex.spark.VortexCatalog
+ * + *

then query a Vortex file, or a directory of Vortex files, directly by path: + * + *

SELECT * FROM vortex.`/path/to/data`;
+ * + *

The table identifier must look like a path — contain a {@code /} — and resolves to the same table a + * {@code spark.read.format("vortex")} load of that path would produce, so reads, writes ({@code INSERT INTO + * vortex.`/path/to/data`}), and pushdown all behave identically. The catalog holds no state and supports no DDL. + */ +public final class VortexCatalog implements TableCatalog { + private static final String PATH_KEY = "path"; + + private String name = "vortex"; + + /** + * Creates a new catalog instance. + * + *

This no-argument constructor is required for Spark to instantiate the catalog through reflection from the + * {@code spark.sql.catalog.} configuration. + */ + public VortexCatalog() {} + + @Override + public void initialize(String name, CaseInsensitiveStringMap options) { + this.name = name; + } + + @Override + public String name() { + return name; + } + + /** + * Returns no identifiers: this catalog holds no state, tables are addressed by path. + * + * @param namespace the namespace to list, ignored + * @return an empty array + */ + @Override + public Identifier[] listTables(String[] namespace) { + return new Identifier[0]; + } + + /** + * Loads the Vortex file or directory of Vortex files at the path given by the identifier name. + * + * @param ident identifier whose name is a filesystem path or URL, e.g. {@code vortex.`/path/to/data`} + * @return a table backed by the Vortex files at the path + * @throws NoSuchTableException if the identifier does not look like a path, or the path cannot be read + */ + @SuppressWarnings("deprecation") + @Override + public Table loadTable(Identifier ident) throws NoSuchTableException { + String path = ident.name(); + if (ident.namespace().length != 0 || !path.contains("/")) { + throw new NoSuchTableException(ident); + } + var options = new CaseInsensitiveStringMap(Map.of(PATH_KEY, path)); + var provider = new VortexDataSourceV2(); + StructType schema; + Transform[] partitioning; + try { + schema = provider.inferSchema(options); + partitioning = provider.inferPartitioning(options); + } catch (RuntimeException e) { + // Missing or unreadable paths surface as "table not found" to SQL users. + throw new NoSuchTableException(ident); + } + return provider.getTable(schema, partitioning, Map.of(PATH_KEY, path)); + } + + /** Unsupported: tables are addressed by path, create them by writing data with the {@code vortex} format. */ + @Override + public Table createTable( + Identifier ident, StructType schema, Transform[] partitions, Map properties) { + throw new UnsupportedOperationException( + "VortexCatalog does not support CREATE TABLE, write data to the path instead"); + } + + /** Unsupported: this catalog holds no table metadata to alter. */ + @Override + public Table alterTable(Identifier ident, TableChange... changes) { + throw new UnsupportedOperationException("VortexCatalog does not support ALTER TABLE"); + } + + /** Unsupported: this catalog never drops data, returns false. */ + @Override + public boolean dropTable(Identifier ident) { + return false; + } + + /** Unsupported: this catalog holds no table metadata to rename. */ + @Override + public void renameTable(Identifier oldIdent, Identifier newIdent) { + throw new UnsupportedOperationException("VortexCatalog does not support RENAME TABLE"); + } +} diff --git a/java/vortex-spark/src/main/java/dev/vortex/spark/VortexDataSourceV2.java b/java/vortex-spark/src/main/java/dev/vortex/spark/VortexDataSourceV2.java index b3d7d637504..a5ce4e3b1ce 100644 --- a/java/vortex-spark/src/main/java/dev/vortex/spark/VortexDataSourceV2.java +++ b/java/vortex-spark/src/main/java/dev/vortex/spark/VortexDataSourceV2.java @@ -126,10 +126,14 @@ public StructType inferSchema(CaseInsensitiveStringMap options) { * explicit partitioning. Returning identity transforms here lets downstream components (notably * {@link dev.vortex.spark.read.VortexScanBuilder}) tell which schema columns are encoded in the directory layout * rather than stored inside the Vortex files, which matters for predicate pushdown. + * + *

The options may contain no path at all: when a managed table is created ({@code CREATE TABLE ... USING vortex} + * without a {@code LOCATION} clause), Spark's session catalog calls this before it has assigned the table's + * warehouse location. No transforms are inferred in that case. */ @Override public Transform[] inferPartitioning(CaseInsensitiveStringMap options) { - var paths = getPaths(options); + var paths = getPathsOrEmpty(options); if (paths.isEmpty()) { return new Transform[0]; } @@ -157,16 +161,20 @@ public Transform[] inferPartitioning(CaseInsensitiveStringMap options) { *

This method creates a VortexWritableTable that can be used to both read from and write to Vortex files. The * partitioning parameter is currently ignored. * + *

The properties may contain no path at all: when a managed table is created ({@code CREATE TABLE ... USING + * vortex} without a {@code LOCATION} clause), Spark's session catalog validates the table before it has assigned + * the table's warehouse location. The returned table then has no paths; once the table is loaded for reads or + * writes, Spark always supplies the resolved table location as the {@code path} property. + * * @param schema the table schema * @param partitioning table partitioning transforms * @param properties the table properties containing file paths and other options * @return a VortexTable instance for reading and writing data - * @throws RuntimeException if required path properties are missing */ @Override public Table getTable(StructType schema, Transform[] partitioning, Map properties) { var uncased = new CaseInsensitiveStringMap(properties); - ImmutableList paths = getPaths(uncased); + ImmutableList paths = getPathsOrEmpty(uncased); return new VortexTable(paths, schema, buildDataSourceOptions(properties), partitioning); } @@ -210,6 +218,13 @@ private Map buildDataSourceOptions(Map propertie return options.build(); } + private static ImmutableList getPathsOrEmpty(CaseInsensitiveStringMap uncased) { + if (!uncased.containsKey(PATH_KEY) && !uncased.containsKey(PATHS_KEY)) { + return ImmutableList.of(); + } + return getPaths(uncased); + } + private static ImmutableList getPaths(CaseInsensitiveStringMap uncased) { if (uncased.containsKey(PATH_KEY)) { return ImmutableList.of(uncased.get(PATH_KEY)); diff --git a/java/vortex-spark/src/main/java/dev/vortex/spark/VortexSessionCatalog.java b/java/vortex-spark/src/main/java/dev/vortex/spark/VortexSessionCatalog.java new file mode 100644 index 00000000000..1d546880cc1 --- /dev/null +++ b/java/vortex-spark/src/main/java/dev/vortex/spark/VortexSessionCatalog.java @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +package dev.vortex.spark; + +import java.util.Map; +import org.apache.spark.sql.catalyst.analysis.NoSuchNamespaceException; +import org.apache.spark.sql.catalyst.analysis.NoSuchTableException; +import org.apache.spark.sql.catalyst.analysis.TableAlreadyExistsException; +import org.apache.spark.sql.connector.catalog.DelegatingCatalogExtension; +import org.apache.spark.sql.connector.catalog.Identifier; +import org.apache.spark.sql.connector.catalog.Table; +import org.apache.spark.sql.connector.catalog.TableCatalog; +import org.apache.spark.sql.connector.expressions.Transform; +import org.apache.spark.sql.types.StructType; + +/** + * A session catalog extension that resolves {@code USING vortex} tables through the Vortex DataSource V2 connector. + * + *

Spark 3.5's built-in session catalog resolves the tables it stores through the V1 {@code DataSource} path, which + * rejects DataSource-V2-only connectors like Vortex, so {@code CREATE TABLE ... USING vortex} tables cannot be read + * back. (Spark 4 resolves them through the V2 provider directly and needs none of this.) Registering this extension as + * the session catalog fixes that on Spark 3.5: + * + *

spark.sql.catalog.spark_catalog=dev.vortex.spark.VortexSessionCatalog
+ * + *

All operations are delegated to the built-in session catalog — table metadata lives wherever it normally would, + * including the Hive metastore — but any table whose provider is {@code vortex} is loaded as a Vortex DataSource V2 + * table, backed by the files at the table's location. Tables of every other provider are untouched. + */ +public final class VortexSessionCatalog extends DelegatingCatalogExtension { + + /** + * Creates a new session catalog extension. + * + *

This no-argument constructor is required for Spark to instantiate the catalog through reflection from the + * {@code spark.sql.catalog.spark_catalog} configuration. + */ + public VortexSessionCatalog() {} + + @Override + public Table loadTable(Identifier ident) throws NoSuchTableException { + return asVortexTableIfVortex(super.loadTable(ident)); + } + + /** + * Creates the table in the delegate session catalog, then returns it resolved through the Vortex connector when its + * provider is {@code vortex}. + * + *

The conversion matters for {@code CREATE TABLE ... AS SELECT}: Spark writes the query result into the table + * returned here, which must therefore support V2 writes. + */ + @SuppressWarnings("deprecation") + @Override + public Table createTable( + Identifier ident, StructType schema, Transform[] partitions, Map properties) + throws TableAlreadyExistsException, NoSuchNamespaceException { + return asVortexTableIfVortex(super.createTable(ident, schema, partitions, properties)); + } + + /** + * Rebuilds a session-catalog table as a Vortex DataSource V2 table when its provider is {@code vortex} and it has a + * location; returns every other table unchanged. The schema and partitioning stored in the catalog are used as-is, + * no file needs to be opened. + */ + @SuppressWarnings("deprecation") + private static Table asVortexTableIfVortex(Table table) { + Map properties = table.properties(); + VortexDataSourceV2 provider = new VortexDataSourceV2(); + if (!provider.shortName().equalsIgnoreCase(properties.get(TableCatalog.PROP_PROVIDER))) { + return table; + } + String location = properties.get(TableCatalog.PROP_LOCATION); + if (location == null) { + return table; + } + return provider.getTable(table.schema(), table.partitioning(), Map.of("path", location)); + } +} diff --git a/java/vortex-spark/src/test/java/dev/vortex/spark/VortexSessionCatalogTest.java b/java/vortex-spark/src/test/java/dev/vortex/spark/VortexSessionCatalogTest.java new file mode 100644 index 00000000000..b31d674d884 --- /dev/null +++ b/java/vortex-spark/src/test/java/dev/vortex/spark/VortexSessionCatalogTest.java @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +package dev.vortex.spark; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.SparkSession; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.io.TempDir; + +/** + * Integration tests for {@link VortexSessionCatalog}, the session catalog extension that makes {@code CREATE TABLE ... + * USING vortex} work on Spark 3.5 as well as Spark 4. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public final class VortexSessionCatalogTest { + + private SparkSession spark; + private Path warehouseDir; + + @TempDir + Path tempDir; + + @BeforeAll + public void setUp() throws IOException { + warehouseDir = Files.createTempDirectory("vortex-warehouse"); + spark = SparkSession.builder() + .appName("VortexSessionCatalogTest") + .master("local[2]") + .config("spark.driver.host", "127.0.0.1") + .config("spark.sql.warehouse.dir", warehouseDir.toUri().toString()) + .config("spark.sql.catalog.spark_catalog", VortexSessionCatalog.class.getName()) + .config("spark.ui.enabled", "false") + .getOrCreate(); + } + + @AfterAll + public void tearDown() { + if (spark != null) { + spark.stop(); + } + } + + @Test + @DisplayName("Managed table lifecycle: CREATE, SELECT while empty, INSERT, INSERT OVERWRITE, DROP") + public void testManagedTableLifecycle() { + spark.sql("CREATE TABLE managed_students (id INT, name STRING, age INT) USING vortex"); + + assertEquals(0, spark.sql("SELECT * FROM managed_students").count(), "New managed table should be empty"); + + spark.sql("INSERT INTO managed_students VALUES (1, 'Alice', 20), (2, 'Bob', 21)"); + List rows = spark.sql("SELECT name FROM managed_students WHERE age > 20 ORDER BY name") + .collectAsList(); + assertEquals(1, rows.size()); + assertEquals("Bob", rows.get(0).getString(0)); + + spark.sql("INSERT OVERWRITE managed_students VALUES (3, 'Carol', 22)"); + assertEquals(1, spark.sql("SELECT * FROM managed_students").count(), "Overwrite should replace all rows"); + + Path tableDir = warehouseDir.resolve("managed_students"); + assertTrue(Files.exists(tableDir), "Managed table data should live under the warehouse dir"); + spark.sql("DROP TABLE managed_students"); + assertFalse(Files.exists(tableDir), "Dropping a managed table should remove its data"); + } + + @Test + @DisplayName("CREATE TABLE AS SELECT without a LOCATION clause") + public void testCreateManagedTableAsSelect() { + spark.sql("CREATE TABLE ctas_source (id INT, name STRING) USING vortex"); + spark.sql("INSERT INTO ctas_source VALUES (1, 'Alice'), (2, 'Bob')"); + + spark.sql("CREATE TABLE ctas_target USING vortex AS SELECT * FROM ctas_source WHERE id > 1"); + List rows = spark.sql("SELECT name FROM ctas_target").collectAsList(); + assertEquals(1, rows.size()); + assertEquals("Bob", rows.get(0).getString(0)); + + spark.sql("DROP TABLE ctas_target"); + spark.sql("DROP TABLE ctas_source"); + } + + @Test + @DisplayName("External table with a LOCATION clause") + public void testExternalTable() { + Path location = tempDir.resolve("ext_students"); + spark.sql( + String.format("CREATE TABLE ext_students (id INT, name STRING) USING vortex LOCATION '%s'", location)); + spark.sql("INSERT INTO ext_students VALUES (1, 'Alice')"); + assertEquals(1, spark.sql("SELECT * FROM ext_students").count()); + spark.sql("DROP TABLE ext_students"); + } + + @Test + @DisplayName("Tables of other providers pass through the extension untouched") + public void testOtherProviderPassthrough() { + spark.sql("CREATE TABLE pq_table (id INT) USING parquet"); + spark.sql("INSERT INTO pq_table VALUES (7)"); + assertEquals( + 7, spark.sql("SELECT * FROM pq_table").collectAsList().get(0).getInt(0)); + spark.sql("DROP TABLE pq_table"); + } +} diff --git a/java/vortex-spark/src/test/java/dev/vortex/spark/VortexSqlTest.java b/java/vortex-spark/src/test/java/dev/vortex/spark/VortexSqlTest.java new file mode 100644 index 00000000000..1a9b21ef779 --- /dev/null +++ b/java/vortex-spark/src/test/java/dev/vortex/spark/VortexSqlTest.java @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +package dev.vortex.spark; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import org.apache.spark.sql.AnalysisException; +import org.apache.spark.sql.Dataset; +import org.apache.spark.sql.Row; +import org.apache.spark.sql.RowFactory; +import org.apache.spark.sql.SaveMode; +import org.apache.spark.sql.SparkSession; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.io.TempDir; + +/** + * Integration tests for Spark SQL access to Vortex: managed tables created without a {@code LOCATION} clause, and + * direct file queries through {@link VortexCatalog}. + */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public final class VortexSqlTest { + + private SparkSession spark; + private Path warehouseDir; + + @TempDir + Path tempDir; + + @BeforeAll + public void setUp() throws IOException { + warehouseDir = Files.createTempDirectory("vortex-warehouse"); + spark = SparkSession.builder() + .appName("VortexSqlTest") + .master("local[2]") + .config("spark.driver.host", "127.0.0.1") + .config("spark.sql.warehouse.dir", warehouseDir.toUri().toString()) + .config("spark.sql.catalog.vortex", VortexCatalog.class.getName()) + .config("spark.ui.enabled", "false") + .getOrCreate(); + } + + @AfterAll + public void tearDown() { + if (spark != null) { + spark.stop(); + } + } + + /** + * Spark 3.5's built-in session catalog cannot read tables backed by a DataSource-V2-only provider: its + * {@code FindDataSourceTable} rule falls back to the V1 {@code DataSource} path, which rejects the provider with + * "vortex is not a valid Spark SQL Data Source". Spark 4 resolves such tables through the provider directly. On + * Spark 3.5 the {@link VortexSessionCatalog} extension provides the same support — see + * {@link VortexSessionCatalogTest}, which runs these scenarios on both versions. + */ + private void assumeSupportsSqlTables() { + assumeTrue( + spark.version().startsWith("4."), + "CREATE TABLE ... USING vortex requires Spark 4 or the VortexSessionCatalog extension"); + } + + @Test + @DisplayName("Managed table lifecycle: CREATE, SELECT while empty, INSERT, INSERT OVERWRITE, DROP") + public void testManagedTableLifecycle() { + assumeSupportsSqlTables(); + spark.sql("CREATE TABLE managed_students (id INT, name STRING, age INT) USING vortex"); + + assertEquals(0, spark.sql("SELECT * FROM managed_students").count(), "New managed table should be empty"); + + spark.sql("INSERT INTO managed_students VALUES (1, 'Alice', 20), (2, 'Bob', 21)"); + List rows = spark.sql("SELECT name FROM managed_students WHERE age > 20 ORDER BY name") + .collectAsList(); + assertEquals(1, rows.size()); + assertEquals("Bob", rows.get(0).getString(0)); + + spark.sql("INSERT OVERWRITE managed_students VALUES (3, 'Carol', 22)"); + assertEquals(1, spark.sql("SELECT * FROM managed_students").count(), "Overwrite should replace all rows"); + + Path tableDir = warehouseDir.resolve("managed_students"); + assertTrue(Files.exists(tableDir), "Managed table data should live under the warehouse dir"); + spark.sql("DROP TABLE managed_students"); + assertFalse(Files.exists(tableDir), "Dropping a managed table should remove its data"); + } + + @Test + @DisplayName("CREATE TABLE AS SELECT without a LOCATION clause") + public void testCreateManagedTableAsSelect() { + assumeSupportsSqlTables(); + spark.sql("CREATE TABLE ctas_source (id INT, name STRING) USING vortex"); + spark.sql("INSERT INTO ctas_source VALUES (1, 'Alice'), (2, 'Bob')"); + + spark.sql("CREATE TABLE ctas_target USING vortex AS SELECT * FROM ctas_source WHERE id > 1"); + List rows = spark.sql("SELECT name FROM ctas_target").collectAsList(); + assertEquals(1, rows.size()); + assertEquals("Bob", rows.get(0).getString(0)); + + spark.sql("DROP TABLE ctas_target"); + spark.sql("DROP TABLE ctas_source"); + } + + @Test + @DisplayName("Reading the vortex format without a path option still fails") + public void testReadWithoutPathStillThrows() { + assertThrows( + IllegalArgumentException.class, + () -> spark.read().format("vortex").load(), + "A read with no path should not silently return an empty DataFrame"); + } + + @Test + @DisplayName("Direct file query through the vortex catalog") + public void testDirectPathQuery() { + Path dataDir = tempDir.resolve("direct_query"); + writeTestData(dataDir); + + List rows = spark.sql(String.format("SELECT name FROM vortex.`%s` WHERE age > 30 ORDER BY name", dataDir)) + .collectAsList(); + assertEquals(2, rows.size()); + assertEquals("Alice", rows.get(0).getString(0)); + assertEquals("Carol", rows.get(1).getString(0)); + } + + @Test + @DisplayName("INSERT INTO a direct path through the vortex catalog") + public void testDirectPathInsert() { + Path dataDir = tempDir.resolve("direct_insert"); + writeTestData(dataDir); + + spark.sql(String.format("INSERT INTO vortex.`%s` VALUES (4, 'Dave', 50)", dataDir)); + assertEquals( + 4, + spark.sql(String.format("SELECT * FROM vortex.`%s`", dataDir)).count()); + } + + @Test + @DisplayName("Direct queries of missing paths and non-path names fail as table-not-found") + public void testDirectPathNotFound() { + assertThrows(AnalysisException.class, () -> spark.sql( + String.format("SELECT * FROM vortex.`%s`", tempDir.resolve("no_such_dir"))) + .collect()); + assertThrows(AnalysisException.class, () -> spark.sql("SELECT * FROM vortex.not_a_path") + .collect()); + } + + private void writeTestData(Path dataDir) { + StructType schema = DataTypes.createStructType(new StructField[] { + DataTypes.createStructField("id", DataTypes.IntegerType, false), + DataTypes.createStructField("name", DataTypes.StringType, false), + DataTypes.createStructField("age", DataTypes.IntegerType, false) + }); + Dataset df = spark.createDataFrame( + Arrays.asList( + RowFactory.create(1, "Alice", 34), + RowFactory.create(2, "Bob", 27), + RowFactory.create(3, "Carol", 45)), + schema); + df.write().format("vortex").mode(SaveMode.Overwrite).save(dataDir.toString()); + } +} From 371c984260147710ed81c4c802dd11a576d3c497 Mon Sep 17 00:00:00 2001 From: ksj1230 <38639076+ksj1230@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:15:31 +0900 Subject: [PATCH 061/104] Fix integer overflow in BitBufferMut::truncate and append_n (#8474) ## Summary Several methods on `BitBufferMut` use unchecked arithmetic on offset/length values, which can overflow in release mode and lead to undefined behavior reachable entirely from safe code (`#![forbid(unsafe_code)]`). Affected methods: - `truncate`: `self.offset + len` overflows, causing the buffer to be truncated to an incorrect size. - `append_n`: `self.offset + self.len + n` overflows, causing incorrect capacity calculation. Fix: add overflow assertions before the unchecked addition. Reproduction (all use `#![forbid(unsafe_code)]`): ```rust // 1. truncate let buffer = ByteBufferMut::zeroed(10); let mut bb = BitBufferMut::from_buffer(buffer, usize::MAX - 2, 80); bb.truncate(3); bb.set(2); // 2. append_n let buffer = ByteBufferMut::zeroed(2); let mut bb = BitBufferMut::from_buffer(buffer, 1000, 0); bb.append_n(true, usize::MAX); bb.set(10000); ``` Related: #7979 (the `BufferMut::zeroed_aligned` overflow I reported to maintainers in April 2025; this PR addresses additional overflow sites found during the same audit) ## Testing Existing `cargo test -p vortex-buffer` passes (789 tests + 6 doc-tests). Both PoCs now panic with overflow message instead of UB. Happy to adjust the approach if maintainers prefer a different fix strategy. --------- Signed-off-by: Sungjin Kim Signed-off-by: Robert Kruszewski Co-authored-by: Robert Kruszewski --- vortex-buffer/src/bit/buf_mut.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/vortex-buffer/src/bit/buf_mut.rs b/vortex-buffer/src/bit/buf_mut.rs index df38b0cd434..1964aa664d4 100644 --- a/vortex-buffer/src/bit/buf_mut.rs +++ b/vortex-buffer/src/bit/buf_mut.rs @@ -378,6 +378,10 @@ impl BitBufferMut { return; } + assert!( + self.offset <= usize::MAX - len, + "Truncate on BitBufferMut overflowed" + ); let end_bit = self.offset + len; let new_len_bytes = end_bit.div_ceil(8); self.buffer.truncate(new_len_bytes); @@ -444,6 +448,13 @@ impl BitBufferMut { return; } + assert!( + self.offset + .checked_add(self.len) + .and_then(|v| v.checked_add(n)) + .is_some(), + "Append on BitBufferMut overflowed" + ); let end_bit_pos = self.offset + self.len + n; let required_bytes = end_bit_pos.div_ceil(8); From 30fff30f37f8e0e8e21f877eed3f585c03d811e1 Mon Sep 17 00:00:00 2001 From: Adam Gutglick Date: Mon, 13 Jul 2026 13:16:39 +0100 Subject: [PATCH 062/104] Reduce Java and vortex-jni build sizes (#8734) ## Rationale for this change Part of my on-going war to try and reduce the size of our builds. 1. Only copy the libvortex_jni binary when it actually changed 2. No need to build it as a staticlib, which is very big --------- Signed-off-by: Adam Gutglick --- java/vortex-jni/build.gradle.kts | 86 ++++++++++++++++---------------- vortex-jni/Cargo.toml | 2 +- 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/java/vortex-jni/build.gradle.kts b/java/vortex-jni/build.gradle.kts index bb652d0d86e..8f5106a1262 100644 --- a/java/vortex-jni/build.gradle.kts +++ b/java/vortex-jni/build.gradle.kts @@ -2,7 +2,8 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar -import org.gradle.kotlin.dsl.support.serviceOf +import org.gradle.api.tasks.Copy +import org.gradle.api.tasks.Exec plugins { `java-library` @@ -111,51 +112,52 @@ tasks.build { dependsOn("shadowJar") } -tasks.register("makeTestFiles") { - description = "Generate files used by unit tests" +val rustWorkspaceDir = rootProject.projectDir.absoluteFile.parentFile +val osName = System.getProperty("os.name").lowercase() +val osArch = System.getProperty("os.arch").lowercase() +val osShortName = + when { + osName.contains("mac") -> "darwin" + osName.contains("nix") || osName.contains("nux") -> "linux" + osName.contains("win") -> "win" + else -> throw GradleException("Unsupported OS for makeTestFiles: $osName") + } +val libExt = + when (osShortName) { + "darwin" -> ".dylib" + "linux" -> ".so" + "win" -> ".dll" + else -> throw GradleException("Unsupported OS short name: $osShortName") + } +val nativeLibrary = rustWorkspaceDir.resolve("target/debug/libvortex_jni$libExt") +val nativeLibraryDir = "src/main/resources/native/$osShortName-$osArch" + +val buildJniLibrary = + tasks.register("buildJniLibrary") { + description = "Build the JNI library for the host architecture" + group = "verification" + + // The publish workflow places release, cross-compiled libs for every supported + // architecture before invoking shadowJar; rebuilding the host-arch debug lib + // here would overwrite them (linux-aarch64 ends up holding a linux-amd64 .so). + onlyIf { System.getenv("VORTEX_SKIP_MAKE_TEST_FILES") != "true" } + + workingDir = rustWorkspaceDir + executable = "cargo" + args("build", "--package", "vortex-jni") + } + +tasks.register("makeTestFiles") { + description = "Stage the JNI library used by unit tests" group = "verification" - // The publish workflow places release, cross-compiled libs for every supported - // architecture before invoking shadowJar; rebuilding the host-arch debug lib - // here would overwrite them (linux-aarch64 ends up holding a linux-amd64 .so). onlyIf { System.getenv("VORTEX_SKIP_MAKE_TEST_FILES") != "true" } + dependsOn(buildJniLibrary) - doLast { - println("makeTestFiles executed") - - val execOps = serviceOf() - - // Build the JNI lib for the host architecture only. - execOps.exec { - workingDir = rootProject.projectDir.absoluteFile.parentFile - executable = "cargo" - args("build", "--package", "vortex-jni") - } - - val osName = System.getProperty("os.name").lowercase() - val osArch = System.getProperty("os.arch").lowercase() - val osShortName = - when { - osName.contains("mac") -> "darwin" - osName.contains("nix") || osName.contains("nux") -> "linux" - osName.contains("win") -> "win" - else -> throw GradleException("Unsupported OS for makeTestFiles: $osName") - } - val libExt = - when (osShortName) { - "darwin" -> ".dylib" - "linux" -> ".so" - "win" -> ".dll" - else -> throw GradleException("Unsupported OS short name: $osShortName") - } - - // Only populate the host-arch directory so cross-compiled libs for other - // architectures (placed by the publish workflow) are preserved. - copy { - from("${rootProject.projectDir.absoluteFile.parentFile}/target/debug/libvortex_jni$libExt") - into("$projectDir/src/main/resources/native/$osShortName-$osArch") - } - } + // Only populate the host-arch directory so cross-compiled libs for other + // architectures (placed by the publish workflow) are preserved. + from(nativeLibrary) + into(nativeLibraryDir) } tasks.named("processResources").configure { diff --git a/vortex-jni/Cargo.toml b/vortex-jni/Cargo.toml index d5f5cea61aa..e6aef3d8756 100644 --- a/vortex-jni/Cargo.toml +++ b/vortex-jni/Cargo.toml @@ -36,7 +36,7 @@ vortex-parquet-variant = { workspace = true } jni = { workspace = true, features = ["invocation"] } [lib] -crate-type = ["staticlib", "cdylib"] +crate-type = ["cdylib"] [lints] workspace = true From 452e0f816207491ebd760dfcdcdc1874cca455eb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:47:45 +0000 Subject: [PATCH 063/104] Update dependency ray to v2.56.0 [SECURITY] (#8732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > ℹ️ **Note** > > This PR body was truncated due to platform limits. This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [ray](https://redirect.github.com/ray-project/ray) | `2.55.1` → `2.56.0` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/ray/2.56.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/ray/2.55.1/2.56.0?slim=true) | --- > [!WARNING] > Some dependencies could not be looked up. Check the [Dependency Dashboard](../issues/357) for more information. --- ### [CVE-2026-57516](https://nvd.nist.gov/vuln/detail/CVE-2026-57516) / [GHSA-hhrp-gw25-jr43](https://redirect.github.com/advisories/GHSA-hhrp-gw25-jr43) / PYSEC-2026-2273

More information #### Details Ray prior to 2.56.0 contains an unsafe deserialization vulnerability in the WebDataset reader that allows attackers to achieve remote code execution by supplying a malicious tar archive to the read_webdataset() function. The _default_decoder() function in webdataset_datasource.py unconditionally calls pickle.loads() on tar entries with .pkl/.pickle extensions and torch.load() with weights_only=False on .pt/.pth entries, executing arbitrary code inside Ray remote workers on every worker that processes the malicious archive. #### Severity - CVSS Score: 8.6 / 10 (High) - Vector String: `CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X` #### References - [https://github.com/ray-project/ray/releases/tag/ray-2.56.0](https://redirect.github.com/ray-project/ray/releases/tag/ray-2.56.0) - [https://www.vulncheck.com/advisories/ray-unsafe-deserialization-rce-via-webdataset-reader](https://www.vulncheck.com/advisories/ray-unsafe-deserialization-rce-via-webdataset-reader) - [https://github.com/ray-project/ray/pull/63469](https://redirect.github.com/ray-project/ray/pull/63469) - [https://github.com/ray-project/ray/pull/63470](https://redirect.github.com/ray-project/ray/pull/63470) - [https://github.com/ray-project/ray/security/advisories/GHSA-hhrp-gw25-jr43](https://redirect.github.com/ray-project/ray/security/advisories/GHSA-hhrp-gw25-jr43) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-2273) and the [PyPI Advisory Database](https://redirect.github.com/pypa/advisory-database) ([CC-BY 4.0](https://redirect.github.com/pypa/advisory-database/blob/main/LICENSE)).
--- ### Release Notes
ray-project/ray (ray) ### [`v2.56.0`](https://redirect.github.com/ray-project/ray/releases/tag/ray-2.56.0) [Compare Source](https://redirect.github.com/ray-project/ray/compare/ray-2.55.1...ray-2.56.0) ### Highlights - **Ray Data Stability:** In this Ray release, we've added a variety of stability improvements, including running multiple datasets in a cluster, adding automatic batch size selection to CPU-based map-batches, and default logical memory configuration to prevent OOMs. We've also tightened `iter_batches` stability by reducing hidden buffering and shutting down the executor when consumers exit early ([#​63660](https://redirect.github.com/ray-project/ray/issues/63660), [#​63682](https://redirect.github.com/ray-project/ray/issues/63682), [#​62949](https://redirect.github.com/ray-project/ray/issues/62949)). This reduces object-store spilling for common training workloads - **Ray Serve:** We re-architected Ray Serve LLM by decoupling request handling from token streaming response path ([#​62667](https://redirect.github.com/ray-project/ray/issues/62667), [#​62680](https://redirect.github.com/ray-project/ray/issues/62680), [#​62668](https://redirect.github.com/ray-project/ray/issues/62668), [#​62669](https://redirect.github.com/ray-project/ray/issues/62669), [#​63167](https://redirect.github.com/ray-project/ray/issues/63167)), resulting in significant LLM serving performance improvements. We've also introduced new routing policies such as session-sticky routing via consistent hashing with `ConsistentHashRouter` ([#​62905](https://redirect.github.com/ray-project/ray/issues/62905), [#​63096](https://redirect.github.com/ray-project/ray/issues/63096), [#​62906](https://redirect.github.com/ray-project/ray/issues/62906)) and `CapacityQueueRouter` ([#​62323](https://redirect.github.com/ray-project/ray/issues/62323)) which is beneficial for supply-constrained workloads. - **Ray Core:** We've added GPU-domain-aware placement groups using label locality ([#​61442](https://redirect.github.com/ray-project/ray/issues/61442), [#​61614](https://redirect.github.com/ray-project/ray/issues/61614), [#​62487](https://redirect.github.com/ray-project/ray/issues/62487), [#​62533](https://redirect.github.com/ray-project/ray/issues/62533)). This enables placement groups to pack bundles onto nodes that share a `ray.io/gpu-domain` label instead of only packing at the single-node level. We've also added initial Kubernetes in-place pod resizing support for Autoscaler v2 ([#​55961](https://redirect.github.com/ray-project/ray/issues/55961), [#​62369](https://redirect.github.com/ray-project/ray/issues/62369), [#​62215](https://redirect.github.com/ray-project/ray/issues/62215)), enabling Ray clusters to resize CPU and memory on existing worker pods before scaling out new pods. ### Ray Data ##### 🎉 New Features - Support multiple datasets per cluster via subcluster labels and resource partitioning ([#​63331](https://redirect.github.com/ray-project/ray/issues/63331), [#​63375](https://redirect.github.com/ray-project/ray/issues/63375), [#​63982](https://redirect.github.com/ray-project/ray/issues/63982)) - Add `Dataset.mix()` public API and `MixOperator` for weighted dataset mixing ([#​63168](https://redirect.github.com/ray-project/ray/issues/63168), [#​62450](https://redirect.github.com/ray-project/ray/issues/62450)) - New DataSourceV2 framework: `ParquetDatasourceV2`, chunked reader, predicate splitting, listing/scanner infra ([#​63113](https://redirect.github.com/ray-project/ray/issues/63113), [#​63454](https://redirect.github.com/ray-project/ray/issues/63454), [#​63163](https://redirect.github.com/ray-project/ray/issues/63163), [#​62975](https://redirect.github.com/ray-project/ray/issues/62975), [#​63027](https://redirect.github.com/ray-project/ray/issues/63027), [#​62182](https://redirect.github.com/ray-project/ray/issues/62182)) - Add `batch_size='auto'` to `map_batches` to derive batch row count from target row batch size ([#​62648](https://redirect.github.com/ray-project/ray/issues/62648)) - Implement distributed upsert for Iceberg using task-based merge algorithm, preventing performance bottleneck on driver ([#​63482](https://redirect.github.com/ray-project/ray/issues/63482)) - Add `include_row_hash` to `read_parquet` ([#​61408](https://redirect.github.com/ray-project/ray/issues/61408)) - Add JAX data iterator ([#​61630](https://redirect.github.com/ray-project/ray/issues/61630)) - Expose flag to run read tasks on isolated worker processes via `isolate_read_workers` ([#​63490](https://redirect.github.com/ray-project/ray/issues/63490)) - Expose flag to set default logical memory for map operators via `default_map_logical_memory_enabled` ([#​63814](https://redirect.github.com/ray-project/ray/issues/63814)) - Support predicate pushdown for Lance format ([#​61400](https://redirect.github.com/ray-project/ray/issues/61400)) - Support per-partition `start_offset` and `end_offset` for `read_kafka` ([#​61620](https://redirect.github.com/ray-project/ray/issues/61620)) - Add obstore async download backend for download operator ([#​61735](https://redirect.github.com/ray-project/ray/issues/61735)) - Support UDF retries on transient exceptions ([#​63023](https://redirect.github.com/ray-project/ray/issues/63023)) ##### 💫 Enhancements - Fix `iter_batches` spilling by replacing `make_async_gen` with `iter_threaded` and reducing buffered batches ([#​63660](https://redirect.github.com/ray-project/ray/issues/63660), [#​63682](https://redirect.github.com/ray-project/ray/issues/63682)) - Gate `restore_original_order` in `iter_batches` behind `preserve_order` ([#​63792](https://redirect.github.com/ray-project/ray/issues/63792)) - Convert `drop_columns` to a `Project` logical operator when input schema is known ([#​63813](https://redirect.github.com/ray-project/ray/issues/63813)) - Make `ConcatAggregation` and `TurbopufferDatasink` use `polars` for sorting ([#​61904](https://redirect.github.com/ray-project/ray/issues/61904)) - Boost and vectorize `hash_partition` with `sort_indices`, zero-copy slices, and pandas ([#​63498](https://redirect.github.com/ray-project/ray/issues/63498), [#​62757](https://redirect.github.com/ray-project/ray/issues/62757), [#​63152](https://redirect.github.com/ray-project/ray/issues/63152), [#​62587](https://redirect.github.com/ray-project/ray/issues/62587)) - Enable `GPU_SHUFFLE` in `grouped_data.py` ([#​62410](https://redirect.github.com/ray-project/ray/issues/62410)) - Eager `StarExpr` expansion, schema inference for non-black-box UDFs, and Expressions struct support ([#​63776](https://redirect.github.com/ray-project/ray/issues/63776), [#​63387](https://redirect.github.com/ray-project/ray/issues/63387), [#​62560](https://redirect.github.com/ray-project/ray/issues/62560)) - Make logging configurable via `RAY_DATA_LOG_LEVEL` and log `RAY_DATA` env vars at execution start ([#​63487](https://redirect.github.com/ray-project/ray/issues/63487), [#​63380](https://redirect.github.com/ray-project/ray/issues/63380)) - Display and track logical memory in progress bar ([#​63379](https://redirect.github.com/ray-project/ray/issues/63379)) - Honor `compute=` in `filter(expr=...)` and deprecate `concurrency=` ([#​63576](https://redirect.github.com/ray-project/ray/issues/63576)) - Enable filter pushdown through `StreamingRepartition` and read stage column-rename removal ([#​62347](https://redirect.github.com/ray-project/ray/issues/62347), [#​63384](https://redirect.github.com/ray-project/ray/issues/63384), [#​63582](https://redirect.github.com/ray-project/ray/issues/63582)) - Cache deserialized Arrow schemas in `BlockMetadataWithSchema` ([#​63462](https://redirect.github.com/ray-project/ray/issues/63462)) - Track scheduling-loop step duration (p50/p90/max), peak USS/object-store memory, and task block locality ([#​63586](https://redirect.github.com/ray-project/ray/issues/63586), [#​63345](https://redirect.github.com/ray-project/ray/issues/63345), [#​63489](https://redirect.github.com/ray-project/ray/issues/63489), [#​63418](https://redirect.github.com/ray-project/ray/issues/63418), [#​62249](https://redirect.github.com/ray-project/ray/issues/62249)) - Replace `TaskDurationStats` and Timer with `DistributionTracker` ([#​63488](https://redirect.github.com/ray-project/ray/issues/63488), [#​63530](https://redirect.github.com/ray-project/ray/issues/63530), [#​63825](https://redirect.github.com/ray-project/ray/issues/63825)) - Introduce `BlockEntry` on `RefBundle` in place of `(ref, metadata)` tuples ([#​63654](https://redirect.github.com/ray-project/ray/issues/63654)) - Pre-resolve filesystem in threaded download to avoid IMDS herd ([#​62898](https://redirect.github.com/ray-project/ray/issues/62898)) - Convert logical operators to frozen dataclasses and consolidate operator base/repr ([#​62593](https://redirect.github.com/ray-project/ray/issues/62593), [#​62568](https://redirect.github.com/ray-project/ray/issues/62568), [#​62400](https://redirect.github.com/ray-project/ray/issues/62400), [#​63137](https://redirect.github.com/ray-project/ray/issues/63137), [#​63140](https://redirect.github.com/ray-project/ray/issues/63140), [#​63108](https://redirect.github.com/ray-project/ray/issues/63108)) - Non-blocking default autoscaling coordinator and resource-aware auto-downscaling ([#​62725](https://redirect.github.com/ray-project/ray/issues/62725), [#​62574](https://redirect.github.com/ray-project/ray/issues/62574)) - Release pinned blocks after dataset execution and shut down executor on early `DataIterator` exit ([#​62456](https://redirect.github.com/ray-project/ray/issues/62456), [#​62949](https://redirect.github.com/ray-project/ray/issues/62949)) - Optimize local shuffle with incremental index and configurable compaction threshold ([#​62539](https://redirect.github.com/ray-project/ray/issues/62539)) - Speed up checkpoint filter and reduce memory usage ([#​60294](https://redirect.github.com/ray-project/ray/issues/60294)) - Preserve Arrow types through pandas roundtrip and reorder block columns by name before schema ops ([#​63017](https://redirect.github.com/ray-project/ray/issues/63017), [#​63582](https://redirect.github.com/ray-project/ray/issues/63582)) - Block pickle object columns when reading untrusted Parquet and gate unsafe WebDataset deserialization ([#​63470](https://redirect.github.com/ray-project/ray/issues/63470), [#​63469](https://redirect.github.com/ray-project/ray/issues/63469)) - Move backpressure escape hatch across all policies ([#​63539](https://redirect.github.com/ray-project/ray/issues/63539)) - Update `pandas`, `modin`, and `pyarrow` minimum versions ([#​62899](https://redirect.github.com/ray-project/ray/issues/62899)) - Add utilization monitoring and correct logical resource usage for `ActorPool` ([#​61987](https://redirect.github.com/ray-project/ray/issues/61987), [#​61528](https://redirect.github.com/ray-project/ray/issues/61528)) - Deprecate `ConcurrencyCapBackpressurePolicy`, `DataIterator.to_torch`, and pandas UDF batches ([#​63392](https://redirect.github.com/ray-project/ray/issues/63392), [#​62540](https://redirect.github.com/ray-project/ray/issues/62540), [#​61733](https://redirect.github.com/ray-project/ray/issues/61733)) - Rank actors per node in a heap and avoid re-exporting actor class via `.options` ([#​62309](https://redirect.github.com/ray-project/ray/issues/62309), [#​62722](https://redirect.github.com/ray-project/ray/issues/62722)) - `read_delta` reads from preconfigured `pyarrow` dataset ([#​61721](https://redirect.github.com/ray-project/ray/issues/61721)) - Include column name and target type in `ArrowConversionError`; reduce arrow conversion warning verbosity ([#​62407](https://redirect.github.com/ray-project/ray/issues/62407), [#​61486](https://redirect.github.com/ray-project/ray/issues/61486), [#​62521](https://redirect.github.com/ray-project/ray/issues/62521)) - Show external consumer bytes in verbose operator progress log ([#​63728](https://redirect.github.com/ray-project/ray/issues/63728)) - Disable `DataSourceV2` by default after earlier enabling ([#​63674](https://redirect.github.com/ray-project/ray/issues/63674), [#​63326](https://redirect.github.com/ray-project/ray/issues/63326)) ##### 🔨 Fixes - Rename subcluster label key from `__subcluster__` to `ray-subcluster` ([#​63982](https://redirect.github.com/ray-project/ray/issues/63982)) - Fix `get_or_create_stats_actor` crash in Ray Client mode ([#​63402](https://redirect.github.com/ray-project/ray/issues/63402)) - Fix datasource pushdown crashes for generic `UDFExpr` filter predicates ([#​63781](https://redirect.github.com/ray-project/ray/issues/63781)) - Fix hash-shuffle aggregator memory estimation: metadata propagation, node-size clamp, column pruning ([#​63809](https://redirect.github.com/ray-project/ray/issues/63809)) - Fix `CheckpointConfig` `FileNotFoundError` on Azure Blob Storage ([#​63606](https://redirect.github.com/ray-project/ray/issues/63606)) - Fix silent credential drop for fsspec-S3 in download expression ([#​62897](https://redirect.github.com/ray-project/ray/issues/62897)) - Fix missing f-string prefix in `_concatenate_extension_column` ([#​62939](https://redirect.github.com/ray-project/ray/issues/62939)) - Fix `HashAggregate` duplicate group rows for `AggregateFnV2` ([#​63066](https://redirect.github.com/ray-project/ray/issues/63066)) - Fix JSONL read retry with advanced file cursor ([#​63233](https://redirect.github.com/ray-project/ray/issues/63233)) - Fix `read_parquet` `ArrowNotImplementedError` for nested column types exceeding \~2GB row group ([#​61824](https://redirect.github.com/ray-project/ray/issues/61824)) - Fix `read_parquet` nested-type fallback and parquet scanner memory accumulation ([#​63175](https://redirect.github.com/ray-project/ray/issues/63175), [#​62745](https://redirect.github.com/ray-project/ray/issues/62745)) - Fix memory leak in `DataIterator.to_torch()` by switching to `PyArrow` ([#​60966](https://redirect.github.com/ray-project/ray/issues/60966)) - Fix `ZipOperator` freeing shared blocks via `_split_at_indices` ([#​62665](https://redirect.github.com/ray-project/ray/issues/62665)) - Fix concurrent writes race condition in `write_parquet` ([#​62377](https://redirect.github.com/ray-project/ray/issues/62377)) - Fix GPU shuffle output ordering when using `ShuffleStrategy.GPU_SHUFFLE` ([#​62351](https://redirect.github.com/ray-project/ray/issues/62351)) - Fix incorrect `DatasetStat` uuid propagation ([#​62255](https://redirect.github.com/ray-project/ray/issues/62255)) - Fix none issue when `DATA_ENABLE_OP_RESOURCE_RESERVATION=False` ([#​61718](https://redirect.github.com/ray-project/ray/issues/61718)) - Fix filesystem compatibility check for fsspec-wrapped `PyFileSystem` ([#​61850](https://redirect.github.com/ray-project/ray/issues/61850)) - Forward `try_create_dir` to `pyarrow.dataset.write_dataset` ([#​58302](https://redirect.github.com/ray-project/ray/issues/58302)) - Fix autoscaler bug blocking timely release of leased resources ([#​62592](https://redirect.github.com/ray-project/ray/issues/62592)) - Ensure consistent `nan_is_null`/nans-as-nulls semantics in encoder ([#​62623](https://redirect.github.com/ray-project/ray/issues/62623), [#​62618](https://redirect.github.com/ray-project/ray/issues/62618)) - Skip unconditional null strip in `find_partition_index` ([#​62594](https://redirect.github.com/ray-project/ray/issues/62594)) - V1 `_split_predicate_by_columns` correctness fix ([#​63176](https://redirect.github.com/ray-project/ray/issues/63176)) - Avoid importing cudf in `_is_cudf_dataframe` when cudf not loaded ([#​62302](https://redirect.github.com/ray-project/ray/issues/62302)) - Revert raw-modulo hash partition fast path ([#​63097](https://redirect.github.com/ray-project/ray/issues/63097)) - Remove `tfx-bsl` support from `read_tfrecords` ([#​63245](https://redirect.github.com/ray-project/ray/issues/63245)) ##### 📖 Documentation - Document `isolate_read_workers` for `read_parquet` ([#​63816](https://redirect.github.com/ray-project/ray/issues/63816)) - Remove docs recommending increased object store memory proportion ([#​63389](https://redirect.github.com/ray-project/ray/issues/63389)) - Update docs minimum version for `build_processor` and `"auto"` batch size ([#​61757](https://redirect.github.com/ray-project/ray/issues/61757), [#​62790](https://redirect.github.com/ray-project/ray/issues/62790)) - Remove outdated limitation of `DefaultClusterAutoscalerV2` and stale object-store-memory warnings ([#​62385](https://redirect.github.com/ray-project/ray/issues/62385), [#​62387](https://redirect.github.com/ray-project/ray/issues/62387)) ### Ray Serve ##### 🎉 New Features: - Add custom ingress request router app interfaces and HAProxy ingress dispatch path ([#​62680](https://redirect.github.com/ray-project/ray/issues/62680), [#​62668](https://redirect.github.com/ray-project/ray/issues/62668), [#​62669](https://redirect.github.com/ray-project/ray/issues/62669), [#​62667](https://redirect.github.com/ray-project/ray/issues/62667)) - Expose `choose_replica`/`dispatch` on deployment handles and `AsyncioRouter` with replica-side slot reservation ([#​63255](https://redirect.github.com/ray-project/ray/issues/63255), [#​63254](https://redirect.github.com/ray-project/ray/issues/63254), [#​63252](https://redirect.github.com/ray-project/ray/issues/63252)) - Introduce experimental round robin router and `ConsistentHashRouter` for session-sticky routing ([#​63238](https://redirect.github.com/ray-project/ray/issues/63238), [#​62906](https://redirect.github.com/ray-project/ray/issues/62906), [#​63096](https://redirect.github.com/ray-project/ray/issues/63096), [#​62905](https://redirect.github.com/ray-project/ray/issues/62905)) - Central capacity queue for token-based request routing via `CapacityQueueRouter` ([#​62323](https://redirect.github.com/ray-project/ray/issues/62323)) - Add experimental `ray-haproxy` support behind `RAY_SERVE_EXPERIMENTAL_PIP_HAPROXY` ([#​62589](https://redirect.github.com/ray-project/ray/issues/62589)) - Add deployment actor context API and broadcast API for deployment handles ([#​62532](https://redirect.github.com/ray-project/ray/issues/62532), [#​61472](https://redirect.github.com/ray-project/ray/issues/61472)) - Add `ControllerOptions` for configurable controller `runtime_env` ([#​63352](https://redirect.github.com/ray-project/ray/issues/63352)) - Make rolling update percentage configurable ([#​62160](https://redirect.github.com/ray-project/ray/issues/62160)) - Support per-request timeout and disconnect in HTTP proxy path ([#​62867](https://redirect.github.com/ray-project/ray/issues/62867)) ##### 💫 Enhancements: - HAProxy stability improvements: wait for old workers before drain, redirect stdout/stderr, redispatch+retry-on, coalesce broadcasts, quarantine released ports ([#​63620](https://redirect.github.com/ray-project/ray/issues/63620), [#​63621](https://redirect.github.com/ray-project/ray/issues/63621), [#​63622](https://redirect.github.com/ray-project/ray/issues/63622), [#​63623](https://redirect.github.com/ray-project/ray/issues/63623), [#​63628](https://redirect.github.com/ray-project/ray/issues/63628)) - Bind direct ingress ports to `0.0.0.0` for cross-node HAProxy routing ([#​62515](https://redirect.github.com/ray-project/ray/issues/62515)) - HAProxy ingress request router metrics, enable splice by default, `TCP_NODELAY` default 1, optional retry knobs, `RAY_SERVE_HAPROXY_STATS_PORT` ([#​63356](https://redirect.github.com/ray-project/ray/issues/63356), [#​63531](https://redirect.github.com/ray-project/ray/issues/63531), [#​63353](https://redirect.github.com/ray-project/ray/issues/63353), [#​63415](https://redirect.github.com/ray-project/ray/issues/63415), [#​62979](https://redirect.github.com/ray-project/ray/issues/62979)) - Resolve bundled ray-haproxy binary before `RAY_SERVE_HAPROXY_BINARY_PATH`; HAProxy abspath env var ([#​63829](https://redirect.github.com/ray-project/ray/issues/63829), [#​62610](https://redirect.github.com/ray-project/ray/issues/62610)) - Replace socat subprocess with Python socket for HAProxy admin communication; bump HAProxy to avoid CVE-2025-11230 ([#​61897](https://redirect.github.com/ray-project/ray/issues/61897), [#​62585](https://redirect.github.com/ray-project/ray/issues/62585)) - Expose controller health metrics via `/api/serve/applications/` API; add `max_replicas_per_node` to response ([#​63556](https://redirect.github.com/ray-project/ray/issues/63556), [#​63234](https://redirect.github.com/ray-project/ray/issues/63234)) - Run health check on user execution path to detect request-serving stalls ([#​61621](https://redirect.github.com/ray-project/ray/issues/61621)) - Mark widely-used APIs as stable ([#​62932](https://redirect.github.com/ray-project/ray/issues/62932)) - Retain recently-stopped replica logs in the dashboard ([#​63678](https://redirect.github.com/ray-project/ray/issues/63678)) - Add observability logs for pack scheduling decisions ([#​63603](https://redirect.github.com/ray-project/ray/issues/63603)) - Gate ingress request router body forwarding behind escape hatch ([#​63183](https://redirect.github.com/ray-project/ray/issues/63183)) - Avoid rolling replicas for no-op config overrides ([#​63034](https://redirect.github.com/ray-project/ray/issues/63034)) - Gate replica/deployment creation during shutdown ([#​62761](https://redirect.github.com/ray-project/ray/issues/62761)) - Defer PG creation for TPU Serve deployments to accelerator backend ([#​62941](https://redirect.github.com/ray-project/ray/issues/62941)) - Expose `DeploymentStateManager` APIs for controller access ([#​62950](https://redirect.github.com/ray-project/ray/issues/62950)) - Add tracing support for Windows and gRPC tracing improvements ([#​62821](https://redirect.github.com/ray-project/ray/issues/62821), [#​63833](https://redirect.github.com/ray-project/ray/issues/63833)) - Split node vs requested resources in deployment scheduler ([#​62778](https://redirect.github.com/ray-project/ray/issues/62778)) - Defer `DEPLOYMENT_TARGETS` broadcast while replicas are RECOVERING ([#​62751](https://redirect.github.com/ray-project/ray/issues/62751)) - Evict per-deployment `LongPollHost` state on deployment delete; enable logs when client stops its event loop ([#​62820](https://redirect.github.com/ray-project/ray/issues/62820), [#​63028](https://redirect.github.com/ray-project/ray/issues/63028)) - Add metrics: max replica processing latency, objref resolution latency, `serve_autoscaling_target_ongoing_requests` ([#​62381](https://redirect.github.com/ray-project/ray/issues/62381), [#​62355](https://redirect.github.com/ray-project/ray/issues/62355), [#​62421](https://redirect.github.com/ray-project/ray/issues/62421)) - Filter stale bootstrap observations from `serve_long_poll_latency_ms` ([#​62868](https://redirect.github.com/ray-project/ray/issues/62868)) - Retry `build_serve_application` task on failure ([#​62987](https://redirect.github.com/ray-project/ray/issues/62987)) - Scale down non-matching primary-label replicas first ([#​61488](https://redirect.github.com/ray-project/ray/issues/61488)) - Refactor internal autoscaling policy state extraction into a single helper ([#​62452](https://redirect.github.com/ray-project/ray/issues/62452)) - Catalog Ray Serve env vars ([#​62006](https://redirect.github.com/ray-project/ray/issues/62006)) - Remove or raise clear error for deprecated deployment items; remove deprecated `DeploymentMode` ([#​63548](https://redirect.github.com/ray-project/ray/issues/63548), [#​63510](https://redirect.github.com/ray-project/ray/issues/63510)) ##### 🔨 Fixes: - Fix orphaned actors on controller crash during shutdown; drop and replace replicas surviving a controller crash without rank assignment ([#​62823](https://redirect.github.com/ray-project/ray/issues/62823), [#​63139](https://redirect.github.com/ray-project/ray/issues/63139)) - Fix deployment actors creating 15K OS threads for sync actor classes ([#​62661](https://redirect.github.com/ray-project/ray/issues/62661)) - Fix gang scheduling PG leak when deployment actors are starting ([#​62469](https://redirect.github.com/ray-project/ray/issues/62469)) - Fix app-level autoscaling policy state cross-deployment contamination and state loss for skipped deployments ([#​62484](https://redirect.github.com/ray-project/ray/issues/62484)) - Fix Serve autoscaling delay to use wall-clock time ([#​62144](https://redirect.github.com/ray-project/ray/issues/62144)) - Fix race condition in multiplex LRU cache update using `move_to_end()` ([#​62548](https://redirect.github.com/ray-project/ray/issues/62548)) - Normalize multiplexed model ID header to support proxy-transformed names ([#​61869](https://redirect.github.com/ray-project/ray/issues/61869)) - Fix `AttributeError` when `request_router` is None in `update_deployment_config` ([#​63180](https://redirect.github.com/ray-project/ray/issues/63180)) - Fix potential `UnboundLocalError` in `ActorReplicaWrapper.check_stopped()` ([#​63339](https://redirect.github.com/ray-project/ray/issues/63339)) - Fail loud when ingress request router dispatch fails ([#​63215](https://redirect.github.com/ray-project/ray/issues/63215)) - Fix stale `_global_client` cache across driver sessions ([#​62368](https://redirect.github.com/ray-project/ray/issues/62368)) - Fix `start_metrics_pusher` crash when deployment has `record_autoscaling_stats` but no autoscaling config ([#​62123](https://redirect.github.com/ray-project/ray/issues/62123)) - Fix high-cardinality namespace tag on long poll metrics ([#​62386](https://redirect.github.com/ray-project/ray/issues/62386)) - Fix Java long poll timeout serialization ([#​61875](https://redirect.github.com/ray-project/ray/issues/61875)) - Avoid destructor error when FastAPI ingress init fails ([#​62172](https://redirect.github.com/ray-project/ray/issues/62172)) - Avoid proxy readiness future timeout race ([#​62194](https://redirect.github.com/ray-project/ray/issues/62194)) - Avoid self-cause on non-gRPC replica exceptions ([#​62412](https://redirect.github.com/ray-project/ray/issues/62412)) - Fix HAProxy startup timeout propagation ([#​61752](https://redirect.github.com/ray-project/ray/issues/61752)) - Include `ingress_request_router.lua.tmpl` in `package_data` ([#​63145](https://redirect.github.com/ray-project/ray/issues/63145)) - Revert support for `root_path` parameter across uvicorn versions ([#​62529](https://redirect.github.com/ray-project/ray/issues/62529)) ##### 📖 Documentation: - Add round robin and consistent hashing router documentation ([#​63636](https://redirect.github.com/ray-project/ray/issues/63636)) - Introduce gang scheduling documentation ([#​61737](https://redirect.github.com/ray-project/ray/issues/61737)) - Add deployment scope actor docs ([#​62735](https://redirect.github.com/ray-project/ray/issues/62735)) - Add Kuberay guide for RayService with HAProxy and High Throughput mode ([#​62408](https://redirect.github.com/ray-project/ray/issues/62408)) - Add Ray Serve office hours invite into documentation ([#​62176](https://redirect.github.com/ray-project/ray/issues/62176)) ### Ray Train ##### 🎉 New Features - Add `LoggingConfig` for configuring the `ray.train` logger on controller and workers ([#​61550](https://redirect.github.com/ray-project/ray/issues/61550)) - Allow `DataParallelTrainer`'s `train_fn` to return data ([#​62021](https://redirect.github.com/ray-project/ray/issues/62021)) - Add async checkpointing/validation with Torch Lightning ([#​62370](https://redirect.github.com/ray-project/ray/issues/62370)) ##### 💫 Enhancements - Report time spent syncing and transferring checkpoints to storage in `ray.train.report(checkpoint)` ([#​62027](https://redirect.github.com/ray-project/ray/issues/62027)) - Block until `create_or_update_train_run` completes on Train initialization ([#​63432](https://redirect.github.com/ray-project/ray/issues/63432)) - Implement `DatasetManager` ([#​63309](https://redirect.github.com/ray-project/ray/issues/63309)) - Forward `label_selector` to `AutoscalingCoordinator` ([#​63287](https://redirect.github.com/ray-project/ray/issues/63287)) - Add log line before launching training function ([#​62911](https://redirect.github.com/ray-project/ray/issues/62911)) - Allow `contextlib.redirect_stdout()` to bypass print redirect to logs ([#​61075](https://redirect.github.com/ray-project/ray/issues/61075)) - Add timeouts to validation functions of `ray.train.report` ([#​62916](https://redirect.github.com/ray-project/ray/issues/62916)) - `ray.train.report` does not hang across replica group restarts; Ray Train manages replica group restarts ([#​62651](https://redirect.github.com/ray-project/ray/issues/62651), [#​61475](https://redirect.github.com/ray-project/ray/issues/61475)) - Swallow `RayTaskError` during `BackendSetupCallback` shutdown ([#​63143](https://redirect.github.com/ray-project/ray/issues/63143)) - Improve `JaxTrainer` TPU multi-slice fault tolerance and reservation ergonomics ([#​62893](https://redirect.github.com/ray-project/ray/issues/62893)) - Export default data execution options ([#​62784](https://redirect.github.com/ray-project/ray/issues/62784)) - Consolidate Train run metadata sanitization and improve readability ([#​63182](https://redirect.github.com/ray-project/ray/issues/63182)) - Fix `PlacementGroupCleaner` race condition: drain queue before cleanup on controller death ([#​62754](https://redirect.github.com/ray-project/ray/issues/62754)) - Harden against unsafe pickle deserialization ([#​62807](https://redirect.github.com/ray-project/ray/issues/62807)) - Raise error when checkpoint is within experiment directory and `delete_local_checkpoint_after_upload=True` ([#​62555](https://redirect.github.com/ray-project/ray/issues/62555)) - Add `timeout_s` to `ray.train.get_all_reported_checkpoints` ([#​61761](https://redirect.github.com/ray-project/ray/issues/61761)) - Change remaining `pytorch_lightning` imports ([#​61291](https://redirect.github.com/ray-project/ray/issues/61291)) - Make controller resilient to errors in all lifecycle hooks ([#​60900](https://redirect.github.com/ray-project/ray/issues/60900)) - Remove `Predictor` from Train v1 ([#​63461](https://redirect.github.com/ray-project/ray/issues/63461)) ##### 🔨 Fixes - Fix missing comma in `DataBatchType` Union type ([#​63872](https://redirect.github.com/ray-project/ray/issues/63872)) - Handle Arrow-backed pandas dtypes in LightGBM examples ([#​63427](https://redirect.github.com/ray-project/ray/issues/63427)) - Fix `exclude_resources` regression for V1 Train + V2 cluster autoscaler ([#​62827](https://redirect.github.com/ray-project/ray/issues/62827)) - Add missing `%s` to `logger.debug` ([#​63039](https://redirect.github.com/ray-project/ray/issues/63039)) - Increase `get_actor` timeout ([#​62516](https://redirect.github.com/ray-project/ray/issues/62516)) ##### 📖 Documentation - Document S3-compatible storage ([#​63103](https://redirect.github.com/ray-project/ray/issues/63103)) - Add Azure Files to persistent storage docs ([#​63406](https://redirect.github.com/ray-project/ray/issues/63406)) - Uncomment `Result.from_path` in docs ([#​62887](https://redirect.github.com/ray-project/ray/issues/62887)) - Document how to tune async validation ([#​62227](https://redirect.github.com/ray-project/ray/issues/62227)) - Document why validation runs need unique names ([#​62224](https://redirect.github.com/ray-project/ray/issues/62224)) ### Ray Tune ##### 💫 Enhancements - Fix Tune search for Python 3.14 ([#​63575](https://redirect.github.com/ray-project/ray/issues/63575)) - Modernize `AxSearch` for Ax Platform 1.0.0+ ([#​60522](https://redirect.github.com/ray-project/ray/issues/60522)) - Use built-in `inspect` for argument capture ([#​60049](https://redirect.github.com/ray-project/ray/issues/60049)) ##### 🔨 Fixes - Fix import count in CIFAR PyTorch tutorial ([#​62756](https://redirect.github.com/ray-project/ray/issues/62756)) ### Ray LLM ##### 🎉 New Features - Major Ray Serve LLM performance improvement with direct streaming ([#​63167](https://redirect.github.com/ray-project/ray/issues/63167), [#​63468](https://redirect.github.com/ray-project/ray/issues/63468), [#​63779](https://redirect.github.com/ray-project/ray/issues/63779)) - TPU support: Add `topology` field to `LLMConfig` for multi-host TPU support ([#​61906](https://redirect.github.com/ray-project/ray/issues/61906)) - Add per-host bundles default and fix fractional TPUs for `TPUAccelerator` ([#​63177](https://redirect.github.com/ray-project/ray/issues/63177)) - Enable Ray Serve LLM session-stickiness routing policy via `RAY_SERVE_SESSION_ID_HEADER_KEY` ([#​63362](https://redirect.github.com/ray-project/ray/issues/63362)) ##### 💫 Enhancements - Upgrade `vLLM` to 0.22.0 ([#​63730](https://redirect.github.com/ray-project/ray/issues/63730), [#​63396](https://redirect.github.com/ray-project/ray/issues/63396), [#​62970](https://redirect.github.com/ray-project/ray/issues/62970), [#​62349](https://redirect.github.com/ray-project/ray/issues/62349)) - Co-locate DP rank 0 worker with advertised master address ([#​63803](https://redirect.github.com/ray-project/ray/issues/63803)) - Add pick-only fast path to `AsyncioRouter` for LLM ingress ([#​63517](https://redirect.github.com/ray-project/ray/issues/63517)) - Replace LLM ingress router replica selection with `choose_replica`; don't fetch `LLMConfig` from replicas at startup ([#​63280](https://redirect.github.com/ray-project/ray/issues/63280), [#​63065](https://redirect.github.com/ray-project/ray/issues/63065)) - Promote `max_tasks_in_flight_per_actor` to a first-class config field and adjust defaults ([#​63214](https://redirect.github.com/ray-project/ray/issues/63214)) - Validate `accelerator_type` against CPU-only configs; replace `GPUType` alias with `AcceleratorType` ([#​62139](https://redirect.github.com/ray-project/ray/issues/62139), [#​62978](https://redirect.github.com/ray-project/ray/issues/62978)) - Add rate-limiter for per-request traceback spam ([#​62440](https://redirect.github.com/ray-project/ray/issues/62440)) - Promote SGLang integration to user guide and move engine to `_internal` ([#​62570](https://redirect.github.com/ray-project/ray/issues/62570)) - Lazy-load batch stage/processor submodules and make boto3/botocore imports lazy ([#​62861](https://redirect.github.com/ray-project/ray/issues/62861), [#​62383](https://redirect.github.com/ray-project/ray/issues/62383)) - LLM telemetry bugfixes ([#​63782](https://redirect.github.com/ray-project/ray/issues/63782)) ##### 🔨 Fixes - Fix flaky GPU-0 worker and NIXL port collisions ([#​63810](https://redirect.github.com/ray-project/ray/issues/63810)) - Fix P/D direct streaming OpenAI routing ([#​63679](https://redirect.github.com/ray-project/ray/issues/63679)) - Remove `guided_decoding`, `truncate_prompt_tokens`, `build_llm_processor` ([#​63569](https://redirect.github.com/ray-project/ray/issues/63569)) - Fix misleading `ImportError` when `vLLM` is installed but fails to import ([#​63305](https://redirect.github.com/ray-project/ray/issues/63305)) - Fix `max_pending_requests` default to track `vLLM`'s GPU-dependent `max_num_seqs` ([#​62918](https://redirect.github.com/ray-project/ray/issues/62918)) - Fix HF config loading for models with custom `rope_scaling` ([#​62464](https://redirect.github.com/ray-project/ray/issues/62464)) - Wait for request router init in `LLMRouter` constructor ([#​63206](https://redirect.github.com/ray-project/ray/issues/63206)) - Materialize chat completion message `content` in sanitizer ([#​63119](https://redirect.github.com/ray-project/ray/issues/63119)) - Fix `lora_request` not forwarded to `vLLM` engine + add regression tests ([#​62609](https://redirect.github.com/ray-project/ray/issues/62609)) - Fix `SGLangEngineProcessor` telemetry for `trust_remote_code` models ([#​62102](https://redirect.github.com/ray-project/ray/issues/62102)) - Fix `TOKENIZER_ONLY` downloads missing `chat_template` for S3-backed models ([#​62121](https://redirect.github.com/ray-project/ray/issues/62121)) - Fix SGLang chat tokenize to respect `add_generation_prompt` ([#​61688](https://redirect.github.com/ray-project/ray/issues/61688)) - Fix bool serialization in `benchmark_vllm` CLI builder ([#​63516](https://redirect.github.com/ray-project/ray/issues/63516)) ##### 📖 Documentation - Document multimodal pixel-budget gotchas and `vLLM` compatibility ([#​63593](https://redirect.github.com/ray-project/ray/issues/63593)) - Add tokenization disaggregation documentation ([#​62494](https://redirect.github.com/ray-project/ray/issues/62494)) - Add benchmark docs and refactor into submodules ([#​62204](https://redirect.github.com/ray-project/ray/issues/62204)) - Remove `VLLM_USE_V1` from docs and examples ([#​63001](https://redirect.github.com/ray-project/ray/issues/63001)) - Fix wrong documented default for `max_tasks_in_flight_per_actor` ([#​62917](https://redirect.github.com/ray-project/ray/issues/62917)) ### Ray RLlib ##### 🎉 New Features - Add `custom_resources_per_learner` config and `custom_resources_for_main_process` to `AlgorithmConfig` ([#​63303](https://redirect.github.com/ray-project/ray/issues/63303), [#​62475](https://redirect.github.com/ray-project/ray/issues/62475)) - Add Importance Sampling `APPO` metrics to the torch learner ([#​63675](https://redirect.github.com/ray-project/ray/issues/63675)) ##### 💫 Enhancements - Put only one copy of weights into the object store ([#​63529](https://redirect.github.com/ray-project/ray/issues/63529)) - Handle the all-evaluation-workers-unhealthy case uniformly across modes ([#​63128](https://redirect.github.com/ray-project/ray/issues/63128)) - Stop `IMPALA`/`APPO` learner thread gracefully to avoid misleading error messages ([#​62763](https://redirect.github.com/ray-project/ray/issues/62763)) - Improve invalid input error messages ([#​62324](https://redirect.github.com/ray-project/ray/issues/62324)) ##### 🔨 Fixes - Fix two substantial edge cases in `PPO`'s value target calculation ([#​59958](https://redirect.github.com/ray-project/ray/issues/59958)) - Fix `EnvRunner` crash loops ([#​62884](https://redirect.github.com/ray-project/ray/issues/62884)) - Fix extra model outputs hanging val indexing ([#​62960](https://redirect.github.com/ray-project/ray/issues/62960)) - Fix `ValueError` in `MultiAgentEpisode.get_rewards()` when an agent is inactive for all requested env steps ([#​62907](https://redirect.github.com/ray-project/ray/issues/62907)) - Preserve Torch optimizer param-group scalar types on restore ([#​61937](https://redirect.github.com/ray-project/ray/issues/61937)) - Fix wrong assert variable in `_update_env_seed_if_necessary` ([#​61823](https://redirect.github.com/ray-project/ray/issues/61823)) - Maintain value in `EMAStat` ([#​63064](https://redirect.github.com/ray-project/ray/issues/63064)) ##### 📖 Documentation - Clarify extra model output docstrings ([#​63524](https://redirect.github.com/ray-project/ray/issues/63524)) ### Ray Core ##### 🎉 New Features - Add support for Furiosa AI NPU ([#​63035](https://redirect.github.com/ray-project/ray/issues/63035)) and `register_collective_backend` API for custom collective backends ([#​60701](https://redirect.github.com/ray-project/ray/issues/60701)) - In-place pod resizing (IPPR) on Kubernetes 1.35: initial implementation and standalone KubeRay IPPR provider ([#​55961](https://redirect.github.com/ray-project/ray/issues/55961), [#​62369](https://redirect.github.com/ray-project/ray/issues/62369), [#​62215](https://redirect.github.com/ray-project/ray/issues/62215)) - Label locality support: GPU-domain-aware placement groups, autoscaler proto changes, and state API observability ([#​61442](https://redirect.github.com/ray-project/ray/issues/61442), [#​61614](https://redirect.github.com/ray-project/ray/issues/61614), [#​62487](https://redirect.github.com/ray-project/ray/issues/62487), [#​62533](https://redirect.github.com/ray-project/ray/issues/62533)) - Publish platform events via Ray Event Recorder and support single-event emission in the Python layer ([#​63329](https://redirect.github.com/ray-project/ray/issues/63329), [#​60858](https://redirect.github.com/ray-project/ray/issues/60858)) - Autoscaler v2: priority-based worker group selection ([#​62997](https://redirect.github.com/ray-project/ray/issues/62997)) and `noDriverTimeoutSeconds` for KubeRay cluster termination ([#​63465](https://redirect.github.com/ray-project/ray/issues/63465)) - RDT: concurrent one-sided transfers for multiple `ObjectRef`s in `ray.get` ([#​61773](https://redirect.github.com/ray-project/ray/issues/61773)), retry support ([#​62842](https://redirect.github.com/ray-project/ray/issues/62842)), and NIXL memory deregistration via `deregister_nixl_memory` ([#​62341](https://redirect.github.com/ray-project/ray/issues/62341)) - Support `.tar.gz` archives for remote `working_dir` URIs ([#​62813](https://redirect.github.com/ray-project/ray/issues/62813)) - Add IPv6 localhost and all-interfaces support ([#​60023](https://redirect.github.com/ray-project/ray/issues/60023)) ##### 💫 Enhancements - Resource isolation: event-based memory monitor, multi-memory-monitor factory, time-based group killing policy, idle-worker prioritization, system/user slice bounds, and OOM policy tuning ([#​62060](https://redirect.github.com/ray-project/ray/issues/62060), [#​62705](https://redirect.github.com/ray-project/ray/issues/62705), [#​62643](https://redirect.github.com/ray-project/ray/issues/62643), [#​62378](https://redirect.github.com/ray-project/ray/issues/62378), [#​62168](https://redirect.github.com/ray-project/ray/issues/62168), [#​63521](https://redirect.github.com/ray-project/ray/issues/63521), [#​63324](https://redirect.github.com/ray-project/ray/issues/63324), [#​63067](https://redirect.github.com/ray-project/ray/issues/63067), [#​62957](https://redirect.github.com/ray-project/ray/issues/62957)) - Compute per-component memory usage in MiB ([#​63932](https://redirect.github.com/ray-project/ray/issues/63932)) and add host vs container memory distinction to memory panels ([#​63111](https://redirect.github.com/ray-project/ray/issues/63111)) - Consider cgroup limit when fetching CPU ([#​63685](https://redirect.github.com/ray-project/ray/issues/63685)) and correct worker OOM score adjustment logic ([#​62470](https://redirect.github.com/ray-project/ray/issues/62470)) - Replace `NodeAffinitySchedulingStrategy` with Label Selector API when `soft=False` ([#​54940](https://redirect.github.com/ray-project/ray/issues/54940)) - Improve `SlicePlacementGroup` lifecycle and support explicit `bundle_label_selector` for TPUs ([#​63171](https://redirect.github.com/ray-project/ray/issues/63171)); add TPU head resource for Ironwood TPU ([#​62786](https://redirect.github.com/ray-project/ray/issues/62786)), `chips_per_vm` arg ([#​62526](https://redirect.github.com/ray-project/ray/issues/62526)), and v6e single-host fixes ([#​62306](https://redirect.github.com/ray-project/ray/issues/62306)) - Batch placement group bundle removal RPCs ([#​63839](https://redirect.github.com/ray-project/ray/issues/63839)); remove PG resource deduction from GCS in favor of resource broadcast ([#​63723](https://redirect.github.com/ray-project/ray/issues/63723)) - Migrate Raylet/GCS timing logic to a shared `ClockInterface` with a fake clock for testing ([#​62562](https://redirect.github.com/ray-project/ray/issues/62562), [#​62502](https://redirect.github.com/ray-project/ray/issues/62502), [#​62476](https://redirect.github.com/ray-project/ray/issues/62476)) - Refactor asio build targets and add `IOContextMonitor`; run GCS health check on `io_service` ([#​63042](https://redirect.github.com/ray-project/ray/issues/63042), [#​63166](https://redirect.github.com/ray-project/ray/issues/63166), [#​62608](https://redirect.github.com/ray-project/ray/issues/62608), [#​62374](https://redirect.github.com/ray-project/ray/issues/62374)) - Autoscaler v2 performance: skip serializations for debug logs ([#​63778](https://redirect.github.com/ray-project/ray/issues/63778)); accept fractional resource values in `request_resources` ([#​63306](https://redirect.github.com/ray-project/ray/issues/63306)) - Reduce traffic: halve task arg pubsub by skipping redundant raylet pull ([#​62583](https://redirect.github.com/ray-project/ray/issues/62583)), avoid extra memcpy when spilling fused objects ([#​63653](https://redirect.github.com/ray-project/ray/issues/63653)), and resolve task dependencies synchronously when objects exist ([#​62561](https://redirect.github.com/ray-project/ray/issues/62561)) - Improve `inspect_serializability` messages and traversal context ([#​63501](https://redirect.github.com/ray-project/ray/issues/63501), [#​63373](https://redirect.github.com/ray-project/ray/issues/63373), [#​63258](https://redirect.github.com/ray-project/ray/issues/63258)); better worker startup error messages ([#​63714](https://redirect.github.com/ray-project/ray/issues/63714)) - Warn when `runtime_env` package approaches upload size limit ([#​63404](https://redirect.github.com/ray-project/ray/issues/63404)); harden zip extraction path containment ([#​63786](https://redirect.github.com/ray-project/ray/issues/63786), [#​62813](https://redirect.github.com/ray-project/ray/issues/62813)) - Include owner node ID in `OwnerDiedError` ([#​63727](https://redirect.github.com/ray-project/ray/issues/63727)); add dependency info to taskspec debug string ([#​62316](https://redirect.github.com/ray-project/ray/issues/62316)) - Add unexpected worker failure metric and dashboard panel ([#​62297](https://redirect.github.com/ray-project/ray/issues/62297)); group observability APIs in `ray` CLI help ([#​62748](https://redirect.github.com/ray-project/ray/issues/62748)) - Normalize OTel metric labels before Prometheus export ([#​63744](https://redirect.github.com/ray-project/ray/issues/63744)) and retry/log when Prometheus queries fail ([#​63578](https://redirect.github.com/ray-project/ray/issues/63578)); add GPU usage instance filter ([#​62214](https://redirect.github.com/ray-project/ray/issues/62214)) - Move observability and control-plane pubsub to dedicated services and rename `InternalPubSub*` to `ControlPlanePubSub*` ([#​62806](https://redirect.github.com/ray-project/ray/issues/62806), [#​63044](https://redirect.github.com/ray-project/ray/issues/63044), [#​62461](https://redirect.github.com/ray-project/ray/issues/62461)) - AMD GPU: replace `rocm-smi` ctypes binding with `amd-smi` Python interface ([#​62393](https://redirect.github.com/ray-project/ray/issues/62393)); detect NVIDIA Blackwell consumer GPUs ([#​63322](https://redirect.github.com/ray-project/ray/issues/63322)) - Add task retry delay for `ACTOR_UNAVAILABLE` retries ([#​62330](https://redirect.github.com/ray-project/ray/issues/62330)); improve State API filter key handling ([#​63638](https://redirect.github.com/ray-project/ray/issues/63638)) - Patch `setproctitle` to skip launch services IPC calls ([#​63366](https://redirect.github.com/ray-project/ray/issues/63366)); add timeout for first redis probe ([#​63148](https://redirect.github.com/ray-project/ray/issues/63148)) - Clarify head node commands in `ray up` output ([#​63409](https://redirect.github.com/ray-project/ray/issues/63409)); pass `logging_config` through Ray Client `ray.init` ([#​62192](https://redirect.github.com/ray-project/ray/issues/62192)) - Print subprocess log tails with exit codes on unexpected exit ([#​61905](https://redirect.github.com/ray-project/ray/issues/61905)); add warning log when GPU profiling command times out ([#​63706](https://redirect.github.com/ray-project/ray/issues/63706)) - Add unique suffix to log filenames ([#​62365](https://redirect.github.com/ray-project/ray/issues/62365)); disable profiling endpoints by default ([#​62531](https://redirect.github.com/ray-project/ray/issues/62531)) - Remove pydantic v1 support ([#​62716](https://redirect.github.com/ray-project/ray/issues/62716)); update Starlette to v1.0.1 ([#​63722](https://redirect.github.com/ray-project/ray/issues/63722)) - Deprecate `DAGNode.execute()` ([#​63716](https://redirect.github.com/ray-project/ray/issues/63716)); remove experimental `_owner` support for `ray.put` ([#​63520](https://redirect.github.com/ray-project/ray/issues/63520)) ##### 🔨 Fixes - Fix `ray.get` hanging forever when an object's owner dies during pull ([#​63694](https://redirect.github.com/ray-project/ray/issues/63694)); resolve `ReferenceCounter` race on `WORKER_REF_REMOVED_CHANNEL` ([#​60495](https://redirect.github.com/ray-project/ray/issues/60495)) - Fix resource leaks in subprocess management ([#​63878](https://redirect.github.com/ray-project/ray/issues/63878)) and `runtime_env` cache not detecting changes in `-r`-referenced requirements files ([#​63403](https://redirect.github.com/ray-project/ray/issues/63403)) - Fix replica actor zombie process after GCS restart ([#​63764](https://redirect.github.com/ray-project/ray/issues/63764)); fix actor creation race condition ([#​62994](https://redirect.github.com/ray-project/ray/issues/62994)); fix actor state counter bug ([#​63647](https://redirect.github.com/ray-project/ray/issues/63647)) - Fix placement groups with label domain stuck on the infeasible queue ([#​62483](https://redirect.github.com/ray-project/ray/issues/62483)); log status for failed PG `PrepareResources`/`CommitResources` ([#​62836](https://redirect.github.com/ray-project/ray/issues/62836)) - Fix env var expansion in `ray job submit` CLI via `shlex.join` ([#​63797](https://redirect.github.com/ray-project/ray/issues/63797)) and `--working-dir` for local zip files and `http://` URLs ([#​62843](https://redirect.github.com/ray-project/ray/issues/62843)) - Surface WebSocket close codes and errors in job log streaming ([#​63364](https://redirect.github.com/ray-project/ray/issues/63364)); fix `ray stop` failing to terminate dashboard/runtime\_env agents on Windows ([#​62428](https://redirect.github.com/ray-project/ray/issues/62428)) - Fix `ray down` not stopping Docker containers on worker nodes for local clusters ([#​62169](https://redirect.github.com/ray-project/ray/issues/62169)); fix delayed/missing worker logs in Jupyter by flushing stdout/stderr ([#​63599](https://redirect.github.com/ray-project/ray/issues/63599)) - Fix Python log monitor handling for same-inode truncated files ([#​63720](https://redirect.github.com/ray-project/ray/issues/63720)); avoid `os.getcwd()` on import by lazily evaluating `scratch_dir` ([#​63040](https://redirect.github.com/ray-project/ray/issues/63040)) - Fix accelerator detection on NVIDIA Blackwell consumer GPUs ([#​63322](https://redirect.github.com/ray-project/ray/issues/63322)); avoid `FabricManager` stall on NVLink systems in `GpuProfilingManager` ([#​63312](https://redirect.github.com/ray-project/ray/issues/63312)) - Fix POSIX semaphore crash in experimental mutable objects ([#​62328](https://redirect.github.com/ray-project/ray/issues/62328)); fix overflow on exponential backoff multiplication ([#​62366](https://redirect.github.com/ray-project/ray/issues/62366)) - Fix OOM kill message wrong threshold with resource isolation ([#​62948](https://redirect.github.com/ray-project/ray/issues/62948)); fix `OpenTelemetryMetricRecorder` singleton init guard ([#​63081](https://redirect.github.com/ray-project/ray/issues/63081)) - Fix `MarkFootprintAsBusy` clearing saved idle state for unrelated items ([#​62588](https://redirect.github.com/ray-project/ray/issues/62588)); fix `HandleIsLocalWorkerDead` for drivers ([#​62688](https://redirect.github.com/ray-project/ray/issues/62688)) - Fix `AttributeError` on `trace` in client mode ([#​62955](https://redirect.github.com/ray-project/ray/issues/62955)); fix `IndexError` in legacy post-mortem debugging ([#&# > ✂ **Note** > > PR body was truncated to here.
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/vortex-data/vortex). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- uv.lock | 98 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/uv.lock b/uv.lock index 8748a91544e..d74f7b0cf4e 100644 --- a/uv.lock +++ b/uv.lock @@ -1270,7 +1270,7 @@ wheels = [ [[package]] name = "ray" -version = "2.55.1" +version = "2.56.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1283,20 +1283,20 @@ dependencies = [ { name = "requests" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/88/7d/48ba2f49b40a34b0071ee27c0144a2573d8836094eaca213d59cef12c271/ray-2.55.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0053fd5b400f7ac56263aa1bbd3d68fb79341b08b8dc697c88782d5aca7b3ed4", size = 65835271, upload-time = "2026-04-22T20:09:34.984Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a3/d6db3a428e4ea17cc72e79f747cfe11e90e63e36e1705bb8324e45f334b7/ray-2.55.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:0ea2f670a7725833ad2333a8c46ab69865ad06c8e5de9f65695e0f8f35331cec", size = 72879783, upload-time = "2026-04-22T20:09:40.986Z" }, - { url = "https://files.pythonhosted.org/packages/46/59/41da0e72a59cd3e8978480ccfeb86ef4235ae5ceb9b8928168a764fa930a/ray-2.55.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:d5382da181c03ee2f502ef46cf0ae4bbc30157b5bd9a67d7651f6a272528a85a", size = 73706515, upload-time = "2026-04-22T20:09:47.079Z" }, - { url = "https://files.pythonhosted.org/packages/65/52/c16bbdc3e31a5178f97be88966ab56db6f7e04882640c5cf2fee5b87757b/ray-2.55.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e56d2e8f304cafe990c198a2b894f5b813de018998cd7212869201f6dc17cff", size = 27882093, upload-time = "2026-04-22T20:09:52.943Z" }, - { url = "https://files.pythonhosted.org/packages/ac/3a/4d34f471a68b958b7f94c974c19ad6836a61a2dc16393df4294169a2e4b0/ray-2.55.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:137f9006eee28caab8260803cca314f37bbda3fc94fdfa31c770b5d019626ad8", size = 65822379, upload-time = "2026-04-22T20:09:58.064Z" }, - { url = "https://files.pythonhosted.org/packages/f1/13/0db535102d0256b350ca116d8987588aca1a1f9ebb4638e1e1ff88bbcef8/ray-2.55.1-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:26541f69bb55607ef8335baac75b2ed12ff2ce02d56313219b29eda003039221", size = 72910802, upload-time = "2026-04-22T20:10:04.382Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f8/fffadf3f4285eebd460e4d7f2ed1c0cd641ed89613c3f49eb881ee9fa7e2/ray-2.55.1-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:263705f6bab29e7622a94f82da25fd7f9cead76cdf89a07aab28f79cdf8f9d95", size = 73765203, upload-time = "2026-04-22T20:10:10.495Z" }, - { url = "https://files.pythonhosted.org/packages/10/f7/5acb86fc9625a0e6bbc40e1c7d42c60770e78585439a921c32738b6d675a/ray-2.55.1-cp312-cp312-win_amd64.whl", hash = "sha256:9ad56704c8bd7e92130162f9c58e4ef473609515637673d5a36e761f95335206", size = 27865547, upload-time = "2026-04-22T20:10:15.364Z" }, - { url = "https://files.pythonhosted.org/packages/d5/95/898699cc1a6a5f304ea95376d079843b5c05f4c8c1ec7e55a5cc7ffcea50/ray-2.55.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:f9844a9272ef2e6eb5771025866072cf4234cf4c7cc1a31e235b7de7111864be", size = 65766823, upload-time = "2026-04-22T20:10:20.786Z" }, - { url = "https://files.pythonhosted.org/packages/c9/13/87deecc090c672e45a0cf6f5eef511de448b93f37ef18fd10eb8e8557a0d/ray-2.55.1-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:b415d590e062f248907e0fe42994943f11726b7178fcf4b1cf5546721fb1a5f8", size = 72818676, upload-time = "2026-04-22T20:10:26.705Z" }, - { url = "https://files.pythonhosted.org/packages/71/d7/fc95d3b8824c62105c64aa1b59c59600b581f608d78a2af753e010936dc9/ray-2.55.1-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:1380e043eb57cde69b7e9199c6f2558ceeb8f0fc41c97d1d5e50ea042115f302", size = 73678908, upload-time = "2026-04-22T20:10:32.795Z" }, - { url = "https://files.pythonhosted.org/packages/a9/03/7e552325572e067b23a4584bda8dc6a67af8bd7e03c424d2610bfa93112d/ray-2.55.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:b062045c64c2bce39a51661624f7292c7bbf30f2a9d878627aae31d46da5712d", size = 65774106, upload-time = "2026-04-22T20:10:39.885Z" }, - { url = "https://files.pythonhosted.org/packages/94/62/607a8859520ce350861425f11f8e15d66c15ee33e6aac812f9e2889b5df4/ray-2.55.1-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:4e618d61e1b14b6fde9a586151f3fd9d435b0b85048b997bcaa7f4a533747b2b", size = 72814044, upload-time = "2026-04-22T20:10:46.985Z" }, - { url = "https://files.pythonhosted.org/packages/04/5a/0699bef04a72d7dc54462960d07ef7a19cd8b1e09979880aba2b6d13cca2/ray-2.55.1-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:156ed3e72ad95b645d2006cd71a8dddbcc89b56bfc00027f6225adf78bd9cb74", size = 73644244, upload-time = "2026-04-22T20:10:52.973Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f2/2cbbbef7a67adc6555b141c7a67557bd7ce21cfb85b85772627c6a582e97/ray-2.56.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:a9ad4e26941eb2f8dbd494ad07f9f2227143164c6114132b26b23ad4f20b1c6f", size = 66354212, upload-time = "2026-06-29T20:50:37.148Z" }, + { url = "https://files.pythonhosted.org/packages/d6/69/09c8320519aa4d075bd6ecb1694c28d26b5b07a5b46050703d9ac2c5a9b6/ray-2.56.0-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:aea655831d25084cb343002a8e67a77b6aa552ddb776a65461d49f62884f096a", size = 73299499, upload-time = "2026-06-29T20:50:42.584Z" }, + { url = "https://files.pythonhosted.org/packages/18/d3/84df370c773a0b853abdba498a6258106cb928d452ff56eec7f86c2f80b4/ray-2.56.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:c5bf1a4384c0e2aa4420c95474b734d064cac354b0f00760be02d886afe96ca4", size = 74143858, upload-time = "2026-06-29T20:50:48.228Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1b/0b4c96879ded375d0b35441c6b3633ccbea7c7c5448335589811fe5f275c/ray-2.56.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e57781685bb4332edf8a7cfb1135ff53d50002b6a0504006e68365bcd26aa7c", size = 28390306, upload-time = "2026-06-29T20:50:53.841Z" }, + { url = "https://files.pythonhosted.org/packages/05/d2/83777f52c10e04654c162bfa093f4e34cf6decf7a0bb4311e2432bfa8ecb/ray-2.56.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:684a427c50745989e92a332343f0812c93b8506f71c768b95b1eefc113492699", size = 66344824, upload-time = "2026-06-29T20:50:58.367Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b2/d610ffe2878dae8ab903cfdded883a054c102b0104670bca7b34b662db51/ray-2.56.0-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:e1fd03c6ecc5fe4c31466569e41ce0a4faf26fb930798c9d1f1eb1f405a687c8", size = 73317367, upload-time = "2026-06-29T20:51:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/8d442116f41e6bebec418d89f1556c67c77d593b55cf59e716503e7a4a17/ray-2.56.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:78ef34a71383c1fcf335e531e0e590867857fce9069f06ed351be6ce7a58fc50", size = 74192607, upload-time = "2026-06-29T20:51:09.528Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9e/cac7454325c79eb32f36f233285d9179858c7073bc27ac91afb74854ff12/ray-2.56.0-cp312-cp312-win_amd64.whl", hash = "sha256:a8fc809dab6fc07cf05d45ea93a776c852c990512eb1fac0e3d15819fe6df10a", size = 28370978, upload-time = "2026-06-29T20:51:14.168Z" }, + { url = "https://files.pythonhosted.org/packages/65/86/29c9444c9f11f0bf3d4da75d3dc7a27c0fb86f8604beea6513968dc2df26/ray-2.56.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:992047f50473b5bfea74c8f528f999968e0b4bc735af23ad476a0f4e04741aea", size = 66289930, upload-time = "2026-06-29T20:51:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/59/59/3c4d533a92e99b12b9d2e8690e41d7ace269d77deecb0aef753df7924b9e/ray-2.56.0-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:a0e9cfe92c88ab74abca23923c15a592f49bc7617ffdda4190daca7785a7c4f6", size = 73252460, upload-time = "2026-06-29T20:51:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/59/ba/285d409253b332af91884daa4b0a3be3ce799682aa2c1e0f8dbbafaf1da4/ray-2.56.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:54d2725f8b65d9615c933fec5ec62e54a67b8f2e14286026fb014606253670ef", size = 74130685, upload-time = "2026-06-29T20:51:31.59Z" }, + { url = "https://files.pythonhosted.org/packages/82/a0/07a00091cf002662946ed2a16fe4b27c80be6fce7fc831e41c7e1471e2f7/ray-2.56.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:f38e03b77c53e3d94091aedb84b14efe7ee5b581d85b7d13925066bcd48c44a2", size = 66298834, upload-time = "2026-06-29T20:51:36.82Z" }, + { url = "https://files.pythonhosted.org/packages/30/3c/2819cc8b40bf4034ff871c18d04a0422b7b0a51b32260c6f03a06460aaa5/ray-2.56.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:c3a16d43d75283a3d64fa1d904a3adaf3f526f3f508f447505b8bb8dc70bad6c", size = 73242922, upload-time = "2026-06-29T20:51:42.467Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5b/33ac5616706d1bc0bd40e22baf12acccd687b505f85d107f549355379d6f/ray-2.56.0-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:73edb6fb5fd05481b1f358ac2e8a4c7f6a031d89f9b823d25a587ddb7529070f", size = 74099117, upload-time = "2026-06-29T20:51:48.19Z" }, ] [[package]] @@ -1546,23 +1546,23 @@ resolution-markers = [ "python_full_version < '3.12'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version < '3.12'" }, - { name = "babel", marker = "python_full_version < '3.12'" }, - { name = "colorama", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version < '3.12'" }, - { name = "imagesize", marker = "python_full_version < '3.12'" }, - { name = "jinja2", marker = "python_full_version < '3.12'" }, - { name = "packaging", marker = "python_full_version < '3.12'" }, - { name = "pygments", marker = "python_full_version < '3.12'" }, - { name = "requests", marker = "python_full_version < '3.12'" }, - { name = "roman-numerals", marker = "python_full_version < '3.12'" }, - { name = "snowballstemmer", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.12'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.12'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } wheels = [ @@ -1577,23 +1577,23 @@ resolution-markers = [ "python_full_version >= '3.12'", ] dependencies = [ - { name = "alabaster", marker = "python_full_version >= '3.12'" }, - { name = "babel", marker = "python_full_version >= '3.12'" }, - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, - { name = "docutils", marker = "python_full_version >= '3.12'" }, - { name = "imagesize", marker = "python_full_version >= '3.12'" }, - { name = "jinja2", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pygments", marker = "python_full_version >= '3.12'" }, - { name = "requests", marker = "python_full_version >= '3.12'" }, - { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, - { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, - { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "roman-numerals" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { name = "sphinxcontrib-qthelp" }, + { name = "sphinxcontrib-serializinghtml" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } wheels = [ From a6513a0a28a0ab16cb4d62eda9635b186b771caf Mon Sep 17 00:00:00 2001 From: Thomas Santerre Date: Mon, 13 Jul 2026 08:49:29 -0400 Subject: [PATCH 064/104] fix(layout/dict): probe with the configured compressor instead of a hardcoded default (#8406) ## Summary Closes: #8405 `DictStrategy`'s dict-fit probe was hardcoded to `BtrBlocksCompressor::default()`, ignoring the caller's configured compressor. The probe now takes `stats_compressor` as a parameter to `DictStrategy::new`, so caller scheme exclusions are honoured. `stats_compressor` rather than `data_compressor` because the probe needs all dictionary schemes to detect eligibility. `data_compressor` excludes `IntDictScheme`. ## Testing New test `dict_probe_honours_configured_compressor`: asserts dict layout with the default builder, asserts no dict layout with `StringDictScheme` excluded. AI use disclosure: fix authored with assistance from Claude Code. --------- Signed-off-by: Thomas Santerre Signed-off-by: Onur Satici Co-authored-by: Onur Satici --- vortex-file/src/strategy.rs | 16 ++++- vortex-file/src/tests.rs | 82 ++++++++++++++++++++++++ vortex-layout/src/layouts/dict/reader.rs | 5 ++ vortex-layout/src/layouts/dict/writer.rs | 9 ++- 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/vortex-file/src/strategy.rs b/vortex-file/src/strategy.rs index 980082b5802..7db2b436a38 100644 --- a/vortex-file/src/strategy.rs +++ b/vortex-file/src/strategy.rs @@ -156,6 +156,7 @@ pub struct WriteStrategyBuilder { field_writers: HashMap>, allow_encodings: Option>, flat_strategy: Option>, + probe_compressor: Option>, } impl Default for WriteStrategyBuilder { @@ -168,6 +169,7 @@ impl Default for WriteStrategyBuilder { field_writers: HashMap::new(), allow_encodings: Some(ALLOWED_ENCODINGS.clone()), flat_strategy: None, + probe_compressor: None, } } } @@ -231,6 +233,12 @@ impl WriteStrategyBuilder { self } + /// Override the compressor used to probe whether a column is dict-eligible. + pub fn with_probe_compressor(mut self, compressor: C) -> Self { + self.probe_compressor = Some(Arc::new(compressor)); + self + } + /// Builds the canonical [`LayoutStrategy`] implementation, with the configured overrides /// applied. pub fn build(self) -> Arc { @@ -284,14 +292,20 @@ impl WriteStrategyBuilder { CompressorConfig::BtrBlocks(builder) => Arc::new(builder.build()), CompressorConfig::Opaque(compressor) => compressor, }; - let compress_then_flat = CompressingStrategy::new(flat, stats_compressor); + let compress_then_flat = CompressingStrategy::new(flat, Arc::clone(&stats_compressor)); // 3. apply dict encoding or fallback + let probe_compressor = if let Some(probe_compressor) = self.probe_compressor { + probe_compressor + } else { + Arc::clone(&stats_compressor) + }; let dict = DictStrategy::new( coalescing.clone(), compress_then_flat.clone(), coalescing, Default::default(), + probe_compressor, ); // 2. calculate stats for each row group diff --git a/vortex-file/src/tests.rs b/vortex-file/src/tests.rs index 7ceb7f21ba9..ea7f8cbeda5 100644 --- a/vortex-file/src/tests.rs +++ b/vortex-file/src/tests.rs @@ -58,6 +58,9 @@ use vortex_array::stats::PRUNING_STATS; use vortex_array::stream::ArrayStreamAdapter; use vortex_array::stream::ArrayStreamExt; use vortex_array::validity::Validity; +use vortex_btrblocks::BtrBlocksCompressorBuilder; +use vortex_btrblocks::SchemeExt; +use vortex_btrblocks::schemes::string::StringDictScheme; use vortex_buffer::Buffer; use vortex_buffer::ByteBufferMut; use vortex_buffer::buffer; @@ -1817,6 +1820,16 @@ fn assert_offsets_ordered(before: &[u64], after: &[u64], context: &str) { } } +/// Whether any node in the layout tree is a dict layout. +fn layout_has_dict(layout: &dyn Layout) -> bool { + layout.encoding_id().as_ref() == "vortex.dict" + || layout + .children() + .unwrap() + .iter() + .any(|child| layout_has_dict(child.as_ref())) +} + /// Mirrors the (private) `IDEAL_SPLIT_SIZE` that `SplitBy::Layout` uses to sub-divide wide /// chunk-boundary spans: layout splits are never wider than this many rows. const MAX_SPLIT_ROWS: u64 = 100_000; @@ -2003,6 +2016,75 @@ async fn test_segment_ordering_dict_codes_before_values() -> VortexResult<()> { Ok(()) } +#[tokio::test] +#[cfg_attr(miri, ignore)] +async fn dict_probe_honours_configured_compressor() -> VortexResult<()> { + // Low-cardinality strings so the default cascade picks a dictionary. + let n = 32_768; + let values: Vec<&str> = (0..n).map(|i| ["alpha", "beta", "gamma"][i % 3]).collect(); + let strings = VarBinArray::from(values).into_array(); + + let mut buf = ByteBufferMut::empty(); + let summary = SESSION + .write_options() + .with_strategy(crate::strategy::WriteStrategyBuilder::default().build()) + .write(&mut buf, strings.clone().to_array_stream()) + .await?; + assert!( + layout_has_dict(summary.footer().layout().as_ref()), + "default builder should produce a dict layout for low-cardinality strings" + ); + + let no_string_dict = + BtrBlocksCompressorBuilder::default().exclude_schemes([StringDictScheme.id()]); + let mut buf = ByteBufferMut::empty(); + let summary = SESSION + .write_options() + .with_strategy( + crate::strategy::WriteStrategyBuilder::default() + .with_btrblocks_builder(no_string_dict) + .build(), + ) + .write(&mut buf, strings.to_array_stream()) + .await?; + assert!( + !layout_has_dict(summary.footer().layout().as_ref()), + "excluding StringDict from the configured compressor should disable the dict layout" + ); + + Ok(()) +} + +#[tokio::test] +#[cfg_attr(miri, ignore)] +async fn probe_compressor_override_is_independent() -> VortexResult<()> { + // Low-cardinality strings the default cascade would dict-encode. + let n = 32_768; + let values: Vec<&str> = (0..n).map(|i| ["alpha", "beta", "gamma"][i % 3]).collect(); + let strings = VarBinArray::from(values).into_array(); + + let probe_without_dict = BtrBlocksCompressorBuilder::default() + .exclude_schemes([StringDictScheme.id()]) + .build(); + + let mut buf = ByteBufferMut::empty(); + let summary = SESSION + .write_options() + .with_strategy( + crate::strategy::WriteStrategyBuilder::default() + .with_probe_compressor(probe_without_dict) + .build(), + ) + .write(&mut buf, strings.to_array_stream()) + .await?; + assert!( + !layout_has_dict(summary.footer().layout().as_ref()), + "probe override should disable the dict layout independently of the data/stats compressor" + ); + + Ok(()) +} + #[tokio::test] #[cfg_attr(miri, ignore)] async fn test_segment_ordering_zonemaps_after_data() -> VortexResult<()> { diff --git a/vortex-layout/src/layouts/dict/reader.rs b/vortex-layout/src/layouts/dict/reader.rs index 489d8823d39..30172eb45d2 100644 --- a/vortex-layout/src/layouts/dict/reader.rs +++ b/vortex-layout/src/layouts/dict/reader.rs @@ -360,6 +360,7 @@ mod tests { use vortex_array::expr::pack; use vortex_array::expr::root; use vortex_array::validity::Validity; + use vortex_btrblocks::BtrBlocksCompressor; use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_io::runtime::Handle; @@ -401,6 +402,7 @@ mod tests { FlatLayoutStrategy::default(), FlatLayoutStrategy::default(), DictLayoutOptions::default(), + Arc::new(BtrBlocksCompressor::default()), ); let segments = Arc::new(TestSegments::default()); let (ptr, eof) = SequenceId::root().split(); @@ -430,6 +432,7 @@ mod tests { FlatLayoutStrategy::default(), FlatLayoutStrategy::default(), DictLayoutOptions::default(), + Arc::new(BtrBlocksCompressor::default()), ); let array = VarBinArray::from_iter( @@ -530,6 +533,7 @@ mod tests { FlatLayoutStrategy::default(), FlatLayoutStrategy::default(), DictLayoutOptions::default(), + Arc::new(BtrBlocksCompressor::default()), ); let array = @@ -582,6 +586,7 @@ mod tests { FlatLayoutStrategy::default(), FlatLayoutStrategy::default(), DictLayoutOptions::default(), + Arc::new(BtrBlocksCompressor::default()), ); let array = VarBinArray::from_iter( diff --git a/vortex-layout/src/layouts/dict/writer.rs b/vortex-layout/src/layouts/dict/writer.rs index 1a376452984..83483c92662 100644 --- a/vortex-layout/src/layouts/dict/writer.rs +++ b/vortex-layout/src/layouts/dict/writer.rs @@ -30,7 +30,6 @@ use vortex_array::builders::dict::dict_encoder; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; -use vortex_btrblocks::BtrBlocksCompressor; use vortex_error::VortexError; use vortex_error::VortexExpect; use vortex_error::VortexResult; @@ -44,6 +43,7 @@ use crate::LayoutRef; use crate::LayoutStrategy; use crate::OwnedLayoutChildren; use crate::layouts::chunked::ChunkedLayout; +use crate::layouts::compressed::CompressorPlugin; use crate::layouts::dict::DictLayout; use crate::segments::SegmentSinkRef; use crate::sequence::SendableSequentialStream; @@ -108,6 +108,7 @@ pub struct DictStrategy { values: Arc, fallback: Arc, options: DictLayoutOptions, + probe_compressor: Arc, } impl DictStrategy { @@ -116,12 +117,14 @@ impl DictStrategy { values: Values, fallback: Fallback, options: DictLayoutOptions, + probe_compressor: Arc, ) -> Self { Self { codes: Arc::new(codes), values: Arc::new(values), fallback: Arc::new(fallback), options, + probe_compressor, } } } @@ -155,7 +158,9 @@ impl LayoutStrategy for DictStrategy { None => true, // empty stream Some(chunk) => { let mut exec_ctx = session.create_execution_ctx(); - let compressed = BtrBlocksCompressor::default().compress(&chunk, &mut exec_ctx)?; + let compressed = self + .probe_compressor + .compress_chunk(&chunk, &mut exec_ctx)?; !compressed.is::() } }; From c27a6120e9279e8f5a648abe10d9656bce30b9d4 Mon Sep 17 00:00:00 2001 From: myrrc Date: Mon, 13 Jul 2026 15:08:00 +0100 Subject: [PATCH 065/104] remove old c++ api (#8736) This is first pr in a stack for migrating to new c++ api. Signed-off-by: Mikhail Kot --- .github/workflows/ci.yml | 33 +- Cargo.lock | 118 ---- Cargo.toml | 1 - docs/Doxyfile.cpp | 31 -- docs/api/cpp/dtypes.rst | 17 - docs/api/cpp/expr.rst | 9 - docs/api/cpp/file.rst | 17 - docs/api/cpp/index.rst | 82 --- docs/api/cpp/scalars.rst | 9 - docs/api/cpp/scan.rst | 18 - docs/api/index.md | 1 - docs/concepts/index.md | 2 +- docs/conf.py | 8 +- vortex-cxx/.clang-tidy | 93 ---- vortex-cxx/CMakeLists.txt | 120 ----- vortex-cxx/Cargo.toml | 32 -- vortex-cxx/README.md | 35 -- vortex-cxx/cpp/include/vortex.hpp | 11 - vortex-cxx/cpp/include/vortex/dtype.hpp | 71 --- vortex-cxx/cpp/include/vortex/exception.hpp | 18 - vortex-cxx/cpp/include/vortex/expr.hpp | 51 -- vortex-cxx/cpp/include/vortex/file.hpp | 38 -- vortex-cxx/cpp/include/vortex/scalar.hpp | 47 -- vortex-cxx/cpp/include/vortex/scan.hpp | 108 ---- .../cpp/include/vortex/write_options.hpp | 29 - vortex-cxx/cpp/src/dtype.cpp | 90 ---- vortex-cxx/cpp/src/expr.cpp | 60 --- vortex-cxx/cpp/src/file.cpp | 36 -- vortex-cxx/cpp/src/scalar.cpp | 66 --- vortex-cxx/cpp/src/scan.cpp | 116 ---- vortex-cxx/cpp/src/write_options.cpp | 18 - vortex-cxx/cpp/tests/basic_test.cpp | 510 ------------------ vortex-cxx/cpp/tests/test_data_generator.cpp | 125 ----- vortex-cxx/cpp/tests/test_data_generator.hpp | 20 - vortex-cxx/examples/.gitignore | 1 - vortex-cxx/examples/CMakeLists.txt | 34 -- vortex-cxx/examples/README.md | 11 - vortex-cxx/examples/hello-vortex.cpp | 129 ----- vortex-cxx/src/dtype.rs | 94 ---- vortex-cxx/src/expr.rs | 81 --- vortex-cxx/src/lib.rs | 139 ----- vortex-cxx/src/read.rs | 193 ------- vortex-cxx/src/scalar.rs | 52 -- vortex-cxx/src/write.rs | 70 --- 44 files changed, 5 insertions(+), 2839 deletions(-) delete mode 100644 docs/Doxyfile.cpp delete mode 100644 docs/api/cpp/dtypes.rst delete mode 100644 docs/api/cpp/expr.rst delete mode 100644 docs/api/cpp/file.rst delete mode 100644 docs/api/cpp/index.rst delete mode 100644 docs/api/cpp/scalars.rst delete mode 100644 docs/api/cpp/scan.rst delete mode 100644 vortex-cxx/.clang-tidy delete mode 100644 vortex-cxx/CMakeLists.txt delete mode 100644 vortex-cxx/Cargo.toml delete mode 100644 vortex-cxx/README.md delete mode 100644 vortex-cxx/cpp/include/vortex.hpp delete mode 100644 vortex-cxx/cpp/include/vortex/dtype.hpp delete mode 100644 vortex-cxx/cpp/include/vortex/exception.hpp delete mode 100644 vortex-cxx/cpp/include/vortex/expr.hpp delete mode 100644 vortex-cxx/cpp/include/vortex/file.hpp delete mode 100644 vortex-cxx/cpp/include/vortex/scalar.hpp delete mode 100644 vortex-cxx/cpp/include/vortex/scan.hpp delete mode 100644 vortex-cxx/cpp/include/vortex/write_options.hpp delete mode 100644 vortex-cxx/cpp/src/dtype.cpp delete mode 100644 vortex-cxx/cpp/src/expr.cpp delete mode 100644 vortex-cxx/cpp/src/file.cpp delete mode 100644 vortex-cxx/cpp/src/scalar.cpp delete mode 100644 vortex-cxx/cpp/src/scan.cpp delete mode 100644 vortex-cxx/cpp/src/write_options.cpp delete mode 100644 vortex-cxx/cpp/tests/basic_test.cpp delete mode 100644 vortex-cxx/cpp/tests/test_data_generator.cpp delete mode 100644 vortex-cxx/cpp/tests/test_data_generator.hpp delete mode 100644 vortex-cxx/examples/.gitignore delete mode 100644 vortex-cxx/examples/CMakeLists.txt delete mode 100644 vortex-cxx/examples/README.md delete mode 100644 vortex-cxx/examples/hello-vortex.cpp delete mode 100644 vortex-cxx/src/dtype.rs delete mode 100644 vortex-cxx/src/expr.rs delete mode 100644 vortex-cxx/src/lib.rs delete mode 100644 vortex-cxx/src/read.rs delete mode 100644 vortex-cxx/src/scalar.rs delete mode 100644 vortex-cxx/src/write.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23af8e2c5ff..4b694bf364d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -355,7 +355,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: C/C++ Lint - clang-format run: | - git ls-files vortex-cuda vortex-cxx vortex-duckdb vortex-ffi \ + git ls-files vortex-cuda vortex-duckdb vortex-ffi \ | grep -E '\.(cpp|hpp|cu|cuh|h)$' \ | grep -v 'arrow/reference/arrow_c_device\.h$' \ | grep -v 'kernels/src/bit_unpack_.*\.cu$' \ @@ -522,37 +522,6 @@ jobs: with: command: check ${{ matrix.checks }} - cxx-test: - name: "C++ build" - timeout-minutes: 30 - runs-on: >- - ${{ github.repository == 'vortex-data/vortex' - && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=cxx-build', github.run_id) - || 'ubuntu-latest' }} - steps: - - uses: runs-on/action@v2 - if: github.repository == 'vortex-data/vortex' - with: - sccache: s3 - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: ./.github/actions/setup-prebuild - with: - enable-sccache: "true" - - name: Build and run C++ unit tests - run: | - mkdir -p vortex-cxx/build - cmake -S vortex-cxx -B vortex-cxx/build -DVORTEX_ENABLE_TESTING=ON -DVORTEX_ENABLE_ASAN=ON - cmake --build vortex-cxx/build --parallel $(nproc) - ctest --test-dir vortex-cxx/build -j $(nproc) -V - - name: Build and run the example in release mode - run: | - cmake -S vortex-cxx/examples -B vortex-cxx/examples/build -DCMAKE_BUILD_TYPE=Release - cmake --build vortex-cxx/examples/build --parallel $(nproc) - vortex-cxx/examples/build/hello-vortex vortex-cxx/examples/goldenfiles/example.vortex - - uses: ./.github/actions/check-rebuild - with: - command: "cargo build --profile ci --locked -p vortex-cxx --lib" - sqllogic-test: name: "SQL logic tests" needs: duckdb-ready diff --git a/Cargo.lock b/Cargo.lock index e227ed34f88..d75d5661a98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1403,17 +1403,6 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" -[[package]] -name = "codespan-reporting" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" -dependencies = [ - "serde", - "termcolor", - "unicode-width 0.2.2", -] - [[package]] name = "codspeed" version = "5.0.1" @@ -1965,68 +1954,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "cxx" -version = "1.0.196" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6f8908ed0826ab0aec7c8358a4de3a9b26c5ed391a5dc5135765d2fd1aa8fb" -dependencies = [ - "cc", - "cxx-build", - "cxxbridge-cmd", - "cxxbridge-flags", - "cxxbridge-macro", - "foldhash 0.2.0", - "link-cplusplus", -] - -[[package]] -name = "cxx-build" -version = "1.0.196" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d8ae25c0ce72ac21a77b5deec4f49b452238ef97072f697dd6fd752b1355ecd" -dependencies = [ - "cc", - "codespan-reporting", - "indexmap 2.14.0", - "proc-macro2", - "quote", - "scratch", - "syn 2.0.118", -] - -[[package]] -name = "cxxbridge-cmd" -version = "1.0.196" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27d53791812143bd27f74ab6055f22e875f04c59bbef0ca03d714ebec6f6f484" -dependencies = [ - "clap", - "codespan-reporting", - "indexmap 2.14.0", - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "cxxbridge-flags" -version = "1.0.196" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd557619f7dc2252bf7373f6bec5600ce60a2b477e5b3b84eb4e074bb297795e" - -[[package]] -name = "cxxbridge-macro" -version = "1.0.196" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe465fc5b9d0231ea141c4fae714a08f4f907855e1a7ca10cc90ab6c8b12ece" -dependencies = [ - "indexmap 2.14.0", - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "darling" version = "0.23.0" @@ -5870,15 +5797,6 @@ dependencies = [ "bitflags 2.13.0", ] -[[package]] -name = "link-cplusplus" -version = "1.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f78c730aaa7d0b9336a299029ea49f9ee53b0ed06e9202e8cb7db9bae7b8c82" -dependencies = [ - "cc", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -8226,12 +8144,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "scratch" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2" - [[package]] name = "scroll" version = "0.11.0" @@ -8953,12 +8865,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" -[[package]] -name = "take_mut" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" - [[package]] name = "tap" version = "1.0.1" @@ -9004,15 +8910,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "terminal_size" version = "0.4.4" @@ -10066,21 +9963,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "vortex-cxx" -version = "0.1.0" -dependencies = [ - "anyhow", - "arrow-array 58.3.0", - "arrow-schema 58.3.0", - "async-fs", - "cxx", - "futures", - "paste", - "take_mut", - "vortex", -] - [[package]] name = "vortex-datafusion" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 109f0a66f0b..e59c5c2c76c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,6 @@ members = [ "vortex-cuda/gpu-scan-cli", "vortex-cuda/macros", "vortex-cuda/nvcomp", - "vortex-cxx", "vortex-ffi", "fuzz", "vortex-jni", diff --git a/docs/Doxyfile.cpp b/docs/Doxyfile.cpp deleted file mode 100644 index 553c07e6dae..00000000000 --- a/docs/Doxyfile.cpp +++ /dev/null @@ -1,31 +0,0 @@ -# Doxygen configuration for Vortex C++ API documentation. -# XML output is consumed by Sphinx via the Breathe extension. - -PROJECT_NAME = "Vortex C++" -OUTPUT_DIRECTORY = _build/doxygen-cpp - -# Input sources -INPUT = ../vortex-cxx/cpp/include/vortex -FILE_PATTERNS = *.hpp -RECURSIVE = NO - -# We only care about XML output for Breathe -GENERATE_XML = YES -GENERATE_HTML = NO -GENERATE_LATEX = NO -XML_PROGRAMLISTING = YES - -# Extract everything, even if not fully documented yet -EXTRACT_ALL = YES -EXTRACT_PRIVATE = NO -EXTRACT_STATIC = YES - -# Preprocessing — resolve includes but don't expand macros -ENABLE_PREPROCESSING = YES -MACRO_EXPANSION = NO - -# Suppress warnings about undocumented members (WIP API) -WARN_IF_UNDOCUMENTED = NO - -# Exclude cxx bridge internals from documentation -EXCLUDE_SYMBOLS = ffi::* diff --git a/docs/api/cpp/dtypes.rst b/docs/api/cpp/dtypes.rst deleted file mode 100644 index ae026af604d..00000000000 --- a/docs/api/cpp/dtypes.rst +++ /dev/null @@ -1,17 +0,0 @@ -Data Types -========== - -Logical data types for Vortex arrays. ``DType`` represents a logical type, and ``PType`` -enumerates the physical primitive types. - -PType ------ - -.. doxygenenum:: vortex::PType - -DType ------ - -.. doxygennamespace:: vortex::dtype - :members: - :undoc-members: diff --git a/docs/api/cpp/expr.rst b/docs/api/cpp/expr.rst deleted file mode 100644 index 4e904fff558..00000000000 --- a/docs/api/cpp/expr.rst +++ /dev/null @@ -1,9 +0,0 @@ -Expressions -=========== - -Expression trees used for filter and projection pushdowns in the scan API. Build expressions -from columns, literals, and comparison operators. - -.. doxygennamespace:: vortex::expr - :members: - :undoc-members: diff --git a/docs/api/cpp/file.rst b/docs/api/cpp/file.rst deleted file mode 100644 index 26736a88979..00000000000 --- a/docs/api/cpp/file.rst +++ /dev/null @@ -1,17 +0,0 @@ -File I/O -======== - -Read and write Vortex files from C++. ``VortexFile`` opens an existing file for scanning, while -``VortexWriteOptions`` writes an Arrow array stream to a new file. - -VortexFile ----------- - -.. doxygenclass:: vortex::VortexFile - :members: - -VortexWriteOptions ------------------- - -.. doxygenclass:: vortex::VortexWriteOptions - :members: diff --git a/docs/api/cpp/index.rst b/docs/api/cpp/index.rst deleted file mode 100644 index befb980f6a9..00000000000 --- a/docs/api/cpp/index.rst +++ /dev/null @@ -1,82 +0,0 @@ -C++ API -======= - -The Vortex C++ API provides an idiomatic C++ wrapper around the Vortex C FFI, built using -`cxx `_. It currently supports reading and writing Vortex files and integrates -with the Arrow C Data Interface via `nanoarrow `_. - -In the future we will expand the C++ API to cover Vortex's plugin and extension points. Please -reach out if you are interested in extending Vortex from C++ so we can prioritize these features. - -.. note:: - Both the C++ API and this documentation are a work in progress. The API surface may change - significantly. Please reach out if you are interested in using Vortex from C++ so we can - prioritize stabilization. - - -Installation ------------- - -The C++ bindings are built using CMake. Requirements: - -* CMake 3.22 or higher -* C++20 compatible compiler -* Rust toolchain (for building the underlying Rust library) - -.. code-block:: bash - - cd vortex-cxx - mkdir build && cd build - cmake .. - make -j$(nproc) - - -Compatibility -------------- - -The C++ bindings are supported on the following architectures: - -* x86_64 Linux -* ARM64 Linux -* Apple Silicon macOS - -They support any Linux distribution with a GLIBC version >= 2.31. This includes - -* Amazon Linux 2022 or newer -* Ubuntu 20.04 or newer - - -Usage Example -------------- - -Here's a basic example of reading a Vortex file into an Arrow array stream: - -.. code-block:: cpp - - #include "vortex/file.hpp" - #include "vortex/scan.hpp" - - // Open a Vortex file and scan with a row limit - auto stream = vortex::VortexFile::Open("data.vortex") - .CreateScanBuilder() - .WithLimit(1000) - .IntoStream(); - - // Consume the Arrow C Data stream - ArrowArray array; - while (stream.get_next(&stream, &array) == 0 && array.release != nullptr) { - // Process each batch... - } - - -API Reference -------------- - -.. toctree:: - :maxdepth: 2 - - dtypes - scalars - expr - file - scan diff --git a/docs/api/cpp/scalars.rst b/docs/api/cpp/scalars.rst deleted file mode 100644 index ab52cc22af0..00000000000 --- a/docs/api/cpp/scalars.rst +++ /dev/null @@ -1,9 +0,0 @@ -Scalars -======= - -A scalar is a single typed value. Factory functions create scalars of each primitive type, and -``cast`` converts between types. - -.. doxygennamespace:: vortex::scalar - :members: - :undoc-members: diff --git a/docs/api/cpp/scan.rst b/docs/api/cpp/scan.rst deleted file mode 100644 index dbbd9e3bd36..00000000000 --- a/docs/api/cpp/scan.rst +++ /dev/null @@ -1,18 +0,0 @@ -Scanning -======== - -The scan API provides a builder pattern for reading data from a Vortex file with optional -filter, projection, row range, and limit pushdowns. The resulting stream exposes the -Arrow C Data Interface (``ArrowArrayStream``). - -ScanBuilder ------------ - -.. doxygenclass:: vortex::ScanBuilder - :members: - -StreamDriver ------------- - -.. doxygenclass:: vortex::StreamDriver - :members: diff --git a/docs/api/index.md b/docs/api/index.md index c4fcf3fe182..343661f6717 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -11,6 +11,5 @@ maxdepth: 2 python/index Rust API c/index -cpp/index java/index ``` diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 878296b3d09..5a2741655cf 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -42,7 +42,7 @@ segment retrieval, FlatBuffer metadata for O(1) schema access, and support for m ## Integrations **Language bindings:** [Rust](https://docs.rs/vortex), [Python](../api/python/index.rst), -[Java](../api/java/index.rst), [C](../api/c/index.rst), [C++](../api/cpp/index.rst) +[Java](../api/java/index.rst), [C](../api/c/index.rst) **Query engines:** [DataFusion](../user-guide/datafusion.md), [DuckDB](../user-guide/duckdb.md), [Spark](../user-guide/spark.md), [Polars](../user-guide/polars.md), [Ray](../user-guide/ray.md) diff --git a/docs/conf.py b/docs/conf.py index edb6342aaee..f795fea0d6b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,8 +1,6 @@ import doctest import os import re -import shutil -import subprocess from pathlib import Path import hawkmoth.docstring @@ -120,9 +118,9 @@ os.makedirs(os.path.dirname(_doxygen_xml_dir), exist_ok=True) -if not shutil.which("doxygen"): - raise RuntimeError("doxygen is required to build the docs but was not found on PATH") -subprocess.run(["doxygen", "Doxyfile.cpp"], cwd=Path(__file__).parent, check=True) +# if not shutil.which("doxygen"): +# raise RuntimeError("doxygen is required to build the docs but was not found on PATH") +# subprocess.run(["doxygen", "Doxyfile.cpp"], cwd=Path(__file__).parent, check=True) breathe_projects = {"vortex-cpp": _doxygen_xml_dir} breathe_default_project = "vortex-cpp" diff --git a/vortex-cxx/.clang-tidy b/vortex-cxx/.clang-tidy deleted file mode 100644 index 80c94310cbc..00000000000 --- a/vortex-cxx/.clang-tidy +++ /dev/null @@ -1,93 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors - -Checks: | - -*, - clang-diagnostic-*, - bugprone-*, - performance-*, - google-explicit-constructor, - google-build-using-namespace, - google-runtime-int, - misc-definitions-in-headers, - modernize-use-nullptr, - modernize-use-override, - -bugprone-macro-parentheses, - readability-braces-around-statements, - -bugprone-branch-clone, - readability-identifier-naming, - hicpp-exception-baseclass, - misc-throw-by-value-catch-by-reference, - -bugprone-signed-char-misuse, - -bugprone-misplaced-widening-cast, - -bugprone-sizeof-expression, - -bugprone-easily-swappable-parameters, - google-global-names-in-headers, - llvm-header-guard, - misc-definitions-in-headers, - modernize-use-emplace, - modernize-use-bool-literals, - -performance-inefficient-string-concatenation, - -performance-no-int-to-ptr, - readability-container-size-empty, - cppcoreguidelines-pro-type-cstyle-cast, - -llvm-header-guard, - -performance-enum-size, - cppcoreguidelines-pro-type-const-cast, - cppcoreguidelines-interfaces-global-init, - cppcoreguidelines-slicing, - cppcoreguidelines-rvalue-reference-param-not-moved, - cppcoreguidelines-virtual-class-destructor, - -readability-identifier-naming, - -bugprone-exception-escape, - -bugprone-unused-local-non-trivial-variable, - -bugprone-empty-catch, -WarningsAsErrors: '*' -HeaderFilterRegex: '(cpp|examples)/.*\.(cpp|hpp)$' -FormatStyle: none -CheckOptions: - - key: readability-identifier-naming.ClassCase - value: CamelCase - - key: readability-identifier-naming.EnumCase - value: CamelCase - - key: readability-identifier-naming.TypedefCase - value: lower_case - - key: readability-identifier-naming.TypedefSuffix - value: _t - - key: readability-identifier-naming.FunctionCase - value: CamelCase - - key: readability-identifier-naming.MemberCase - value: lower_case - - key: readability-identifier-naming.ParameterCase - value: lower_case - - key: readability-identifier-naming.ConstantCase - value: aNy_CasE - - key: readability-identifier-naming.ConstantParameterCase - value: lower_case - - key: readability-identifier-naming.NamespaceCase - value: lower_case - - key: readability-identifier-naming.MacroDefinitionCase - value: UPPER_CASE - - key: readability-identifier-naming.StaticConstantCase - value: UPPER_CASE - - key: readability-identifier-naming.ConstantMemberCase - value: aNy_CasE - - key: readability-identifier-naming.StaticVariableCase - value: UPPER_CASE - - key: readability-identifier-naming.ClassConstantCase - value: UPPER_CASE - - key: readability-identifier-naming.EnumConstantCase - value: UPPER_CASE - - key: readability-identifier-naming.ConstexprVariableCase - value: aNy_CasE - - key: readability-identifier-naming.StaticConstantCase - value: UPPER_CASE - - key: readability-identifier-naming.TemplateTemplateParameterCase - value: UPPER_CASE - - key: readability-identifier-naming.TypeTemplateParameterCase - value: UPPER_CASE - - key: readability-identifier-naming.VariableCase - value: lower_case - - key: cppcoreguidelines-rvalue-reference-param-not-moved.IgnoreUnnamedParams - value: true - diff --git a/vortex-cxx/CMakeLists.txt b/vortex-cxx/CMakeLists.txt deleted file mode 100644 index ab5474d0617..00000000000 --- a/vortex-cxx/CMakeLists.txt +++ /dev/null @@ -1,120 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors - -cmake_minimum_required(VERSION 3.22) - -# FetchContent from URL timestamp handling -cmake_policy(SET CMP0135 NEW) - -project(vortex) - -set(CMAKE_CXX_STANDARD 20) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_EXPORT_COMPILE_COMMANDS ON) - -find_program(SCCACHE_PROGRAM sccache) -if (SCCACHE_PROGRAM) - set(CMAKE_C_COMPILER_LAUNCHER "${SCCACHE_PROGRAM}") - set(CMAKE_CXX_COMPILER_LAUNCHER "${SCCACHE_PROGRAM}") - message(STATUS "Sccache found: ${SCCACHE_PROGRAM}") -else () - message(STATUS "Sccache not found") -endif () - -if (NOT CMAKE_BUILD_TYPE) - set(CMAKE_BUILD_TYPE Debug) -endif() - -set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra -Wpedantic -Wno-dollar-in-identifier-extension") - -option(VORTEX_ENABLE_TESTING "Enable building test binary for vortex-cxx" OFF) -option(VORTEX_ENABLE_ASAN "Enable address sanitizer" OFF) - -include(FetchContent) -FetchContent_Declare( - Corrosion - GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git - GIT_TAG v0.5 -) -FetchContent_MakeAvailable(Corrosion) - -set(RUST_SOURCE_FILE lib.rs) - -# Import Rust crate using Corrosion -corrosion_import_crate( - MANIFEST_PATH ${CMAKE_CURRENT_SOURCE_DIR}/Cargo.toml - FEATURES ${CORROSION_FEATURES} - CRATES vortex-cxx -) - -# Enable CXX bridge for the Rust crate -corrosion_add_cxxbridge(vortex_cxx_bridge CRATE vortex_cxx FILES ${RUST_SOURCE_FILE}) - -FetchContent_Declare( - nanoarrow - GIT_REPOSITORY https://github.com/apache/arrow-nanoarrow.git - GIT_TAG a579fbf5d192e85b6249935e117de7d02a6dc4e9 # v0.8.0 -) -FetchContent_MakeAvailable(nanoarrow) - -file(GLOB_RECURSE CPP_SOURCE_FILE CONFIGURE_DEPENDS - "${CMAKE_CURRENT_SOURCE_DIR}/cpp/src/*.cpp" -) - -# Public headers -set(CPP_INCLUDE_DIRS - ${CMAKE_CURRENT_SOURCE_DIR}/cpp/include -) - -# Private headers -set(CPP_PRIVATE_INCLUDE_DIRS - ${CMAKE_CURRENT_SOURCE_DIR}/cpp/src -) - -# Create the main library combining C++ and Rust code -add_library(vortex STATIC ${CPP_SOURCE_FILE}) -target_include_directories(vortex PUBLIC ${CPP_INCLUDE_DIRS} - ${CMAKE_CURRENT_BINARY_DIR}/corrosion_generated/cxxbridge/vortex_cxx_bridge/include) -target_include_directories(vortex PRIVATE - ${CPP_PRIVATE_INCLUDE_DIRS} -) -target_link_libraries(vortex - PUBLIC nanoarrow_static - PRIVATE vortex_cxx_bridge -) - -if (VORTEX_ENABLE_ASAN) - target_compile_options(vortex PRIVATE -fsanitize=leak,address,undefined -fno-omit-frame-pointer -fno-common -O1) - target_link_options(vortex PRIVATE -fsanitize=leak,address,undefined) -endif() - -# Tests -if (VORTEX_ENABLE_TESTING) - FetchContent_Declare( - googletest - GIT_REPOSITORY https://github.com/google/googletest.git - GIT_TAG v1.17.0 - GIT_SHALLOW TRUE - ) - FetchContent_MakeAvailable(googletest) - - enable_testing() - add_executable(vortex_cxx_test cpp/tests/basic_test.cpp cpp/tests/test_data_generator.cpp) - target_include_directories(vortex_cxx_test PUBLIC ${CPP_INCLUDE_DIRS}) - target_include_directories(vortex_cxx_test PRIVATE cpp/tests) - target_link_libraries(vortex_cxx_test PRIVATE gtest_main vortex nanoarrow_static) - target_include_directories(vortex_cxx_test PRIVATE - ${CMAKE_CURRENT_BINARY_DIR}/corrosion_generated/cxxbridge/vortex_cxx_bridge/include - ) - # Platform-specific configuration - if(APPLE) - set(APPLE_LINK_FLAGS "-framework CoreFoundation -framework Security") - endif() - target_link_libraries(vortex_cxx_test PRIVATE vortex_cxx_bridge ${APPLE_LINK_FLAGS}) - if (VORTEX_ENABLE_ASAN) - target_compile_options(vortex_cxx_test PRIVATE -fsanitize=leak,address,undefined -fno-omit-frame-pointer -fno-common -O1) - target_link_options(vortex_cxx_test PRIVATE -fsanitize=leak,address,undefined) - endif() - include(GoogleTest) - gtest_discover_tests(vortex_cxx_test) -endif() diff --git a/vortex-cxx/Cargo.toml b/vortex-cxx/Cargo.toml deleted file mode 100644 index d75f7b74cee..00000000000 --- a/vortex-cxx/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "vortex-cxx" -authors = { workspace = true } -categories = { workspace = true } -description = "C++ bindings for Vortex generated from Rust using cxx" -edition = { workspace = true } -homepage = { workspace = true } -include = { workspace = true } -keywords = { workspace = true } -license = { workspace = true } -publish = false -readme = { workspace = true } -repository = { workspace = true } -rust-version = { workspace = true } -version = { workspace = true } - -[lints] -workspace = true - -[lib] -crate-type = ["staticlib"] - -[dependencies] -anyhow = { workspace = true } -arrow-array = { workspace = true, features = ["ffi"] } -arrow-schema = { workspace = true } -async-fs = { workspace = true } -cxx = "1.0" -futures = { workspace = true } -paste = { workspace = true } -take_mut = { workspace = true } -vortex = { workspace = true } diff --git a/vortex-cxx/README.md b/vortex-cxx/README.md deleted file mode 100644 index aa761f98b1a..00000000000 --- a/vortex-cxx/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Vortex C++ Bindings - -This directory contains C++ bindings for Vortex using the [cxx](https://cxx.rs/) crate. The bindings provide a C++ interface to Vortex file operations, including roundtripping with Arrow Array stream with advanced pushdown support. - -## Building - -### Requirements - -- CMake 3.22 or higher -- C++20 compatible compiler -- Rust toolchain (for building the Rust components) -- (optional) Ninja (`ninja-build`) - -### Build Steps - -```bash -mkdir build -cmake -Bbuild -GNinja -cmake --build build -j -``` - -### Running Tests - -```bash -# Enable tests in CMake -cmake -Bbuild -DVORTEX_ENABLE_TESTING=ON -GNinja -cmake --build build -j -./vortex_cxx_test -``` - -## C++ Coding Convention - -We use `.clang-tidy` and `.clang-format` to setup the coding convention. Both are borrowed from DuckDB. - -`cppcoreguidelines-avoid-non-const-global-variables` is removed from `.clang-tidy` because GTest violates it. diff --git a/vortex-cxx/cpp/include/vortex.hpp b/vortex-cxx/cpp/include/vortex.hpp deleted file mode 100644 index 1f2dd1625c1..00000000000 --- a/vortex-cxx/cpp/include/vortex.hpp +++ /dev/null @@ -1,11 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#pragma once - -#include "vortex/file.hpp" -#include "vortex/scan.hpp" -#include "vortex/write_options.hpp" -#include "vortex/exception.hpp" -#include "vortex/expr.hpp" -#include "vortex/scalar.hpp" \ No newline at end of file diff --git a/vortex-cxx/cpp/include/vortex/dtype.hpp b/vortex-cxx/cpp/include/vortex/dtype.hpp deleted file mode 100644 index 9bf4a16d9bc..00000000000 --- a/vortex-cxx/cpp/include/vortex/dtype.hpp +++ /dev/null @@ -1,71 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#pragma once - -#include -#include -#include "vortex_cxx_bridge/lib.h" - -namespace vortex { - -enum class PType : uint8_t { - U8 = 0, - U16, - U32, - U64, - I8, - I16, - I32, - I64, - F16, - F32, - F64, -}; - -namespace dtype { - class DType { - public: - DType() = delete; - explicit DType(rust::Box impl) : impl_(std::move(impl)) { - } - DType(DType &&other) noexcept = default; - DType &operator=(DType &&other) = default; - ~DType() = default; - - DType(const DType &) = delete; - DType &operator=(const DType &) = delete; - - std::string ToString() const; - - const rust::Box &GetImpl() { - return impl_; - } - - private: - rust::Box impl_; - }; - - // Factory functions - DType null(); - DType bool_(bool nullable = false); - DType primitive(PType ptype, bool nullable = false); - DType int8(bool nullable = false); - DType int16(bool nullable = false); - DType int32(bool nullable = false); - DType int64(bool nullable = false); - DType uint8(bool nullable = false); - DType uint16(bool nullable = false); - DType uint32(bool nullable = false); - DType uint64(bool nullable = false); - DType float16(bool nullable = false); - DType float32(bool nullable = false); - DType float64(bool nullable = false); - DType decimal(uint8_t precision = 10, int8_t scale = 0, bool nullable = false); - DType utf8(bool nullable = false); - DType binary(bool nullable = false); - /// TODO: Other DTypes are only supported by creating from Arrow for now. - DType from_arrow(struct ArrowSchema &schema, bool non_nullable = false); -} // namespace dtype - -} // namespace vortex \ No newline at end of file diff --git a/vortex-cxx/cpp/include/vortex/exception.hpp b/vortex-cxx/cpp/include/vortex/exception.hpp deleted file mode 100644 index a7fe44fb91c..00000000000 --- a/vortex-cxx/cpp/include/vortex/exception.hpp +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#pragma once - -#include -#include - -namespace vortex { - -/// TODO(xinyu): better error handling -class VortexException : public std::runtime_error { -public: - explicit VortexException(const std::string &message) : std::runtime_error(message) { - } -}; - -} // namespace vortex \ No newline at end of file diff --git a/vortex-cxx/cpp/include/vortex/expr.hpp b/vortex-cxx/cpp/include/vortex/expr.hpp deleted file mode 100644 index 3060a8e3536..00000000000 --- a/vortex-cxx/cpp/include/vortex/expr.hpp +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#pragma once - -#include "vortex/scalar.hpp" -#include "vortex_cxx_bridge/lib.h" -#include - -namespace vortex::expr { -class Expr { -public: - Expr() = delete; - explicit Expr(rust::Box impl) : impl_(std::move(impl)) { - } - Expr(Expr &&other) noexcept = default; - Expr &operator=(Expr &&other) noexcept = default; - ~Expr() = default; - - Expr(const Expr &) = delete; - Expr &operator=(const Expr &) = delete; - - rust::Box IntoImpl() && { - return std::move(impl_); - } - - const ffi::Expr &Impl() const & { - return *impl_; - } - -private: - rust::Box impl_; -}; - -Expr literal(scalar::Scalar scalar); -Expr root(); -Expr column(std::string_view name); -Expr get_item(std::string_view field, Expr expr); -Expr not_(Expr expr); -Expr is_null(Expr expr); -Expr eq(Expr lhs, Expr rhs); -Expr not_eq_(Expr lhs, Expr rhs); -Expr gt(Expr lhs, Expr rhs); -Expr gt_eq(Expr lhs, Expr rhs); -Expr lt(Expr lhs, Expr rhs); -Expr lt_eq(Expr lhs, Expr rhs); -Expr and_(Expr lhs, Expr rhs); -Expr or_(Expr lhs, Expr rhs); -Expr checked_add(Expr lhs, Expr rhs); -Expr select(const std::vector &fields, Expr child); -} // namespace vortex::expr \ No newline at end of file diff --git a/vortex-cxx/cpp/include/vortex/file.hpp b/vortex-cxx/cpp/include/vortex/file.hpp deleted file mode 100644 index c7b93e326d8..00000000000 --- a/vortex-cxx/cpp/include/vortex/file.hpp +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#pragma once - -#include -#include "vortex_cxx_bridge/lib.h" - -namespace vortex { -class ScanBuilder; - -class VortexFile { -public: - static VortexFile Open(const std::string &path); - static VortexFile Open(const uint8_t *data, size_t length); - - VortexFile(VortexFile &&other) noexcept = default; - VortexFile &operator=(VortexFile &&other) noexcept = default; - ~VortexFile() = default; - - VortexFile(const VortexFile &) = delete; - VortexFile &operator=(const VortexFile &) = delete; - - /// Get the number of rows in the file. - uint64_t RowCount() const; - - /// Create a scan builder for the file. - /// The scan builder can be used to scan the file. - ScanBuilder CreateScanBuilder() const; - -private: - explicit VortexFile(rust::Box impl) : impl_(std::move(impl)) { - } - - rust::Box impl_; -}; - -} // namespace vortex \ No newline at end of file diff --git a/vortex-cxx/cpp/include/vortex/scalar.hpp b/vortex-cxx/cpp/include/vortex/scalar.hpp deleted file mode 100644 index 77706aef98a..00000000000 --- a/vortex-cxx/cpp/include/vortex/scalar.hpp +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#pragma once - -#include -#include "dtype.hpp" -#include "vortex_cxx_bridge/lib.h" - -namespace vortex::scalar { -class Scalar { -public: - Scalar() = delete; - explicit Scalar(rust::Box impl) : impl_(std::move(impl)) { - } - Scalar(Scalar &&other) noexcept = default; - Scalar &operator=(Scalar &&other) noexcept = default; - ~Scalar() = default; - - Scalar(const Scalar &) = delete; - Scalar &operator=(const Scalar &) = delete; - - rust::Box IntoImpl() && { - return std::move(impl_); - } - -private: - rust::Box impl_; -}; - -// Factory functions for creating scalar values -Scalar bool_(bool value); -Scalar int8(int8_t value); -Scalar int16(int16_t value); -Scalar int32(int32_t value); -Scalar int64(int64_t value); -Scalar uint8(uint8_t value); -Scalar uint16(uint16_t value); -Scalar uint32(uint32_t value); -Scalar uint64(uint64_t value); -Scalar float32(float value); -Scalar float64(double value); -Scalar string(std::string_view value); -Scalar binary(const uint8_t *data, size_t length); -/// TODO: Other Scalars are only supported by casting for now. -Scalar cast(Scalar scalar, dtype::DType dtype); -} // namespace vortex::scalar diff --git a/vortex-cxx/cpp/include/vortex/scan.hpp b/vortex-cxx/cpp/include/vortex/scan.hpp deleted file mode 100644 index 7debb9ef6aa..00000000000 --- a/vortex-cxx/cpp/include/vortex/scan.hpp +++ /dev/null @@ -1,108 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#pragma once - -#include "vortex/expr.hpp" -#include -#include "vortex_cxx_bridge/lib.h" - -#include -#include - -namespace vortex { -/// The StreamDriver internally holds a `RecordBatchIteratorAdapter` from the Rust side, which is thread-safe -/// and cloneable. The `RecordBatchIteratorAdapter` internally holds a `WorkStealingArrayIterator`. -class StreamDriver { -public: - StreamDriver(StreamDriver &&other) noexcept = default; - StreamDriver &operator=(StreamDriver &&other) noexcept = default; - ~StreamDriver() = default; - - StreamDriver(const StreamDriver &) = delete; - StreamDriver &operator=(const StreamDriver &) = delete; - - /// Create a stream of record batches. - /// - /// This function is thread-safe and can be called from multiple threads to create one stream per - /// thread to make progress on the same StreamDriver that is built from a ScanBuilder concurrently. - /// - /// Within each thread, the record batches will be emitted in the original order they are within - /// the scan. Between threads, the order is not guaranteed. - /// - /// Example: If the scan contains batches [b0, b1, b2, b3, b4, b5] and two threads call this - /// function respectively to make progress on their own stream, Thread 1 might receive [b0, - /// b2, b4] and Thread 2 might receive [b1, b3, b5]. Each thread maintains order within its - /// subset, but overall ordering between threads is not guaranteed (e.g., Thread 2 could emit b1 - /// before Thread 1 emits b0). - ArrowArrayStream CreateArrayStream() const; - -private: - friend class ScanBuilder; - - explicit StreamDriver(rust::Box impl) : impl_(std::move(impl)) { - } - - rust::Box impl_; -}; - -class ScanBuilder { -public: - ScanBuilder(ScanBuilder &&other) noexcept = default; - ScanBuilder &operator=(ScanBuilder &&other) noexcept { - if (this != &other) { - impl_ = std::move(other.impl_); - } - return *this; - } - ~ScanBuilder() = default; - - ScanBuilder(const ScanBuilder &) = delete; - ScanBuilder &operator=(const ScanBuilder &) = delete; - - /// Only include rows that match the filter expressions. - ScanBuilder &WithFilter(expr::Expr &&expr) &; - ScanBuilder &WithFilter(const expr::Expr &expr) &; - ScanBuilder &&WithFilter(expr::Expr &&expr) &&; - ScanBuilder &&WithFilter(const expr::Expr &expr) &&; - - /// Only include columns that match the projection expressions. - ScanBuilder &WithProjection(expr::Expr &&expr) &; - ScanBuilder &WithProjection(const expr::Expr &expr) &; - ScanBuilder &&WithProjection(expr::Expr &&expr) &&; - ScanBuilder &&WithProjection(const expr::Expr &expr) &&; - - /// Only include rows in the range [row_range_start, row_range_end). - ScanBuilder &WithRowRange(uint64_t row_range_start, uint64_t row_range_end) &; - ScanBuilder &&WithRowRange(uint64_t row_range_start, uint64_t row_range_end) &&; - - /// Only include rows with the given indices. - ScanBuilder &WithIncludeByIndex(const uint64_t *indices, std::size_t size) &; - ScanBuilder &&WithIncludeByIndex(const uint64_t *indices, std::size_t size) &&; - - /// Set the limit on the number of rows to scan out. - ScanBuilder &WithLimit(uint64_t limit) &; - ScanBuilder &&WithLimit(uint64_t limit) &&; - - /// Set the output schema on the scan builder. - /// TODO: currently if pass in this option, the schema needs to be the schema after adding projection. - ScanBuilder &WithOutputSchema(ArrowSchema &output_schema) &; - ScanBuilder &&WithOutputSchema(ArrowSchema &output_schema) &&; - - /// Take ownership and consume the scan builder to a stream of record batches. - ArrowArrayStream IntoStream() &&; - - /// Take ownership and consume the scan builder to a stream driver. - /// Under the hood, this function calls `ScanBuilder::into_record_batch_reader` and holds a - /// `WorkStealingArrayIterator` in StreamDriver. - StreamDriver IntoStreamDriver() &&; - -private: - friend class VortexFile; - - explicit ScanBuilder(rust::Box impl) : impl_(std::move(impl)) { - } - - rust::Box impl_; -}; -} // namespace vortex \ No newline at end of file diff --git a/vortex-cxx/cpp/include/vortex/write_options.hpp b/vortex-cxx/cpp/include/vortex/write_options.hpp deleted file mode 100644 index 08b903bf2fb..00000000000 --- a/vortex-cxx/cpp/include/vortex/write_options.hpp +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#pragma once - -#include -#include "vortex_cxx_bridge/lib.h" - -namespace vortex { - -class VortexWriteOptions { -public: - VortexWriteOptions() : impl_(ffi::write_options_new()) { - } - VortexWriteOptions(VortexWriteOptions &&other) noexcept = default; - VortexWriteOptions &operator=(VortexWriteOptions &&other) noexcept = default; - ~VortexWriteOptions() = default; - - VortexWriteOptions(const VortexWriteOptions &) = delete; - VortexWriteOptions &operator=(const VortexWriteOptions &) = delete; - - /// Write an ArrowArrayStream to a Vortex file - void WriteArrayStream(ArrowArrayStream &stream, const std::string &path); - -private: - rust::Box impl_; -}; - -} // namespace vortex \ No newline at end of file diff --git a/vortex-cxx/cpp/src/dtype.cpp b/vortex-cxx/cpp/src/dtype.cpp deleted file mode 100644 index 5b85f36835e..00000000000 --- a/vortex-cxx/cpp/src/dtype.cpp +++ /dev/null @@ -1,90 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#include "vortex/dtype.hpp" -#include "vortex/exception.hpp" - -#include "rust/cxx.h" - -namespace vortex::dtype { -DType null() { - return DType(ffi::dtype_null()); -} - -DType bool_(bool nullable) { - return DType(ffi::dtype_bool(nullable)); -} - -DType primitive(PType ptype, bool nullable) { - return DType(ffi::dtype_primitive(static_cast(ptype), nullable)); -} - -DType int8(bool nullable) { - return primitive(PType::I8, nullable); -} - -DType int16(bool nullable) { - return primitive(PType::I16, nullable); -} - -DType int32(bool nullable) { - return primitive(PType::I32, nullable); -} - -DType int64(bool nullable) { - return primitive(PType::I64, nullable); -} - -DType uint8(bool nullable) { - return primitive(PType::U8, nullable); -} - -DType uint16(bool nullable) { - return primitive(PType::U16, nullable); -} - -DType uint32(bool nullable) { - return primitive(PType::U32, nullable); -} - -DType uint64(bool nullable) { - return primitive(PType::U64, nullable); -} - -DType float16(bool nullable) { - return primitive(PType::F16, nullable); -} - -DType float32(bool nullable) { - return primitive(PType::F32, nullable); -} - -DType float64(bool nullable) { - return primitive(PType::F64, nullable); -} - -DType decimal(uint8_t precision, int8_t scale, bool nullable) { - return DType(ffi::dtype_decimal(precision, scale, nullable)); -} - -DType utf8(bool nullable) { - return DType(ffi::dtype_utf8(nullable)); -} - -DType binary(bool nullable) { - return DType(ffi::dtype_binary(nullable)); -} - -DType from_arrow(struct ArrowSchema &schema, bool non_nullable) { - try { - return DType(ffi::from_arrow(reinterpret_cast(&schema), non_nullable)); - } catch (const rust::cxxbridge1::Error &e) { - throw VortexException(e.what()); - } -} -// Methods -std::string DType::ToString() const { - auto rust_str = impl_->to_string(); - return std::string(rust_str.data(), rust_str.length()); -} -} // namespace vortex::dtype diff --git a/vortex-cxx/cpp/src/expr.cpp b/vortex-cxx/cpp/src/expr.cpp deleted file mode 100644 index 0e2d67395c1..00000000000 --- a/vortex-cxx/cpp/src/expr.cpp +++ /dev/null @@ -1,60 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#include "vortex/expr.hpp" -#include "vortex_cxx_bridge/lib.h" - -namespace vortex::expr { - -Expr literal(scalar::Scalar scalar) { - return Expr(ffi::literal(std::move(scalar).IntoImpl())); -} - -Expr root() { - return Expr(ffi::root()); -} - -Expr column(std::string_view name) { - return Expr(ffi::column(rust::String(name.data(), name.length()))); -} - -Expr get_item(std::string_view field, Expr child) { - return Expr(ffi::get_item(rust::String(field.data(), field.length()), std::move(child).IntoImpl())); -} - -Expr not_(Expr child) { - return Expr(ffi::not_(std::move(child).IntoImpl())); -} - -Expr is_null(Expr child) { - return Expr(ffi::is_null(std::move(child).IntoImpl())); -} - -// Macro to define binary operator functions -#define DEFINE_BINARY_OP(name) \ - Expr name(Expr lhs, Expr rhs) { \ - return Expr(ffi::name(std::move(lhs).IntoImpl(), std::move(rhs).IntoImpl())); \ - } - -DEFINE_BINARY_OP(eq) -DEFINE_BINARY_OP(not_eq_) -DEFINE_BINARY_OP(gt) -DEFINE_BINARY_OP(gt_eq) -DEFINE_BINARY_OP(lt) -DEFINE_BINARY_OP(lt_eq) -DEFINE_BINARY_OP(and_) -DEFINE_BINARY_OP(or_) -DEFINE_BINARY_OP(checked_add) - -#undef DEFINE_BINARY_OP - -Expr select(const std::vector &fields, Expr child) { - ::rust::Vec<::rust::String> rs_fields; - rs_fields.reserve(fields.size()); - for (std::string_view f : fields) { - rs_fields.emplace_back(f.data(), f.length()); - } - return Expr(ffi::select(rs_fields, std::move(child).IntoImpl())); -} - -} // namespace vortex::expr \ No newline at end of file diff --git a/vortex-cxx/cpp/src/file.cpp b/vortex-cxx/cpp/src/file.cpp deleted file mode 100644 index 078c305639d..00000000000 --- a/vortex-cxx/cpp/src/file.cpp +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#include "vortex/file.hpp" -#include "vortex/scan.hpp" -#include "vortex/exception.hpp" -#include "rust/cxx.h" - -namespace vortex { - -VortexFile VortexFile::Open(const uint8_t *data, size_t length) { - try { - rust::Slice slice(data, length); - return VortexFile(ffi::open_file_from_buffer(slice)); - } catch (const rust::cxxbridge1::Error &e) { - throw VortexException(e.what()); - } -} - -VortexFile VortexFile::Open(const std::string &path) { - try { - return VortexFile(ffi::open_file(path)); - } catch (const rust::cxxbridge1::Error &e) { - throw VortexException(e.what()); - } -} - -uint64_t VortexFile::RowCount() const { - return impl_->row_count(); -} - -ScanBuilder VortexFile::CreateScanBuilder() const { - return ScanBuilder(impl_->scan_builder()); -} - -} // namespace vortex \ No newline at end of file diff --git a/vortex-cxx/cpp/src/scalar.cpp b/vortex-cxx/cpp/src/scalar.cpp deleted file mode 100644 index 87502ee2f00..00000000000 --- a/vortex-cxx/cpp/src/scalar.cpp +++ /dev/null @@ -1,66 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#include "vortex/scalar.hpp" - -#include "vortex_cxx_bridge/lib.h" - -namespace vortex::scalar { - -Scalar bool_(bool value) { - return Scalar(ffi::bool_scalar_new(value)); -} - -Scalar int8(int8_t value) { - return Scalar(ffi::i8_scalar_new(value)); -} - -Scalar int16(int16_t value) { - return Scalar(ffi::i16_scalar_new(value)); -} - -Scalar int32(int32_t value) { - return Scalar(ffi::i32_scalar_new(value)); -} - -Scalar int64(int64_t value) { - return Scalar(ffi::i64_scalar_new(value)); -} - -Scalar uint8(uint8_t value) { - return Scalar(ffi::u8_scalar_new(value)); -} - -Scalar uint16(uint16_t value) { - return Scalar(ffi::u16_scalar_new(value)); -} - -Scalar uint32(uint32_t value) { - return Scalar(ffi::u32_scalar_new(value)); -} - -Scalar uint64(uint64_t value) { - return Scalar(ffi::u64_scalar_new(value)); -} - -Scalar float32(float value) { - return Scalar(ffi::f32_scalar_new(value)); -} - -Scalar float64(double value) { - return Scalar(ffi::f64_scalar_new(value)); -} - -Scalar string(std::string_view value) { - return Scalar(ffi::string_scalar_new(rust::Str(value.data(), value.length()))); -} - -Scalar binary(const uint8_t *data, size_t length) { - return Scalar(ffi::binary_scalar_new(rust::Slice(data, length))); -} - -Scalar cast(Scalar scalar, dtype::DType dtype) { - return Scalar(std::move(scalar).IntoImpl()->cast_scalar(*std::move(dtype).GetImpl())); -} - -} // namespace vortex::scalar \ No newline at end of file diff --git a/vortex-cxx/cpp/src/scan.cpp b/vortex-cxx/cpp/src/scan.cpp deleted file mode 100644 index 9b40ec00e0d..00000000000 --- a/vortex-cxx/cpp/src/scan.cpp +++ /dev/null @@ -1,116 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#include "vortex/scan.hpp" -#include "vortex/exception.hpp" -#include "rust/cxx.h" -#include "vortex/expr.hpp" - -namespace vortex { -ScanBuilder &ScanBuilder::WithFilter(expr::Expr &&expr) & { - impl_->with_filter(std::move(expr).IntoImpl()); - return *this; -} -ScanBuilder &ScanBuilder::WithFilter(const expr::Expr &expr) & { - impl_->with_filter_ref(expr.Impl()); - return *this; -} -ScanBuilder &&ScanBuilder::WithFilter(expr::Expr &&expr) && { - impl_->with_filter(std::move(expr).IntoImpl()); - return std::move(*this); -} -ScanBuilder &&ScanBuilder::WithFilter(const expr::Expr &expr) && { - impl_->with_filter_ref(expr.Impl()); - return std::move(*this); -} - -ScanBuilder &ScanBuilder::WithProjection(expr::Expr &&expr) & { - impl_->with_projection(std::move(expr).IntoImpl()); - return *this; -} -ScanBuilder &ScanBuilder::WithProjection(const expr::Expr &expr) & { - impl_->with_projection_ref(expr.Impl()); - return *this; -} -ScanBuilder &&ScanBuilder::WithProjection(expr::Expr &&expr) && { - impl_->with_projection(std::move(expr).IntoImpl()); - return std::move(*this); -} -ScanBuilder &&ScanBuilder::WithProjection(const expr::Expr &expr) && { - impl_->with_projection_ref(expr.Impl()); - return std::move(*this); -} - -ScanBuilder &ScanBuilder::WithRowRange(uint64_t row_range_start, uint64_t row_range_end) & { - impl_->with_row_range(row_range_start, row_range_end); - return *this; -} -ScanBuilder &&ScanBuilder::WithRowRange(uint64_t row_range_start, uint64_t row_range_end) && { - impl_->with_row_range(row_range_start, row_range_end); - return std::move(*this); -} - -ScanBuilder &ScanBuilder::WithLimit(uint64_t limit) & { - impl_->with_limit(limit); - return *this; -} - -ScanBuilder &&ScanBuilder::WithLimit(uint64_t limit) && { - impl_->with_limit(limit); - return std::move(*this); -} - -ScanBuilder &ScanBuilder::WithIncludeByIndex(const uint64_t *indices, std::size_t size) & { - impl_->with_include_by_index(rust::Slice(indices, size)); - return *this; -} - -ScanBuilder &&ScanBuilder::WithIncludeByIndex(const uint64_t *indices, std::size_t size) && { - impl_->with_include_by_index(rust::Slice(indices, size)); - return std::move(*this); -} - -ScanBuilder &ScanBuilder::WithOutputSchema(ArrowSchema &output_schema) & { - try { - impl_->with_output_schema(reinterpret_cast(&output_schema)); - } catch (const rust::cxxbridge1::Error &e) { - throw VortexException(e.what()); - } - return *this; -} - -ScanBuilder &&ScanBuilder::WithOutputSchema(ArrowSchema &output_schema) && { - try { - impl_->with_output_schema(reinterpret_cast(&output_schema)); - } catch (const rust::cxxbridge1::Error &e) { - throw VortexException(e.what()); - } - return std::move(*this); -} - -ArrowArrayStream ScanBuilder::IntoStream() && { - try { - ArrowArrayStream stream; - ffi::scan_builder_into_stream(std::move(impl_), reinterpret_cast(&stream)); - return stream; - } catch (const rust::cxxbridge1::Error &e) { - throw VortexException(e.what()); - } -} - -StreamDriver ScanBuilder::IntoStreamDriver() && { - try { - rust::Box reader = - ffi::scan_builder_into_threadsafe_cloneable_reader(std::move(impl_)); - return StreamDriver(std::move(reader)); - } catch (const rust::cxxbridge1::Error &e) { - throw VortexException(e.what()); - } -} - -ArrowArrayStream StreamDriver::CreateArrayStream() const { - ArrowArrayStream stream; - impl_->clone_a_stream(reinterpret_cast(&stream)); - return stream; -} -} // namespace vortex \ No newline at end of file diff --git a/vortex-cxx/cpp/src/write_options.cpp b/vortex-cxx/cpp/src/write_options.cpp deleted file mode 100644 index 712a6933208..00000000000 --- a/vortex-cxx/cpp/src/write_options.cpp +++ /dev/null @@ -1,18 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#include "vortex/write_options.hpp" -#include "vortex/exception.hpp" - -#include "rust/cxx.h" - -namespace vortex { -void VortexWriteOptions::WriteArrayStream(ArrowArrayStream &stream, const std::string &path) { - try { - ffi::write_array_stream(std::move(impl_), reinterpret_cast(&stream), path); - } catch (const rust::cxxbridge1::Error &e) { - throw VortexException(e.what()); - } -} - -} // namespace vortex \ No newline at end of file diff --git a/vortex-cxx/cpp/tests/basic_test.cpp b/vortex-cxx/cpp/tests/basic_test.cpp deleted file mode 100644 index eb44715285a..00000000000 --- a/vortex-cxx/cpp/tests/basic_test.cpp +++ /dev/null @@ -1,510 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "vortex/file.hpp" -#include "vortex/scan.hpp" -#include "vortex/write_options.hpp" -#include "vortex/scalar.hpp" -#include "test_data_generator.hpp" -#include "vortex_cxx_bridge/lib.h" - -#include -#include - -class VortexTest : public ::testing::Test { -protected: - // Helper function to create unique temporary files for each test - static std::string GetUniqueTempFile(const std::string &suffix = "vortex") { - std::filesystem::path temp_dir = std::filesystem::temp_directory_path(); - std::filesystem::path vortex_test_dir = temp_dir / "vortex_test"; - - if (!std::filesystem::exists(vortex_test_dir)) { - std::filesystem::create_directories(vortex_test_dir); - } - - // Use a unique random filename to prevent races between parallel test runs - std::string unique_name = "test_" + std::to_string(std::random_device {}()) + "_" + suffix; - return (vortex_test_dir / unique_name).string(); - } - - // Write test data to a unique temporary file and return the path - static std::string WriteTestData(const std::string &suffix = "test_data.vortex") { - std::string path = GetUniqueTempFile(suffix); - auto stream = vortex::testing::CreateTestDataStream(); - auto write_options = vortex::ffi::write_options_new(); - vortex::ffi::write_array_stream(std::move(write_options), - reinterpret_cast(&stream), - path.c_str()); - return path; - } - - // Helper function to create and initialize array view - nanoarrow::UniqueArrayView CreateArrayView(const nanoarrow::UniqueArray &array, - const nanoarrow::UniqueSchema &schema) { - nanoarrow::UniqueArrayView array_view; - ArrowError error; - ArrowErrorCode init_result = ArrowArrayViewInitFromSchema(array_view.get(), schema.get(), &error); - if (init_result != NANOARROW_OK) { - std::cerr << "Error: " << error.message << '\n'; - std::abort(); - } - ArrowErrorCode set_result = ArrowArrayViewSetArray(array_view.get(), array.get(), nullptr); - if (set_result != NANOARROW_OK) { - std::cerr << "Error: " << error.message << '\n'; - std::abort(); - } - return array_view; - } - - std::pair - StreamToUniqueStreamSchema(ArrowArrayStream &stream) { - nanoarrow::UniqueArrayStream array_stream; - ArrowArrayStreamMove(&stream, array_stream.get()); - ArrowError error; - nanoarrow::UniqueSchema schema; - ArrowErrorCode set_result = ArrowArrayStreamGetSchema(array_stream.get(), schema.get(), &error); - if (set_result != NANOARROW_OK) { - std::cerr << "Error: " << error.message << '\n'; - std::abort(); - } - return {std::move(array_stream), std::move(schema)}; - } - - nanoarrow::UniqueArray ReadFirstArrayFromUniqueStream(nanoarrow::UniqueArrayStream &array_stream) { - nanoarrow::UniqueArray array; - int get_next_result = array_stream->get_next(array_stream.get(), array.get()); - EXPECT_EQ(get_next_result, 0); - return array; - } - - std::pair - ReadFirstArrayFromStream(ArrowArrayStream reference_stream) { - auto [ref_array_stream, ref_schema] = StreamToUniqueStreamSchema(reference_stream); - auto ref_array = ReadFirstArrayFromUniqueStream(ref_array_stream); - return {std::move(ref_array), std::move(ref_schema)}; - } - - /// Both array are struct of int64 - void ValidateArray(const nanoarrow::UniqueArray &actual_array, - const nanoarrow::UniqueSchema &actual_schema, - const nanoarrow::UniqueArray &ref_array, - const nanoarrow::UniqueSchema &ref_schema) { - // Basic properties validation - ASSERT_EQ(actual_schema->n_children, ref_schema->n_children); - - auto actual_view = CreateArrayView(actual_array, actual_schema); - auto ref_view = CreateArrayView(ref_array, ref_schema); - - ASSERT_EQ(actual_array->length, ref_array->length); - - // Compare all fields - for (int64_t field_idx = 0; field_idx < actual_schema->n_children; ++field_idx) { - auto actual_field = actual_view->children[field_idx]; - auto expected_field = ref_view->children[field_idx]; - - ASSERT_EQ(actual_field->array->length, expected_field->array->length); - - for (int64_t i = 0; i < actual_field->array->length; ++i) { - int64_t actual_value = ArrowArrayViewGetIntUnsafe(actual_field, i); - int64_t expected_value = ArrowArrayViewGetIntUnsafe(expected_field, i); - - ASSERT_EQ(actual_value, expected_value); - } - } - } - - /// Both array are struct of int64 - void ValidateArrayWithSelection(const nanoarrow::UniqueArray &actual_array, - const nanoarrow::UniqueSchema &actual_schema, - const nanoarrow::UniqueArray &ref_array, - const nanoarrow::UniqueSchema &ref_schema, - const std::vector &row_indices) { - // Basic properties validation - ASSERT_EQ(actual_schema->n_children, ref_schema->n_children); - if (row_indices.empty()) { - ASSERT_EQ(actual_array->length, 0); - return; - } - auto actual_view = CreateArrayView(actual_array, actual_schema); - auto ref_view = CreateArrayView(ref_array, ref_schema); - - ASSERT_EQ(actual_array->length, row_indices.size()); - - // Selective row comparison using indices - ASSERT_EQ(actual_array->length, static_cast(row_indices.size())); - - for (int64_t i = 0; i < static_cast(row_indices.size()); ++i) { - int64_t ref_idx = row_indices[i]; - - for (int64_t field_idx = 0; field_idx < actual_schema->n_children; ++field_idx) { - int64_t actual_val = ArrowArrayViewGetIntUnsafe(actual_view->children[field_idx], i); - int64_t expected_val = ArrowArrayViewGetIntUnsafe(ref_view->children[field_idx], ref_idx); - - ASSERT_EQ(actual_val, expected_val); - } - } - } - - // Helper to execute scan builder and get array+schema - std::pair ScanFirstArrayFromTestData( - const std::function &configureScanBuilder) { - auto test_data_path = WriteTestData(); - auto file = vortex::VortexFile::Open(test_data_path); - auto scan_builder = file.CreateScanBuilder(); - auto stream = configureScanBuilder(scan_builder); - - auto [array_stream, schema] = StreamToUniqueStreamSchema(stream); - auto array = ReadFirstArrayFromUniqueStream(array_stream); - - return {std::move(array), std::move(schema)}; - } - - /// Validate array with projection - only checks specified field indices - void ValidateArrayWithProjection(const nanoarrow::UniqueArray &actual_array, - const nanoarrow::UniqueSchema &actual_schema, - const nanoarrow::UniqueArray &ref_array, - const nanoarrow::UniqueSchema &ref_schema, - const std::vector &field_idxs) { - ASSERT_EQ(actual_schema->n_children, field_idxs.size()); - - auto actual_view = CreateArrayView(actual_array, actual_schema); - auto ref_view = CreateArrayView(ref_array, ref_schema); - - ASSERT_EQ(actual_array->length, ref_array->length); - - // Compare only the specified fields - for (int64_t i = 0; i < actual_array->length; ++i) { - for (size_t proj_idx = 0; proj_idx < field_idxs.size(); ++proj_idx) { - int64_t ref_field_idx = field_idxs[proj_idx]; - int64_t actual_val = ArrowArrayViewGetIntUnsafe(actual_view->children[proj_idx], i); - int64_t expected_val = ArrowArrayViewGetIntUnsafe(ref_view->children[ref_field_idx], i); - ASSERT_EQ(actual_val, expected_val); - } - } - } - - // Top-level test helper that all tests can use - void - RunScanBuilderTest(const std::function &configureScanBuilder, - ArrowArrayStream expected_stream, - const std::vector &expected_row_indices = {}, - bool selection = false) { - - auto [array, schema] = ScanFirstArrayFromTestData(configureScanBuilder); - auto [ref_array, ref_schema] = ReadFirstArrayFromStream(expected_stream); - selection == false - ? ValidateArray(array, schema, ref_array, ref_schema) - : ValidateArrayWithSelection(array, schema, ref_array, ref_schema, expected_row_indices); - } - - // New helper for projection tests - void RunScanBuilderProjectionTest( - const std::function &configureScanBuilder, - ArrowArrayStream expected_stream, - const std::vector &field_idxs) { - - auto [array, schema] = ScanFirstArrayFromTestData(configureScanBuilder); - auto [ref_array, ref_schema] = ReadFirstArrayFromStream(expected_stream); - - ValidateArrayWithProjection(array, schema, ref_array, ref_schema, field_idxs); - } -}; - -TEST_F(VortexTest, ScanToStream) { - RunScanBuilderTest([](vortex::ScanBuilder &builder) { return std::move(builder).IntoStream(); }, - vortex::testing::CreateTestDataStream()); -} - -TEST_F(VortexTest, ScanBuilderWithLimitWithRowRange) { - // Test field "a" and "b" - should contain values from rows 1-2 from original data (indices 1 and - // 2) - RunScanBuilderTest( - [](vortex::ScanBuilder &scan_builder) { - return std::move(scan_builder.WithLimit(2).WithRowRange(1, 4)).IntoStream(); - }, - vortex::testing::CreateTestDataStream(), - {1, 2}, - true); -} - -TEST_F(VortexTest, ScanBuilderWithIncludeByIndex) { - std::vector include_by_index = {1, 3}; - - RunScanBuilderTest( - [&include_by_index](vortex::ScanBuilder &scan_builder) { - return std::move( - scan_builder.WithIncludeByIndex(include_by_index.data(), include_by_index.size())) - .IntoStream(); - }, - vortex::testing::CreateTestDataStream(), - {1, 3}, - true); -} - -TEST_F(VortexTest, ScanBuilderWithRowRangeWithIncludeByIndex) { - std::vector include_by_index = {1, 3, 4}; - - RunScanBuilderTest( - [&include_by_index](vortex::ScanBuilder &scan_builder) { - return std::move(scan_builder.WithRowRange(2, 5).WithIncludeByIndex(include_by_index.data(), - include_by_index.size())) - .IntoStream(); - }, - vortex::testing::CreateTestDataStream(), - {3, 4}, - true); -} - -TEST_F(VortexTest, WriteArrayStream) { - auto test_data_path = WriteTestData(); - auto file = vortex::VortexFile::Open(test_data_path); - auto stream = file.CreateScanBuilder().IntoStream(); - - // Write the stream to a new Vortex file - std::string test_output_path = GetUniqueTempFile("write_output.vortex"); - vortex::VortexWriteOptions write_options; - ASSERT_NO_THROW(write_options.WriteArrayStream(stream, test_output_path)); - - // Verify the written file - auto written_file = vortex::VortexFile::Open(test_output_path); - ASSERT_EQ(written_file.RowCount(), 5); - - // Verify data integrity by reading from the written file - auto out_stream = written_file.CreateScanBuilder().IntoStream(); - auto [array_stream, schema] = StreamToUniqueStreamSchema(out_stream); - auto array = ReadFirstArrayFromUniqueStream(array_stream); - auto [ref_array, ref_schema] = ReadFirstArrayFromStream(vortex::testing::CreateTestDataStream()); - ValidateArray(array, schema, ref_array, ref_schema); -} - -TEST_F(VortexTest, ConcurrentMultiStreamRead) { - std::string test_data_path_1m = GetUniqueTempFile("concurrent_1m.vortex"); - auto stream_1m = vortex::testing::CreateTestData1MStream(); - auto write_options = vortex::ffi::write_options_new(); - vortex::ffi::write_array_stream(std::move(write_options), - reinterpret_cast(&stream_1m), - test_data_path_1m.c_str()); - - auto file = vortex::VortexFile::Open(test_data_path_1m); - auto stream_driver = file.CreateScanBuilder().IntoStreamDriver(); - - // Structure to hold batch data with first ID and nanoarrow array - struct BatchData { - int64_t first_id; - nanoarrow::UniqueArray array; - - BatchData(int64_t first_id, nanoarrow::UniqueArray array) - : first_id(first_id), array(std::move(array)) { - } - }; - - std::vector thread1_batches; - std::vector thread2_batches; - - // Helper function to read from a stream and collect batches - auto read_stream = [&](std::vector &batches) { - // Each thread creates its own stream - auto stream = stream_driver.CreateArrayStream(); - auto [array_stream, schema] = StreamToUniqueStreamSchema(stream); - - std::vector local_batches; - - while (true) { - nanoarrow::UniqueArray array; - int get_next_result = array_stream->get_next(array_stream.get(), array.get()); - - if (get_next_result != 0) { - std::cerr << "Error: " << array_stream->get_last_error(array_stream.get()) << '\n'; - std::abort(); - } - - if (array->length == 0) { - break; // Empty array indicates end - } - - auto array_view = CreateArrayView(array, schema); - - int64_t first_id = ArrowArrayViewGetIntUnsafe(array_view->children[0], 0); - - local_batches.emplace_back(first_id, std::move(array)); - } - batches = std::move(local_batches); - }; - - // Launch two threads - std::thread thread1(read_stream, std::ref(thread1_batches)); - std::thread thread2(read_stream, std::ref(thread2_batches)); - - // Wait for both threads to complete - thread1.join(); - thread2.join(); - - // Combine all batches from both threads - std::vector all_batches; - all_batches.insert(all_batches.end(), - std::make_move_iterator(thread1_batches.begin()), - std::make_move_iterator(thread1_batches.end())); - all_batches.insert(all_batches.end(), - std::make_move_iterator(thread2_batches.begin()), - std::make_move_iterator(thread2_batches.end())); - - // Sort batches by first ID to ensure proper validation order - std::sort(all_batches.begin(), all_batches.end(), [](const BatchData &a, const BatchData &b) { - return a.first_id < b.first_id; - }); - - // Create reference data for validation - auto [ref_array, ref_schema] = ReadFirstArrayFromStream(vortex::testing::CreateTestData1MStream()); - auto ref_array_view = CreateArrayView(ref_array, ref_schema); - - // Validate all data against reference - constexpr size_t EXPECTED_ROWS = static_cast(1024) * 1024; - size_t total_rows_read = 0; - int64_t reference_offset = 0; - - auto stream_for_schema = stream_driver.CreateArrayStream(); - auto [_, schema] = StreamToUniqueStreamSchema(stream_for_schema); - for (const auto &batch : all_batches) { - auto array_view = CreateArrayView(batch.array, schema); - - for (int64_t i = 0; i < batch.array->length; ++i) { - - int64_t actual_id = ArrowArrayViewGetIntUnsafe(array_view->children[0], i); - int32_t actual_value = - static_cast(ArrowArrayViewGetIntUnsafe(array_view->children[1], i)); - - int64_t expected_id = ArrowArrayViewGetIntUnsafe(ref_array_view->children[0], reference_offset); - int32_t expected_value = static_cast( - ArrowArrayViewGetIntUnsafe(ref_array_view->children[1], reference_offset)); - - ASSERT_EQ(actual_id, expected_id); - ASSERT_EQ(actual_value, expected_value); - reference_offset++; - } - total_rows_read += batch.array->length; - } - - // Verify we read all expected data - ASSERT_EQ(total_rows_read, EXPECTED_ROWS) - << "Expected to read " << EXPECTED_ROWS << " rows, but read " << total_rows_read << " rows"; - ASSERT_EQ(reference_offset, EXPECTED_ROWS) << "Reference validation didn't cover all rows"; - - ASSERT_GT(all_batches.size(), 1) << "Expected multiple batches, but got " << all_batches.size(); -} - -namespace ve = vortex::expr; -namespace vs = vortex::scalar; - -TEST_F(VortexTest, ScanBuilderWithFilter) { - // Test filtering with eq(column("a"), val) - should return only rows where column "a" equals 30 - RunScanBuilderTest( - [](vortex::ScanBuilder &scan_builder) { - auto filter = ve::eq(ve::column("a"), ve::literal(vs::int32(30))); - return std::move(scan_builder.WithFilter(std::move(filter))).IntoStream(); - }, - vortex::testing::CreateTestDataStream(), - {2}, - true); // Row index 2 corresponds to value 30 -} - -TEST_F(VortexTest, ScanBuilderWithFilterLvalueref) { - // Test filtering with eq(column("a"), val) - should return only rows where column "a" equals 30 - RunScanBuilderTest( - [](vortex::ScanBuilder &scan_builder) { - const auto filter = ve::eq(ve::column("a"), ve::literal(vs::int32(30))); - return std::move(scan_builder.WithFilter(filter)).IntoStream(); - }, - vortex::testing::CreateTestDataStream(), - {2}, - true); // Row index 2 corresponds to value 30 -} - -TEST_F(VortexTest, ScanBuilderWithFilterNoMatches) { - // Test filtering with eq(column("a"), val) where no rows match - should return empty result - RunScanBuilderTest( - [](vortex::ScanBuilder &scan_builder) { - auto filter = ve::eq(ve::column("a"), - ve::literal(vs::int32(999)) // Value that doesn't exist in test data - ); - return std::move(scan_builder.WithFilter(std::move(filter))).IntoStream(); - }, - vortex::testing::CreateTestDataStream(), - {}, - true); // No matching rows -} - -TEST_F(VortexTest, ScanBuilderWithFilterUsingDTypeFromArrowAndScalarCast) { - // Test filtering using DType::from_arrow and Scalar::cast functionality - // This test creates a filter expression by casting a scalar to match the column type - - // Test DType::from_arrow with int32 schema - nanoarrow::UniqueSchema int32_schema; - ArrowSchemaInit(int32_schema.get()); - ArrowErrorCode result = ArrowSchemaSetType(int32_schema.get(), NANOARROW_TYPE_INT32); - EXPECT_EQ(result, NANOARROW_OK); - result = ArrowSchemaSetName(int32_schema.get(), "test_field"); - EXPECT_EQ(result, NANOARROW_OK); - - auto dtype = vortex::dtype::from_arrow(*int32_schema.get()); - - // Use the casted scalar in filter expression - create a new scalar for lambda - RunScanBuilderTest( - [&](vortex::ScanBuilder &scan_builder) { - auto test_scalar = vs::cast(vs::int64(30), std::move(dtype)); - auto filter = ve::eq(ve::column("a"), ve::literal(std::move(test_scalar))); - return std::move(scan_builder.WithFilter(std::move(filter))).IntoStream(); - }, - vortex::testing::CreateTestDataStream(), - {2}, - true); // Row index 2 corresponds to value 30 -} - -TEST_F(VortexTest, ScanBuilderWithProjectionSingleColumn) { - // Test projection selecting only column "a" (field index 0) - RunScanBuilderProjectionTest( - [](vortex::ScanBuilder &scan_builder) { - auto projection = ve::select({"a"}, ve::root()); - return std::move(scan_builder.WithProjection(std::move(projection))).IntoStream(); - }, - vortex::testing::CreateTestDataStream(), - {0}); -} - -TEST_F(VortexTest, OpenFromBuffer) { - std::string test_file_path = GetUniqueTempFile("buffer.vortex"); - auto stream = vortex::testing::CreateTestDataStream(); - auto write_options = vortex::ffi::write_options_new(); - vortex::ffi::write_array_stream(std::move(write_options), - reinterpret_cast(&stream), - test_file_path.c_str()); - - std::ifstream file(test_file_path, std::ios::binary | std::ios::ate); - ASSERT_TRUE(file.is_open()) << "Failed to open file: " << test_file_path; - - std::streamsize file_size = file.tellg(); - file.seekg(0, std::ios::beg); - - std::vector buffer(file_size); - ASSERT_TRUE(file.read(reinterpret_cast(buffer.data()), file_size)) - << "Failed to read file into buffer"; - file.close(); - - auto vortex_file = vortex::VortexFile::Open(buffer.data(), buffer.size()); - ASSERT_EQ(vortex_file.RowCount(), 5); - - auto scan_stream = vortex_file.CreateScanBuilder().IntoStream(); - auto [array_stream, schema] = StreamToUniqueStreamSchema(scan_stream); - auto array = ReadFirstArrayFromUniqueStream(array_stream); - - auto [ref_array, ref_schema] = ReadFirstArrayFromStream(vortex::testing::CreateTestDataStream()); - ValidateArray(array, schema, ref_array, ref_schema); -} diff --git a/vortex-cxx/cpp/tests/test_data_generator.cpp b/vortex-cxx/cpp/tests/test_data_generator.cpp deleted file mode 100644 index 67dcb290509..00000000000 --- a/vortex-cxx/cpp/tests/test_data_generator.cpp +++ /dev/null @@ -1,125 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#include "test_data_generator.hpp" -#include -#include -#include -#include -#include - -namespace vortex { -namespace testing { - - ArrowArrayStream CreateTestDataStream() { - // Create a simple two-column struct with int32 data - // Schema: struct{a: int32, b: int32} - nanoarrow::UniqueSchema schema; - ArrowSchemaInit(schema.get()); - NANOARROW_THROW_NOT_OK(ArrowSchemaSetType(schema.get(), NANOARROW_TYPE_STRUCT)); - NANOARROW_THROW_NOT_OK(ArrowSchemaAllocateChildren(schema.get(), 2)); - ArrowSchemaInit(schema->children[0]); - ArrowSchemaInit(schema->children[1]); - NANOARROW_THROW_NOT_OK(ArrowSchemaSetName(schema->children[0], "a")); - NANOARROW_THROW_NOT_OK(ArrowSchemaSetType(schema->children[0], NANOARROW_TYPE_INT32)); - NANOARROW_THROW_NOT_OK(ArrowSchemaSetName(schema->children[1], "b")); - NANOARROW_THROW_NOT_OK(ArrowSchemaSetType(schema->children[1], NANOARROW_TYPE_INT32)); - - // Create arrays for each field - nanoarrow::UniqueArray field_a, field_b; - NANOARROW_THROW_NOT_OK(ArrowArrayInitFromType(field_a.get(), NANOARROW_TYPE_INT32)); - NANOARROW_THROW_NOT_OK(ArrowArrayInitFromType(field_b.get(), NANOARROW_TYPE_INT32)); - - // Reserve for 5 elements - NANOARROW_THROW_NOT_OK(ArrowArrayStartAppending(field_a.get())); - NANOARROW_THROW_NOT_OK(ArrowArrayStartAppending(field_b.get())); - - // Add data: [10, 20, 30, 40, 50] - std::vector data = {10, 20, 30, 40, 50}; - for (int32_t value : data) { - NANOARROW_THROW_NOT_OK(ArrowArrayAppendInt(field_a.get(), value)); - NANOARROW_THROW_NOT_OK(ArrowArrayAppendInt(field_b.get(), value)); - } - - NANOARROW_THROW_NOT_OK( - ArrowArrayFinishBuilding(field_a.get(), NANOARROW_VALIDATION_LEVEL_NONE, nullptr)); - NANOARROW_THROW_NOT_OK( - ArrowArrayFinishBuilding(field_b.get(), NANOARROW_VALIDATION_LEVEL_NONE, nullptr)); - - // Create struct array - nanoarrow::UniqueArray struct_array; - NANOARROW_THROW_NOT_OK(ArrowArrayInitFromType(struct_array.get(), NANOARROW_TYPE_STRUCT)); - NANOARROW_THROW_NOT_OK(ArrowArrayAllocateChildren(struct_array.get(), 2)); - struct_array->length = 5; - ArrowArrayMove(field_a.get(), struct_array->children[0]); - ArrowArrayMove(field_b.get(), struct_array->children[1]); - - // Create vector and move array into it - std::vector arrays; - arrays.push_back(std::move(struct_array)); - - // Create stream - ArrowArrayStream stream; - nanoarrow::VectorArrayStream vector_stream(schema.get(), std::move(arrays)); - vector_stream.ToArrayStream(&stream); - - return stream; - } - - ArrowArrayStream CreateTestData1MStream() { - constexpr size_t NUM_ROWS = 1024UL * 1024; - - // Create schema: struct{id: int64, value: int32} - nanoarrow::UniqueSchema schema; - ArrowSchemaInit(schema.get()); - NANOARROW_THROW_NOT_OK(ArrowSchemaSetType(schema.get(), NANOARROW_TYPE_STRUCT)); - NANOARROW_THROW_NOT_OK(ArrowSchemaAllocateChildren(schema.get(), 2)); - ArrowSchemaInit(schema->children[0]); - ArrowSchemaInit(schema->children[1]); - NANOARROW_THROW_NOT_OK(ArrowSchemaSetName(schema->children[0], "id")); - NANOARROW_THROW_NOT_OK(ArrowSchemaSetType(schema->children[0], NANOARROW_TYPE_INT64)); - NANOARROW_THROW_NOT_OK(ArrowSchemaSetName(schema->children[1], "value")); - NANOARROW_THROW_NOT_OK(ArrowSchemaSetType(schema->children[1], NANOARROW_TYPE_INT32)); - - // Create arrays for each field - nanoarrow::UniqueArray id_field, value_field; - NANOARROW_THROW_NOT_OK(ArrowArrayInitFromType(id_field.get(), NANOARROW_TYPE_INT64)); - NANOARROW_THROW_NOT_OK(ArrowArrayInitFromType(value_field.get(), NANOARROW_TYPE_INT32)); - - // Reserve space - NANOARROW_THROW_NOT_OK(ArrowArrayStartAppending(id_field.get())); - NANOARROW_THROW_NOT_OK(ArrowArrayStartAppending(value_field.get())); - - // Add data - for (size_t i = 0; i < NUM_ROWS; ++i) { - NANOARROW_THROW_NOT_OK(ArrowArrayAppendInt(id_field.get(), static_cast(i))); - NANOARROW_THROW_NOT_OK(ArrowArrayAppendInt(value_field.get(), static_cast(i * 2))); - } - - NANOARROW_THROW_NOT_OK( - ArrowArrayFinishBuilding(id_field.get(), NANOARROW_VALIDATION_LEVEL_NONE, nullptr)); - NANOARROW_THROW_NOT_OK( - ArrowArrayFinishBuilding(value_field.get(), NANOARROW_VALIDATION_LEVEL_NONE, nullptr)); - - // Create struct array - nanoarrow::UniqueArray struct_array; - NANOARROW_THROW_NOT_OK(ArrowArrayInitFromType(struct_array.get(), NANOARROW_TYPE_STRUCT)); - NANOARROW_THROW_NOT_OK(ArrowArrayAllocateChildren(struct_array.get(), 2)); - struct_array->length = NUM_ROWS; - ArrowArrayMove(id_field.get(), struct_array->children[0]); - ArrowArrayMove(value_field.get(), struct_array->children[1]); - - // Create vector and move array into it - std::vector arrays; - arrays.push_back(std::move(struct_array)); - - // Create stream - ArrowArrayStream stream; - nanoarrow::VectorArrayStream vector_stream(schema.get(), std::move(arrays)); - vector_stream.ToArrayStream(&stream); - - return stream; - } - -} // namespace testing -} // namespace vortex \ No newline at end of file diff --git a/vortex-cxx/cpp/tests/test_data_generator.hpp b/vortex-cxx/cpp/tests/test_data_generator.hpp deleted file mode 100644 index ec5dba55d50..00000000000 --- a/vortex-cxx/cpp/tests/test_data_generator.hpp +++ /dev/null @@ -1,20 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#pragma once - -#include - -namespace vortex { -namespace testing { - - /// Create test data with structure {a: [10, 20, 30, 40, 50], b: [10, 20, 30, 40, 50]} - /// This stream only has one Array - ArrowArrayStream CreateTestDataStream(); - - /// Create 1M rows of test data with structure {id: [0..1M], value: [0, 2, 4, ..., 2M]} - /// This stream only has one Array - ArrowArrayStream CreateTestData1MStream(); - -} // namespace testing -} // namespace vortex \ No newline at end of file diff --git a/vortex-cxx/examples/.gitignore b/vortex-cxx/examples/.gitignore deleted file mode 100644 index 065a36090f7..00000000000 --- a/vortex-cxx/examples/.gitignore +++ /dev/null @@ -1 +0,0 @@ -!goldenfiles/example.parquet \ No newline at end of file diff --git a/vortex-cxx/examples/CMakeLists.txt b/vortex-cxx/examples/CMakeLists.txt deleted file mode 100644 index 348d80e1547..00000000000 --- a/vortex-cxx/examples/CMakeLists.txt +++ /dev/null @@ -1,34 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright the Vortex contributors - -cmake_minimum_required(VERSION 3.22) - -project(vortex-examples) - -set(CMAKE_CXX_STANDARD 20) -set(CMAKE_CXX_STANDARD_REQUIRED ON) - -find_program(SCCACHE_PROGRAM sccache) -if (SCCACHE_PROGRAM) - set(CMAKE_C_COMPILER_LAUNCHER "${SCCACHE_PROGRAM}") - set(CMAKE_CXX_COMPILER_LAUNCHER "${SCCACHE_PROGRAM}") - message(STATUS "Sccache found: ${SCCACHE_PROGRAM}") -else () - message(STATUS "Sccache not found") -endif () - -include(FetchContent) -FetchContent_Declare( - vortex - SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../ - SOURCE_SUBDIR vortex-cxx -) -FetchContent_MakeAvailable(vortex) - -add_executable(hello-vortex hello-vortex.cpp) - -if(APPLE) - set(APPLE_LINK_FLAGS "-framework CoreFoundation -framework Security") -endif() - -target_link_libraries(hello-vortex PRIVATE vortex ${APPLE_LINK_FLAGS}) diff --git a/vortex-cxx/examples/README.md b/vortex-cxx/examples/README.md deleted file mode 100644 index 40e7708fa33..00000000000 --- a/vortex-cxx/examples/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# C++ examples - -This example shows how to interface with the C++ API using CMake. - -```bash -mkdir -p build -cd build -cmake .. -make -j$(nproc) -./hello-vortex -``` \ No newline at end of file diff --git a/vortex-cxx/examples/hello-vortex.cpp b/vortex-cxx/examples/hello-vortex.cpp deleted file mode 100644 index c6a7fec3b9a..00000000000 --- a/vortex-cxx/examples/hello-vortex.cpp +++ /dev/null @@ -1,129 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#include "nanoarrow/common/inline_types.h" -#include "nanoarrow/hpp/unique.hpp" -#include "nanoarrow/nanoarrow.hpp" -#include "vortex/file.hpp" -#include "vortex/scan.hpp" -#include "vortex/write_options.hpp" -#include -#include -#include -#include -#include - -/// Create test data with structure {a: [10, 20, 30, 40, 50], b: [100, 200, 300, 400, 500]} -ArrowArrayStream CreateTestDataStream() { - // Create a simple two-column struct with int32 data - // Schema: struct{a: int32, b: int32} - nanoarrow::UniqueSchema schema; - ArrowSchemaInit(schema.get()); - NANOARROW_THROW_NOT_OK(ArrowSchemaSetType(schema.get(), NANOARROW_TYPE_STRUCT)); - NANOARROW_THROW_NOT_OK(ArrowSchemaAllocateChildren(schema.get(), 2)); - ArrowSchemaInit(schema->children[0]); - ArrowSchemaInit(schema->children[1]); - NANOARROW_THROW_NOT_OK(ArrowSchemaSetName(schema->children[0], "a")); - NANOARROW_THROW_NOT_OK(ArrowSchemaSetType(schema->children[0], NANOARROW_TYPE_INT32)); - NANOARROW_THROW_NOT_OK(ArrowSchemaSetName(schema->children[1], "b")); - NANOARROW_THROW_NOT_OK(ArrowSchemaSetType(schema->children[1], NANOARROW_TYPE_INT32)); - - // Create arrays for each field - nanoarrow::UniqueArray field_a, field_b; - NANOARROW_THROW_NOT_OK(ArrowArrayInitFromType(field_a.get(), NANOARROW_TYPE_INT32)); - NANOARROW_THROW_NOT_OK(ArrowArrayInitFromType(field_b.get(), NANOARROW_TYPE_INT32)); - - // Reserve for 5 elements - NANOARROW_THROW_NOT_OK(ArrowArrayStartAppending(field_a.get())); - NANOARROW_THROW_NOT_OK(ArrowArrayStartAppending(field_b.get())); - - // Add data: a=[10, 20, 30, 40, 50], b=[100, 200, 300, 400, 500] - std::vector data_a = {10, 20, 30, 40, 50}; - std::vector data_b = {100, 200, 300, 400, 500}; - for (size_t i = 0; i < data_a.size(); ++i) { - NANOARROW_THROW_NOT_OK(ArrowArrayAppendInt(field_a.get(), data_a[i])); - NANOARROW_THROW_NOT_OK(ArrowArrayAppendInt(field_b.get(), data_b[i])); - } - - NANOARROW_THROW_NOT_OK(ArrowArrayFinishBuilding(field_a.get(), NANOARROW_VALIDATION_LEVEL_NONE, nullptr)); - NANOARROW_THROW_NOT_OK(ArrowArrayFinishBuilding(field_b.get(), NANOARROW_VALIDATION_LEVEL_NONE, nullptr)); - - // Create struct array - nanoarrow::UniqueArray struct_array; - NANOARROW_THROW_NOT_OK(ArrowArrayInitFromType(struct_array.get(), NANOARROW_TYPE_STRUCT)); - NANOARROW_THROW_NOT_OK(ArrowArrayAllocateChildren(struct_array.get(), 2)); - struct_array->length = 5; - ArrowArrayMove(field_a.get(), struct_array->children[0]); - ArrowArrayMove(field_b.get(), struct_array->children[1]); - - // Create vector and move array into it - std::vector arrays; - arrays.push_back(std::move(struct_array)); - - // Create stream - ArrowArrayStream stream; - nanoarrow::VectorArrayStream vector_stream(schema.get(), std::move(arrays)); - vector_stream.ToArrayStream(&stream); - - return stream; -} - -int main() { - // Create a temporary file path - std::filesystem::path temp_dir = std::filesystem::temp_directory_path(); - std::string vortex_file = (temp_dir / "hello_vortex_example.vortex").string(); - - std::cout << "=== Vortex C++ Example ===" << '\n'; - std::cout << "Writing to: " << vortex_file << '\n'; - - // Write test data to a Vortex file - { - auto stream = CreateTestDataStream(); - vortex::VortexWriteOptions write_options; - write_options.WriteArrayStream(stream, vortex_file); - std::cout << "Wrote test data to file" << '\n'; - } - - auto check_stream = [](ArrowArrayStream &stream) { - nanoarrow::UniqueArray array; - int get_next_result = stream.get_next(&stream, array.get()); - assert(get_next_result == 0); - std::cout << "Number of rows: " << array->length << '\n'; - std::cout << "Number of columns in schema: " << array->n_children << '\n'; - }; - - // 1. Classic C++ builder pattern - std::cout << "\n1. Classic C++ builder pattern:" << '\n'; - { - auto builder = vortex::VortexFile::Open(vortex_file).CreateScanBuilder(); - builder.WithLimit(3); - auto stream = std::move(builder).IntoStream(); - check_stream(stream); - } - // 2. One-line Rusty function chain - std::cout << "\n2. One-line Rusty function chain:" << '\n'; - { - auto stream = vortex::VortexFile::Open(vortex_file).CreateScanBuilder().WithLimit(3).IntoStream(); - check_stream(stream); - } - // 3. Conditionally set the builder - std::cout << "\n3. Conditionally set the builder:" << '\n'; - { - auto limit = 1; - auto builder = vortex::VortexFile::Open(vortex_file).CreateScanBuilder(); - if (limit > 0) { - // prefer C++ way - builder.WithLimit(1); - // Rusty way is Ok, but you have to move the builder to an rvalue. - // builder = std::move(builder).WithLimit(3); - } - auto stream = std::move(builder).IntoStream(); - check_stream(stream); - } - - // Clean up - std::filesystem::remove(vortex_file); - std::cout << "\nCleaned up temporary file" << '\n'; - - return 0; -} \ No newline at end of file diff --git a/vortex-cxx/src/dtype.rs b/vortex-cxx/src/dtype.rs deleted file mode 100644 index 0c125bcf822..00000000000 --- a/vortex-cxx/src/dtype.rs +++ /dev/null @@ -1,94 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -use std::fmt::Display; -use std::fmt::Formatter; - -use anyhow::Result; -use arrow_array::ffi::FFI_ArrowSchema; -use arrow_schema::Field; -use vortex::dtype::DType as RustDType; -use vortex::dtype::DecimalDType; -use vortex::dtype::Nullability; -use vortex::dtype::PType as RustPType; -use vortex::dtype::arrow::FromArrowType; - -use crate::ffi; -pub(crate) struct DType { - pub(crate) inner: RustDType, -} - -pub(crate) fn dtype_null() -> Box { - Box::new(DType { - inner: RustDType::Null, - }) -} - -pub(crate) fn dtype_bool(nullable: bool) -> Box { - Box::new(DType { - inner: RustDType::Bool(nullability_from_bool(nullable)), - }) -} - -pub(crate) fn dtype_primitive(ptype: ffi::PType, nullable: bool) -> Box { - let vortex_ptype = match ptype { - ffi::PType::U8 => RustPType::U8, - ffi::PType::U16 => RustPType::U16, - ffi::PType::U32 => RustPType::U32, - ffi::PType::U64 => RustPType::U64, - ffi::PType::I8 => RustPType::I8, - ffi::PType::I16 => RustPType::I16, - ffi::PType::I32 => RustPType::I32, - ffi::PType::I64 => RustPType::I64, - ffi::PType::F16 => RustPType::F16, - ffi::PType::F32 => RustPType::F32, - ffi::PType::F64 => RustPType::F64, - _ => unreachable!(), - }; - Box::new(DType { - inner: RustDType::Primitive(vortex_ptype, nullability_from_bool(nullable)), - }) -} - -pub(crate) fn dtype_decimal(precision: u8, scale: i8, nullable: bool) -> Box { - Box::new(DType { - inner: RustDType::Decimal( - DecimalDType::new(precision, scale), - nullability_from_bool(nullable), - ), - }) -} - -pub(crate) fn dtype_utf8(nullable: bool) -> Box { - Box::new(DType { - inner: RustDType::Utf8(nullability_from_bool(nullable)), - }) -} - -pub(crate) fn dtype_binary(nullable: bool) -> Box { - Box::new(DType { - inner: RustDType::Binary(nullability_from_bool(nullable)), - }) -} - -impl Display for DType { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{0}", self.inner) - } -} - -pub(crate) fn nullability_from_bool(nullable: bool) -> Nullability { - if nullable { - Nullability::Nullable - } else { - Nullability::NonNullable - } -} - -pub(crate) unsafe fn from_arrow(ffi_schema: *mut u8, non_nullable: bool) -> Result> { - let arrow_schema = unsafe { FFI_ArrowSchema::from_raw(ffi_schema as *mut FFI_ArrowSchema) }; - let arrow_dtype = arrow_schema::DataType::try_from(&arrow_schema)?; - Ok(Box::new(DType { - inner: RustDType::from_arrow(&Field::new("_", arrow_dtype, !non_nullable)), - })) -} diff --git a/vortex-cxx/src/expr.rs b/vortex-cxx/src/expr.rs deleted file mode 100644 index b980db7111b..00000000000 --- a/vortex-cxx/src/expr.rs +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -use vortex::dtype::FieldName; -use vortex::expr::Expression; - -use crate::scalar::Scalar; - -pub(crate) struct Expr { - pub(crate) inner: Expression, -} - -pub(crate) fn literal(scalar: Box) -> Box { - Box::new(Expr { - inner: vortex::expr::lit(scalar.inner), - }) -} - -pub(crate) fn root() -> Box { - Box::new(Expr { - inner: vortex::expr::root(), - }) -} - -pub(crate) fn column(name: String) -> Box { - Box::new(Expr { - inner: vortex::expr::get_item(name, vortex::expr::root()), - }) -} - -pub(crate) fn get_item(field: String, child: Box) -> Box { - Box::new(Expr { - inner: vortex::expr::get_item(field, child.inner), - }) -} - -pub(crate) fn not_(child: Box) -> Box { - Box::new(Expr { - inner: vortex::expr::not(child.inner), - }) -} - -pub(crate) fn is_null(child: Box) -> Box { - Box::new(Expr { - inner: vortex::expr::is_null(child.inner), - }) -} - -macro_rules! binary_op { - ($fn_name:ident $(, $suffix:tt)?) => { - paste::paste! { - pub(crate) fn [<$fn_name $($suffix)?>]( - lhs: Box, - rhs: Box, - ) -> Box { - Box::new(Expr { - inner: vortex::expr::$fn_name(lhs.inner, rhs.inner), - }) - } - } - }; -} - -binary_op!(eq); -binary_op!(not_eq, _); -binary_op!(gt); -binary_op!(gt_eq); -binary_op!(lt); -binary_op!(lt_eq); -binary_op!(and, _); -binary_op!(or, _); -binary_op!(checked_add); - -pub(crate) fn select(fields: Vec, child: Box) -> Box { - Box::new(Expr { - inner: vortex::expr::select( - fields.into_iter().map(FieldName::from).collect::>(), - child.inner, - ), - }) -} diff --git a/vortex-cxx/src/lib.rs b/vortex-cxx/src/lib.rs deleted file mode 100644 index b6d002513f6..00000000000 --- a/vortex-cxx/src/lib.rs +++ /dev/null @@ -1,139 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#![allow(clippy::boxed_local)] - -mod dtype; -mod expr; -mod read; -mod scalar; -mod write; - -use std::sync::LazyLock; - -use dtype::*; -use expr::*; -use read::*; -use scalar::*; -use vortex::VortexSessionDefault; -use vortex::io::runtime::BlockingRuntime; -use vortex::io::runtime::current::CurrentThreadRuntime; -use vortex::io::session::RuntimeSessionExt; -use vortex::session::VortexSession; -use write::*; - -/// By default, the C++ API uses a current-thread runtime, providing control of the threading -/// model to the C++ side. -// TODO(ngates): in the future, we could expose an API for C++ to spawn threads that can drive -// this runtime. -pub(crate) static RUNTIME: LazyLock = - LazyLock::new(CurrentThreadRuntime::new); -pub(crate) static SESSION: LazyLock = - LazyLock::new(|| VortexSession::default().with_handle(RUNTIME.handle())); - -#[cxx::bridge(namespace = "vortex::ffi")] -#[allow(let_underscore_drop)] -#[allow(clippy::absolute_paths)] -mod ffi { - extern "Rust" { - type DType; - // Factory functions for creating DType - fn dtype_null() -> Box; - fn dtype_bool(nullable: bool) -> Box; - fn dtype_primitive(ptype: PType, nullable: bool) -> Box; - fn dtype_decimal(precision: u8, scale: i8, nullable: bool) -> Box; - fn dtype_utf8(nullable: bool) -> Box; - fn dtype_binary(nullable: bool) -> Box; - unsafe fn from_arrow(ffi_schema: *mut u8, non_nullable: bool) -> Result>; - // Methods for DType - fn to_string(self: &DType) -> String; - - type Scalar; - fn bool_scalar_new(value: bool) -> Box; - fn i8_scalar_new(value: i8) -> Box; - fn i16_scalar_new(value: i16) -> Box; - fn i32_scalar_new(value: i32) -> Box; - fn i64_scalar_new(value: i64) -> Box; - fn u8_scalar_new(value: u8) -> Box; - fn u16_scalar_new(value: u16) -> Box; - fn u32_scalar_new(value: u32) -> Box; - fn u64_scalar_new(value: u64) -> Box; - fn f32_scalar_new(value: f32) -> Box; - fn f64_scalar_new(value: f64) -> Box; - fn string_scalar_new(value: &str) -> Box; - fn binary_scalar_new(value: &[u8]) -> Box; - fn cast_scalar(self: &Scalar, dtype: &DType) -> Result>; - - type Expr; - fn literal(scalar: Box) -> Box; - fn root() -> Box; - fn column(name: String) -> Box; - fn get_item(field: String, child: Box) -> Box; - fn not_(child: Box) -> Box; - fn is_null(child: Box) -> Box; - // binary op - fn eq(lhs: Box, rhs: Box) -> Box; - fn not_eq_(lhs: Box, rhs: Box) -> Box; - fn gt(lhs: Box, rhs: Box) -> Box; - fn gt_eq(lhs: Box, rhs: Box) -> Box; - fn lt(lhs: Box, rhs: Box) -> Box; - fn lt_eq(lhs: Box, rhs: Box) -> Box; - fn and_(lhs: Box, rhs: Box) -> Box; - fn or_(lhs: Box, rhs: Box) -> Box; - fn checked_add(lhs: Box, rhs: Box) -> Box; - fn select(fields: Vec, child: Box) -> Box; - - type VortexFile; - fn row_count(self: &VortexFile) -> u64; - fn scan_builder(self: &VortexFile) -> Result>; - fn open_file(path: &str) -> Result>; - fn open_file_from_buffer(data: &[u8]) -> Result>; - - type VortexScanBuilder; - fn with_filter(self: &mut VortexScanBuilder, filter: Box); - fn with_filter_ref(self: &mut VortexScanBuilder, filter: &Expr); - fn with_projection(self: &mut VortexScanBuilder, projection: Box); - fn with_projection_ref(self: &mut VortexScanBuilder, projection: &Expr); - fn with_row_range(self: &mut VortexScanBuilder, row_range_start: u64, row_range_end: u64); - fn with_include_by_index(self: &mut VortexScanBuilder, include_by_index: &[u64]); - fn with_limit(self: &mut VortexScanBuilder, limit: usize); - unsafe fn with_output_schema( - self: &mut VortexScanBuilder, - output_schema: *mut u8, - ) -> Result<()>; - unsafe fn scan_builder_into_stream( - builder: Box, - out_stream: *mut u8, - ) -> Result<()>; - fn scan_builder_into_threadsafe_cloneable_reader( - builder: Box, - ) -> Result>; - - type ThreadsafeCloneableReader; - unsafe fn clone_a_stream(self: &ThreadsafeCloneableReader, out_stream: *mut u8); - - type VortexWriteOptions; - fn write_options_new() -> Box; - unsafe fn write_array_stream( - options: Box, - input_stream: *mut u8, - path: &str, - ) -> Result<()>; - } - - #[repr(u8)] - #[derive(Debug, Clone, Copy)] - enum PType { - U8, - U16, - U32, - U64, - I8, - I16, - I32, - I64, - F16, - F32, - F64, - } -} diff --git a/vortex-cxx/src/read.rs b/vortex-cxx/src/read.rs deleted file mode 100644 index 110e42d70f0..00000000000 --- a/vortex-cxx/src/read.rs +++ /dev/null @@ -1,193 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -use std::sync::Arc; - -use anyhow::Result; -use arrow_array::RecordBatch; -use arrow_array::RecordBatchReader; -use arrow_array::cast::AsArray; -use arrow_array::ffi::FFI_ArrowSchema; -use arrow_array::ffi_stream::FFI_ArrowArrayStream; -use arrow_schema::ArrowError; -use arrow_schema::Field; -use arrow_schema::Schema; -use arrow_schema::SchemaRef; -use futures::stream::TryStreamExt; -use vortex::array::ArrayRef; -use vortex::array::VortexSessionExecute; -use vortex::array::arrow::ArrowSessionExt; -use vortex::buffer::Buffer; -use vortex::file::OpenOptionsSessionExt; -use vortex::io::runtime::BlockingRuntime; -use vortex::layout::scan::arrow::RecordBatchIteratorAdapter; -use vortex::layout::scan::scan_builder::ScanBuilder; -use vortex::scan::selection::Selection; - -use crate::RUNTIME; -use crate::SESSION; -use crate::expr::Expr; - -pub(crate) struct VortexFile { - inner: vortex::file::VortexFile, -} - -impl VortexFile { - pub(crate) fn row_count(&self) -> u64 { - self.inner.row_count() - } - - pub(crate) fn scan_builder(&self) -> Result> { - Ok(Box::new(VortexScanBuilder { - inner: self.inner.scan()?, - output_schema: None, - })) - } -} - -/// File operations - using blocking operations for simplicity -/// TODO(xinyu): object store (see vortex-ffi) -pub(crate) fn open_file(path: &str) -> Result> { - let file = RUNTIME.block_on(SESSION.open_options().open_path(std::path::Path::new(path)))?; - Ok(Box::new(VortexFile { inner: file })) -} - -pub(crate) fn open_file_from_buffer(data: &[u8]) -> Result> { - let buffer = Buffer::from(data.to_vec()); - let file = SESSION.open_options().open_buffer(buffer)?; - Ok(Box::new(VortexFile { inner: file })) -} - -pub(crate) struct VortexScanBuilder { - inner: ScanBuilder, - output_schema: Option, -} - -impl VortexScanBuilder { - pub(crate) fn with_filter(&mut self, filter: Box) { - take_mut::take(&mut self.inner, |inner| inner.with_filter(filter.inner)); - } - - pub(crate) fn with_filter_ref(&mut self, filter: &Expr) { - take_mut::take(&mut self.inner, |inner| { - inner.with_filter(filter.inner.clone()) - }); - } - - pub(crate) fn with_projection(&mut self, filter: Box) { - take_mut::take(&mut self.inner, |inner| inner.with_projection(filter.inner)); - } - - pub(crate) fn with_projection_ref(&mut self, filter: &Expr) { - take_mut::take(&mut self.inner, |inner| { - inner.with_projection(filter.inner.clone()) - }); - } - - pub(crate) fn with_row_range(&mut self, row_range_start: u64, row_range_end: u64) { - take_mut::take(&mut self.inner, |inner| { - inner.with_row_range(row_range_start..row_range_end) - }); - } - - pub(crate) fn with_include_by_index(&mut self, include_by_index: &[u64]) { - let selection = Selection::IncludeByIndex(Buffer::copy_from(include_by_index)); - take_mut::take(&mut self.inner, |inner| inner.with_selection(selection)); - } - - pub(crate) fn with_limit(&mut self, limit: usize) { - take_mut::take(&mut self.inner, |inner| inner.with_limit(limit as u64)); - } - - pub(crate) unsafe fn with_output_schema(&mut self, output_schema: *mut u8) -> Result<()> { - let ffi_schema = - unsafe { FFI_ArrowSchema::from_raw(output_schema as *mut FFI_ArrowSchema) }; - self.output_schema = Some(Arc::new(Schema::try_from(&ffi_schema)?)); - Ok(()) - } -} - -/// # Safety -/// -/// out_stream should be properly aligned according to the Arrow C stream interface and valid for write. -pub(crate) unsafe fn scan_builder_into_stream( - builder: Box, - out_stream: *mut u8, -) -> Result<()> { - let schema = match builder.output_schema { - Some(schema) => schema, - None => { - let dtype = builder.inner.dtype()?; - let arrow_schema = dtype.to_arrow_schema()?; - Arc::new(arrow_schema) - } - }; - let reader = builder.inner.into_record_batch_reader(schema, &*RUNTIME)?; - let stream = FFI_ArrowArrayStream::new(Box::new(reader)); - let out_stream = out_stream as *mut FFI_ArrowArrayStream; - // # Safety - // Arrow C stream interface - unsafe { std::ptr::write(out_stream, stream) }; - Ok(()) -} - -trait ThreadsafeCloneableReaderTrait: RecordBatchReader + Send + 'static { - fn clone_boxed(&self) -> Box; -} - -impl ThreadsafeCloneableReaderTrait for T -where - T: RecordBatchReader + Send + Clone + 'static, -{ - fn clone_boxed(&self) -> Box { - Box::new(self.clone()) - } -} - -pub(crate) struct ThreadsafeCloneableReader { - inner: Box, -} - -#[allow(clippy::disallowed_methods)] -pub(crate) fn scan_builder_into_threadsafe_cloneable_reader( - builder: Box, -) -> Result, Box> { - let schema = match builder.output_schema { - Some(schema) => schema, - None => { - let dtype = builder.inner.dtype()?; - let arrow_schema = dtype.to_arrow_schema()?; - Arc::new(arrow_schema) - } - }; - let target = Field::new_struct("", schema.fields.clone(), false); - - let stream = builder - .inner - .map(move |b| { - SESSION - .arrow() - .execute_arrow(b, Some(&target), &mut SESSION.create_execution_ctx()) - .map(|struct_array| RecordBatch::from(struct_array.as_struct())) - }) - .into_stream()? - .map_err(|e| ArrowError::ExternalError(Box::new(e))); - - let iter = RUNTIME.block_on_stream_thread_safe(|_h| stream); - let rbr = RecordBatchIteratorAdapter::new(iter, schema); - - Ok(Box::new(ThreadsafeCloneableReader { - inner: Box::new(rbr), - })) -} - -impl ThreadsafeCloneableReader { - pub(crate) fn clone_a_stream(&self, out_stream: *mut u8) { - let cloned_reader = self.inner.clone_boxed(); - let stream = FFI_ArrowArrayStream::new(cloned_reader); - let out_stream = out_stream as *mut FFI_ArrowArrayStream; - // # Safety - // Arrow C stream interface - unsafe { std::ptr::write(out_stream, stream) }; - } -} diff --git a/vortex-cxx/src/scalar.rs b/vortex-cxx/src/scalar.rs deleted file mode 100644 index 24cd38be0f1..00000000000 --- a/vortex-cxx/src/scalar.rs +++ /dev/null @@ -1,52 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -use anyhow::Result; - -use crate::dtype::DType; - -pub(crate) struct Scalar { - pub(crate) inner: vortex::scalar::Scalar, -} - -macro_rules! primitive_scalar_new { - ($name:ident, $type:ty) => { - pub(crate) fn $name(value: $type) -> Box { - Box::new(Scalar { - inner: vortex::scalar::Scalar::from(value), - }) - } - }; -} - -primitive_scalar_new!(bool_scalar_new, bool); // bool is not primitive but reuse the macro here -primitive_scalar_new!(i8_scalar_new, i8); -primitive_scalar_new!(i16_scalar_new, i16); -primitive_scalar_new!(i32_scalar_new, i32); -primitive_scalar_new!(i64_scalar_new, i64); -primitive_scalar_new!(u8_scalar_new, u8); -primitive_scalar_new!(u16_scalar_new, u16); -primitive_scalar_new!(u32_scalar_new, u32); -primitive_scalar_new!(u64_scalar_new, u64); -primitive_scalar_new!(f32_scalar_new, f32); -primitive_scalar_new!(f64_scalar_new, f64); - -pub(crate) fn string_scalar_new(value: &str) -> Box { - Box::new(Scalar { - inner: vortex::scalar::Scalar::from(value), - }) -} - -pub(crate) fn binary_scalar_new(value: &[u8]) -> Box { - Box::new(Scalar { - inner: vortex::scalar::Scalar::from(value), - }) -} - -impl Scalar { - pub(crate) fn cast_scalar(&self, dtype: &DType) -> Result> { - Ok(Box::new(Scalar { - inner: self.inner.cast(&dtype.inner)?, - })) - } -} diff --git a/vortex-cxx/src/write.rs b/vortex-cxx/src/write.rs deleted file mode 100644 index ff77c5a3dc5..00000000000 --- a/vortex-cxx/src/write.rs +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -use anyhow::Result; -use arrow_array::RecordBatchReader; -use arrow_array::ffi_stream::ArrowArrayStreamReader; -use arrow_array::ffi_stream::FFI_ArrowArrayStream; -use vortex::array::ArrayRef; -use vortex::array::arrow::FromArrowArray; -use vortex::array::iter::ArrayIteratorAdapter; -use vortex::array::iter::ArrayIteratorExt; -use vortex::array::stream::ArrayStream; -use vortex::dtype::DType; -use vortex::dtype::arrow::FromArrowType; -use vortex::error::VortexError; -use vortex::file::VortexWriteOptions as WriteOptions; -use vortex::file::WriteOptionsSessionExt; -use vortex::io::VortexWrite; -use vortex::io::runtime::BlockingRuntime; - -use crate::RUNTIME; -use crate::SESSION; - -pub(crate) struct VortexWriteOptions { - inner: WriteOptions, -} - -pub(crate) fn write_options_new() -> Box { - Box::new(VortexWriteOptions { - inner: SESSION.write_options(), - }) -} - -/// Convert an ArrowArrayStreamReader to a Vortex ArrayStream -fn arrow_stream_to_vortex_stream(reader: ArrowArrayStreamReader) -> Result { - let array_iter = ArrayIteratorAdapter::new( - DType::from_arrow(reader.schema()), - reader.map(|result| { - result - .map_err(VortexError::from) - .and_then(|record_batch| ArrayRef::from_arrow(record_batch, false)) - }), - ); - - Ok(array_iter.into_array_stream()) -} - -/// # Safety -/// -/// input_stream should be valid FFI_ArrowArrayStream. -/// See [`FFI_ArrowArrayStream::from_raw`] -pub(crate) unsafe fn write_array_stream( - options: Box, - input_stream: *mut u8, - path: &str, -) -> Result<()> { - let path = path.to_string(); - - let stream_reader = - unsafe { ArrowArrayStreamReader::from_raw(input_stream as *mut FFI_ArrowArrayStream) }?; - - let vortex_stream = arrow_stream_to_vortex_stream(stream_reader)?; - - RUNTIME.block_on(async { - let mut file = async_fs::File::create(path).await?; - options.inner.write(&mut file, vortex_stream).await?; - file.shutdown().await?; - Ok(()) - }) -} From 1d7951a77b84fb7bef4fcf7cd35cac940f1b4fc1 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Mon, 13 Jul 2026 15:22:49 +0100 Subject: [PATCH 066/104] Split arrow dependency from vortex-array (#8717) Vortex-array doesn't need arrow for any particular functionality. We want to be arrow compatible and support conversion from one to another but that logic doesn't need to live with array implementations. Instead we move all arrow related logic to vortex-arrow crate that users can depend on which can depend on other crates not just vortex-array N.B. we still have arrow-buffer dependency in vortex-array since we use their i256. Having that dependency doesn't seem particularly problematic --- Cargo.lock | 49 ++++- Cargo.toml | 2 + benchmarks/compress-bench/Cargo.toml | 1 + benchmarks/compress-bench/src/vortex.rs | 1 + benchmarks/datafusion-bench/Cargo.toml | 1 + benchmarks/datafusion-bench/src/main.rs | 1 + encodings/parquet-variant/Cargo.toml | 1 + encodings/parquet-variant/src/array.rs | 14 +- encodings/parquet-variant/src/arrow.rs | 16 +- encodings/parquet-variant/src/kernel.rs | 7 +- encodings/parquet-variant/src/lib.rs | 2 +- encodings/pco/Cargo.toml | 1 + encodings/pco/src/tests.rs | 2 +- encodings/runend/Cargo.toml | 4 - encodings/runend/src/array.rs | 4 +- encodings/runend/src/lib.rs | 4 +- encodings/runend/src/ops.rs | 5 +- encodings/sparse/Cargo.toml | 1 + encodings/sparse/src/canonical.rs | 2 +- vortex-array/Cargo.toml | 12 +- vortex-array/benches/kleene_bool.rs | 2 - .../src/aggregate_fn/fns/is_sorted/mod.rs | 19 +- vortex-array/src/arc_swap_map.rs | 14 +- .../src/arrays/constant/compute/fill_null.rs | 59 ++---- vortex-array/src/arrays/decimal/mod.rs | 14 -- vortex-array/src/canonical.rs | 169 ++---------------- vortex-array/src/dtype/mod.rs | 1 - vortex-array/src/expr/exprs.rs | 12 +- vortex-array/src/extension/uuid/mod.rs | 1 - vortex-array/src/lib.rs | 11 +- vortex-array/src/scalar/mod.rs | 1 - vortex-array/src/scalar_fn/fns/zip/mod.rs | 51 +++--- vortex-arrow/Cargo.toml | 46 +++++ .../benches/to_arrow.rs | 14 +- .../src/arrow => vortex-arrow/src}/convert.rs | 116 ++++++------ .../src/arrow => vortex-arrow/src}/datum.rs | 18 +- .../arrow.rs => vortex-arrow/src/dtype.rs | 118 ++++++------ .../src}/executor/bool.rs | 10 +- .../src}/executor/byte.rs | 40 ++--- .../src}/executor/byte_view.rs | 16 +- .../src}/executor/decimal.rs | 46 ++--- .../src}/executor/dictionary.rs | 51 +++--- .../src}/executor/fixed_size_list.rs | 14 +- .../src}/executor/list.rs | 63 +++---- .../src}/executor/list_view.rs | 44 ++--- .../src}/executor/mod.rs | 50 +++--- .../src}/executor/null.rs | 7 +- .../src}/executor/primitive.rs | 16 +- .../src}/executor/run_end.rs | 109 +++++------ .../src}/executor/struct_.rs | 63 +++---- .../src}/executor/temporal.rs | 24 +-- .../src}/executor/validity.rs | 2 +- .../src/arrow => vortex-arrow/src}/iter.rs | 10 +- .../arrow/mod.rs => vortex-arrow/src/lib.rs | 38 +++- .../arrow => vortex-arrow/src}/null_buffer.rs | 7 +- .../src/run_end_import.rs | 22 +-- .../arrow.rs => vortex-arrow/src/scalar.rs | 151 ++++++++-------- .../src/arrow => vortex-arrow/src}/session.rs | 99 +++++----- .../uuid/arrow.rs => vortex-arrow/src/uuid.rs | 48 ++--- vortex-arrow/tests/canonical.rs | 168 +++++++++++++++++ vortex-bench/Cargo.toml | 1 + vortex-bench/src/conversions.rs | 4 +- .../src/datasets/struct_list_of_ints.rs | 3 +- .../src/spatialbench/datagen/native.rs | 2 +- vortex-bench/src/tpch/tpchgen.rs | 4 +- vortex-cuda/Cargo.toml | 1 + vortex-cuda/src/arrow/mod.rs | 2 +- vortex-datafusion/Cargo.toml | 1 + vortex-datafusion/src/convert/exprs.rs | 4 +- vortex-datafusion/src/convert/scalars.rs | 2 +- vortex-datafusion/src/convert/schema.rs | 2 +- vortex-datafusion/src/lib.rs | 2 +- vortex-datafusion/src/persistent/format.rs | 2 +- vortex-datafusion/src/persistent/opener.rs | 4 +- vortex-datafusion/src/persistent/sink.rs | 2 +- vortex-datafusion/src/persistent/tests.rs | 4 +- vortex-datafusion/src/v2/source.rs | 2 +- vortex-ffi/Cargo.toml | 1 + vortex-ffi/src/array.rs | 2 +- vortex-ffi/src/dtype.rs | 3 +- vortex-ffi/src/scan.rs | 3 +- vortex-geo/Cargo.toml | 1 + vortex-geo/src/extension/linestring.rs | 16 +- vortex-geo/src/extension/mod.rs | 2 +- vortex-geo/src/extension/multilinestring.rs | 16 +- vortex-geo/src/extension/multipoint.rs | 16 +- vortex-geo/src/extension/multipolygon.rs | 16 +- vortex-geo/src/extension/point.rs | 16 +- vortex-geo/src/extension/polygon.rs | 16 +- vortex-geo/src/extension/wkb.rs | 14 +- vortex-geo/src/lib.rs | 2 +- vortex-geo/src/tests/linestring.rs | 2 +- vortex-geo/src/tests/multilinestring.rs | 2 +- vortex-geo/src/tests/multipoint.rs | 2 +- vortex-geo/src/tests/multipolygon.rs | 2 +- vortex-geo/src/tests/point.rs | 2 +- vortex-geo/src/tests/wkb.rs | 2 +- vortex-jni/Cargo.toml | 1 + vortex-jni/src/dtype.rs | 1 + vortex-jni/src/scan.rs | 3 +- vortex-jni/src/writer.rs | 2 +- vortex-json/Cargo.toml | 1 + vortex-json/src/arrow.rs | 16 +- vortex-json/src/lib.rs | 2 +- vortex-layout/Cargo.toml | 1 + vortex-layout/src/scan/arrow.rs | 4 +- vortex-python/Cargo.toml | 1 + vortex-python/src/arrays/from_arrow.rs | 4 +- vortex-python/src/arrays/mod.rs | 2 +- vortex-python/src/dataset.rs | 1 + vortex-python/src/dtype/mod.rs | 5 +- vortex-python/src/file.rs | 1 + vortex-python/src/io.rs | 4 +- vortex-python/src/iter/mod.rs | 3 +- vortex-tensor/Cargo.toml | 1 + vortex-tensor/src/lib.rs | 2 +- vortex-tensor/src/types/vector/arrow.rs | 24 +-- vortex-test/compat-gen/Cargo.toml | 1 + .../fixtures/arrays/datasets/clickbench.rs | 2 +- .../src/fixtures/arrays/datasets/tpch.rs | 2 +- vortex-test/e2e-cuda/src/lib.rs | 2 +- vortex-tui/Cargo.toml | 1 + vortex-tui/src/convert.rs | 4 +- vortex-web/crate/Cargo.toml | 1 + vortex-web/crate/src/wasm.rs | 2 +- vortex/Cargo.toml | 3 +- vortex/src/lib.rs | 13 +- 127 files changed, 1134 insertions(+), 1048 deletions(-) create mode 100644 vortex-arrow/Cargo.toml rename {vortex-array => vortex-arrow}/benches/to_arrow.rs (95%) rename {vortex-array/src/arrow => vortex-arrow/src}/convert.rs (95%) rename {vortex-array/src/arrow => vortex-arrow/src}/datum.rs (94%) rename vortex-array/src/dtype/arrow.rs => vortex-arrow/src/dtype.rs (89%) rename {vortex-array/src/arrow => vortex-arrow/src}/executor/bool.rs (83%) rename {vortex-array/src/arrow => vortex-arrow/src}/executor/byte.rs (91%) rename {vortex-array/src/arrow => vortex-arrow/src}/executor/byte_view.rs (87%) rename {vortex-array/src/arrow => vortex-arrow/src}/executor/decimal.rs (92%) rename {vortex-array/src/arrow => vortex-arrow/src}/executor/dictionary.rs (87%) rename {vortex-array/src/arrow => vortex-arrow/src}/executor/fixed_size_list.rs (86%) rename {vortex-array/src/arrow => vortex-arrow/src}/executor/list.rs (90%) rename {vortex-array/src/arrow => vortex-arrow/src}/executor/list_view.rs (89%) rename {vortex-array/src/arrow => vortex-arrow/src}/executor/mod.rs (88%) rename {vortex-array/src/arrow => vortex-arrow/src}/executor/null.rs (86%) rename {vortex-array/src/arrow => vortex-arrow/src}/executor/primitive.rs (81%) rename {vortex-array/src/arrow => vortex-arrow/src}/executor/run_end.rs (76%) rename {vortex-array/src/arrow => vortex-arrow/src}/executor/struct_.rs (89%) rename {vortex-array/src/arrow => vortex-arrow/src}/executor/temporal.rs (92%) rename {vortex-array/src/arrow => vortex-arrow/src}/executor/validity.rs (61%) rename {vortex-array/src/arrow => vortex-arrow/src}/iter.rs (86%) rename vortex-array/src/arrow/mod.rs => vortex-arrow/src/lib.rs (64%) rename {vortex-array/src/arrow => vortex-arrow/src}/null_buffer.rs (93%) rename encodings/runend/src/arrow.rs => vortex-arrow/src/run_end_import.rs (96%) rename vortex-array/src/scalar/arrow.rs => vortex-arrow/src/scalar.rs (83%) rename {vortex-array/src/arrow => vortex-arrow/src}/session.rs (93%) rename vortex-array/src/extension/uuid/arrow.rs => vortex-arrow/src/uuid.rs (85%) create mode 100644 vortex-arrow/tests/canonical.rs diff --git a/Cargo.lock b/Cargo.lock index d75d5661a98..881744baa91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1559,6 +1559,7 @@ dependencies = [ "tokio", "tracing", "vortex", + "vortex-arrow", "vortex-bench", ] @@ -2125,6 +2126,7 @@ dependencies = [ "tokio", "url", "vortex", + "vortex-arrow", "vortex-bench", "vortex-cuda", "vortex-datafusion", @@ -9605,6 +9607,7 @@ dependencies = [ "vortex", "vortex-alp", "vortex-array", + "vortex-arrow", "vortex-btrblocks", "vortex-buffer", "vortex-bytebool", @@ -9660,12 +9663,7 @@ dependencies = [ "arbitrary", "arc-swap", "arcref", - "arrow-array 58.3.0", "arrow-buffer 58.3.0", - "arrow-cast 58.3.0", - "arrow-data 58.3.0", - "arrow-schema 58.3.0", - "arrow-select 58.3.0", "async-lock", "bytes", "cfg-if", @@ -9728,6 +9726,29 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "vortex-arrow" +version = "0.1.0" +dependencies = [ + "arrow-array 58.3.0", + "arrow-buffer 58.3.0", + "arrow-cast 58.3.0", + "arrow-data 58.3.0", + "arrow-schema 58.3.0", + "arrow-select 58.3.0", + "codspeed-divan-compat", + "itertools 0.14.0", + "num-traits", + "rstest", + "tracing", + "vortex-array", + "vortex-buffer", + "vortex-error", + "vortex-mask", + "vortex-runend", + "vortex-session", +] + [[package]] name = "vortex-bench" version = "0.1.0" @@ -9775,6 +9796,7 @@ dependencies = [ "url", "uuid", "vortex", + "vortex-arrow", "vortex-geo", "vortex-tensor", "wkb", @@ -9862,6 +9884,7 @@ dependencies = [ "tpchgen-arrow", "vortex", "vortex-array", + "vortex-arrow", "vortex-buffer", "vortex-error", "vortex-session", @@ -9935,6 +9958,7 @@ dependencies = [ "tracing", "vortex", "vortex-array", + "vortex-arrow", "vortex-cub", "vortex-cuda", "vortex-cuda-macros", @@ -9996,6 +10020,7 @@ dependencies = [ "tracing", "url", "vortex", + "vortex-arrow", "vortex-utils", ] @@ -10118,6 +10143,7 @@ dependencies = [ "tracing-subscriber", "vortex", "vortex-array", + "vortex-arrow", ] [[package]] @@ -10234,6 +10260,7 @@ dependencies = [ "prost 0.14.4", "rstest", "vortex-array", + "vortex-arrow", "vortex-error", "vortex-session", "wkb", @@ -10305,6 +10332,7 @@ dependencies = [ "tracing-subscriber", "url", "vortex", + "vortex-arrow", "vortex-parquet-variant", ] @@ -10316,6 +10344,7 @@ dependencies = [ "arrow-schema 58.3.0", "prost 0.14.4", "vortex-array", + "vortex-arrow", "vortex-error", "vortex-proto", "vortex-session", @@ -10351,6 +10380,7 @@ dependencies = [ "tokio", "tracing", "vortex-array", + "vortex-arrow", "vortex-btrblocks", "vortex-buffer", "vortex-error", @@ -10427,6 +10457,7 @@ dependencies = [ "rstest", "tokio", "vortex-array", + "vortex-arrow", "vortex-buffer", "vortex-error", "vortex-file", @@ -10446,6 +10477,7 @@ dependencies = [ "prost 0.14.4", "rstest", "vortex-array", + "vortex-arrow", "vortex-buffer", "vortex-error", "vortex-mask", @@ -10481,6 +10513,7 @@ dependencies = [ "url", "vortex", "vortex-array", + "vortex-arrow", "vortex-python-abi", "vortex-tui", ] @@ -10525,8 +10558,6 @@ name = "vortex-runend" version = "0.1.0" dependencies = [ "arbitrary", - "arrow-array 58.3.0", - "arrow-schema 58.3.0", "codspeed-divan-compat", "itertools 0.14.0", "num-traits", @@ -10592,6 +10623,7 @@ dependencies = [ "prost 0.14.4", "rstest", "vortex-array", + "vortex-arrow", "vortex-buffer", "vortex-error", "vortex-mask", @@ -10632,6 +10664,7 @@ dependencies = [ "prost 0.14.4", "rstest", "vortex-array", + "vortex-arrow", "vortex-buffer", "vortex-compressor", "vortex-error", @@ -10675,6 +10708,7 @@ dependencies = [ "taffy", "tokio", "vortex", + "vortex-arrow", "vortex-datafusion", "wasm-bindgen", "wasm-bindgen-futures", @@ -10702,6 +10736,7 @@ dependencies = [ "serde", "serde_json", "vortex", + "vortex-arrow", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", diff --git a/Cargo.toml b/Cargo.toml index e59c5c2c76c..12e91c7ef20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "vortex-io", "vortex-proto", "vortex-array", + "vortex-arrow", "vortex-row", "vortex-tensor", "vortex-json", @@ -285,6 +286,7 @@ zstd = { version = "0.13.3", default-features = false, features = [ vortex = { version = "0.1.0", path = "./vortex" } vortex-alp = { version = "0.1.0", path = "./encodings/alp", default-features = false } vortex-array = { version = "0.1.0", path = "./vortex-array", default-features = false } +vortex-arrow = { version = "0.1.0", path = "./vortex-arrow", default-features = false } vortex-btrblocks = { version = "0.1.0", path = "./vortex-btrblocks", default-features = false } vortex-buffer = { version = "0.1.0", path = "./vortex-buffer", default-features = false } vortex-bytebool = { version = "0.1.0", path = "./encodings/bytebool", default-features = false } diff --git a/benchmarks/compress-bench/Cargo.toml b/benchmarks/compress-bench/Cargo.toml index baff1daad74..d214af23511 100644 --- a/benchmarks/compress-bench/Cargo.toml +++ b/benchmarks/compress-bench/Cargo.toml @@ -30,6 +30,7 @@ regex = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } vortex = { workspace = true } +vortex-arrow = { workspace = true } vortex-bench = { workspace = true } [features] diff --git a/benchmarks/compress-bench/src/vortex.rs b/benchmarks/compress-bench/src/vortex.rs index 87416cdf924..fae0cc44189 100644 --- a/benchmarks/compress-bench/src/vortex.rs +++ b/benchmarks/compress-bench/src/vortex.rs @@ -18,6 +18,7 @@ use vortex::expr::root; use vortex::expr::select; use vortex::file::OpenOptionsSessionExt; use vortex::file::WriteOptionsSessionExt; +use vortex_arrow::ToArrowType; use vortex_bench::Format; use vortex_bench::SESSION; use vortex_bench::compress::Compressor; diff --git a/benchmarks/datafusion-bench/Cargo.toml b/benchmarks/datafusion-bench/Cargo.toml index 2f99d621a85..a195bbaa03f 100644 --- a/benchmarks/datafusion-bench/Cargo.toml +++ b/benchmarks/datafusion-bench/Cargo.toml @@ -39,6 +39,7 @@ parking_lot = { workspace = true } tokio = { workspace = true, features = ["full"] } url = { workspace = true } vortex = { workspace = true, features = ["object_store", "files", "tokio"] } +vortex-arrow = { workspace = true } vortex-bench = { workspace = true } vortex-cuda = { workspace = true, optional = true } vortex-datafusion = { workspace = true } diff --git a/benchmarks/datafusion-bench/src/main.rs b/benchmarks/datafusion-bench/src/main.rs index b8f9ac42df6..67521a8147e 100644 --- a/benchmarks/datafusion-bench/src/main.rs +++ b/benchmarks/datafusion-bench/src/main.rs @@ -29,6 +29,7 @@ use parking_lot::Mutex; use tokio::fs::File; use vortex::io::filesystem::FileSystemRef; use vortex::scan::DataSourceRef; +use vortex_arrow::ToArrowType; use vortex_bench::Benchmark; use vortex_bench::BenchmarkArg; use vortex_bench::CompactionStrategy; diff --git a/encodings/parquet-variant/Cargo.toml b/encodings/parquet-variant/Cargo.toml index d0c0a0d418e..6cdc2b3fa8b 100644 --- a/encodings/parquet-variant/Cargo.toml +++ b/encodings/parquet-variant/Cargo.toml @@ -25,6 +25,7 @@ parquet-variant = { workspace = true } parquet-variant-compute = { workspace = true } prost = { workspace = true } vortex-array = { workspace = true } +vortex-arrow = { workspace = true } vortex-buffer = { workspace = true } vortex-error = { workspace = true } vortex-json = { workspace = true } diff --git a/encodings/parquet-variant/src/array.rs b/encodings/parquet-variant/src/array.rs index 7e55357c78f..bc7fd2c22ec 100644 --- a/encodings/parquet-variant/src/array.rs +++ b/encodings/parquet-variant/src/array.rs @@ -24,13 +24,6 @@ use vortex_array::arrays::VariantArray; use vortex_array::arrays::list::ListArrayExt; use vortex_array::arrays::struct_::StructArrayExt; use vortex_array::arrays::variant::VariantArrayExt; -#[expect( - deprecated, - reason = "TODO(aduffy): figure out what to do with Parquet Variant" -)] -use vortex_array::arrow::ArrowArrayExecutor; -use vortex_array::arrow::FromArrowArray; -use vortex_array::arrow::to_arrow_null_buffer; use vortex_array::builtins::ArrayBuiltins; use vortex_array::dtype::DType; use vortex_array::dtype::FieldName; @@ -41,6 +34,13 @@ use vortex_array::smallvec::smallvec; use vortex_array::validity::Validity; use vortex_array::vtable::child_to_validity; use vortex_array::vtable::validity_to_child; +#[expect( + deprecated, + reason = "TODO(aduffy): figure out what to do with Parquet Variant" +)] +use vortex_arrow::ArrowArrayExecutor; +use vortex_arrow::FromArrowArray; +use vortex_arrow::to_arrow_null_buffer; use vortex_buffer::BitBuffer; use vortex_error::VortexExpect; use vortex_error::VortexResult; diff --git a/encodings/parquet-variant/src/arrow.rs b/encodings/parquet-variant/src/arrow.rs index da4e648a138..65efd6c571c 100644 --- a/encodings/parquet-variant/src/arrow.rs +++ b/encodings/parquet-variant/src/arrow.rs @@ -20,14 +20,14 @@ use vortex_array::IntoArray; use vortex_array::VTable; use vortex_array::arrays::Variant; use vortex_array::arrays::variant::VariantArrayExt; -use vortex_array::arrow::ArrowExport; -use vortex_array::arrow::ArrowExportVTable; -use vortex_array::arrow::ArrowImport; -use vortex_array::arrow::ArrowImportVTable; -use vortex_array::arrow::ArrowSession; -use vortex_array::arrow::ArrowSessionExt; -use vortex_array::arrow::to_arrow_null_buffer; use vortex_array::dtype::DType; +use vortex_arrow::ArrowExport; +use vortex_arrow::ArrowExportVTable; +use vortex_arrow::ArrowImport; +use vortex_arrow::ArrowImportVTable; +use vortex_arrow::ArrowSession; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::to_arrow_null_buffer; use vortex_error::VortexResult; use vortex_error::vortex_bail; use vortex_error::vortex_err; @@ -300,11 +300,11 @@ mod tests { use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::VarBinViewArray; use vortex_array::arrays::VariantArray; - use vortex_array::arrow::ArrowSessionExt; use vortex_array::assert_arrays_eq; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; use vortex_array::validity::Validity; + use vortex_arrow::ArrowSessionExt; use vortex_buffer::buffer; use vortex_error::VortexResult; use vortex_session::VortexSession; diff --git a/encodings/parquet-variant/src/kernel.rs b/encodings/parquet-variant/src/kernel.rs index bd7d0bfaa7c..c783e6600ac 100644 --- a/encodings/parquet-variant/src/kernel.rs +++ b/encodings/parquet-variant/src/kernel.rs @@ -37,8 +37,6 @@ use vortex_array::arrays::scalar_fn::ExactScalarFn; use vortex_array::arrays::scalar_fn::ScalarFnArrayView; use vortex_array::arrays::slice::SliceExecuteAdaptor; use vortex_array::arrays::slice::SliceKernel; -use vortex_array::arrow::ArrowSessionExt; -use vortex_array::arrow::FromArrowArray; use vortex_array::dtype::DType; use vortex_array::kernel::ExecuteParentKernel; use vortex_array::optimizer::kernels::ArrayKernelsExt; @@ -46,6 +44,9 @@ use vortex_array::scalar_fn::ScalarFnVTable; use vortex_array::scalar_fn::fns::variant_get::VariantGet; use vortex_array::scalar_fn::fns::variant_get::VariantPath; use vortex_array::scalar_fn::fns::variant_get::VariantPathElement; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::FromArrowArray; +use vortex_arrow::ToArrowType; use vortex_error::VortexResult; use vortex_error::vortex_ensure_eq; use vortex_error::vortex_err; @@ -318,7 +319,6 @@ mod tests { use vortex_array::arrays::VariantArray; use vortex_array::arrays::struct_::StructArrayExt; use vortex_array::arrays::variant::VariantArrayExt; - use vortex_array::arrow::FromArrowArray; use vortex_array::assert_arrays_eq; use vortex_array::assert_nth_scalar_is_null; use vortex_array::dtype::DType as VortexDType; @@ -329,6 +329,7 @@ mod tests { use vortex_array::scalar_fn::fns::variant_get::VariantPath; use vortex_array::scalar_fn::fns::variant_get::VariantPathElement; use vortex_array::validity::Validity; + use vortex_arrow::FromArrowArray; use vortex_error::VortexResult; use vortex_error::vortex_bail; use vortex_error::vortex_ensure; diff --git a/encodings/parquet-variant/src/lib.rs b/encodings/parquet-variant/src/lib.rs index c71ea81e734..88125af04fd 100644 --- a/encodings/parquet-variant/src/lib.rs +++ b/encodings/parquet-variant/src/lib.rs @@ -37,8 +37,8 @@ mod vtable; use std::sync::Arc; pub use array::ParquetVariantArrayExt; -use vortex_array::arrow::ArrowSessionExt; use vortex_array::session::ArraySessionExt; +use vortex_arrow::ArrowSessionExt; pub use vortex_json::JsonToVariant; pub use vortex_json::JsonToVariantOptions; pub use vortex_json::ShreddingSpec; diff --git a/encodings/pco/Cargo.toml b/encodings/pco/Cargo.toml index 8d560d5f068..1683d4875e9 100644 --- a/encodings/pco/Cargo.toml +++ b/encodings/pco/Cargo.toml @@ -28,4 +28,5 @@ vortex-session = { workspace = true } [dev-dependencies] rstest = { workspace = true } vortex-array = { workspace = true, features = ["_test-harness"] } +vortex-arrow = { workspace = true } diff --git a/encodings/pco/src/tests.rs b/encodings/pco/src/tests.rs index 573bd6a0fb4..5cc771601f0 100644 --- a/encodings/pco/src/tests.rs +++ b/encodings/pco/src/tests.rs @@ -9,7 +9,6 @@ use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; use vortex_array::arrays::BoolArray; use vortex_array::arrays::PrimitiveArray; -use vortex_array::arrow::ArrowSessionExt; use vortex_array::assert_arrays_eq; use vortex_array::assert_nth_scalar; use vortex_array::dtype::DType; @@ -20,6 +19,7 @@ use vortex_array::serde::SerializedArray; use vortex_array::session::ArraySessionExt; use vortex_array::validity::Validity; use vortex_array::vtable::child_to_validity; +use vortex_arrow::ArrowSessionExt; use vortex_buffer::Buffer; use vortex_buffer::BufferMut; use vortex_error::VortexExpect; diff --git a/encodings/runend/Cargo.toml b/encodings/runend/Cargo.toml index c7b38d2494f..4d36d0c1548 100644 --- a/encodings/runend/Cargo.toml +++ b/encodings/runend/Cargo.toml @@ -15,7 +15,6 @@ version = { workspace = true } [dependencies] arbitrary = { workspace = true, optional = true } -arrow-array = { workspace = true, optional = true } itertools = { workspace = true } num-traits = { workspace = true } prost = { workspace = true } @@ -29,8 +28,6 @@ vortex-session = { workspace = true } workspace = true [dev-dependencies] -arrow-array = { workspace = true } -arrow-schema = { workspace = true } divan = { workspace = true } itertools = { workspace = true } rand = { workspace = true } @@ -39,7 +36,6 @@ vortex-array = { workspace = true, features = ["_test-harness"] } [features] arbitrary = ["dep:arbitrary", "vortex-array/arbitrary"] -arrow = ["dep:arrow-array"] [[bench]] name = "run_end_null_count" diff --git a/encodings/runend/src/array.rs b/encodings/runend/src/array.rs index 637947bba53..2a9659efdd8 100644 --- a/encodings/runend/src/array.rs +++ b/encodings/runend/src/array.rs @@ -323,7 +323,9 @@ impl RunEndData { } } - pub(crate) fn validate_parts( + /// Validate that `ends` and `values` form a well-formed run-end array covering + /// `offset..offset + length`. + pub fn validate_parts( ends: &ArrayRef, values: &ArrayRef, offset: usize, diff --git a/encodings/runend/src/lib.rs b/encodings/runend/src/lib.rs index a0b26b54dab..ed3ff946d31 100644 --- a/encodings/runend/src/lib.rs +++ b/encodings/runend/src/lib.rs @@ -9,14 +9,12 @@ pub use array::*; pub use iter::trimmed_ends_iter; mod array; -#[cfg(feature = "arrow")] -mod arrow; pub mod compress; mod compute; pub mod decompress_bool; mod iter; mod kernel; -mod ops; +pub mod ops; mod rules; #[doc(hidden)] diff --git a/encodings/runend/src/ops.rs b/encodings/runend/src/ops.rs index c929f3d4251..39fb004c3e7 100644 --- a/encodings/runend/src/ops.rs +++ b/encodings/runend/src/ops.rs @@ -31,7 +31,7 @@ impl OperationsVTable for RunEnd { /// /// If the index exists in the array we want to take that position (as we are searching from the right) /// otherwise we want to take the next one -pub(crate) fn find_slice_end_index( +pub fn find_slice_end_index( array: &ArrayRef, index: usize, ctx: &mut ExecutionCtx, @@ -52,7 +52,8 @@ pub(crate) fn find_slice_end_index( }) } -pub(crate) fn find_physical_index( +/// Find the physical index (the run) that contains the logical `index`, given the run `ends`. +pub fn find_physical_index( array: &ArrayRef, index: usize, ctx: &mut ExecutionCtx, diff --git a/encodings/sparse/Cargo.toml b/encodings/sparse/Cargo.toml index acf99a74ccf..56e87745b77 100644 --- a/encodings/sparse/Cargo.toml +++ b/encodings/sparse/Cargo.toml @@ -31,6 +31,7 @@ divan = { workspace = true } itertools = { workspace = true } rstest = { workspace = true } vortex-array = { workspace = true, features = ["_test-harness"] } +vortex-arrow = { workspace = true } [[bench]] name = "sparse_canonical" diff --git a/encodings/sparse/src/canonical.rs b/encodings/sparse/src/canonical.rs index afca2b29a18..0be6d0a4162 100644 --- a/encodings/sparse/src/canonical.rs +++ b/encodings/sparse/src/canonical.rs @@ -613,7 +613,6 @@ mod test { use vortex_array::arrays::VarBinArray; use vortex_array::arrays::VarBinViewArray; use vortex_array::arrays::listview::ListViewArrayExt; - use vortex_array::arrow::ArrowSessionExt; use vortex_array::assert_arrays_eq; use vortex_array::dtype::DType; use vortex_array::dtype::DecimalDType; @@ -625,6 +624,7 @@ mod test { use vortex_array::scalar::DecimalValue; use vortex_array::scalar::Scalar; use vortex_array::validity::Validity; + use vortex_arrow::ArrowSessionExt; use vortex_buffer::ByteBuffer; use vortex_buffer::buffer; use vortex_buffer::buffer_mut; diff --git a/vortex-array/Cargo.toml b/vortex-array/Cargo.toml index 507c7345621..dcd8af6d76a 100644 --- a/vortex-array/Cargo.toml +++ b/vortex-array/Cargo.toml @@ -23,12 +23,7 @@ workspace = true arbitrary = { workspace = true, optional = true } arc-swap = { workspace = true } arcref = { workspace = true } -arrow-array = { workspace = true, features = ["ffi"] } arrow-buffer = { workspace = true } -arrow-cast = { workspace = true } -arrow-data = { workspace = true } -arrow-schema = { workspace = true, features = ["canonical_extension_types"] } -arrow-select = { workspace = true } async-lock = { workspace = true } bytes = { workspace = true } cfg-if = { workspace = true } @@ -69,7 +64,7 @@ termtree = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } vortex-array-macros = { workspace = true } -vortex-buffer = { workspace = true, features = ["arrow"] } +vortex-buffer = { workspace = true } vortex-compute = { workspace = true } vortex-error = { workspace = true, features = ["flatbuffers"] } vortex-flatbuffers = { workspace = true, features = ["array", "dtype"] } @@ -87,7 +82,6 @@ _test-harness = ["dep:goldenfile", "dep:rstest", "dep:rstest_reuse"] serde = ["dep:serde", "vortex-buffer/serde", "vortex-mask/serde"] [dev-dependencies] -arrow-cast = { workspace = true } divan = { workspace = true } futures = { workspace = true, features = ["executor"] } insta = { workspace = true } @@ -271,7 +265,3 @@ harness = false [[bench]] name = "slice_dict_primitive" harness = false - -[[bench]] -name = "to_arrow" -harness = false diff --git a/vortex-array/benches/kleene_bool.rs b/vortex-array/benches/kleene_bool.rs index b23a0580a5a..60a0171a9b9 100644 --- a/vortex-array/benches/kleene_bool.rs +++ b/vortex-array/benches/kleene_bool.rs @@ -13,7 +13,6 @@ use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; use vortex_array::arrays::BoolArray; use vortex_array::arrays::ConstantArray; -use vortex_array::arrow::ArrowSession; use vortex_array::builtins::ArrayBuiltins; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; @@ -31,7 +30,6 @@ static SESSION: LazyLock = LazyLock::new(|| { VortexSession::empty() .with::() .with::() - .with::() }); const LEN: usize = 65_536; diff --git a/vortex-array/src/aggregate_fn/fns/is_sorted/mod.rs b/vortex-array/src/aggregate_fn/fns/is_sorted/mod.rs index 6256dc22327..ada9e6eb2e5 100644 --- a/vortex-array/src/aggregate_fn/fns/is_sorted/mod.rs +++ b/vortex-array/src/aggregate_fn/fns/is_sorted/mod.rs @@ -668,16 +668,14 @@ mod tests { // Tests migrated from arrays/decimal/compute/is_sorted.rs #[test] fn test_decimal_is_sorted() -> VortexResult<()> { - use arrow_array::types::Decimal128Type; - use arrow_cast::parse::parse_decimal; - use crate::arrays::DecimalArray; use crate::dtype::DecimalDType; let mut ctx = array_session().create_execution_ctx(); let dtype = DecimalDType::new(19, 2); - let i100 = parse_decimal::("100.00", dtype.precision(), dtype.scale())?; - let i200 = parse_decimal::("200.00", dtype.precision(), dtype.scale())?; + // "100.00" and "200.00" at scale 2. + let i100 = 10_000i128; + let i200 = 20_000i128; let sorted = buffer![i100, i200, i200]; let unsorted = buffer![i200, i100, i200]; @@ -693,17 +691,14 @@ mod tests { #[test] fn test_decimal_is_strict_sorted() -> VortexResult<()> { - use arrow_array::types::Decimal128Type; - use arrow_cast::parse::parse_decimal; - use crate::arrays::DecimalArray; use crate::dtype::DecimalDType; let mut ctx = array_session().create_execution_ctx(); - let dtype = DecimalDType::new(19, 2); - let i100 = parse_decimal::("100.00", dtype.precision(), dtype.scale())?; - let i200 = parse_decimal::("200.00", dtype.precision(), dtype.scale())?; - let i300 = parse_decimal::("300.00", dtype.precision(), dtype.scale())?; + // "100.00", "200.00" and "300.00" at scale 2. + let i100 = 10_000i128; + let i200 = 20_000i128; + let i300 = 30_000i128; let strict_sorted = buffer![i100, i200, i300]; let sorted = buffer![i100, i200, i200]; diff --git a/vortex-array/src/arc_swap_map.rs b/vortex-array/src/arc_swap_map.rs index 73108ae5d57..1788f704feb 100644 --- a/vortex-array/src/arc_swap_map.rs +++ b/vortex-array/src/arc_swap_map.rs @@ -28,7 +28,7 @@ use vortex_utils::aliases::hash_map::HashMap; /// underlying cell: a registry mutated through one clone is observed by all /// others. Session variables rely on this so that encodings registered after a /// session is built remain visible to clones of that session. -pub(crate) struct ArcSwapMap { +pub struct ArcSwapMap { inner: Arc>>, } @@ -56,7 +56,7 @@ impl Debug for ArcSwapMap { impl ArcSwapMap { /// Return the currently published map snapshot. - pub(crate) fn snapshot(&self) -> Arc> { + pub fn snapshot(&self) -> Arc> { self.inner.load_full() } @@ -64,7 +64,7 @@ impl ArcSwapMap { /// /// Every lookup inside `f` observes the same snapshot, which matters when a /// single logical read consults more than one key. - pub(crate) fn read(&self, f: impl FnOnce(&HashMap) -> R) -> R { + pub fn read(&self, f: impl FnOnce(&HashMap) -> R) -> R { f(&self.inner.load()) } @@ -87,7 +87,7 @@ impl ArcSwapMap { impl ArcSwapMap { /// Return a clone of the value stored under `key`, if present. - pub(crate) fn get(&self, key: &Q) -> Option + pub fn get(&self, key: &Q) -> Option where K: Borrow, Q: Eq + Hash + ?Sized, @@ -96,7 +96,7 @@ impl ArcSwapMap { } /// Insert `value` under `key`, replacing any existing value. - pub(crate) fn insert(&self, key: K, value: V) + pub fn insert(&self, key: K, value: V) where K: Clone, { @@ -111,7 +111,7 @@ impl ArcSwapMap> { /// /// Each key maps to an immutable `Arc<[T]>`; appending rebuilds that slice /// copy-on-write so existing readers keep their previous snapshot. - pub(crate) fn extend(&self, key: K, values: &[T]) { + pub fn extend(&self, key: K, values: &[T]) { self.modify(|map| { let merged: Arc<[T]> = match map.get(&key) { Some(existing) => existing.iter().chain(values).cloned().collect(), @@ -123,7 +123,7 @@ impl ArcSwapMap> { /// Append a single `value` to the list stored under `key`, creating it if /// absent. - pub(crate) fn push(&self, key: K, value: T) { + pub fn push(&self, key: K, value: T) { self.extend(key, &[value]); } } diff --git a/vortex-array/src/arrays/constant/compute/fill_null.rs b/vortex-array/src/arrays/constant/compute/fill_null.rs index 1c9c2aaf903..67551a0592a 100644 --- a/vortex-array/src/arrays/constant/compute/fill_null.rs +++ b/vortex-array/src/arrays/constant/compute/fill_null.rs @@ -25,7 +25,7 @@ mod test { use crate::VortexSessionExecute; use crate::array_session; use crate::arrays::ConstantArray; - use crate::arrow::ArrowSessionExt; + use crate::assert_arrays_eq; use crate::builtins::ArrayBuiltins; use crate::dtype::DType; use crate::dtype::Nullability; @@ -43,21 +43,7 @@ mod test { assert!(!actual.dtype().is_nullable()); - let actual_arrow = array_session() - .arrow() - .execute_arrow(actual.clone(), None, &mut ctx) - .unwrap(); - let expected_arrow = array_session() - .arrow() - .execute_arrow(expected.clone(), None, &mut ctx) - .unwrap(); - assert_eq!( - &actual_arrow, - &expected_arrow, - "{}, {}", - actual.display_values(), - expected.display_values() - ); + assert_arrays_eq!(actual, expected, &mut ctx); } #[test] @@ -71,21 +57,7 @@ mod test { assert!(!actual.dtype().is_nullable()); - let actual_arrow = array_session() - .arrow() - .execute_arrow(actual.clone(), None, &mut ctx) - .unwrap(); - let expected_arrow = array_session() - .arrow() - .execute_arrow(expected.clone(), None, &mut ctx) - .unwrap(); - assert_eq!( - &actual_arrow, - &expected_arrow, - "{}, {}", - actual.display_values(), - expected.display_values() - ); + assert_arrays_eq!(actual, expected, &mut ctx); } #[test] @@ -98,26 +70,19 @@ mod test { Some(1.into()), )) .unwrap(); - let expected = ConstantArray::new(Scalar::from(1), 3).into_array(); + let expected = ConstantArray::new( + Scalar::new( + DType::Primitive(PType::I32, Nullability::Nullable), + Some(1.into()), + ), + 3, + ) + .into_array(); assert!(!Scalar::from(1).dtype().is_nullable()); assert!(actual.dtype().is_nullable()); - let actual_arrow = array_session() - .arrow() - .execute_arrow(actual.clone(), None, &mut ctx) - .unwrap(); - let expected_arrow = array_session() - .arrow() - .execute_arrow(expected.clone(), None, &mut ctx) - .unwrap(); - assert_eq!( - &actual_arrow, - &expected_arrow, - "{}, {}", - actual.display_values(), - expected.display_values() - ); + assert_arrays_eq!(actual, expected, &mut ctx); } } diff --git a/vortex-array/src/arrays/decimal/mod.rs b/vortex-array/src/arrays/decimal/mod.rs index e740a75ea91..d8af8529e87 100644 --- a/vortex-array/src/arrays/decimal/mod.rs +++ b/vortex-array/src/arrays/decimal/mod.rs @@ -19,17 +19,3 @@ pub(crate) fn initialize(session: &vortex_session::VortexSession) { mod utils; pub use utils::*; - -#[cfg(test)] -mod tests { - use arrow_array::Decimal128Array; - - #[test] - fn test_decimal() { - // They pass it b/c the DType carries the information. No other way to carry a - // dtype except via the array. - let value = Decimal128Array::new_null(100); - let numeric = value.value(10); - assert_eq!(numeric, 0i128); - } -} diff --git a/vortex-array/src/canonical.rs b/vortex-array/src/canonical.rs index 62749db4650..71da52e1300 100644 --- a/vortex-array/src/canonical.rs +++ b/vortex-array/src/canonical.rs @@ -85,14 +85,14 @@ use crate::validity::Validity; /// /// The full list of canonical types and their equivalent Arrow array types are: /// -/// * `NullArray`: [`arrow_array::NullArray`] -/// * `BoolArray`: [`arrow_array::BooleanArray`] -/// * `PrimitiveArray`: [`arrow_array::PrimitiveArray`] -/// * `DecimalArray`: [`arrow_array::Decimal128Array`] and [`arrow_array::Decimal256Array`] -/// * `VarBinViewArray`: [`arrow_array::GenericByteViewArray`] -/// * `ListViewArray`: [`arrow_array::ListViewArray`] -/// * `FixedSizeListArray`: [`arrow_array::FixedSizeListArray`] -/// * `StructArray`: [`arrow_array::StructArray`] +/// * `NullArray`: `arrow_array::NullArray` +/// * `BoolArray`: `arrow_array::BooleanArray` +/// * `PrimitiveArray`: `arrow_array::PrimitiveArray` +/// * `DecimalArray`: `arrow_array::Decimal128Array` and `arrow_array::Decimal256Array` +/// * `VarBinViewArray`: `arrow_array::GenericByteViewArray` +/// * `ListViewArray`: `arrow_array::ListViewArray` +/// * `FixedSizeListArray`: `arrow_array::FixedSizeListArray` +/// * `StructArray`: `arrow_array::StructArray` /// /// Vortex uses a logical type system, unlike Arrow which uses physical encodings for its types. /// As an example, there are at least six valid physical encodings for a `Utf8` array. This can @@ -102,7 +102,7 @@ use crate::validity::Validity; /// variants to hold the data. /// /// To disambiguate, we choose a canonical physical encoding for every Vortex [`DType`], which -/// will correspond to an arrow-rs [`arrow_schema::DataType`]. +/// will correspond to an arrow-rs `arrow_schema::DataType`. /// /// # Views support /// @@ -111,7 +111,7 @@ use crate::validity::Validity; /// fully supported by the Datafusion query engine. We use them as our canonical string encoding /// for all `Utf8` and `Binary` typed arrays in Vortex. They provide considerably faster filter /// execution than the core `StringArray` and `BinaryArray` types, at the expense of potentially -/// needing [garbage collection][arrow_array::GenericByteViewArray::gc] to clear unreferenced items +/// needing garbage collection (`arrow_array::GenericByteViewArray::gc`) to clear unreferenced items /// from memory. /// /// # For Developers @@ -1118,25 +1118,8 @@ impl Matcher for AnyCanonical { #[cfg(test)] mod test { - use std::sync::Arc; use std::sync::LazyLock; - use arrow_array::Array as ArrowArray; - use arrow_array::ArrayRef as ArrowArrayRef; - use arrow_array::ListArray as ArrowListArray; - use arrow_array::PrimitiveArray as ArrowPrimitiveArray; - use arrow_array::StringArray; - use arrow_array::StringViewArray; - use arrow_array::StructArray as ArrowStructArray; - use arrow_array::cast::AsArray; - use arrow_array::types::Int32Type; - use arrow_array::types::Int64Type; - use arrow_array::types::UInt64Type; - use arrow_buffer::NullBufferBuilder; - use arrow_buffer::OffsetBuffer; - use arrow_schema::DataType; - use arrow_schema::Field; - use vortex_buffer::buffer; use vortex_error::VortexResult; use vortex_error::vortex_err; use vortex_session::VortexSession; @@ -1154,8 +1137,6 @@ mod test { use crate::arrays::VariantArray; use crate::arrays::struct_::StructArrayExt; use crate::arrays::variant::VariantArrayExt; - use crate::arrow::ArrowSessionExt; - use crate::arrow::FromArrowArray; use crate::canonical::StructArray; use crate::dtype::Nullability; use crate::scalar::Scalar; @@ -1207,134 +1188,4 @@ mod test { Ok(()) } - - #[test] - fn test_canonicalize_nested_struct() { - let mut ctx = SESSION.create_execution_ctx(); - // Create a struct array with multiple internal components. - let nested_struct_array = StructArray::from_fields(&[ - ("a", buffer![1u64].into_array()), - ( - "b", - StructArray::from_fields(&[( - "inner_a", - // The nested struct contains a ConstantArray representing the primitive array - // [100i64] - // ConstantArray is not a canonical type, so converting `into_arrow()` should - // map this to the nearest canonical type (PrimitiveArray). - ConstantArray::new(100i64, 1).into_array(), - )]) - .unwrap() - .into_array(), - ), - ]) - .unwrap(); - - let arrow_struct = SESSION - .arrow() - .execute_arrow(nested_struct_array.into_array(), None, &mut ctx) - .unwrap() - .as_any() - .downcast_ref::() - .cloned() - .unwrap(); - - assert!( - arrow_struct - .column(0) - .as_any() - .downcast_ref::>() - .is_some() - ); - - let inner_struct = Arc::clone(arrow_struct.column(1)) - .as_any() - .downcast_ref::() - .cloned() - .unwrap(); - - let inner_a = inner_struct - .column(0) - .as_any() - .downcast_ref::>(); - assert!(inner_a.is_some()); - - assert_eq!( - inner_a.cloned().unwrap(), - ArrowPrimitiveArray::from_iter([100i64]) - ); - } - - #[test] - fn roundtrip_struct() { - let mut ctx = SESSION.create_execution_ctx(); - let mut nulls = NullBufferBuilder::new(6); - nulls.append_n_non_nulls(4); - nulls.append_null(); - nulls.append_non_null(); - let names = Arc::new(StringViewArray::from_iter(vec![ - Some("Joseph"), - None, - Some("Angela"), - Some("Mikhail"), - None, - None, - ])); - let ages = Arc::new(ArrowPrimitiveArray::::from(vec![ - Some(25), - Some(31), - None, - Some(57), - None, - None, - ])); - - let arrow_struct = ArrowStructArray::new( - vec![ - Arc::new(Field::new("name", DataType::Utf8View, true)), - Arc::new(Field::new("age", DataType::Int32, true)), - ] - .into(), - vec![names, ages], - nulls.finish(), - ); - - let vortex_struct = ArrayRef::from_arrow(&arrow_struct, true).unwrap(); - let vortex_struct = SESSION - .arrow() - .execute_arrow(vortex_struct, None, &mut ctx) - .unwrap(); - assert_eq!(&arrow_struct, vortex_struct.as_struct()); - } - - #[test] - fn roundtrip_list() { - let mut ctx = SESSION.create_execution_ctx(); - let names = Arc::new(StringArray::from_iter(vec![ - Some("Joseph"), - Some("Angela"), - Some("Mikhail"), - ])); - - let arrow_list = ArrowListArray::new( - Arc::new(Field::new_list_field(DataType::Utf8, true)), - OffsetBuffer::from_lengths(vec![0, 2, 1]), - names, - None, - ); - let list_data_type = arrow_list.data_type(); - let list_field = Field::new(String::new(), list_data_type.clone(), true); - - let vortex_list = ArrayRef::from_arrow(&arrow_list, true).unwrap(); - - let rt_arrow_list = SESSION - .arrow() - .execute_arrow(vortex_list, Some(&list_field), &mut ctx) - .unwrap(); - - assert_eq!( - (Arc::new(arrow_list.clone()) as ArrowArrayRef).as_ref(), - rt_arrow_list.as_ref() - ); - } } diff --git a/vortex-array/src/dtype/mod.rs b/vortex-array/src/dtype/mod.rs index 6a166a868ff..0160b4c36c7 100644 --- a/vortex-array/src/dtype/mod.rs +++ b/vortex-array/src/dtype/mod.rs @@ -15,7 +15,6 @@ #[cfg(feature = "arbitrary")] mod arbitrary; -pub mod arrow; mod bigint; mod coercion; mod decimal; diff --git a/vortex-array/src/expr/exprs.rs b/vortex-array/src/expr/exprs.rs index 73fa76fce92..da70427a0f7 100644 --- a/vortex-array/src/expr/exprs.rs +++ b/vortex-array/src/expr/exprs.rs @@ -440,7 +440,8 @@ where /// /// ``` /// # use vortex_array::IntoArray; -/// # use vortex_array::arrow::ArrowArrayExecutor; +/// # use vortex_array::arrays::PrimitiveArray; +/// # use vortex_array::builtins::ArrayBuiltins; /// # use vortex_array::{VortexSessionExecute, array_session}; /// # use vortex_buffer::buffer; /// # use vortex_array::expr::{checked_add, lit, root}; @@ -448,13 +449,8 @@ where /// let result = xs.apply(&checked_add(root(), lit(5))).unwrap(); /// /// let mut ctx = array_session().create_execution_ctx(); -/// assert_eq!( -/// &result.execute_arrow(None, &mut ctx).unwrap(), -/// &buffer![6, 7, 8] -/// .into_array() -/// .execute_arrow(None, &mut ctx) -/// .unwrap() -/// ); +/// let result = result.execute::(&mut ctx).unwrap(); +/// assert_eq!(result.as_slice::(), [6, 7, 8]); /// ``` pub fn checked_add(lhs: Expression, rhs: Expression) -> Expression { Binary diff --git a/vortex-array/src/extension/uuid/mod.rs b/vortex-array/src/extension/uuid/mod.rs index 8a178035d2f..e4347c2513c 100644 --- a/vortex-array/src/extension/uuid/mod.rs +++ b/vortex-array/src/extension/uuid/mod.rs @@ -13,7 +13,6 @@ mod metadata; pub use metadata::UuidMetadata; -mod arrow; pub(crate) mod vtable; /// The VTable for the UUID extension type. diff --git a/vortex-array/src/lib.rs b/vortex-array/src/lib.rs index 6456c412929..9d3a59eb7a0 100644 --- a/vortex-array/src/lib.rs +++ b/vortex-array/src/lib.rs @@ -95,7 +95,6 @@ use vortex_session::VortexSession; use vortex_session::registry::Context; use crate::aggregate_fn::session::AggregateFnSession; -use crate::arrow::ArrowSession; use crate::dtype::session::DTypeSession; use crate::memory::MemorySession; use crate::optimizer::kernels::KernelSession; @@ -106,10 +105,9 @@ use crate::stats::session::StatsSession; pub mod aggregate_fn; #[doc(hidden)] pub mod aliases; -mod arc_swap_map; +pub mod arc_swap_map; mod array; pub mod arrays; -pub mod arrow; pub mod buffer; pub mod builders; pub mod builtins; @@ -163,11 +161,13 @@ pub fn initialize(session: &VortexSession) { /// Builds a fresh [`VortexSession`] registered with all of vortex-array's built-in session /// variables: arrays, dtypes, scalar functions, stats, optimizer kernels, aggregate functions, -/// Arrow conversion, and memory. +/// and memory. /// /// Each call returns an independent session (with its own registries), so callers may register /// additional encodings or kernels into it without affecting any other session. This does not -/// register file, layout, or runtime state — those live in higher-level crates. +/// register file, layout, or runtime state — those live in higher-level crates. Arrow +/// interoperability lives in the `vortex-arrow` crate, whose session variables are registered +/// lazily on first use (or eagerly by consumers). pub fn array_session() -> VortexSession { VortexSession::empty() .with::() @@ -176,7 +176,6 @@ pub fn array_session() -> VortexSession { .with::() .with::() .with::() - .with::() .with::() } diff --git a/vortex-array/src/scalar/mod.rs b/vortex-array/src/scalar/mod.rs index ff8d2c6134b..dbc1e17d04c 100644 --- a/vortex-array/src/scalar/mod.rs +++ b/vortex-array/src/scalar/mod.rs @@ -15,7 +15,6 @@ #[cfg(feature = "arbitrary")] pub mod arbitrary; -mod arrow; mod cast; mod constructor; diff --git a/vortex-array/src/scalar_fn/fns/zip/mod.rs b/vortex-array/src/scalar_fn/fns/zip/mod.rs index 48449bc8428..40fedbe85eb 100644 --- a/vortex-array/src/scalar_fn/fns/zip/mod.rs +++ b/vortex-array/src/scalar_fn/fns/zip/mod.rs @@ -267,8 +267,6 @@ pub(crate) fn zip_validity( #[cfg(test)] mod tests { - use arrow_array::cast::AsArray; - use arrow_select::zip::zip as arrow_zip; use vortex_buffer::buffer; use vortex_error::VortexResult; use vortex_mask::Mask; @@ -283,7 +281,7 @@ mod tests { use crate::arrays::Struct; use crate::arrays::StructArray; use crate::arrays::VarBinView; - use crate::arrow::ArrowSessionExt; + use crate::arrays::VarBinViewArray; use crate::assert_arrays_eq; use crate::builders::ArrayBuilder; use crate::builders::BufferGrowthStrategy; @@ -489,35 +487,34 @@ mod tests { let mut ctx = array_session().create_execution_ctx(); let zipped = mask_array - .zip(if_true.clone(), if_false.clone()) + .zip(if_true, if_false) .unwrap() .execute::(&mut ctx) .unwrap(); let zipped = zipped.as_opt::().unwrap(); assert_eq!(zipped.data_buffers().len(), 2); - let mut arrow_ctx = array_session().create_execution_ctx(); - let expected = arrow_zip( - array_session() - .arrow() - .execute_arrow(mask.into_array(), None, &mut arrow_ctx) - .unwrap() - .as_boolean(), - &array_session() - .arrow() - .execute_arrow(if_true, None, &mut arrow_ctx) - .unwrap(), - &array_session() - .arrow() - .execute_arrow(if_false, None, &mut arrow_ctx) - .unwrap(), - ) - .unwrap(); - - let actual = array_session() - .arrow() - .execute_arrow(zipped.array().clone(), None, &mut arrow_ctx) - .unwrap(); - assert_eq!(actual.as_ref(), expected.as_ref()); + let true_value = |i: usize| { + if i.is_multiple_of(2) { + "Hello" + } else { + "Hello this is a long string that won't be inlined." + } + }; + let false_value = |i: usize| { + if i.is_multiple_of(2) { + "Hello2" + } else { + "Hello2 this is a long string that won't be inlined." + } + }; + let expected = VarBinViewArray::from_iter_str((0..200).map(|i| { + if mask.value(i) { + true_value(i) + } else { + false_value(i) + } + })); + assert_arrays_eq!(zipped.array().clone(), expected, &mut ctx); } } diff --git a/vortex-arrow/Cargo.toml b/vortex-arrow/Cargo.toml new file mode 100644 index 00000000000..9d69a06fd7d --- /dev/null +++ b/vortex-arrow/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "vortex-arrow" +authors = { workspace = true } +categories = { workspace = true } +description = "Apache Arrow interoperability for Vortex arrays" +edition = { workspace = true } +homepage = { workspace = true } +include = { workspace = true } +keywords = { workspace = true } +license = { workspace = true } +readme = { workspace = true } +repository = { workspace = true } +rust-version = { workspace = true } +version = { workspace = true } + +[package.metadata.docs.rs] +all-features = true + +[lints] +workspace = true + +[dependencies] +arrow-array = { workspace = true, features = ["ffi"] } +arrow-buffer = { workspace = true } +arrow-cast = { workspace = true } +arrow-data = { workspace = true } +arrow-schema = { workspace = true, features = ["canonical_extension_types"] } +arrow-select = { workspace = true } +itertools = { workspace = true } +num-traits = { workspace = true } +tracing = { workspace = true } +vortex-array = { workspace = true } +vortex-buffer = { workspace = true, features = ["arrow"] } +vortex-error = { workspace = true } +vortex-mask = { workspace = true } +vortex-runend = { workspace = true } +vortex-session = { workspace = true } + +[dev-dependencies] +divan = { workspace = true } +rstest = { workspace = true } +vortex-array = { workspace = true, features = ["_test-harness"] } + +[[bench]] +name = "to_arrow" +harness = false diff --git a/vortex-array/benches/to_arrow.rs b/vortex-arrow/benches/to_arrow.rs similarity index 95% rename from vortex-array/benches/to_arrow.rs rename to vortex-arrow/benches/to_arrow.rs index 99fc0fa4f80..1cec274c28e 100644 --- a/vortex-array/benches/to_arrow.rs +++ b/vortex-arrow/benches/to_arrow.rs @@ -15,17 +15,19 @@ use vortex_array::arrays::DecimalArray; use vortex_array::arrays::ListArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::StructArray; -#[expect( - deprecated, - reason = "benchmark comparing deprecated method with new one" -)] -use vortex_array::arrow::ArrowArrayExecutor; -use vortex_array::arrow::ArrowSessionExt; use vortex_array::dtype::DType; use vortex_array::dtype::DecimalDType; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; use vortex_array::dtype::StructFields; +#[expect( + deprecated, + reason = "benchmark comparing deprecated method with new one" +)] +use vortex_arrow::ArrowArrayExecutor; +use vortex_arrow::ArrowSessionExt; +#[allow(deprecated)] +use vortex_arrow::dtype::ToArrowType as _; use vortex_session::VortexSession; fn main() { diff --git a/vortex-array/src/arrow/convert.rs b/vortex-arrow/src/convert.rs similarity index 95% rename from vortex-array/src/arrow/convert.rs rename to vortex-arrow/src/convert.rs index ee1171a1848..0bab0937a8a 100644 --- a/vortex-array/src/arrow/convert.rs +++ b/vortex-arrow/src/convert.rs @@ -57,6 +57,28 @@ use arrow_buffer::buffer::NullBuffer; use arrow_buffer::buffer::OffsetBuffer; use arrow_schema::DataType; use arrow_schema::TimeUnit as ArrowTimeUnit; +use vortex_array::ArrayRef; +use vortex_array::IntoArray; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::DecimalArray; +use vortex_array::arrays::DictArray; +use vortex_array::arrays::FixedSizeListArray; +use vortex_array::arrays::ListArray; +use vortex_array::arrays::ListViewArray; +use vortex_array::arrays::NullArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::arrays::StructArray; +use vortex_array::arrays::TemporalArray; +use vortex_array::arrays::VarBinArray; +use vortex_array::arrays::VarBinViewArray; +use vortex_array::dtype::DType; +use vortex_array::dtype::DecimalDType; +use vortex_array::dtype::IntegerPType; +use vortex_array::dtype::NativePType; +use vortex_array::dtype::PType; +use vortex_array::dtype::i256; +use vortex_array::extension::datetime::TimeUnit; +use vortex_array::validity::Validity; use vortex_buffer::Alignment; use vortex_buffer::BitBuffer; use vortex_buffer::Buffer; @@ -68,31 +90,19 @@ use vortex_error::vortex_ensure_eq; use vortex_error::vortex_err; use vortex_error::vortex_panic; -use crate::ArrayRef; -use crate::IntoArray; -use crate::arrays::BoolArray; -use crate::arrays::DecimalArray; -use crate::arrays::DictArray; -use crate::arrays::FixedSizeListArray; -use crate::arrays::ListArray; -use crate::arrays::ListViewArray; -use crate::arrays::NullArray; -use crate::arrays::PrimitiveArray; -use crate::arrays::StructArray; -use crate::arrays::TemporalArray; -use crate::arrays::VarBinArray; -use crate::arrays::VarBinViewArray; -use crate::arrow::FromArrowArray; -use crate::dtype::DType; -use crate::dtype::DecimalDType; -use crate::dtype::IntegerPType; -use crate::dtype::NativePType; -use crate::dtype::PType; -use crate::dtype::i256; -use crate::extension::datetime::TimeUnit; -use crate::validity::Validity; - -impl IntoArray for ArrowBuffer { +use crate::FromArrowArray; +use crate::dtype::FromArrowType; + +/// Zero-copy conversion of Arrow buffers into non-nullable Vortex arrays. +/// +/// This mirrors [`IntoArray`] for Arrow buffer types; a separate trait is required because both +/// [`IntoArray`] and the Arrow buffer types are foreign to this crate. +pub trait IntoVortexArray { + /// Convert this Arrow buffer into a non-nullable Vortex array without copying. + fn into_array(self) -> ArrayRef; +} + +impl IntoVortexArray for ArrowBuffer { fn into_array(self) -> ArrayRef { PrimitiveArray::from_byte_buffer( ByteBuffer::from_arrow_buffer(self, Alignment::of::()), @@ -103,13 +113,13 @@ impl IntoArray for ArrowBuffer { } } -impl IntoArray for BooleanBuffer { +impl IntoVortexArray for BooleanBuffer { fn into_array(self) -> ArrayRef { BoolArray::new(self.into(), Validity::NonNullable).into_array() } } -impl IntoArray for ScalarBuffer +impl IntoVortexArray for ScalarBuffer where T: ArrowNativeType + NativePType, { @@ -122,7 +132,7 @@ where } } -impl IntoArray for OffsetBuffer +impl IntoVortexArray for OffsetBuffer where O: IntegerPType + OffsetSizeTrait, { @@ -257,10 +267,14 @@ where Ok(match value.data_type() { DataType::Timestamp(time_unit, tz) => { - TemporalArray::new_timestamp(arr, time_unit.into(), tz.clone()).into() + TemporalArray::new_timestamp(arr, TimeUnit::from_arrow(time_unit), tz.clone()).into() + } + DataType::Time32(time_unit) => { + TemporalArray::new_time(arr, TimeUnit::from_arrow(time_unit)).into() + } + DataType::Time64(time_unit) => { + TemporalArray::new_time(arr, TimeUnit::from_arrow(time_unit)).into() } - DataType::Time32(time_unit) => TemporalArray::new_time(arr, time_unit.into()).into(), - DataType::Time64(time_unit) => TemporalArray::new_time(arr, time_unit.into()).into(), DataType::Date32 => TemporalArray::new_date(arr, TimeUnit::Days).into(), DataType::Date64 => TemporalArray::new_date(arr, TimeUnit::Milliseconds).into(), DataType::Duration(_) => unimplemented!(), @@ -693,26 +707,26 @@ mod tests { use arrow_schema::Fields; use arrow_schema::Schema; use rstest::rstest; - - use crate::ArrayRef; - use crate::IntoArray; - use crate::arrays::Decimal; - use crate::arrays::FixedSizeList; - use crate::arrays::List; - use crate::arrays::ListView; - use crate::arrays::Primitive; - use crate::arrays::Struct; - use crate::arrays::VarBinView; - use crate::arrays::fixed_size_list::FixedSizeListArrayExt; - use crate::arrays::list::ListArrayExt; - use crate::arrays::listview::ListViewArrayExt; - use crate::arrays::struct_::StructArrayExt; - use crate::arrow::FromArrowArray as _; - use crate::dtype::DType; - use crate::dtype::Nullability; - use crate::dtype::PType; - use crate::extension::datetime::TimeUnit; - use crate::extension::datetime::Timestamp; + use vortex_array::ArrayRef; + use vortex_array::arrays::Decimal; + use vortex_array::arrays::FixedSizeList; + use vortex_array::arrays::List; + use vortex_array::arrays::ListView; + use vortex_array::arrays::Primitive; + use vortex_array::arrays::Struct; + use vortex_array::arrays::VarBinView; + use vortex_array::arrays::fixed_size_list::FixedSizeListArrayExt; + use vortex_array::arrays::list::ListArrayExt; + use vortex_array::arrays::listview::ListViewArrayExt; + use vortex_array::arrays::struct_::StructArrayExt; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::extension::datetime::TimeUnit; + use vortex_array::extension::datetime::Timestamp; + + use crate::FromArrowArray as _; + use crate::IntoVortexArray as _; #[rstest] #[case::i8( diff --git a/vortex-array/src/arrow/datum.rs b/vortex-arrow/src/datum.rs similarity index 94% rename from vortex-array/src/arrow/datum.rs rename to vortex-arrow/src/datum.rs index 03e1b8dc24b..5019642c452 100644 --- a/vortex-array/src/arrow/datum.rs +++ b/vortex-arrow/src/datum.rs @@ -6,19 +6,19 @@ use arrow_array::ArrayRef as ArrowArrayRef; use arrow_array::Datum as ArrowDatum; use arrow_schema::DataType; use arrow_schema::Field; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::Constant; +use vortex_array::arrays::ConstantArray; +use vortex_array::legacy_session; use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_error::vortex_panic; -use crate::ArrayRef; -use crate::IntoArray; -use crate::VortexSessionExecute; -use crate::arrays::Constant; -use crate::arrays::ConstantArray; -use crate::arrow::ArrowSessionExt; -use crate::arrow::FromArrowArray; -use crate::executor::ExecutionCtx; -use crate::legacy_session; +use crate::ArrowSessionExt; +use crate::FromArrowArray; /// A wrapper around a generic Arrow array that can be used as a Datum in Arrow compute. #[derive(Debug)] diff --git a/vortex-array/src/dtype/arrow.rs b/vortex-arrow/src/dtype.rs similarity index 89% rename from vortex-array/src/dtype/arrow.rs rename to vortex-arrow/src/dtype.rs index 9d381b6eab2..a87c931f3df 100644 --- a/vortex-array/src/dtype/arrow.rs +++ b/vortex-arrow/src/dtype.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -//! Convert between Vortex [`crate::dtype::DType`] and Apache Arrow [`arrow_schema::DataType`]. +//! Convert between Vortex [`vortex_array::dtype::DType`] and Apache Arrow [`arrow_schema::DataType`]. //! //! Apache Arrow's type system includes physical information, which could lead to ambiguities as //! Vortex treats encodings as separate from logical types. @@ -23,26 +23,24 @@ use arrow_schema::Schema; use arrow_schema::SchemaBuilder; use arrow_schema::SchemaRef; use arrow_schema::TimeUnit as ArrowTimeUnit; -use vortex_error::VortexError; +use vortex_array::dtype::DType; +use vortex_array::dtype::DecimalDType; +use vortex_array::dtype::FieldName; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::PType; +use vortex_array::dtype::StructFields; +use vortex_array::extension::datetime::AnyTemporal; +use vortex_array::extension::datetime::Date; +use vortex_array::extension::datetime::TemporalMetadata; +use vortex_array::extension::datetime::Time; +use vortex_array::extension::datetime::TimeUnit; +use vortex_array::extension::datetime::Timestamp; use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_error::vortex_bail; use vortex_error::vortex_err; use vortex_error::vortex_panic; -use crate::dtype::DType; -use crate::dtype::DecimalDType; -use crate::dtype::FieldName; -use crate::dtype::Nullability; -use crate::dtype::PType; -use crate::dtype::StructFields; -use crate::extension::datetime::AnyTemporal; -use crate::extension::datetime::Date; -use crate::extension::datetime::TemporalMetadata; -use crate::extension::datetime::Time; -use crate::extension::datetime::TimeUnit; -use crate::extension::datetime::Timestamp; - /// Trait for converting Arrow types to Vortex types. pub trait FromArrowType: Sized { /// Convert the Arrow type to a Vortex type. @@ -93,14 +91,14 @@ impl TryFromArrowType<&DataType> for DecimalDType { } } -impl From<&ArrowTimeUnit> for TimeUnit { - fn from(value: &ArrowTimeUnit) -> Self { - (*value).into() +impl FromArrowType<&ArrowTimeUnit> for TimeUnit { + fn from_arrow(value: &ArrowTimeUnit) -> Self { + Self::from_arrow(*value) } } -impl From for TimeUnit { - fn from(value: ArrowTimeUnit) -> Self { +impl FromArrowType for TimeUnit { + fn from_arrow(value: ArrowTimeUnit) -> Self { match value { ArrowTimeUnit::Second => Self::Seconds, ArrowTimeUnit::Millisecond => Self::Milliseconds, @@ -110,18 +108,19 @@ impl From for TimeUnit { } } -impl TryFrom for ArrowTimeUnit { - type Error = VortexError; - - fn try_from(value: TimeUnit) -> VortexResult { - Ok(match value { - TimeUnit::Seconds => Self::Second, - TimeUnit::Milliseconds => Self::Millisecond, - TimeUnit::Microseconds => Self::Microsecond, - TimeUnit::Nanoseconds => Self::Nanosecond, - _ => vortex_bail!("Cannot convert {value} to Arrow TimeUnit"), - }) - } +/// Convert a Vortex [`TimeUnit`] to an Arrow [`ArrowTimeUnit`]. +/// +/// # Errors +/// +/// Returns an error for units with no Arrow equivalent (e.g. [`TimeUnit::Days`]). +pub fn to_arrow_time_unit(value: TimeUnit) -> VortexResult { + Ok(match value { + TimeUnit::Seconds => ArrowTimeUnit::Second, + TimeUnit::Milliseconds => ArrowTimeUnit::Millisecond, + TimeUnit::Microseconds => ArrowTimeUnit::Microsecond, + TimeUnit::Nanoseconds => ArrowTimeUnit::Nanosecond, + _ => vortex_bail!("Cannot convert {value} to Arrow TimeUnit"), + }) } impl FromArrowType for DType { @@ -204,13 +203,14 @@ impl TryFromArrowType<(&DataType, Nullability)> for DType { DType::Extension(Date::new(TimeUnit::Milliseconds, nullability).erased()) } DataType::Time32(unit) => { - DType::Extension(Time::new(unit.into(), nullability).erased()) + DType::Extension(Time::new(TimeUnit::from_arrow(unit), nullability).erased()) } DataType::Time64(unit) => { - DType::Extension(Time::new(unit.into(), nullability).erased()) + DType::Extension(Time::new(TimeUnit::from_arrow(unit), nullability).erased()) } DataType::Timestamp(unit, tz) => DType::Extension( - Timestamp::new_with_tz(unit.into(), tz.clone(), nullability).erased(), + Timestamp::new_with_tz(TimeUnit::from_arrow(unit), tz.clone(), nullability) + .erased(), ), DataType::List(e) | DataType::LargeList(e) @@ -255,14 +255,32 @@ impl TryFromArrowType<&Field> for DType { } } -impl DType { +/// Extension trait converting Vortex [`DType`]s into Arrow schemas and data types. +/// +/// This mirrors inherent methods that lived on [`DType`] before Arrow interoperability moved +/// into this crate. +pub trait ToArrowType { /// Convert a Vortex [`DType`] into an Arrow [`Schema`]. /// /// **Prefer `ArrowSession::to_arrow_schema`.** This method is not plugin-aware and /// strips any `ARROW:extension:name` metadata for non-builtin extensions (only /// `arrow.parquet.variant` is special-cased here). Use the session method when you /// need round-trippable extension metadata. - pub fn to_arrow_schema(&self) -> VortexResult { + fn to_arrow_schema(&self) -> VortexResult; + + /// Returns the Arrow [`DataType`] that best corresponds to this Vortex [`DType`]. + /// + /// **Prefer `ArrowSession::to_arrow_datatype` (or `to_arrow_field`).** This method + /// has no awareness of registered Arrow extension plugins, so any [`DType::Extension`] + /// outside the builtin temporal set will fail or silently lose its + /// `ARROW:extension:name` metadata. The session methods recurse through containers + /// and dispatch plugins at every extension node. + #[deprecated(note = "Use `ArrowSession::to_arrow_datatype` instead")] + fn to_arrow_dtype(&self) -> VortexResult; +} + +impl ToArrowType for DType { + fn to_arrow_schema(&self) -> VortexResult { let DType::Struct(struct_dtype, nullable) = self else { vortex_bail!("only DType::Struct can be converted to arrow schema"); }; @@ -295,15 +313,7 @@ impl DType { Ok(builder.finish()) } - /// Returns the Arrow [`DataType`] that best corresponds to this Vortex [`DType`]. - /// - /// **Prefer `ArrowSession::to_arrow_data_type` (or `to_arrow_field`).** This method - /// has no awareness of registered Arrow extension plugins, so any [`DType::Extension`] - /// outside the builtin temporal set will fail or silently lose its - /// `ARROW:extension:name` metadata. The session methods recurse through containers - /// and dispatch plugins at every extension node. - #[deprecated(note = "Use `ArrowSession::to_arrow_datatype` instead")] - pub fn to_arrow_dtype(&self) -> VortexResult { + fn to_arrow_dtype(&self) -> VortexResult { to_data_type_naive(self) } } @@ -382,7 +392,7 @@ pub(crate) fn to_data_type_naive(dtype: &DType) -> VortexResult { if let Some(temporal) = ext_dtype.metadata_opt::() { return Ok(match temporal { TemporalMetadata::Timestamp(unit, tz) => { - DataType::Timestamp(ArrowTimeUnit::try_from(*unit)?, tz.clone()) + DataType::Timestamp(to_arrow_time_unit(*unit)?, tz.clone()) } TemporalMetadata::Date(unit) => match unit { TimeUnit::Days => DataType::Date32, @@ -425,14 +435,14 @@ mod test { use arrow_schema::Schema; use rstest::fixture; use rstest::rstest; + use vortex_array::dtype::DType; + use vortex_array::dtype::FieldName; + use vortex_array::dtype::FieldNames; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::dtype::StructFields; use super::*; - use crate::dtype::DType; - use crate::dtype::FieldName; - use crate::dtype::FieldNames; - use crate::dtype::Nullability; - use crate::dtype::PType; - use crate::dtype::StructFields; #[test] fn test_dtype_conversion_success() { @@ -489,7 +499,7 @@ mod test { #[case(39, DataType::Decimal256(39, 0))] #[case(76, DataType::Decimal256(76, 0))] fn test_decimal_dtype_to_arrow(#[case] precision: u8, #[case] expected: DataType) { - use crate::dtype::DecimalDType; + use vortex_array::dtype::DecimalDType; let dtype = DType::Decimal(DecimalDType::new(precision, 0), Nullability::NonNullable); assert_eq!(dtype.to_arrow_dtype().unwrap(), expected); diff --git a/vortex-array/src/arrow/executor/bool.rs b/vortex-arrow/src/executor/bool.rs similarity index 83% rename from vortex-array/src/arrow/executor/bool.rs rename to vortex-arrow/src/executor/bool.rs index 68f2451cd77..64bffa804f2 100644 --- a/vortex-array/src/arrow/executor/bool.rs +++ b/vortex-arrow/src/executor/bool.rs @@ -5,13 +5,13 @@ use std::sync::Arc; use arrow_array::ArrayRef as ArrowArrayRef; use arrow_array::BooleanArray as ArrowBooleanArray; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::bool::BoolArrayExt; use vortex_error::VortexResult; -use crate::ArrayRef; -use crate::ExecutionCtx; -use crate::arrays::BoolArray; -use crate::arrays::bool::BoolArrayExt; -use crate::arrow::null_buffer::to_null_buffer; +use crate::null_buffer::to_null_buffer; /// Convert a canonical BoolArray directly to Arrow. pub fn canonical_bool_to_arrow( diff --git a/vortex-array/src/arrow/executor/byte.rs b/vortex-arrow/src/executor/byte.rs similarity index 91% rename from vortex-array/src/arrow/executor/byte.rs rename to vortex-arrow/src/executor/byte.rs index 83c1c681284..ae40ee01c26 100644 --- a/vortex-array/src/arrow/executor/byte.rs +++ b/vortex-arrow/src/executor/byte.rs @@ -8,22 +8,22 @@ use arrow_array::GenericByteArray; use arrow_array::types::BinaryViewType; use arrow_array::types::ByteArrayType; use arrow_array::types::StringViewType; +use vortex_array::ArrayRef; +use vortex_array::ArrayView; +use vortex_array::Canonical; +use vortex_array::ExecutionCtx; +use vortex_array::arrays::VarBin; +use vortex_array::arrays::VarBinViewArray; +use vortex_array::arrays::varbin::VarBinArrayExt; +use vortex_array::builtins::ArrayBuiltins; +use vortex_array::dtype::DType; +use vortex_array::dtype::NativePType; +use vortex_array::dtype::Nullability; use vortex_error::VortexError; use vortex_error::VortexResult; -use crate::ArrayRef; -use crate::Canonical; -use crate::ExecutionCtx; -use crate::array::ArrayView; -use crate::arrays::VarBin; -use crate::arrays::VarBinViewArray; -use crate::arrays::varbin::VarBinArrayExt; -use crate::arrow::byte_view::execute_varbinview_to_arrow; -use crate::arrow::executor::validity::to_arrow_null_buffer; -use crate::builtins::ArrayBuiltins; -use crate::dtype::DType; -use crate::dtype::NativePType; -use crate::dtype::Nullability; +use crate::byte_view::execute_varbinview_to_arrow; +use crate::executor::validity::to_arrow_null_buffer; /// Convert a Vortex array into an Arrow GenericBinaryArray. pub(super) fn to_arrow_byte_array( @@ -81,16 +81,16 @@ mod tests { use arrow_schema::DataType; use arrow_schema::Field; use rstest::rstest; + use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::array_session; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; use vortex_error::VortexResult; use vortex_mask::Mask; - use crate::IntoArray; - use crate::VortexSessionExecute; - use crate::array_session; - use crate::arrow::ArrowSessionExt; - use crate::arrow::executor::byte::VarBinViewArray; - use crate::dtype::DType; - use crate::dtype::Nullability; + use crate::ArrowSessionExt; + use crate::executor::byte::VarBinViewArray; fn make_utf8_array() -> VarBinViewArray { VarBinViewArray::from_iter_str(["hello", "world", "this is a longer string for testing"]) diff --git a/vortex-array/src/arrow/executor/byte_view.rs b/vortex-arrow/src/executor/byte_view.rs similarity index 87% rename from vortex-array/src/arrow/executor/byte_view.rs rename to vortex-arrow/src/executor/byte_view.rs index 4e17b7f2376..ffddc49a4dd 100644 --- a/vortex-array/src/arrow/executor/byte_view.rs +++ b/vortex-arrow/src/executor/byte_view.rs @@ -7,16 +7,16 @@ use arrow_array::ArrayRef as ArrowArrayRef; use arrow_array::GenericByteViewArray; use arrow_array::types::ByteViewType; use arrow_buffer::ScalarBuffer; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::arrays::VarBinViewArray; +use vortex_array::builtins::ArrayBuiltins; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; use vortex_error::VortexResult; -use crate::ArrayRef; -use crate::ExecutionCtx; -use crate::arrays::VarBinViewArray; -use crate::arrow::null_buffer::to_null_buffer; -use crate::builtins::ArrayBuiltins; -use crate::dtype::DType; -use crate::dtype::Nullability; -use crate::dtype::arrow::FromArrowType; +use crate::dtype::FromArrowType; +use crate::null_buffer::to_null_buffer; /// Convert a canonical VarBinViewArray directly to Arrow. pub fn canonical_varbinview_to_arrow( diff --git a/vortex-array/src/arrow/executor/decimal.rs b/vortex-arrow/src/executor/decimal.rs similarity index 92% rename from vortex-array/src/arrow/executor/decimal.rs rename to vortex-arrow/src/executor/decimal.rs index a9aa9fdd2f5..d5eabc77145 100644 --- a/vortex-array/src/arrow/executor/decimal.rs +++ b/vortex-arrow/src/executor/decimal.rs @@ -13,15 +13,15 @@ use arrow_schema::DataType; use itertools::Itertools; use num_traits::AsPrimitive; use num_traits::ToPrimitive; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::arrays::DecimalArray; +use vortex_array::dtype::DecimalType; use vortex_buffer::Buffer; use vortex_error::VortexResult; use vortex_error::vortex_err; -use crate::ArrayRef; -use crate::ExecutionCtx; -use crate::arrays::DecimalArray; -use crate::arrow::null_buffer::to_null_buffer; -use crate::dtype::DecimalType; +use crate::null_buffer::to_null_buffer; pub(super) fn to_arrow_decimal( array: ArrayRef, @@ -72,7 +72,7 @@ fn to_arrow_decimal32(array: DecimalArray, ctx: &mut ExecutionCtx) -> VortexResu }) .process_results(|iter| Buffer::from_trusted_len_iter(iter))?, DecimalType::I256 => array - .buffer::() + .buffer::() .into_iter() .map(|x| { x.to_i32() @@ -116,7 +116,7 @@ fn to_arrow_decimal64(array: DecimalArray, ctx: &mut ExecutionCtx) -> VortexResu }) .process_results(|iter| Buffer::from_trusted_len_iter(iter))?, DecimalType::I256 => array - .buffer::() + .buffer::() .into_iter() .map(|x| { x.to_i64() @@ -155,7 +155,7 @@ fn to_arrow_decimal128(array: DecimalArray, ctx: &mut ExecutionCtx) -> VortexRes } DecimalType::I128 => array.buffer::(), DecimalType::I256 => array - .buffer::() + .buffer::() .into_iter() .map(|x| { x.to_i128() @@ -196,7 +196,7 @@ fn to_arrow_decimal256(array: DecimalArray, ctx: &mut ExecutionCtx) -> VortexRes array .buffer::() .into_iter() - .map(|x| crate::dtype::i256::from_i128(x).into()), + .map(|x| vortex_array::dtype::i256::from_i128(x).into()), ), DecimalType::I256 => { Buffer::::from_byte_buffer(array.buffer_handle().clone().into_host_sync()) @@ -219,19 +219,19 @@ mod tests { use arrow_buffer::i256; use arrow_schema::DataType; use rstest::rstest; + use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::array_session; + use vortex_array::builders::ArrayBuilder; + use vortex_array::builders::DecimalBuilder; + use vortex_array::dtype::DecimalDType; + use vortex_array::dtype::NativeDecimalType; + use vortex_array::validity::Validity; use vortex_buffer::buffer; use vortex_error::VortexResult; - use crate::VortexSessionExecute; - use crate::array::IntoArray; - use crate::array_session; - use crate::arrow::ArrowArrayExecutor; - use crate::arrow::executor::decimal::DecimalArray; - use crate::builders::ArrayBuilder; - use crate::builders::DecimalBuilder; - use crate::dtype::DecimalDType; - use crate::dtype::NativeDecimalType; - use crate::validity::Validity; + use crate::ArrowArrayExecutor; + use crate::executor::decimal::DecimalArray; #[test] fn decimal_to_arrow() -> VortexResult<()> { @@ -260,7 +260,7 @@ mod tests { #[case(0i32)] #[case(0i64)] #[case(0i128)] - #[case(crate::dtype::i256::ZERO)] + #[case(vortex_array::dtype::i256::ZERO)] fn test_to_arrow_decimal128( #[case] _decimal_type: T, ) -> VortexResult<()> { @@ -288,7 +288,7 @@ mod tests { #[case(0i32)] #[case(0i64)] #[case(0i128)] - #[case(crate::dtype::i256::ZERO)] + #[case(vortex_array::dtype::i256::ZERO)] fn test_to_arrow_decimal32(#[case] _decimal_type: T) -> VortexResult<()> { use arrow_array::Decimal32Array; @@ -316,7 +316,7 @@ mod tests { #[case(0i32)] #[case(0i64)] #[case(0i128)] - #[case(crate::dtype::i256::ZERO)] + #[case(vortex_array::dtype::i256::ZERO)] fn test_to_arrow_decimal64(#[case] _decimal_type: T) -> VortexResult<()> { use arrow_array::Decimal64Array; @@ -344,7 +344,7 @@ mod tests { #[case(0i32)] #[case(0i64)] #[case(0i128)] - #[case(crate::dtype::i256::ZERO)] + #[case(vortex_array::dtype::i256::ZERO)] fn test_to_arrow_decimal256( #[case] _decimal_type: T, ) -> VortexResult<()> { diff --git a/vortex-array/src/arrow/executor/dictionary.rs b/vortex-arrow/src/executor/dictionary.rs similarity index 87% rename from vortex-array/src/arrow/executor/dictionary.rs rename to vortex-arrow/src/executor/dictionary.rs index 97d25c3f7cc..f5be1076a4b 100644 --- a/vortex-array/src/arrow/executor/dictionary.rs +++ b/vortex-arrow/src/executor/dictionary.rs @@ -10,19 +10,19 @@ use arrow_array::cast::AsArray; use arrow_array::new_null_array; use arrow_array::types::*; use arrow_schema::DataType; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::arrays::Constant; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::Dict; +use vortex_array::arrays::DictArray; +use vortex_array::arrays::dict::DictArraySlotsExt; use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_error::vortex_bail; -use crate::ArrayRef; -use crate::ExecutionCtx; -use crate::IntoArray; -use crate::arrays::Constant; -use crate::arrays::ConstantArray; -use crate::arrays::Dict; -use crate::arrays::DictArray; -use crate::arrays::dict::DictArraySlotsExt; -use crate::arrow::ArrowArrayExecutor; +use crate::ArrowArrayExecutor; pub(super) fn to_arrow_dictionary( array: ArrayRef, @@ -145,30 +145,33 @@ mod tests { use arrow_array::types::UInt32Type; use arrow_schema::DataType; use rstest::rstest; + use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::array_session; + use vortex_array::arrays::PrimitiveArray; + use vortex_array::arrays::VarBinViewArray; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability::Nullable; + use vortex_array::scalar::Scalar; use vortex_buffer::buffer; use vortex_error::VortexResult; - use crate::IntoArray; - use crate::array_session; - use crate::arrays::PrimitiveArray; - use crate::arrays::VarBinViewArray; - use crate::arrow::ArrowArrayExecutor; - use crate::arrow::executor::dictionary::ConstantArray; - use crate::arrow::executor::dictionary::DictArray; - use crate::dtype::DType; - use crate::dtype::Nullability::Nullable; - use crate::executor::VortexSessionExecute; - use crate::scalar::Scalar; + use crate::ArrowArrayExecutor; + use crate::executor::dictionary::ConstantArray; + use crate::executor::dictionary::DictArray; fn dict_type(codes: DataType, values: DataType) -> DataType { DataType::Dictionary(Box::new(codes), Box::new(values)) } - fn execute(array: crate::ArrayRef, dt: &DataType) -> VortexResult { + fn execute( + array: vortex_array::ArrayRef, + dt: &DataType, + ) -> VortexResult { array.execute_arrow(Some(dt), &mut array_session().create_execution_ctx()) } - fn dict_basic_input() -> crate::ArrayRef { + fn dict_basic_input() -> vortex_array::ArrayRef { DictArray::try_new( buffer![0u8, 1, 0].into_array(), VarBinViewArray::from_iter_str(["a", "b"]).into_array(), @@ -177,7 +180,7 @@ mod tests { .into_array() } - fn dict_with_null_codes_input() -> crate::ArrayRef { + fn dict_with_null_codes_input() -> vortex_array::ArrayRef { DictArray::try_new( PrimitiveArray::from_option_iter(vec![Some(0u8), None, Some(1)]).into_array(), VarBinViewArray::from_iter_str(["a", "b"]).into_array(), @@ -213,7 +216,7 @@ mod tests { Arc::new(vec![Some("a"), None, Some("a"), Some("b"), Some("a")].into_iter().collect::>()) as arrow_array::ArrayRef, )] fn to_arrow_dictionary( - #[case] input: crate::ArrayRef, + #[case] input: vortex_array::ArrayRef, #[case] target_type: DataType, #[case] expected: arrow_array::ArrayRef, ) -> VortexResult<()> { diff --git a/vortex-array/src/arrow/executor/fixed_size_list.rs b/vortex-arrow/src/executor/fixed_size_list.rs similarity index 86% rename from vortex-array/src/arrow/executor/fixed_size_list.rs rename to vortex-arrow/src/executor/fixed_size_list.rs index caeb5804da2..72ce7c57ff0 100644 --- a/vortex-array/src/arrow/executor/fixed_size_list.rs +++ b/vortex-arrow/src/executor/fixed_size_list.rs @@ -4,16 +4,16 @@ use std::sync::Arc; use arrow_schema::FieldRef; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::arrays::FixedSizeList; +use vortex_array::arrays::FixedSizeListArray; +use vortex_array::arrays::fixed_size_list::FixedSizeListArrayExt; use vortex_error::VortexResult; use vortex_error::vortex_ensure; -use crate::ArrayRef; -use crate::ExecutionCtx; -use crate::arrays::FixedSizeList; -use crate::arrays::FixedSizeListArray; -use crate::arrays::fixed_size_list::FixedSizeListArrayExt; -use crate::arrow::executor::validity::to_arrow_null_buffer; -use crate::arrow::session::ArrowSessionExt; +use crate::executor::validity::to_arrow_null_buffer; +use crate::session::ArrowSessionExt; pub(super) fn to_arrow_fixed_list( array: ArrayRef, diff --git a/vortex-array/src/arrow/executor/list.rs b/vortex-arrow/src/executor/list.rs similarity index 90% rename from vortex-array/src/arrow/executor/list.rs rename to vortex-arrow/src/executor/list.rs index 7bf7a16f496..0b0d7f625b5 100644 --- a/vortex-array/src/arrow/executor/list.rs +++ b/vortex-arrow/src/executor/list.rs @@ -8,30 +8,30 @@ use arrow_array::GenericListArray; use arrow_array::OffsetSizeTrait; use arrow_buffer::OffsetBuffer; use arrow_schema::FieldRef; +use vortex_array::ArrayRef; +use vortex_array::Canonical; +use vortex_array::ExecutionCtx; +use vortex_array::arrays::Chunked; +use vortex_array::arrays::List; +use vortex_array::arrays::ListArray; +use vortex_array::arrays::ListView; +use vortex_array::arrays::ListViewArray; +use vortex_array::arrays::chunked::ChunkedArrayExt; +use vortex_array::arrays::list::ListArrayExt; +use vortex_array::arrays::listview::ListViewArrayExt; +use vortex_array::arrays::listview::ListViewDataParts; +use vortex_array::arrays::listview::ListViewRebuildMode; +use vortex_array::builtins::ArrayBuiltins; +use vortex_array::dtype::DType; +use vortex_array::dtype::NativePType; +use vortex_array::dtype::Nullability; use vortex_buffer::BufferMut; use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_error::vortex_ensure; -use crate::ArrayRef; -use crate::Canonical; -use crate::ExecutionCtx; -use crate::arrays::Chunked; -use crate::arrays::List; -use crate::arrays::ListArray; -use crate::arrays::ListView; -use crate::arrays::ListViewArray; -use crate::arrays::chunked::ChunkedArrayExt; -use crate::arrays::list::ListArrayExt; -use crate::arrays::listview::ListViewArrayExt; -use crate::arrays::listview::ListViewDataParts; -use crate::arrays::listview::ListViewRebuildMode; -use crate::arrow::executor::validity::to_arrow_null_buffer; -use crate::arrow::session::ArrowSessionExt; -use crate::builtins::ArrayBuiltins; -use crate::dtype::DType; -use crate::dtype::NativePType; -use crate::dtype::Nullability; +use crate::executor::validity::to_arrow_null_buffer; +use crate::session::ArrowSessionExt; #[allow(rustdoc::broken_intra_doc_links)] /// Convert a Vortex VarBinArray into an Arrow [`GenericListArray`](arrow_array:array::GenericListArray). @@ -210,22 +210,22 @@ mod tests { use arrow_array::Int32Array; use arrow_schema::DataType; use arrow_schema::Field; + use vortex_array::Canonical; + use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::arrays::PrimitiveArray; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability::NonNullable; + use vortex_array::validity::Validity; use vortex_buffer::buffer; use vortex_error::VortexResult; use vortex_session::VortexSession; - use crate::Canonical; - use crate::IntoArray; - use crate::VortexSessionExecute; - use crate::arrays::PrimitiveArray; - use crate::arrow::ArrowArrayExecutor; - use crate::arrow::executor::list::ListViewArray; - use crate::dtype::DType; - use crate::dtype::Nullability::NonNullable; - use crate::validity::Validity; + use crate::ArrowArrayExecutor; + use crate::executor::list::ListViewArray; /// A shared session for these list-executor tests, used to create execution contexts. - static SESSION: LazyLock = LazyLock::new(crate::array_session); + static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); #[test] fn test_to_arrow_list_i32() -> VortexResult<()> { @@ -368,7 +368,10 @@ mod tests { fn test_to_arrow_list_empty_zctl() -> VortexResult<()> { let mut ctx = SESSION.create_execution_ctx(); let dtype = DType::List( - Arc::new(DType::Primitive(crate::dtype::PType::I32, NonNullable)), + Arc::new(DType::Primitive( + vortex_array::dtype::PType::I32, + NonNullable, + )), NonNullable, ); let list_array = unsafe { diff --git a/vortex-array/src/arrow/executor/list_view.rs b/vortex-arrow/src/executor/list_view.rs similarity index 89% rename from vortex-array/src/arrow/executor/list_view.rs rename to vortex-arrow/src/executor/list_view.rs index 7d5666b3762..da242eb9719 100644 --- a/vortex-array/src/arrow/executor/list_view.rs +++ b/vortex-arrow/src/executor/list_view.rs @@ -6,24 +6,24 @@ use std::sync::Arc; use arrow_array::GenericListViewArray; use arrow_array::OffsetSizeTrait; use arrow_schema::FieldRef; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::arrays::ListViewArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::arrays::listview::DEFAULT_REBUILD_DENSITY_THRESHOLD; +use vortex_array::arrays::listview::DEFAULT_TRIM_ELEMENTS_THRESHOLD; +use vortex_array::arrays::listview::ListViewArrayExt; +use vortex_array::arrays::listview::ListViewDataParts; +use vortex_array::arrays::listview::ListViewRebuildMode; +use vortex_array::builtins::ArrayBuiltins; +use vortex_array::dtype::DType; +use vortex_array::dtype::IntegerPType; +use vortex_array::dtype::Nullability::NonNullable; use vortex_error::VortexResult; use vortex_error::vortex_ensure; -use crate::ArrayRef; -use crate::ExecutionCtx; -use crate::arrays::ListViewArray; -use crate::arrays::PrimitiveArray; -use crate::arrays::listview::DEFAULT_REBUILD_DENSITY_THRESHOLD; -use crate::arrays::listview::DEFAULT_TRIM_ELEMENTS_THRESHOLD; -use crate::arrays::listview::ListViewArrayExt; -use crate::arrays::listview::ListViewDataParts; -use crate::arrays::listview::ListViewRebuildMode; -use crate::arrow::executor::validity::to_arrow_null_buffer; -use crate::arrow::session::ArrowSessionExt; -use crate::builtins::ArrayBuiltins; -use crate::dtype::DType; -use crate::dtype::IntegerPType; -use crate::dtype::Nullability::NonNullable; +use crate::executor::validity::to_arrow_null_buffer; +use crate::session::ArrowSessionExt; pub(super) fn to_arrow_list_view( array: ArrayRef, @@ -115,19 +115,19 @@ mod tests { use arrow_array::GenericListViewArray; use arrow_schema::DataType; use arrow_schema::Field; + use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::validity::Validity; use vortex_buffer::buffer; use vortex_error::VortexResult; use vortex_session::VortexSession; - use crate::IntoArray; - use crate::VortexSessionExecute; - use crate::arrow::ArrowArrayExecutor; - use crate::arrow::executor::list_view::ListViewArray; - use crate::arrow::executor::list_view::PrimitiveArray; - use crate::validity::Validity; + use crate::ArrowArrayExecutor; + use crate::executor::list_view::ListViewArray; + use crate::executor::list_view::PrimitiveArray; /// A shared session for these list-view-executor tests, used to create execution contexts. - static SESSION: LazyLock = LazyLock::new(crate::array_session); + static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); #[test] fn trims_zero_copy_with_significant_trailing_waste() -> VortexResult<()> { diff --git a/vortex-array/src/arrow/executor/mod.rs b/vortex-arrow/src/executor/mod.rs similarity index 88% rename from vortex-array/src/arrow/executor/mod.rs rename to vortex-arrow/src/executor/mod.rs index 49bd87bcbdf..875577788b4 100644 --- a/vortex-array/src/arrow/executor/mod.rs +++ b/vortex-arrow/src/executor/mod.rs @@ -30,35 +30,35 @@ use arrow_schema::Field; use arrow_schema::FieldRef; use arrow_schema::Schema; use itertools::Itertools; -use vortex_array::dtype::arrow::to_data_type_naive; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::arrays::List; +use vortex_array::arrays::VarBin; +use vortex_array::arrays::list::ListArrayExt; +use vortex_array::arrays::varbin::VarBinArrayExt; +use vortex_array::dtype::DType; +use vortex_array::dtype::PType; use vortex_error::VortexResult; use vortex_error::vortex_bail; use vortex_error::vortex_ensure; -use crate::ArrayRef; -use crate::arrays::List; -use crate::arrays::VarBin; -use crate::arrays::list::ListArrayExt; -use crate::arrays::varbin::VarBinArrayExt; -use crate::arrow::executor::bool::to_arrow_bool; -use crate::arrow::executor::byte::to_arrow_byte_array; -use crate::arrow::executor::byte_view::to_arrow_byte_view; -use crate::arrow::executor::decimal::to_arrow_decimal; -use crate::arrow::executor::dictionary::to_arrow_dictionary; -use crate::arrow::executor::fixed_size_list::to_arrow_fixed_list; -use crate::arrow::executor::list::to_arrow_list; -use crate::arrow::executor::list_view::to_arrow_list_view; -use crate::arrow::executor::null::to_arrow_null; -use crate::arrow::executor::primitive::to_arrow_primitive; -use crate::arrow::executor::run_end::to_arrow_run_end; -use crate::arrow::executor::struct_::to_arrow_struct; -use crate::arrow::executor::temporal::to_arrow_date; -use crate::arrow::executor::temporal::to_arrow_time; -use crate::arrow::executor::temporal::to_arrow_timestamp; -use crate::arrow::session::ArrowSessionExt; -use crate::dtype::DType; -use crate::dtype::PType; -use crate::executor::ExecutionCtx; +use crate::dtype::to_data_type_naive; +use crate::executor::bool::to_arrow_bool; +use crate::executor::byte::to_arrow_byte_array; +use crate::executor::byte_view::to_arrow_byte_view; +use crate::executor::decimal::to_arrow_decimal; +use crate::executor::dictionary::to_arrow_dictionary; +use crate::executor::fixed_size_list::to_arrow_fixed_list; +use crate::executor::list::to_arrow_list; +use crate::executor::list_view::to_arrow_list_view; +use crate::executor::null::to_arrow_null; +use crate::executor::primitive::to_arrow_primitive; +use crate::executor::run_end::to_arrow_run_end; +use crate::executor::struct_::to_arrow_struct; +use crate::executor::temporal::to_arrow_date; +use crate::executor::temporal::to_arrow_time; +use crate::executor::temporal::to_arrow_timestamp; +use crate::session::ArrowSessionExt; /// Trait for executing a Vortex array to produce an Arrow array. #[deprecated(note = "Use an `ArrowSession` to perform conversions to/from Arrow arrays")] diff --git a/vortex-array/src/arrow/executor/null.rs b/vortex-arrow/src/executor/null.rs similarity index 86% rename from vortex-array/src/arrow/executor/null.rs rename to vortex-arrow/src/executor/null.rs index 9d2b5ef6fc4..c5ffc991006 100644 --- a/vortex-array/src/arrow/executor/null.rs +++ b/vortex-arrow/src/executor/null.rs @@ -5,12 +5,11 @@ use std::sync::Arc; use arrow_array::ArrayRef as ArrowArrayRef; use arrow_array::NullArray as ArrowNullArray; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::arrays::NullArray; use vortex_error::VortexResult; -use crate::ArrayRef; -use crate::ExecutionCtx; -use crate::arrays::NullArray; - /// Convert a canonical NullArray directly to Arrow. pub fn canonical_null_to_arrow(array: &NullArray) -> ArrowArrayRef { Arc::new(ArrowNullArray::new(array.len())) diff --git a/vortex-array/src/arrow/executor/primitive.rs b/vortex-arrow/src/executor/primitive.rs similarity index 81% rename from vortex-array/src/arrow/executor/primitive.rs rename to vortex-arrow/src/executor/primitive.rs index 0d41176972b..6cea08c0a72 100644 --- a/vortex-array/src/arrow/executor/primitive.rs +++ b/vortex-arrow/src/executor/primitive.rs @@ -6,16 +6,16 @@ use std::sync::Arc; use arrow_array::ArrayRef as ArrowArrayRef; use arrow_array::ArrowPrimitiveType; use arrow_array::PrimitiveArray as ArrowPrimitiveArray; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::builtins::ArrayBuiltins; +use vortex_array::dtype::DType; +use vortex_array::dtype::NativePType; +use vortex_array::dtype::Nullability; use vortex_error::VortexResult; -use crate::ArrayRef; -use crate::ExecutionCtx; -use crate::arrays::PrimitiveArray; -use crate::arrow::null_buffer::to_null_buffer; -use crate::builtins::ArrayBuiltins; -use crate::dtype::DType; -use crate::dtype::NativePType; -use crate::dtype::Nullability; +use crate::null_buffer::to_null_buffer; /// Convert a canonical PrimitiveArray directly to Arrow. pub fn canonical_primitive_to_arrow( diff --git a/vortex-array/src/arrow/executor/run_end.rs b/vortex-arrow/src/executor/run_end.rs similarity index 76% rename from vortex-array/src/arrow/executor/run_end.rs rename to vortex-arrow/src/executor/run_end.rs index 0632f871f29..b6913c1ea1c 100644 --- a/vortex-array/src/arrow/executor/run_end.rs +++ b/vortex-arrow/src/executor/run_end.rs @@ -11,35 +11,20 @@ use arrow_array::types::*; use arrow_buffer::ArrowNativeType; use arrow_schema::DataType; use arrow_schema::Field; -use prost::Message; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::arrays::Constant; +use vortex_array::arrays::ConstantArray; use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_error::vortex_bail; -use vortex_error::vortex_ensure; use vortex_error::vortex_err; +use vortex_runend::RunEnd; +use vortex_runend::RunEndArray; +use vortex_runend::RunEndArrayExt; -use crate::ArrayRef; -use crate::ExecutionCtx; -use crate::IntoArray; -use crate::arrays::Constant; -use crate::arrays::ConstantArray; -use crate::arrow::ArrowArrayExecutor; -use crate::session::ArraySessionExt; - -/// The encoding ID used by `vortex-runend`. We match on this string to avoid a crate dependency. -const VORTEX_RUNEND_ID: &str = "vortex.runend"; - -/// Mirror of `RunEndMetadata` from the `vortex-runend` crate. Same prost field tags so we can -/// decode the serialized metadata without depending on that crate. -#[derive(Clone, prost::Message)] -struct RunEndMetadata { - #[prost(int32, tag = "1")] - pub ends_ptype: i32, - #[prost(uint64, tag = "2")] - pub num_runs: u64, - #[prost(uint64, tag = "3")] - pub offset: u64, -} +use crate::ArrowArrayExecutor; pub(super) fn to_arrow_run_end( array: ArrayRef, @@ -57,47 +42,32 @@ pub(super) fn to_arrow_run_end( // Execute to unwrap any wrapper VTables (Slice, Filter, etc.) which may // reveal a RunEndArray. let array = array.execute::(ctx)?; - if array.encoding_id().as_ref() == VORTEX_RUNEND_ID { - // NOTE(ngates): while this module still lives in vortex-array, we cannot depend on the - // vortex-runend crate. Therefore, we match on the encoding ID string and extract the children - // and metadata directly. - return run_end_to_arrow(array, ends_type, values_type, ctx); + match array.try_downcast::() { + Ok(run_end) => run_end_to_arrow(run_end, ends_type, values_type, ctx), + Err(array) => { + // Fallback: canonicalize to flat Arrow, then cast to REE. + let flat = array.execute_arrow(Some(values_type.data_type()), ctx)?; + let ree_type = DataType::RunEndEncoded( + Arc::new(Field::new("run_ends", ends_type.clone(), false)), + Arc::new(values_type.clone()), + ); + arrow_cast::cast(&flat, &ree_type).map_err(VortexError::from) + } } - - // Fallback: canonicalize to flat Arrow, then cast to REE. - let flat = array.execute_arrow(Some(values_type.data_type()), ctx)?; - let ree_type = DataType::RunEndEncoded( - Arc::new(Field::new("run_ends", ends_type.clone(), false)), - Arc::new(values_type.clone()), - ); - arrow_cast::cast(&flat, &ree_type).map_err(VortexError::from) } fn run_end_to_arrow( - array: ArrayRef, + array: RunEndArray, ends_type: &DataType, values_type: &Field, ctx: &mut ExecutionCtx, ) -> VortexResult { let length = array.len(); - let metadata_bytes = ctx - .session() - .array_serialize(&array)? - .ok_or_else(|| vortex_err!("RunEndArray missing metadata"))?; - let metadata = RunEndMetadata::decode(&*metadata_bytes) - .map_err(|e| vortex_err!("Failed to decode RunEndMetadata: {e}"))?; - let offset = usize::try_from(metadata.offset) - .map_err(|_| vortex_err!("RunEndArray offset {} overflows usize", metadata.offset))?; - - let children = array.children(); - vortex_ensure!( - children.len() == 2, - "Expected RunEndArray to have 2 children, got {}", - children.len() - ); - - let arrow_ends = children[0].clone().execute_arrow(Some(ends_type), ctx)?; - let arrow_values = children[1] + let offset = array.offset(); + + let arrow_ends = array.ends().clone().execute_arrow(Some(ends_type), ctx)?; + let arrow_values = array + .values() .clone() .execute_arrow(Some(values_type.data_type()), ctx)?; @@ -204,21 +174,21 @@ mod tests { use arrow_schema::DataType; use arrow_schema::Field; use rstest::rstest; + use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::arrays::PrimitiveArray; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability::Nullable; + use vortex_array::dtype::PType; + use vortex_array::scalar::Scalar; use vortex_error::VortexResult; use vortex_error::vortex_err; use vortex_session::VortexSession; - use crate::IntoArray; - use crate::arrays::PrimitiveArray; - use crate::arrow::ArrowArrayExecutor; - use crate::arrow::executor::run_end::ConstantArray; - use crate::dtype::DType; - use crate::dtype::Nullability::Nullable; - use crate::dtype::PType; - use crate::executor::VortexSessionExecute; - use crate::scalar::Scalar; + use crate::ArrowArrayExecutor; + use crate::executor::run_end::ConstantArray; - static SESSION: LazyLock = LazyLock::new(crate::array_session); + static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); fn ree_type(ends: DataType, values_dtype: DataType) -> DataType { DataType::RunEndEncoded( @@ -227,7 +197,10 @@ mod tests { ) } - fn execute(array: crate::ArrayRef, dt: &DataType) -> VortexResult { + fn execute( + array: vortex_array::ArrayRef, + dt: &DataType, + ) -> VortexResult { array.execute_arrow(Some(dt), &mut SESSION.create_execution_ctx()) } @@ -279,7 +252,7 @@ mod tests { ), )] fn constant_to_ree( - #[case] input: crate::ArrayRef, + #[case] input: vortex_array::ArrayRef, #[case] target_type: DataType, #[case] expected: arrow_array::ArrayRef, ) -> VortexResult<()> { diff --git a/vortex-array/src/arrow/executor/struct_.rs b/vortex-arrow/src/executor/struct_.rs similarity index 89% rename from vortex-array/src/arrow/executor/struct_.rs rename to vortex-arrow/src/executor/struct_.rs index d3b7bcda2dc..e2ce2700d4a 100644 --- a/vortex-array/src/arrow/executor/struct_.rs +++ b/vortex-arrow/src/executor/struct_.rs @@ -9,27 +9,27 @@ use arrow_buffer::NullBuffer; use arrow_schema::Field; use arrow_schema::Fields; use itertools::Itertools; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::arrays::Chunked; +use vortex_array::arrays::ScalarFn; +use vortex_array::arrays::Struct; +use vortex_array::arrays::StructArray; +use vortex_array::arrays::scalar_fn::ScalarFnArrayExt; +use vortex_array::arrays::struct_::StructDataParts; +use vortex_array::builtins::ArrayBuiltins; +use vortex_array::dtype::DType; +use vortex_array::dtype::FieldNames; +use vortex_array::dtype::StructFields; +use vortex_array::scalar_fn::fns::pack::Pack; use vortex_error::VortexResult; use vortex_error::vortex_ensure; -use crate::ArrayRef; -use crate::ExecutionCtx; -use crate::IntoArray; -use crate::arrays::Chunked; -use crate::arrays::ScalarFn; -use crate::arrays::Struct; -use crate::arrays::StructArray; -use crate::arrays::scalar_fn::ScalarFnArrayExt; -use crate::arrays::struct_::StructDataParts; -use crate::arrow::ArrowArrayExecutor; -use crate::arrow::executor::validity::to_arrow_null_buffer; -use crate::arrow::session::ArrowSessionExt; -use crate::builtins::ArrayBuiltins; -use crate::dtype::DType; -use crate::dtype::FieldNames; -use crate::dtype::StructFields; -use crate::dtype::arrow::FromArrowType; -use crate::scalar_fn::fns::pack::Pack; +use crate::ArrowArrayExecutor; +use crate::dtype::FromArrowType; +use crate::executor::validity::to_arrow_null_buffer; +use crate::session::ArrowSessionExt; pub(super) fn to_arrow_struct( array: ArrayRef, @@ -91,7 +91,7 @@ pub(super) fn to_arrow_struct( // We apply a cast to ensure we push down casting where possible into the struct fields. array.cast(DType::Struct( vx_fields, - crate::dtype::Nullability::Nullable, + vortex_array::dtype::Nullability::Nullable, ))? } else { array @@ -204,20 +204,21 @@ mod tests { use arrow_buffer::NullBuffer; use arrow_schema::DataType; use arrow_schema::Field; + use vortex_array as array; + use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::array_session; + use vortex_array::arrays; + use vortex_array::arrays::PrimitiveArray; + use vortex_array::arrays::StructArray; + use vortex_array::dtype::FieldNames; + use vortex_array::validity::Validity; use vortex_buffer::buffer; use vortex_error::VortexResult; - use crate::IntoArray; - use crate::VortexSessionExecute; - use crate::array; - use crate::array_session; - use crate::arrays; - use crate::arrays::PrimitiveArray; - use crate::arrays::StructArray; - use crate::arrow::ArrowArrayExecutor; - use crate::arrow::FromArrowArray; - use crate::dtype::FieldNames; - use crate::validity::Validity; + use crate::ArrowArrayExecutor; + use crate::FromArrowArray; + use crate::dtype::to_data_type_naive; #[test] fn struct_nullable_non_null_to_arrow() -> VortexResult<()> { @@ -328,7 +329,7 @@ mod tests { ), ])?); - let arrow_dtype = array.dtype().to_arrow_dtype()?; + let arrow_dtype = to_data_type_naive(array.dtype())?; assert_eq!( &array .into_array() diff --git a/vortex-array/src/arrow/executor/temporal.rs b/vortex-arrow/src/executor/temporal.rs similarity index 92% rename from vortex-array/src/arrow/executor/temporal.rs rename to vortex-arrow/src/executor/temporal.rs index bed70f8baec..8af9aa0b2c4 100644 --- a/vortex-array/src/arrow/executor/temporal.rs +++ b/vortex-arrow/src/executor/temporal.rs @@ -26,20 +26,20 @@ use arrow_array::types::TimestampNanosecondType; use arrow_array::types::TimestampSecondType; use arrow_schema::DataType; use arrow_schema::TimeUnit as ArrowTimeUnit; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::builtins::ArrayBuiltins; +use vortex_array::dtype::DType; +use vortex_array::dtype::NativePType; +use vortex_array::dtype::Nullability; +use vortex_array::extension::datetime::AnyTemporal; +use vortex_array::extension::datetime::TemporalMetadata; +use vortex_array::extension::datetime::TimeUnit; use vortex_error::VortexResult; use vortex_error::vortex_bail; -use crate::ArrayRef; -use crate::ExecutionCtx; -use crate::arrays::PrimitiveArray; -use crate::arrow::null_buffer::to_null_buffer; -use crate::builtins::ArrayBuiltins; -use crate::dtype::DType; -use crate::dtype::NativePType; -use crate::dtype::Nullability; -use crate::extension::datetime::AnyTemporal; -use crate::extension::datetime::TemporalMetadata; -use crate::extension::datetime::TimeUnit; +use crate::null_buffer::to_null_buffer; pub(super) fn to_arrow_date( array: ArrayRef, @@ -168,7 +168,7 @@ fn validate_temporal_extension(array: &ArrayRef, target: &DataType) -> VortexRes Ok(()) } (TemporalMetadata::Timestamp(unit, src_tz), DataType::Timestamp(arrow_unit, tgt_tz)) => { - let src_arrow_unit = ArrowTimeUnit::try_from(*unit)?; + let src_arrow_unit = crate::dtype::to_arrow_time_unit(*unit)?; if src_arrow_unit != *arrow_unit { vortex_bail!( "Cannot convert Timestamp({unit}) to Arrow Timestamp({arrow_unit:?}): unit mismatch" diff --git a/vortex-array/src/arrow/executor/validity.rs b/vortex-arrow/src/executor/validity.rs similarity index 61% rename from vortex-array/src/arrow/executor/validity.rs rename to vortex-arrow/src/executor/validity.rs index fa848dfc79a..ab8a88b5ee3 100644 --- a/vortex-array/src/arrow/executor/validity.rs +++ b/vortex-arrow/src/executor/validity.rs @@ -1,4 +1,4 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -pub(super) use crate::arrow::null_buffer::to_arrow_null_buffer; +pub(super) use crate::null_buffer::to_arrow_null_buffer; diff --git a/vortex-array/src/arrow/iter.rs b/vortex-arrow/src/iter.rs similarity index 86% rename from vortex-array/src/arrow/iter.rs rename to vortex-arrow/src/iter.rs index f05bb0a4e7b..0d7ebe5c82a 100644 --- a/vortex-array/src/arrow/iter.rs +++ b/vortex-arrow/src/iter.rs @@ -2,14 +2,14 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors use arrow_array::ffi_stream; +use vortex_array::ArrayRef; +use vortex_array::dtype::DType; +use vortex_array::iter::ArrayIterator; use vortex_error::VortexError; use vortex_error::VortexResult; -use crate::ArrayRef; -use crate::arrow::FromArrowArray; -use crate::dtype::DType; -use crate::dtype::arrow::FromArrowType; -use crate::iter::ArrayIterator; +use crate::FromArrowArray; +use crate::dtype::FromArrowType; /// An adapter for converting an `ArrowArrayStreamReader` into a Vortex `ArrayStream`. pub struct ArrowArrayStreamAdapter { diff --git a/vortex-array/src/arrow/mod.rs b/vortex-arrow/src/lib.rs similarity index 64% rename from vortex-array/src/arrow/mod.rs rename to vortex-arrow/src/lib.rs index feda2a5f4d8..73fa4c724ce 100644 --- a/vortex-array/src/arrow/mod.rs +++ b/vortex-arrow/src/lib.rs @@ -1,38 +1,62 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -//! Utilities to work with `Arrow` data and types. +//! Apache Arrow interoperability for Vortex. +//! +//! This crate owns every conversion between Vortex and Arrow: importing Arrow arrays, record +//! batches, schemas, and fields into Vortex ([`FromArrowArray`], [`FromArrowType`], +//! [`TryFromArrowType`]), and executing Vortex arrays into Arrow ([`ArrowSession::execute_arrow`] +//! and the [`ArrowArrayExecutor`] convenience trait). +//! +//! The [`ArrowSession`] session variable is created lazily on first use of +//! [`ArrowSessionExt::arrow`], so no explicit registration is required for plain conversions. use arrow_array::ArrayRef as ArrowArrayRef; use arrow_schema::DataType; +use vortex_array::ArrayRef; +use vortex_array::VortexSessionExecute; +use vortex_array::legacy_session; use vortex_error::VortexResult; +use vortex_session::VortexSession; mod convert; mod datum; +pub mod dtype; mod executor; mod iter; mod null_buffer; +mod run_end_import; +mod scalar; mod session; +mod uuid; +pub use convert::IntoVortexArray; pub(crate) use convert::nulls; pub use datum::*; +pub use dtype::FromArrowType; +pub use dtype::ToArrowType; +pub use dtype::TryFromArrowType; pub use executor::*; pub use iter::*; pub use null_buffer::to_arrow_null_buffer; pub use null_buffer::to_null_buffer; +pub use scalar::ToArrowDatum; pub use session::*; -use crate::ArrayRef; -use crate::VortexSessionExecute; -use crate::legacy_session; +/// Register vortex-arrow's session extensions into `session`. +/// +/// This eagerly registers the [`ArrowSession`], which is otherwise created lazily on first use. +pub fn initialize(session: &VortexSession) { + session.register(ArrowSession::default()); +} /// Construct a Vortex array from an Arrow array (or other Arrow container) of type `A`. /// /// Implementations reuse the underlying Arrow buffers without copying wherever the Arrow and /// Vortex memory layouts allow it. pub trait FromArrowArray
{ - /// Convert `array` into a Vortex array whose [`DType`](crate::dtype::DType) has the requested - /// `nullable` [`Nullability`](crate::dtype::Nullability). + /// Convert `array` into a Vortex array whose [`DType`](vortex_array::dtype::DType) has the requested + /// `nullable` [`Nullability`](vortex_array::dtype::Nullability). /// /// An Arrow array can carry a validity (null) buffer regardless of whether its schema declares /// the field nullable, so the desired nullability is supplied separately by the caller @@ -64,7 +88,7 @@ pub trait IntoArrowArray { #[allow(deprecated)] impl IntoArrowArray for ArrayRef { - /// Convert this [`crate::ArrayRef`] into an Arrow [`crate::ArrayRef`] by using the array's + /// Convert this [`vortex_array::ArrayRef`] into an Arrow [`ArrowArrayRef`] by using the array's /// preferred (cheapest) Arrow [`DataType`]. #[allow(clippy::disallowed_methods)] fn into_arrow_preferred(self) -> VortexResult { diff --git a/vortex-array/src/arrow/null_buffer.rs b/vortex-arrow/src/null_buffer.rs similarity index 93% rename from vortex-array/src/arrow/null_buffer.rs rename to vortex-arrow/src/null_buffer.rs index 484dd5e55bc..b50adb30cd2 100644 --- a/vortex-array/src/arrow/null_buffer.rs +++ b/vortex-arrow/src/null_buffer.rs @@ -3,12 +3,11 @@ use arrow_buffer::BooleanBuffer; use arrow_buffer::NullBuffer; +use vortex_array::ExecutionCtx; +use vortex_array::validity::Validity; use vortex_error::VortexResult; use vortex_mask::Mask; -use crate::ExecutionCtx; -use crate::validity::Validity; - /// Converts a [`Validity`] to an Arrow [`NullBuffer`], executing the validity array if needed. pub fn to_arrow_null_buffer( validity: Validity, @@ -38,7 +37,7 @@ mod tests { use vortex_buffer::BitBuffer; use vortex_mask::Mask; - use crate::arrow::null_buffer::to_null_buffer; + use crate::null_buffer::to_null_buffer; #[test] fn test_mask_to_null_buffer() { diff --git a/encodings/runend/src/arrow.rs b/vortex-arrow/src/run_end_import.rs similarity index 96% rename from encodings/runend/src/arrow.rs rename to vortex-arrow/src/run_end_import.rs index 1f58e21bef2..69deba71fdf 100644 --- a/encodings/runend/src/arrow.rs +++ b/vortex-arrow/src/run_end_import.rs @@ -8,16 +8,16 @@ use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::primitive::PrimitiveArrayExt; -use vortex_array::arrow::FromArrowArray; use vortex_array::dtype::NativePType; use vortex_array::legacy_session; use vortex_array::validity::Validity; use vortex_buffer::Buffer; use vortex_error::VortexResult; +use vortex_runend::RunEndData; +use vortex_runend::ops::find_physical_index; +use vortex_runend::ops::find_slice_end_index; -use crate::RunEndData; -use crate::ops::find_physical_index; -use crate::ops::find_slice_end_index; +use crate::FromArrowArray; impl FromArrowArray<&RunArray> for RunEndData where @@ -80,8 +80,6 @@ mod tests { use vortex_array::VortexSessionExecute as _; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::primitive::PrimitiveArrayExt; - use vortex_array::arrow::ArrowSessionExt; - use vortex_array::arrow::FromArrowArray; use vortex_array::assert_arrays_eq; use vortex_array::dtype::DType; use vortex_array::dtype::NativePType; @@ -91,16 +89,18 @@ mod tests { use vortex_buffer::Buffer; use vortex_buffer::buffer; use vortex_error::VortexResult; + use vortex_runend::RunEnd; + use vortex_runend::RunEndArray; + use vortex_runend::ops::find_physical_index; + use vortex_runend::ops::find_slice_end_index; use vortex_session::VortexSession; - use crate::RunEnd; - use crate::RunEndArray; - use crate::ops::find_physical_index; - use crate::ops::find_slice_end_index; + use crate::ArrowSessionExt; + use crate::FromArrowArray; static SESSION: LazyLock = LazyLock::new(|| { let session = vortex_array::array_session(); - crate::initialize(&session); + vortex_runend::initialize(&session); session }); diff --git a/vortex-array/src/scalar/arrow.rs b/vortex-arrow/src/scalar.rs similarity index 83% rename from vortex-array/src/scalar/arrow.rs rename to vortex-arrow/src/scalar.rs index cca6749cb19..aeac56f43d3 100644 --- a/vortex-array/src/scalar/arrow.rs +++ b/vortex-arrow/src/scalar.rs @@ -7,24 +7,23 @@ use std::sync::Arc; use arrow_array::Scalar as ArrowScalar; use arrow_array::*; +use vortex_array::dtype::DType; +use vortex_array::dtype::PType; +use vortex_array::extension::datetime::AnyTemporal; +use vortex_array::extension::datetime::TemporalMetadata; +use vortex_array::extension::datetime::TimeUnit; +use vortex_array::scalar::BinaryScalar; +use vortex_array::scalar::BoolScalar; +use vortex_array::scalar::DecimalScalar; +use vortex_array::scalar::DecimalValue; +use vortex_array::scalar::ExtScalar; +use vortex_array::scalar::PrimitiveScalar; +use vortex_array::scalar::Scalar; +use vortex_array::scalar::Utf8Scalar; use vortex_error::VortexError; use vortex_error::vortex_bail; use vortex_error::vortex_err; -use crate::dtype::DType; -use crate::dtype::PType; -use crate::extension::datetime::AnyTemporal; -use crate::extension::datetime::TemporalMetadata; -use crate::extension::datetime::TimeUnit; -use crate::scalar::BinaryScalar; -use crate::scalar::BoolScalar; -use crate::scalar::DecimalScalar; -use crate::scalar::DecimalValue; -use crate::scalar::ExtScalar; -use crate::scalar::PrimitiveScalar; -use crate::scalar::Scalar; -use crate::scalar::Utf8Scalar; - /// Arrow represents scalars as single-element arrays. This constant is the length of those arrays. const SCALAR_ARRAY_LEN: usize = 1; @@ -50,10 +49,18 @@ macro_rules! timestamp_to_arrow_scalar { }}; } -impl TryFrom<&Scalar> for Arc { - type Error = VortexError; +/// Convert a Vortex [`Scalar`] into an Arrow [`Datum`] (a single-element Arrow array). +/// +/// This mirrors a `TryFrom<&Scalar> for Arc` conversion; a separate trait is +/// required because both `Scalar` and `Datum` are foreign to this crate. +pub trait ToArrowDatum { + /// Convert this scalar to an Arrow [`Datum`]. + fn to_arrow_datum(&self) -> Result, VortexError>; +} - fn try_from(value: &Scalar) -> Result, Self::Error> { +impl ToArrowDatum for Scalar { + fn to_arrow_datum(&self) -> Result, VortexError> { + let value = self; match value.dtype() { DType::Null => Ok(Arc::new(NullArray::new(SCALAR_ARRAY_LEN))), DType::Bool(_) => bool_to_arrow(value.as_bool()), @@ -192,149 +199,149 @@ fn extension_to_arrow(scalar: ExtScalar<'_>) -> Result, VortexErr mod tests { use std::sync::Arc; - use arrow_array::Datum; use rstest::rstest; + use vortex_array::dtype::DType; + use vortex_array::dtype::DecimalDType; + use vortex_array::dtype::FieldDType; + use vortex_array::dtype::NativeDType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::dtype::StructFields; + use vortex_array::dtype::extension::ExtDType; + use vortex_array::dtype::extension::ExtId; + use vortex_array::dtype::extension::ExtVTable; + use vortex_array::dtype::i256; + use vortex_array::extension::datetime::Date; + use vortex_array::extension::datetime::Time; + use vortex_array::extension::datetime::TimeUnit; + use vortex_array::extension::datetime::Timestamp; + use vortex_array::extension::datetime::TimestampOptions; + use vortex_array::scalar::DecimalValue; + use vortex_array::scalar::Scalar; + use vortex_array::scalar::ScalarValue; use vortex_error::VortexResult; use vortex_error::vortex_bail; - use crate::dtype::DType; - use crate::dtype::DecimalDType; - use crate::dtype::FieldDType; - use crate::dtype::NativeDType; - use crate::dtype::Nullability; - use crate::dtype::PType; - use crate::dtype::StructFields; - use crate::dtype::extension::ExtDType; - use crate::dtype::extension::ExtId; - use crate::dtype::extension::ExtVTable; - use crate::dtype::i256; - use crate::extension::datetime::Date; - use crate::extension::datetime::Time; - use crate::extension::datetime::TimeUnit; - use crate::extension::datetime::Timestamp; - use crate::extension::datetime::TimestampOptions; - use crate::scalar::DecimalValue; - use crate::scalar::Scalar; - use crate::scalar::ScalarValue; + use super::ToArrowDatum; #[test] fn test_null_scalar_to_arrow() { let scalar = Scalar::null(DType::Null); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_bool_scalar_to_arrow() { let scalar = Scalar::bool(true, Nullability::NonNullable); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_null_bool_scalar_to_arrow() { let scalar = Scalar::null(bool::dtype().as_nullable()); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_primitive_u8_to_arrow() { let scalar = Scalar::primitive(42u8, Nullability::NonNullable); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_primitive_u16_to_arrow() { let scalar = Scalar::primitive(1000u16, Nullability::NonNullable); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_primitive_u32_to_arrow() { let scalar = Scalar::primitive(100000u32, Nullability::NonNullable); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_primitive_u64_to_arrow() { let scalar = Scalar::primitive(10000000000u64, Nullability::NonNullable); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_primitive_i8_to_arrow() { let scalar = Scalar::primitive(-42i8, Nullability::NonNullable); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_primitive_i16_to_arrow() { let scalar = Scalar::primitive(-1000i16, Nullability::NonNullable); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_primitive_i32_to_arrow() { let scalar = Scalar::primitive(-100000i32, Nullability::NonNullable); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_primitive_i64_to_arrow() { let scalar = Scalar::primitive(-10000000000i64, Nullability::NonNullable); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_primitive_f16_to_arrow() { - use crate::dtype::half::f16; + use vortex_array::dtype::half::f16; let scalar = Scalar::primitive(f16::from_f32(1.234), Nullability::NonNullable); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_primitive_f32_to_arrow() { let scalar = Scalar::primitive(1.234f32, Nullability::NonNullable); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_primitive_f64_to_arrow() { let scalar = Scalar::primitive(1.234567890123f64, Nullability::NonNullable); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_null_primitive_to_arrow() { let scalar = Scalar::null(i32::dtype().as_nullable()); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_utf8_scalar_to_arrow() { let scalar = Scalar::utf8("hello world".to_string(), Nullability::NonNullable); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_null_utf8_scalar_to_arrow() { let scalar = Scalar::null(String::dtype().as_nullable()); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } @@ -342,14 +349,14 @@ mod tests { fn test_binary_scalar_to_arrow() { let data = vec![1u8, 2, 3, 4, 5]; let scalar = Scalar::binary(data, Nullability::NonNullable); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } #[test] fn test_null_binary_scalar_to_arrow() { let scalar = Scalar::null(DType::Binary(Nullability::Nullable)); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } @@ -363,35 +370,35 @@ mod tests { decimal_dtype, Nullability::NonNullable, ); - assert!(Arc::::try_from(&scalar_i8).is_ok()); + assert!(scalar_i8.to_arrow_datum().is_ok()); let scalar_i16 = Scalar::decimal( DecimalValue::I16(10000), decimal_dtype, Nullability::NonNullable, ); - assert!(Arc::::try_from(&scalar_i16).is_ok()); + assert!(scalar_i16.to_arrow_datum().is_ok()); let scalar_i32 = Scalar::decimal( DecimalValue::I32(99999), decimal_dtype, Nullability::NonNullable, ); - assert!(Arc::::try_from(&scalar_i32).is_ok()); + assert!(scalar_i32.to_arrow_datum().is_ok()); let scalar_i64 = Scalar::decimal( DecimalValue::I64(99999), decimal_dtype, Nullability::NonNullable, ); - assert!(Arc::::try_from(&scalar_i64).is_ok()); + assert!(scalar_i64.to_arrow_datum().is_ok()); let scalar_i128 = Scalar::decimal( DecimalValue::I128(99999), decimal_dtype, Nullability::NonNullable, ); - assert!(Arc::::try_from(&scalar_i128).is_ok()); + assert!(scalar_i128.to_arrow_datum().is_ok()); // Test i256 @@ -401,14 +408,14 @@ mod tests { decimal_dtype, Nullability::NonNullable, ); - assert!(Arc::::try_from(&scalar_i256).is_ok()); + assert!(scalar_i256.to_arrow_datum().is_ok()); } #[test] fn test_null_decimal_to_arrow() { let decimal_dtype = DecimalDType::new(10, 2); let scalar = Scalar::null(DType::Decimal(decimal_dtype, Nullability::Nullable)); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } @@ -427,7 +434,7 @@ mod tests { struct_dtype, vec![Scalar::primitive(42i32, Nullability::NonNullable)], ); - Arc::::try_from(&struct_scalar).unwrap(); + struct_scalar.to_arrow_datum().unwrap(); } #[test] @@ -443,7 +450,7 @@ mod tests { Nullability::NonNullable, ); - Arc::::try_from(&list_scalar).unwrap(); + list_scalar.to_arrow_datum().unwrap(); } #[test] @@ -485,7 +492,7 @@ mod tests { Scalar::primitive(42i32, Nullability::NonNullable), ); - Arc::::try_from(&scalar).unwrap(); + scalar.to_arrow_datum().unwrap(); } #[rstest] @@ -510,7 +517,7 @@ mod tests { }, ); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } @@ -534,7 +541,7 @@ mod tests { }, ); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } @@ -552,7 +559,7 @@ mod tests { Scalar::primitive(value, Nullability::NonNullable), ); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } @@ -576,7 +583,7 @@ mod tests { Scalar::primitive(value, Nullability::NonNullable), ); - let result = Arc::::try_from(&scalar); + let result = scalar.to_arrow_datum(); assert!(result.is_ok()); } @@ -587,7 +594,7 @@ mod tests { Scalar::null(DType::Primitive(PType::I32, Nullability::Nullable)), ); - let _result = Arc::::try_from(&scalar).unwrap(); + let _result = scalar.to_arrow_datum().unwrap(); } #[test] diff --git a/vortex-array/src/arrow/session.rs b/vortex-arrow/src/session.rs similarity index 93% rename from vortex-array/src/arrow/session.rs rename to vortex-arrow/src/session.rs index d3f43443d5d..745e8afeb47 100644 --- a/vortex-array/src/arrow/session.rs +++ b/vortex-arrow/src/session.rs @@ -3,8 +3,8 @@ //! Plugin layer for moving Arrow extension types in and out of Vortex. //! -//! Vortex's canonical Arrow conversion (see [`crate::dtype::arrow`] and the executor in -//! [`crate::arrow::executor`]) handles every non-extension Arrow type and the builtin temporal +//! Vortex's canonical Arrow conversion (see [`crate::dtype`] and the executor in +//! [`crate::executor`]) handles every non-extension Arrow type and the builtin temporal //! extensions. The plugins registered here cover the remaining case: **Arrow extension types**. //! //! * An [`ArrowExportVTable`] is dispatched purely by the **target Arrow extension Id** — @@ -36,6 +36,23 @@ use arrow_schema::extension::EXTENSION_TYPE_NAME_KEY; use arrow_schema::extension::ExtensionType; use tracing::debug; use tracing::trace; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::arc_swap_map::ArcSwapMap; +use vortex_array::arrays::FixedSizeListArray; +use vortex_array::arrays::ListArray; +use vortex_array::arrays::ListViewArray; +use vortex_array::arrays::StructArray; +use vortex_array::dtype::DType; +use vortex_array::dtype::FieldName; +use vortex_array::dtype::FieldNames; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::StructFields; +use vortex_array::dtype::extension::ExtId; +use vortex_array::extension::datetime::AnyTemporal; +use vortex_array::extension::uuid::Uuid; +use vortex_array::validity::Validity; use vortex_error::VortexResult; use vortex_error::vortex_bail; use vortex_error::vortex_ensure; @@ -44,26 +61,13 @@ use vortex_session::SessionGuard; use vortex_session::SessionVar; use vortex_session::registry::Id; -use crate::ArrayRef; -use crate::ExecutionCtx; -use crate::IntoArray; -use crate::arc_swap_map::ArcSwapMap; -use crate::arrays::StructArray; -use crate::arrow::FromArrowArray; -use crate::arrow::convert::nulls; -use crate::arrow::convert::remove_nulls; -use crate::arrow::executor::execute_arrow_naive; -use crate::dtype::DType; -use crate::dtype::FieldName; -use crate::dtype::FieldNames; -use crate::dtype::Nullability; -use crate::dtype::StructFields; -use crate::dtype::arrow::TryFromArrowType; -use crate::dtype::arrow::to_data_type_naive; -use crate::dtype::extension::ExtId; -use crate::extension::datetime::AnyTemporal; -use crate::extension::uuid::Uuid; -use crate::validity::Validity; +use crate::FromArrowArray; +use crate::IntoVortexArray; +use crate::convert::nulls; +use crate::convert::remove_nulls; +use crate::dtype::TryFromArrowType; +use crate::dtype::to_data_type_naive; +use crate::executor::execute_arrow_naive; /// Outcome of a successful call to [`ArrowExportVTable::execute_arrow`]. /// @@ -296,9 +300,7 @@ impl ArrowSession { /// extensions are preserved via [`Self::to_arrow_field`]. pub fn to_arrow_schema(&self, dtype: &DType) -> VortexResult { let DType::Struct(struct_dtype, _) = dtype else { - vortex_error::vortex_bail!( - "to_arrow_schema requires a top-level struct dtype, got {dtype}" - ); + vortex_bail!("to_arrow_schema requires a top-level struct dtype, got {dtype}"); }; let mut fields = Vec::with_capacity(struct_dtype.names().len()); for (name, field_dtype) in struct_dtype.names().iter().zip(struct_dtype.fields()) { @@ -537,7 +539,7 @@ impl ArrowSession { .from_arrow_array(ArrowArrayRef::clone(list.values()), elem_field.as_ref())?; let offsets = list.offsets().clone().into_array(); let validity = nulls(list.nulls(), field.is_nullable())?; - Ok(crate::arrays::ListArray::try_new(elements, offsets, validity)?.into_array()) + Ok(ListArray::try_new(elements, offsets, validity)?.into_array()) } DataType::LargeList(elem_field) => { let list = array.as_list::(); @@ -545,20 +547,17 @@ impl ArrowSession { .from_arrow_array(ArrowArrayRef::clone(list.values()), elem_field.as_ref())?; let offsets = list.offsets().clone().into_array(); let validity = nulls(list.nulls(), field.is_nullable())?; - Ok(crate::arrays::ListArray::try_new(elements, offsets, validity)?.into_array()) + Ok(ListArray::try_new(elements, offsets, validity)?.into_array()) } DataType::FixedSizeList(elem_field, list_size) => { let fsl = array.as_fixed_size_list(); let elements = self.from_arrow_array(ArrowArrayRef::clone(fsl.values()), elem_field.as_ref())?; let validity = nulls(fsl.nulls(), field.is_nullable())?; - Ok(crate::arrays::FixedSizeListArray::try_new( - elements, - *list_size as u32, - validity, - fsl.len(), - )? - .into_array()) + Ok( + FixedSizeListArray::try_new(elements, *list_size as u32, validity, fsl.len())? + .into_array(), + ) } DataType::ListView(elem_field) => { let list = array.as_list_view::(); @@ -567,10 +566,7 @@ impl ArrowSession { let offsets = list.offsets().clone().into_array(); let sizes = list.sizes().clone().into_array(); let validity = nulls(list.nulls(), field.is_nullable())?; - Ok( - crate::arrays::ListViewArray::try_new(elements, offsets, sizes, validity)? - .into_array(), - ) + Ok(ListViewArray::try_new(elements, offsets, sizes, validity)?.into_array()) } DataType::LargeListView(elem_field) => { let list = array.as_list_view::(); @@ -579,10 +575,7 @@ impl ArrowSession { let offsets = list.offsets().clone().into_array(); let sizes = list.sizes().clone().into_array(); let validity = nulls(list.nulls(), field.is_nullable())?; - Ok( - crate::arrays::ListViewArray::try_new(elements, offsets, sizes, validity)? - .into_array(), - ) + Ok(ListViewArray::try_new(elements, offsets, sizes, validity)?.into_array()) } _ => ArrayRef::from_arrow(array.as_ref(), field.is_nullable()), } @@ -631,20 +624,20 @@ mod tests { use arrow_schema::DataType; use arrow_schema::Field; use arrow_schema::extension::Uuid as ArrowUuid; + use vortex_array::VortexSessionExecute; + use vortex_array::array_session; + use vortex_array::dtype::DType; + use vortex_array::dtype::FieldName; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::dtype::StructFields; + use vortex_array::dtype::extension::ExtDType; + use vortex_array::dtype::extension::ExtVTable; + use vortex_array::extension::uuid::Uuid; + use vortex_array::extension::uuid::UuidMetadata; use vortex_error::VortexResult; use super::*; - use crate::VortexSessionExecute; - use crate::array_session; - use crate::dtype::DType; - use crate::dtype::FieldName; - use crate::dtype::Nullability; - use crate::dtype::PType; - use crate::dtype::StructFields; - use crate::dtype::extension::ExtDType; - use crate::dtype::extension::ExtVTable; - use crate::extension::uuid::Uuid; - use crate::extension::uuid::UuidMetadata; fn uuid_dtype(nullable: bool) -> DType { let storage = DType::FixedSizeList( diff --git a/vortex-array/src/extension/uuid/arrow.rs b/vortex-arrow/src/uuid.rs similarity index 85% rename from vortex-array/src/extension/uuid/arrow.rs rename to vortex-arrow/src/uuid.rs index cd4b97ea60a..5901f56389b 100644 --- a/vortex-array/src/extension/uuid/arrow.rs +++ b/vortex-arrow/src/uuid.rs @@ -18,8 +18,22 @@ use arrow_schema::DataType; use arrow_schema::Field; use arrow_schema::extension::ExtensionType; use arrow_schema::extension::Uuid as ArrowUuid; -use vortex_array::arrow::ArrowSession; -use vortex_array::arrow::has_valid_extension_type; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::arrays::ExtensionArray; +use vortex_array::arrays::FixedSizeListArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::arrays::extension::ExtensionArrayExt; +use vortex_array::buffer::BufferHandle; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::PType; +use vortex_array::dtype::extension::ExtDType; +use vortex_array::dtype::extension::ExtVTable; +use vortex_array::extension::uuid::Uuid; +use vortex_array::extension::uuid::UuidMetadata; +use vortex_array::validity::Validity; use vortex_buffer::Alignment; use vortex_buffer::Buffer; use vortex_error::VortexExpect; @@ -27,28 +41,14 @@ use vortex_error::VortexResult; use vortex_session::registry::CachedId; use vortex_session::registry::Id; -use crate::ArrayRef; -use crate::ExecutionCtx; -use crate::IntoArray; -use crate::arrays::ExtensionArray; -use crate::arrays::FixedSizeListArray; -use crate::arrays::PrimitiveArray; -use crate::arrays::extension::ExtensionArrayExt; -use crate::arrow::ArrowExport; -use crate::arrow::ArrowExportVTable; -use crate::arrow::ArrowImport; -use crate::arrow::ArrowImportVTable; -use crate::arrow::ArrowSessionExt; -use crate::arrow::nulls; -use crate::buffer::BufferHandle; -use crate::dtype::DType; -use crate::dtype::Nullability; -use crate::dtype::PType; -use crate::dtype::extension::ExtDType; -use crate::dtype::extension::ExtVTable; -use crate::extension::uuid::Uuid; -use crate::extension::uuid::UuidMetadata; -use crate::validity::Validity; +use crate::ArrowExport; +use crate::ArrowExportVTable; +use crate::ArrowImport; +use crate::ArrowImportVTable; +use crate::ArrowSession; +use crate::ArrowSessionExt; +use crate::nulls; +use crate::session::has_valid_extension_type; const UUID_BYTE_LEN: i32 = 16; diff --git a/vortex-arrow/tests/canonical.rs b/vortex-arrow/tests/canonical.rs new file mode 100644 index 00000000000..f67e0b73115 --- /dev/null +++ b/vortex-arrow/tests/canonical.rs @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#![expect(clippy::tests_outside_test_module)] + +//! Round-trip tests between canonical Vortex arrays and Arrow, moved from +//! `vortex-array/src/canonical.rs` when Arrow interoperability moved into this crate. + +use std::sync::Arc; +use std::sync::LazyLock; + +use arrow_array::Array as ArrowArray; +use arrow_array::ArrayRef as ArrowArrayRef; +use arrow_array::ListArray as ArrowListArray; +use arrow_array::PrimitiveArray as ArrowPrimitiveArray; +use arrow_array::StringArray; +use arrow_array::StringViewArray; +use arrow_array::StructArray as ArrowStructArray; +use arrow_array::cast::AsArray; +use arrow_array::types::Int32Type; +use arrow_array::types::Int64Type; +use arrow_array::types::UInt64Type; +use arrow_buffer::NullBufferBuilder; +use arrow_buffer::OffsetBuffer; +use arrow_schema::DataType; +use arrow_schema::Field; +use vortex_array::ArrayRef; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::StructArray; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::FromArrowArray; +use vortex_buffer::buffer; +use vortex_session::VortexSession; + +/// A shared session for these canonical tests, used to create execution contexts. +static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); + +#[test] +fn test_canonicalize_nested_struct() { + let mut ctx = SESSION.create_execution_ctx(); + // Create a struct array with multiple internal components. + let nested_struct_array = StructArray::from_fields(&[ + ("a", buffer![1u64].into_array()), + ( + "b", + StructArray::from_fields(&[( + "inner_a", + // The nested struct contains a ConstantArray representing the primitive array + // [100i64] + // ConstantArray is not a canonical type, so converting `into_arrow()` should + // map this to the nearest canonical type (PrimitiveArray). + ConstantArray::new(100i64, 1).into_array(), + )]) + .unwrap() + .into_array(), + ), + ]) + .unwrap(); + + let arrow_struct = SESSION + .arrow() + .execute_arrow(nested_struct_array.into_array(), None, &mut ctx) + .unwrap() + .as_any() + .downcast_ref::() + .cloned() + .unwrap(); + + assert!( + arrow_struct + .column(0) + .as_any() + .downcast_ref::>() + .is_some() + ); + + let inner_struct = Arc::clone(arrow_struct.column(1)) + .as_any() + .downcast_ref::() + .cloned() + .unwrap(); + + let inner_a = inner_struct + .column(0) + .as_any() + .downcast_ref::>(); + assert!(inner_a.is_some()); + + assert_eq!( + inner_a.cloned().unwrap(), + ArrowPrimitiveArray::from_iter([100i64]) + ); +} + +#[test] +fn roundtrip_struct() { + let mut ctx = SESSION.create_execution_ctx(); + let mut nulls = NullBufferBuilder::new(6); + nulls.append_n_non_nulls(4); + nulls.append_null(); + nulls.append_non_null(); + let names = Arc::new(StringViewArray::from_iter(vec![ + Some("Joseph"), + None, + Some("Angela"), + Some("Mikhail"), + None, + None, + ])); + let ages = Arc::new(ArrowPrimitiveArray::::from(vec![ + Some(25), + Some(31), + None, + Some(57), + None, + None, + ])); + + let arrow_struct = ArrowStructArray::new( + vec![ + Arc::new(Field::new("name", DataType::Utf8View, true)), + Arc::new(Field::new("age", DataType::Int32, true)), + ] + .into(), + vec![names, ages], + nulls.finish(), + ); + + let vortex_struct = ArrayRef::from_arrow(&arrow_struct, true).unwrap(); + let vortex_struct = SESSION + .arrow() + .execute_arrow(vortex_struct, None, &mut ctx) + .unwrap(); + assert_eq!(&arrow_struct, vortex_struct.as_struct()); +} + +#[test] +fn roundtrip_list() { + let mut ctx = SESSION.create_execution_ctx(); + let names = Arc::new(StringArray::from_iter(vec![ + Some("Joseph"), + Some("Angela"), + Some("Mikhail"), + ])); + + let arrow_list = ArrowListArray::new( + Arc::new(Field::new_list_field(DataType::Utf8, true)), + OffsetBuffer::from_lengths(vec![0, 2, 1]), + names, + None, + ); + let list_data_type = arrow_list.data_type(); + let list_field = Field::new(String::new(), list_data_type.clone(), true); + + let vortex_list = ArrayRef::from_arrow(&arrow_list, true).unwrap(); + + let rt_arrow_list = SESSION + .arrow() + .execute_arrow(vortex_list, Some(&list_field), &mut ctx) + .unwrap(); + + assert_eq!( + (Arc::new(arrow_list.clone()) as ArrowArrayRef).as_ref(), + rt_arrow_list.as_ref() + ); +} diff --git a/vortex-bench/Cargo.toml b/vortex-bench/Cargo.toml index 069f33a57e9..ff604fdb48a 100644 --- a/vortex-bench/Cargo.toml +++ b/vortex-bench/Cargo.toml @@ -23,6 +23,7 @@ vortex = { workspace = true, features = [ "tokio", "zstd", ] } +vortex-arrow = { workspace = true } vortex-geo = { workspace = true } vortex-tensor = { workspace = true } # TODO(connor): In the future, this might be inside vortex. diff --git a/vortex-bench/src/conversions.rs b/vortex-bench/src/conversions.rs index b7c367ef8f6..92064c25785 100644 --- a/vortex-bench/src/conversions.rs +++ b/vortex-bench/src/conversions.rs @@ -32,7 +32,6 @@ use vortex::array::arrays::Struct; use vortex::array::arrays::StructArray; use vortex::array::arrays::VarBinViewArray; use vortex::array::arrays::struct_::StructArrayExt; -use vortex::array::arrow::FromArrowArray; use vortex::array::builders::builder_with_capacity; use vortex::array::stream::ArrayStreamAdapter; use vortex::array::stream::ArrayStreamExt; @@ -40,7 +39,6 @@ use vortex::compressor::BtrBlocksCompressorBuilder; use vortex::dtype::DType; use vortex::dtype::FieldPath; use vortex::dtype::StructFields; -use vortex::dtype::arrow::FromArrowType; use vortex::dtype::extension::ExtDType; use vortex::dtype::extension::ExtDTypeRef; use vortex::error::VortexResult; @@ -54,6 +52,8 @@ use vortex::layout::layouts::compressed::CompressingStrategy; use vortex::layout::layouts::flat::writer::FlatLayoutStrategy; use vortex::session::VortexSession; use vortex::utils::aliases::hash_set::HashSet; +use vortex_arrow::FromArrowArray; +use vortex_arrow::FromArrowType; use vortex_geo::extension::GeoMetadata; use vortex_geo::extension::WellKnownBinary; use wkb::Endianness; diff --git a/vortex-bench/src/datasets/struct_list_of_ints.rs b/vortex-bench/src/datasets/struct_list_of_ints.rs index fe9aa991681..164ad4534fc 100644 --- a/vortex-bench/src/datasets/struct_list_of_ints.rs +++ b/vortex-bench/src/datasets/struct_list_of_ints.rs @@ -24,9 +24,10 @@ use vortex::array::arrays::PrimitiveArray; use vortex::array::arrays::StructArray; use vortex::array::arrays::chunked::ChunkedArrayExt; use vortex::array::arrays::listview::recursive_list_from_list_view; -use vortex::array::arrow::ArrowSessionExt; use vortex::array::validity::Validity; use vortex::dtype::FieldNames; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::ToArrowType; use crate::IdempotentPath; use crate::SESSION; diff --git a/vortex-bench/src/spatialbench/datagen/native.rs b/vortex-bench/src/spatialbench/datagen/native.rs index 1d600a8b8cf..2e03b61b39f 100644 --- a/vortex-bench/src/spatialbench/datagen/native.rs +++ b/vortex-bench/src/spatialbench/datagen/native.rs @@ -32,8 +32,8 @@ use tokio::fs::File as TokioFile; use vortex::array::ArrayRef; use vortex::array::IntoArray; use vortex::array::arrays::ChunkedArray; -use vortex::array::arrow::ArrowSessionExt; use vortex::file::WriteOptionsSessionExt; +use vortex_arrow::ArrowSessionExt; use super::table::GeometryKind; use super::table::Table; diff --git a/vortex-bench/src/tpch/tpchgen.rs b/vortex-bench/src/tpch/tpchgen.rs index 5dbeb380a7e..79b91200e48 100644 --- a/vortex-bench/src/tpch/tpchgen.rs +++ b/vortex-bench/src/tpch/tpchgen.rs @@ -32,12 +32,12 @@ use tpchgen::generators::SupplierGenerator; use tpchgen_arrow::RecordBatchIterator; use tracing::info; use vortex::array::ArrayRef; -use vortex::array::arrow::FromArrowArray; use vortex::array::stream::ArrayStreamAdapter; use vortex::dtype::DType; -use vortex::dtype::arrow::FromArrowType; use vortex::error::VortexExpect; use vortex::file::WriteOptionsSessionExt; +use vortex_arrow::FromArrowArray; +use vortex_arrow::FromArrowType; use crate::CompactionStrategy; use crate::Format; diff --git a/vortex-cuda/Cargo.toml b/vortex-cuda/Cargo.toml index 6ad2662e416..be6faa77e7b 100644 --- a/vortex-cuda/Cargo.toml +++ b/vortex-cuda/Cargo.toml @@ -38,6 +38,7 @@ tokio = { workspace = true, features = ["fs"] } tracing = { workspace = true, features = ["std", "attributes"] } vortex = { workspace = true, features = ["zstd"] } vortex-array = { workspace = true, features = ["cudarc"] } +vortex-arrow = { workspace = true } vortex-cub = { path = "cub" } vortex-cuda-macros = { workspace = true } vortex-error = { workspace = true, features = ["object_store"] } diff --git a/vortex-cuda/src/arrow/mod.rs b/vortex-cuda/src/arrow/mod.rs index 9545ba6abee..aff582159b9 100644 --- a/vortex-cuda/src/arrow/mod.rs +++ b/vortex-cuda/src/arrow/mod.rs @@ -41,7 +41,6 @@ use vortex::array::arrays::fixed_size_list::FixedSizeListArrayExt; use vortex::array::arrays::list::ListArrayExt; use vortex::array::arrays::listview::ListViewArrayExt; use vortex::array::arrays::struct_::StructArrayExt; -use vortex::array::arrow::ArrowSessionExt; use vortex::array::buffer::BufferHandle; use vortex::array::stream::SendableArrayStream; use vortex::dtype::DType; @@ -55,6 +54,7 @@ use vortex::error::vortex_err; use vortex::io::runtime::BlockingRuntime; use vortex::io::runtime::current::CurrentThreadRuntime; use vortex::session::VortexSession; +use vortex_arrow::ArrowSessionExt; use crate::CudaBufferExt; use crate::CudaExecutionCtx; diff --git a/vortex-datafusion/Cargo.toml b/vortex-datafusion/Cargo.toml index 72ebb3362b4..49fb22d4f59 100644 --- a/vortex-datafusion/Cargo.toml +++ b/vortex-datafusion/Cargo.toml @@ -37,6 +37,7 @@ tokio = { workspace = true, features = ["rt-multi-thread", "fs"] } tokio-stream = { workspace = true } tracing = { workspace = true, features = ["std", "attributes"] } vortex = { workspace = true, features = ["object_store", "tokio", "files"] } +vortex-arrow = { workspace = true } vortex-utils = { workspace = true, features = ["dashmap"] } [dev-dependencies] diff --git a/vortex-datafusion/src/convert/exprs.rs b/vortex-datafusion/src/convert/exprs.rs index 862c56bc3cc..847a020b887 100644 --- a/vortex-datafusion/src/convert/exprs.rs +++ b/vortex-datafusion/src/convert/exprs.rs @@ -24,7 +24,6 @@ use datafusion_physical_expr_common::physical_expr::is_dynamic_physical_expr; use datafusion_physical_plan::expressions as df_expr; use itertools::Itertools; use vortex::VortexSessionDefault; -use vortex::array::arrow::ArrowSessionExt; use vortex::dtype::Nullability; use vortex::expr::Expression; use vortex::expr::and_collect; @@ -47,6 +46,7 @@ use vortex::scalar_fn::fns::like::Like; use vortex::scalar_fn::fns::like::LikeOptions; use vortex::scalar_fn::fns::operators::Operator; use vortex::session::VortexSession; +use vortex_arrow::ArrowSessionExt; use crate::convert::FromDataFusion; @@ -1192,8 +1192,8 @@ mod tests { use vortex::array::ArrayRef; use vortex::array::Canonical; use vortex::array::VortexSessionExecute as _; - use vortex::array::arrow::FromArrowArray; use vortex::session::VortexSession; + use vortex_arrow::FromArrowArray; // Create test data let values = Arc::new(Int32Array::from(vec![1, 5, 10, 15, 20])); diff --git a/vortex-datafusion/src/convert/scalars.rs b/vortex-datafusion/src/convert/scalars.rs index e26aa005adc..e72a1f9ace3 100644 --- a/vortex-datafusion/src/convert/scalars.rs +++ b/vortex-datafusion/src/convert/scalars.rs @@ -14,7 +14,6 @@ use vortex::dtype::DecimalDType; use vortex::dtype::NativeDecimalType; use vortex::dtype::Nullability; use vortex::dtype::PType; -use vortex::dtype::arrow::FromArrowType; use vortex::dtype::half::f16; use vortex::dtype::i256; use vortex::error::VortexExpect; @@ -27,6 +26,7 @@ use vortex::extension::datetime::TemporalMetadata; use vortex::extension::datetime::TimeUnit; use vortex::scalar::DecimalValue; use vortex::scalar::Scalar; +use vortex_arrow::FromArrowType; use crate::convert::FromDataFusion; use crate::convert::TryToDataFusion; diff --git a/vortex-datafusion/src/convert/schema.rs b/vortex-datafusion/src/convert/schema.rs index ecfaa72d01e..2ec17758593 100644 --- a/vortex-datafusion/src/convert/schema.rs +++ b/vortex-datafusion/src/convert/schema.rs @@ -6,8 +6,8 @@ use arrow_schema::Field; use arrow_schema::Schema; use datafusion_common::Result as DFResult; use datafusion_common::exec_datafusion_err; -use vortex::array::arrow::ArrowSession; use vortex::dtype::DType; +use vortex_arrow::ArrowSession; /// Calculate the physical Arrow schema for a Vortex file given its DType and the expected logical schema. /// diff --git a/vortex-datafusion/src/lib.rs b/vortex-datafusion/src/lib.rs index 64992849383..fd430403e55 100644 --- a/vortex-datafusion/src/lib.rs +++ b/vortex-datafusion/src/lib.rs @@ -141,11 +141,11 @@ mod common_tests { use url::Url; use vortex::VortexSessionDefault; use vortex::array::ArrayRef; - use vortex::array::arrow::FromArrowArray; use vortex::file::WriteOptionsSessionExt; use vortex::io::VortexWrite; use vortex::io::object_store::ObjectStoreWrite; use vortex::session::VortexSession; + use vortex_arrow::FromArrowArray; use crate::VortexFormatFactory; use crate::VortexTableOptions; diff --git a/vortex-datafusion/src/persistent/format.rs b/vortex-datafusion/src/persistent/format.rs index c139fe2767f..779b74b212e 100644 --- a/vortex-datafusion/src/persistent/format.rs +++ b/vortex-datafusion/src/persistent/format.rs @@ -44,7 +44,6 @@ use futures::stream; use object_store::ObjectMeta; use object_store::ObjectStore; use vortex::VortexSessionDefault; -use vortex::array::arrow::ArrowSessionExt; use vortex::array::memory::MemorySessionExt; use vortex::dtype::DType; use vortex::dtype::Nullability; @@ -63,6 +62,7 @@ use vortex::io::session::RuntimeSessionExt; use vortex::scalar::Scalar; use vortex::scalar::ScalarValue as VortexScalarValue; use vortex::session::VortexSession; +use vortex_arrow::ArrowSessionExt; use super::cache::CachedVortexMetadata; use super::sink::VortexSink; diff --git a/vortex-datafusion/src/persistent/opener.rs b/vortex-datafusion/src/persistent/opener.rs index d50b003f1dc..b1c999656f6 100644 --- a/vortex-datafusion/src/persistent/opener.rs +++ b/vortex-datafusion/src/persistent/opener.rs @@ -37,7 +37,6 @@ use itertools::Itertools; use object_store::path::Path; use tracing::Instrument; use vortex::array::VortexSessionExecute; -use vortex::array::arrow::ArrowSessionExt; use vortex::dtype::FieldMask; use vortex::error::VortexError; use vortex::error::VortexExpect; @@ -49,6 +48,7 @@ use vortex::layout::scan::split_by::SplitBy; use vortex::metrics::Label; use vortex::metrics::MetricsRegistry; use vortex::session::VortexSession; +use vortex_arrow::ArrowSessionExt; use vortex_utils::aliases::dash_map::DashMap; use vortex_utils::aliases::dash_map::Entry; @@ -596,7 +596,6 @@ mod tests { use rstest::rstest; use vortex::VortexSessionDefault; use vortex::array::ArrayRef; - use vortex::array::arrow::FromArrowArray; use vortex::buffer::Buffer; use vortex::file::WriteOptionsSessionExt; use vortex::io::VortexWrite; @@ -604,6 +603,7 @@ mod tests { use vortex::metrics::DefaultMetricsRegistry; use vortex::scan::selection::Selection; use vortex::session::VortexSession; + use vortex_arrow::FromArrowArray; use super::*; use crate::VortexAccessPlan; diff --git a/vortex-datafusion/src/persistent/sink.rs b/vortex-datafusion/src/persistent/sink.rs index 828d55e97e3..32be0c6873b 100644 --- a/vortex-datafusion/src/persistent/sink.rs +++ b/vortex-datafusion/src/persistent/sink.rs @@ -25,13 +25,13 @@ use futures::StreamExt; use object_store::ObjectStore; use object_store::path::Path; use tokio_stream::wrappers::ReceiverStream; -use vortex::array::arrow::ArrowSessionExt; use vortex::array::stream::ArrayStreamAdapter; use vortex::file::WriteOptionsSessionExt; use vortex::file::WriteSummary; use vortex::io::VortexWrite; use vortex::io::object_store::ObjectStoreWrite; use vortex::session::VortexSession; +use vortex_arrow::ArrowSessionExt; /// Implements [`DataSink`] for writing Vortex files. pub struct VortexSink { diff --git a/vortex-datafusion/src/persistent/tests.rs b/vortex-datafusion/src/persistent/tests.rs index 220a1477b13..41d8141165f 100644 --- a/vortex-datafusion/src/persistent/tests.rs +++ b/vortex-datafusion/src/persistent/tests.rs @@ -494,7 +494,7 @@ async fn arrow_uuid_extension_roundtrip() -> anyhow::Result<()> { use datafusion::arrow::array::FixedSizeBinaryArray; use datafusion::arrow::array::RecordBatch; use datafusion::assert_batches_sorted_eq; - use vortex::array::arrow::ArrowSessionExt; + use vortex_arrow::ArrowSessionExt; let ctx = TestSessionContext::default(); // Default vortex session has importer/exporter for Arrow UUID @@ -561,7 +561,7 @@ async fn arrow_uuid_extension_roundtrip_nested_struct() -> anyhow::Result<()> { use datafusion::arrow::array::RecordBatch; use datafusion::arrow::array::StructArray as ArrowStructArray; use datafusion::assert_batches_sorted_eq; - use vortex::array::arrow::ArrowSessionExt; + use vortex_arrow::ArrowSessionExt; let ctx = TestSessionContext::default(); let session = VortexSession::default(); diff --git a/vortex-datafusion/src/v2/source.rs b/vortex-datafusion/src/v2/source.rs index 82fae9bf56d..325f8eae92f 100644 --- a/vortex-datafusion/src/v2/source.rs +++ b/vortex-datafusion/src/v2/source.rs @@ -98,7 +98,6 @@ use futures::StreamExt; use futures::TryStreamExt; use futures::future::try_join_all; use vortex::array::VortexSessionExecute; -use vortex::array::arrow::ArrowSessionExt; use vortex::dtype::DType; use vortex::dtype::FieldPath; use vortex::dtype::Nullability; @@ -115,6 +114,7 @@ use vortex::io::session::RuntimeSessionExt; use vortex::scan::DataSourceRef; use vortex::scan::ScanRequest; use vortex::session::VortexSession; +use vortex_arrow::ArrowSessionExt; use vortex_utils::parallelism::get_available_parallelism; use crate::convert::exprs::DefaultExpressionConvertor; diff --git a/vortex-ffi/Cargo.toml b/vortex-ffi/Cargo.toml index 61b1c4071b4..bf9d6c1c342 100644 --- a/vortex-ffi/Cargo.toml +++ b/vortex-ffi/Cargo.toml @@ -30,6 +30,7 @@ paste = { workspace = true } tracing = { workspace = true, features = ["std", "log"] } tracing-subscriber = { workspace = true, features = ["env-filter"] } vortex = { workspace = true, features = ["object_store"] } +vortex-arrow = { workspace = true } [dev-dependencies] rand = { workspace = true } diff --git a/vortex-ffi/src/array.rs b/vortex-ffi/src/array.rs index 61a8ab00a08..3eda7732190 100644 --- a/vortex-ffi/src/array.rs +++ b/vortex-ffi/src/array.rs @@ -24,7 +24,6 @@ use vortex::array::arrays::StructArray; use vortex::array::arrays::VarBinView; use vortex::array::arrays::bool::BoolArrayExt; use vortex::array::arrays::struct_::StructArrayExt; -use vortex::array::arrow::FromArrowArray; use vortex::array::legacy_session; use vortex::array::validity::Validity; use vortex::buffer::Buffer; @@ -36,6 +35,7 @@ use vortex::error::vortex_bail; use vortex::error::vortex_ensure; use vortex::error::vortex_err; use vortex::error::vortex_panic; +use vortex_arrow::FromArrowArray; use crate::arc_wrapper; use crate::dtype::vx_dtype; diff --git a/vortex-ffi/src/dtype.rs b/vortex-ffi/src/dtype.rs index fa1f32c46ac..baeab5fd280 100644 --- a/vortex-ffi/src/dtype.rs +++ b/vortex-ffi/src/dtype.rs @@ -9,7 +9,6 @@ use arrow_array::ffi::FFI_ArrowSchema; use arrow_schema::Schema; use vortex::dtype::DType; use vortex::dtype::DecimalDType; -use vortex::dtype::arrow::FromArrowType; use vortex::error::VortexExpect; use vortex::error::vortex_ensure; use vortex::error::vortex_panic; @@ -17,6 +16,8 @@ use vortex::extension::datetime::AnyTemporal; use vortex::extension::datetime::Date; use vortex::extension::datetime::Time; use vortex::extension::datetime::Timestamp; +use vortex_arrow::FromArrowType; +use vortex_arrow::ToArrowType; use crate::arc_wrapper; use crate::error::try_or; diff --git a/vortex-ffi/src/scan.rs b/vortex-ffi/src/scan.rs index fdf8615d065..b21f654634a 100644 --- a/vortex-ffi/src/scan.rs +++ b/vortex-ffi/src/scan.rs @@ -18,7 +18,6 @@ use futures::StreamExt; use vortex::array::ArrayRef; use vortex::array::ExecutionCtx; use vortex::array::VortexSessionExecute; -use vortex::array::arrow::ArrowSessionExt; use vortex::array::expr::stats::Precision; use vortex::array::stream::SendableArrayStream; use vortex::buffer::Buffer; @@ -33,6 +32,8 @@ use vortex::scan::Partition; use vortex::scan::PartitionStream; use vortex::scan::ScanRequest; use vortex::scan::selection::Selection; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::ToArrowType; use crate::RUNTIME; use crate::array::vx_array; diff --git a/vortex-geo/Cargo.toml b/vortex-geo/Cargo.toml index 2f0583b49e6..bfb6a639742 100644 --- a/vortex-geo/Cargo.toml +++ b/vortex-geo/Cargo.toml @@ -23,6 +23,7 @@ geoarrow = { workspace = true } geoarrow-cast = { workspace = true } prost = { workspace = true } vortex-array = { workspace = true } +vortex-arrow = { workspace = true } vortex-error = { workspace = true } vortex-session = { workspace = true } wkb = { workspace = true } diff --git a/vortex-geo/src/extension/linestring.rs b/vortex-geo/src/extension/linestring.rs index f7d8fcff1de..274d20f28ba 100644 --- a/vortex-geo/src/extension/linestring.rs +++ b/vortex-geo/src/extension/linestring.rs @@ -24,20 +24,20 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::extension::ExtensionArrayExt; -use vortex_array::arrow::ArrowExport; -use vortex_array::arrow::ArrowExportVTable; -use vortex_array::arrow::ArrowImport; -use vortex_array::arrow::ArrowImportVTable; -use vortex_array::arrow::ArrowSession; -use vortex_array::arrow::ArrowSessionExt; -use vortex_array::arrow::FromArrowArray; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; -use vortex_array::dtype::arrow::FromArrowType; use vortex_array::dtype::extension::ExtDType; use vortex_array::dtype::extension::ExtId; use vortex_array::dtype::extension::ExtVTable; use vortex_array::scalar::ScalarValue; +use vortex_arrow::ArrowExport; +use vortex_arrow::ArrowExportVTable; +use vortex_arrow::ArrowImport; +use vortex_arrow::ArrowImportVTable; +use vortex_arrow::ArrowSession; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::FromArrowArray; +use vortex_arrow::FromArrowType; use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_error::vortex_bail; diff --git a/vortex-geo/src/extension/mod.rs b/vortex-geo/src/extension/mod.rs index 9da09020dbf..73f6b6317b0 100644 --- a/vortex-geo/src/extension/mod.rs +++ b/vortex-geo/src/extension/mod.rs @@ -43,10 +43,10 @@ use vortex_array::IntoArray; use vortex_array::arrays::ConstantArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::extension::ExtensionArrayExt; -use vortex_array::arrow::FromArrowArray; use vortex_array::dtype::extension::ExtDType; use vortex_array::dtype::extension::ExtVTable; use vortex_array::scalar::Scalar; +use vortex_arrow::FromArrowArray; use vortex_error::VortexResult; use vortex_error::vortex_bail; use vortex_error::vortex_err; diff --git a/vortex-geo/src/extension/multilinestring.rs b/vortex-geo/src/extension/multilinestring.rs index edfb0d3554c..6ba4c4c5324 100644 --- a/vortex-geo/src/extension/multilinestring.rs +++ b/vortex-geo/src/extension/multilinestring.rs @@ -25,20 +25,20 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::extension::ExtensionArrayExt; -use vortex_array::arrow::ArrowExport; -use vortex_array::arrow::ArrowExportVTable; -use vortex_array::arrow::ArrowImport; -use vortex_array::arrow::ArrowImportVTable; -use vortex_array::arrow::ArrowSession; -use vortex_array::arrow::ArrowSessionExt; -use vortex_array::arrow::FromArrowArray; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; -use vortex_array::dtype::arrow::FromArrowType; use vortex_array::dtype::extension::ExtDType; use vortex_array::dtype::extension::ExtId; use vortex_array::dtype::extension::ExtVTable; use vortex_array::scalar::ScalarValue; +use vortex_arrow::ArrowExport; +use vortex_arrow::ArrowExportVTable; +use vortex_arrow::ArrowImport; +use vortex_arrow::ArrowImportVTable; +use vortex_arrow::ArrowSession; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::FromArrowArray; +use vortex_arrow::FromArrowType; use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_error::vortex_bail; diff --git a/vortex-geo/src/extension/multipoint.rs b/vortex-geo/src/extension/multipoint.rs index 3e8778744e5..7d8e429dcc0 100644 --- a/vortex-geo/src/extension/multipoint.rs +++ b/vortex-geo/src/extension/multipoint.rs @@ -25,20 +25,20 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::extension::ExtensionArrayExt; -use vortex_array::arrow::ArrowExport; -use vortex_array::arrow::ArrowExportVTable; -use vortex_array::arrow::ArrowImport; -use vortex_array::arrow::ArrowImportVTable; -use vortex_array::arrow::ArrowSession; -use vortex_array::arrow::ArrowSessionExt; -use vortex_array::arrow::FromArrowArray; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; -use vortex_array::dtype::arrow::FromArrowType; use vortex_array::dtype::extension::ExtDType; use vortex_array::dtype::extension::ExtId; use vortex_array::dtype::extension::ExtVTable; use vortex_array::scalar::ScalarValue; +use vortex_arrow::ArrowExport; +use vortex_arrow::ArrowExportVTable; +use vortex_arrow::ArrowImport; +use vortex_arrow::ArrowImportVTable; +use vortex_arrow::ArrowSession; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::FromArrowArray; +use vortex_arrow::FromArrowType; use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_error::vortex_bail; diff --git a/vortex-geo/src/extension/multipolygon.rs b/vortex-geo/src/extension/multipolygon.rs index 67dac438970..524e470749c 100644 --- a/vortex-geo/src/extension/multipolygon.rs +++ b/vortex-geo/src/extension/multipolygon.rs @@ -24,20 +24,20 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::extension::ExtensionArrayExt; -use vortex_array::arrow::ArrowExport; -use vortex_array::arrow::ArrowExportVTable; -use vortex_array::arrow::ArrowImport; -use vortex_array::arrow::ArrowImportVTable; -use vortex_array::arrow::ArrowSession; -use vortex_array::arrow::ArrowSessionExt; -use vortex_array::arrow::FromArrowArray; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; -use vortex_array::dtype::arrow::FromArrowType; use vortex_array::dtype::extension::ExtDType; use vortex_array::dtype::extension::ExtId; use vortex_array::dtype::extension::ExtVTable; use vortex_array::scalar::ScalarValue; +use vortex_arrow::ArrowExport; +use vortex_arrow::ArrowExportVTable; +use vortex_arrow::ArrowImport; +use vortex_arrow::ArrowImportVTable; +use vortex_arrow::ArrowSession; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::FromArrowArray; +use vortex_arrow::FromArrowType; use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_error::vortex_bail; diff --git a/vortex-geo/src/extension/point.rs b/vortex-geo/src/extension/point.rs index 6371105ca25..cfebecee461 100644 --- a/vortex-geo/src/extension/point.rs +++ b/vortex-geo/src/extension/point.rs @@ -23,20 +23,20 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::extension::ExtensionArrayExt; -use vortex_array::arrow::ArrowExport; -use vortex_array::arrow::ArrowExportVTable; -use vortex_array::arrow::ArrowImport; -use vortex_array::arrow::ArrowImportVTable; -use vortex_array::arrow::ArrowSession; -use vortex_array::arrow::ArrowSessionExt; -use vortex_array::arrow::FromArrowArray; use vortex_array::dtype::DType; -use vortex_array::dtype::arrow::FromArrowType; use vortex_array::dtype::extension::ExtDType; use vortex_array::dtype::extension::ExtId; use vortex_array::dtype::extension::ExtVTable; use vortex_array::scalar::Scalar; use vortex_array::scalar::ScalarValue; +use vortex_arrow::ArrowExport; +use vortex_arrow::ArrowExportVTable; +use vortex_arrow::ArrowImport; +use vortex_arrow::ArrowImportVTable; +use vortex_arrow::ArrowSession; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::FromArrowArray; +use vortex_arrow::FromArrowType; use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_error::vortex_ensure; diff --git a/vortex-geo/src/extension/polygon.rs b/vortex-geo/src/extension/polygon.rs index aeda30931a2..9a74c3ce7b3 100644 --- a/vortex-geo/src/extension/polygon.rs +++ b/vortex-geo/src/extension/polygon.rs @@ -24,20 +24,20 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::extension::ExtensionArrayExt; -use vortex_array::arrow::ArrowExport; -use vortex_array::arrow::ArrowExportVTable; -use vortex_array::arrow::ArrowImport; -use vortex_array::arrow::ArrowImportVTable; -use vortex_array::arrow::ArrowSession; -use vortex_array::arrow::ArrowSessionExt; -use vortex_array::arrow::FromArrowArray; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; -use vortex_array::dtype::arrow::FromArrowType; use vortex_array::dtype::extension::ExtDType; use vortex_array::dtype::extension::ExtId; use vortex_array::dtype::extension::ExtVTable; use vortex_array::scalar::ScalarValue; +use vortex_arrow::ArrowExport; +use vortex_arrow::ArrowExportVTable; +use vortex_arrow::ArrowImport; +use vortex_arrow::ArrowImportVTable; +use vortex_arrow::ArrowSession; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::FromArrowArray; +use vortex_arrow::FromArrowType; use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_error::vortex_bail; diff --git a/vortex-geo/src/extension/wkb.rs b/vortex-geo/src/extension/wkb.rs index a45f1b309a1..fb8c443a7b3 100644 --- a/vortex-geo/src/extension/wkb.rs +++ b/vortex-geo/src/extension/wkb.rs @@ -19,18 +19,18 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::extension::ExtensionArrayExt; -use vortex_array::arrow::ArrowExport; -use vortex_array::arrow::ArrowExportVTable; -use vortex_array::arrow::ArrowImport; -use vortex_array::arrow::ArrowImportVTable; -use vortex_array::arrow::ArrowSession; -use vortex_array::arrow::ArrowSessionExt; -use vortex_array::arrow::FromArrowArray; use vortex_array::dtype::DType; use vortex_array::dtype::extension::ExtDType; use vortex_array::dtype::extension::ExtId; use vortex_array::dtype::extension::ExtVTable; use vortex_array::scalar::ScalarValue; +use vortex_arrow::ArrowExport; +use vortex_arrow::ArrowExportVTable; +use vortex_arrow::ArrowImport; +use vortex_arrow::ArrowImportVTable; +use vortex_arrow::ArrowSession; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::FromArrowArray; use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_error::vortex_ensure; diff --git a/vortex-geo/src/lib.rs b/vortex-geo/src/lib.rs index c2637ecac55..4ddb8c25c3b 100644 --- a/vortex-geo/src/lib.rs +++ b/vortex-geo/src/lib.rs @@ -3,9 +3,9 @@ use std::sync::Arc; -use vortex_array::arrow::ArrowSessionExt; use vortex_array::dtype::session::DTypeSessionExt; use vortex_array::scalar_fn::session::ScalarFnSessionExt; +use vortex_arrow::ArrowSessionExt; use vortex_session::VortexSession; use crate::extension::LineString; diff --git a/vortex-geo/src/tests/linestring.rs b/vortex-geo/src/tests/linestring.rs index a1a9b3a4845..46e2be94647 100644 --- a/vortex-geo/src/tests/linestring.rs +++ b/vortex-geo/src/tests/linestring.rs @@ -13,9 +13,9 @@ use geoarrow::datatypes::Crs; use geoarrow::datatypes::Dimension as GeoArrowDimension; use geoarrow::datatypes::LineStringType; use geoarrow::datatypes::Metadata; -use vortex_array::arrow::ArrowSessionExt; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; +use vortex_arrow::ArrowSessionExt; use vortex_error::VortexResult; use super::SESSION; diff --git a/vortex-geo/src/tests/multilinestring.rs b/vortex-geo/src/tests/multilinestring.rs index cf5b5204681..694c77cdf3b 100644 --- a/vortex-geo/src/tests/multilinestring.rs +++ b/vortex-geo/src/tests/multilinestring.rs @@ -13,9 +13,9 @@ use geoarrow::datatypes::Crs; use geoarrow::datatypes::Dimension as GeoArrowDimension; use geoarrow::datatypes::Metadata; use geoarrow::datatypes::MultiLineStringType; -use vortex_array::arrow::ArrowSessionExt; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; +use vortex_arrow::ArrowSessionExt; use vortex_error::VortexResult; use super::SESSION; diff --git a/vortex-geo/src/tests/multipoint.rs b/vortex-geo/src/tests/multipoint.rs index c1de946f0ba..366dac73fc2 100644 --- a/vortex-geo/src/tests/multipoint.rs +++ b/vortex-geo/src/tests/multipoint.rs @@ -13,9 +13,9 @@ use geoarrow::datatypes::Crs; use geoarrow::datatypes::Dimension as GeoArrowDimension; use geoarrow::datatypes::Metadata; use geoarrow::datatypes::MultiPointType; -use vortex_array::arrow::ArrowSessionExt; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; +use vortex_arrow::ArrowSessionExt; use vortex_error::VortexResult; use super::SESSION; diff --git a/vortex-geo/src/tests/multipolygon.rs b/vortex-geo/src/tests/multipolygon.rs index 38f2543a96a..407661f6a43 100644 --- a/vortex-geo/src/tests/multipolygon.rs +++ b/vortex-geo/src/tests/multipolygon.rs @@ -13,9 +13,9 @@ use geoarrow::datatypes::Crs; use geoarrow::datatypes::Dimension as GeoArrowDimension; use geoarrow::datatypes::Metadata; use geoarrow::datatypes::MultiPolygonType; -use vortex_array::arrow::ArrowSessionExt; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; +use vortex_arrow::ArrowSessionExt; use vortex_error::VortexResult; use super::SESSION; diff --git a/vortex-geo/src/tests/point.rs b/vortex-geo/src/tests/point.rs index ff74b01ba01..61a2e98dbe7 100644 --- a/vortex-geo/src/tests/point.rs +++ b/vortex-geo/src/tests/point.rs @@ -20,9 +20,9 @@ use geoarrow::datatypes::Dimension as GeoArrowDimension; use geoarrow::datatypes::Metadata; use geoarrow::datatypes::PointType; use vortex_array::VortexSessionExecute; -use vortex_array::arrow::ArrowSessionExt; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; +use vortex_arrow::ArrowSessionExt; use vortex_error::VortexResult; use vortex_error::vortex_err; diff --git a/vortex-geo/src/tests/wkb.rs b/vortex-geo/src/tests/wkb.rs index 4ef3d6e2ba8..d61c48bdc32 100644 --- a/vortex-geo/src/tests/wkb.rs +++ b/vortex-geo/src/tests/wkb.rs @@ -27,11 +27,11 @@ use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::varbin::builder::VarBinBuilder; -use vortex_array::arrow::ArrowSessionExt; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; use vortex_array::dtype::extension::ExtDType; use vortex_array::dtype::extension::ExtVTable; +use vortex_arrow::ArrowSessionExt; use vortex_error::VortexResult; use vortex_error::vortex_err; use wkb::writer::WriteOptions; diff --git a/vortex-jni/Cargo.toml b/vortex-jni/Cargo.toml index e6aef3d8756..767770db46f 100644 --- a/vortex-jni/Cargo.toml +++ b/vortex-jni/Cargo.toml @@ -30,6 +30,7 @@ tracing = { workspace = true, features = ["std", "log"] } tracing-subscriber = { workspace = true, features = ["env-filter"] } url = { workspace = true } vortex = { workspace = true, features = ["object_store", "files"] } +vortex-arrow = { workspace = true } vortex-parquet-variant = { workspace = true } [dev-dependencies] diff --git a/vortex-jni/src/dtype.rs b/vortex-jni/src/dtype.rs index 5a0b54d2c14..8969d7b573a 100644 --- a/vortex-jni/src/dtype.rs +++ b/vortex-jni/src/dtype.rs @@ -13,6 +13,7 @@ use arrow_schema::Fields; use arrow_schema::Schema; use vortex::dtype::DType; use vortex::error::VortexResult; +use vortex_arrow::ToArrowType; /// Export a Vortex [`DType`] to the Arrow C Data Interface struct at `schema_addr`. Views /// (Utf8View/BinaryView) are downgraded to regular Utf8/Binary so Spark and other consumers diff --git a/vortex-jni/src/scan.rs b/vortex-jni/src/scan.rs index 7f923af1ac9..87819681686 100644 --- a/vortex-jni/src/scan.rs +++ b/vortex-jni/src/scan.rs @@ -29,7 +29,6 @@ use jni::objects::JLongArray; use jni::sys::jboolean; use jni::sys::jlong; use vortex::array::VortexSessionExecute; -use vortex::array::arrow::ArrowSessionExt; use vortex::array::stream::SendableArrayStream; use vortex::buffer::Buffer; use vortex::error::VortexResult; @@ -44,6 +43,8 @@ use vortex::scan::PartitionRef; use vortex::scan::PartitionStream; use vortex::scan::ScanRequest; use vortex::scan::selection::Selection; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::ToArrowType; use crate::POOL; use crate::RUNTIME; diff --git a/vortex-jni/src/writer.rs b/vortex-jni/src/writer.rs index 78e7c268f19..5b098b4c320 100644 --- a/vortex-jni/src/writer.rs +++ b/vortex-jni/src/writer.rs @@ -29,7 +29,6 @@ use object_store::ObjectStore; use object_store::path::Path as ObjectStorePath; use vortex::array::ArrayRef; use vortex::array::VTable; -use vortex::array::arrow::ArrowSessionExt; use vortex::array::stream::ArrayStreamAdapter; use vortex::dtype::DType; use vortex::dtype::Field as DTypeField; @@ -48,6 +47,7 @@ use vortex::io::runtime::Task; use vortex::io::session::RuntimeSessionExt; use vortex::session::VortexSession; use vortex::utils::aliases::hash_map::HashMap; +use vortex_arrow::ArrowSessionExt; use vortex_parquet_variant::ParquetVariant; use crate::RUNTIME; diff --git a/vortex-json/Cargo.toml b/vortex-json/Cargo.toml index 71eb015b5ee..b839c0ac76c 100644 --- a/vortex-json/Cargo.toml +++ b/vortex-json/Cargo.toml @@ -21,6 +21,7 @@ arrow-array = { workspace = true } arrow-schema = { workspace = true, features = ["canonical_extension_types"] } prost = { workspace = true } vortex-array = { workspace = true, default-features = false } +vortex-arrow = { workspace = true } vortex-error = { workspace = true, default-features = false } vortex-proto = { workspace = true, features = ["expr"] } vortex-session = { workspace = true } diff --git a/vortex-json/src/arrow.rs b/vortex-json/src/arrow.rs index 9db94d86743..987d6e16347 100644 --- a/vortex-json/src/arrow.rs +++ b/vortex-json/src/arrow.rs @@ -13,16 +13,16 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::extension::ExtensionArrayExt; -use vortex_array::arrow::ArrowExport; -use vortex_array::arrow::ArrowExportVTable; -use vortex_array::arrow::ArrowImport; -use vortex_array::arrow::ArrowImportVTable; -use vortex_array::arrow::ArrowSession; -use vortex_array::arrow::ArrowSessionExt; -use vortex_array::arrow::FromArrowArray; use vortex_array::dtype::DType; use vortex_array::dtype::extension::ExtDType; use vortex_array::dtype::extension::ExtVTable; +use vortex_arrow::ArrowExport; +use vortex_arrow::ArrowExportVTable; +use vortex_arrow::ArrowImport; +use vortex_arrow::ArrowImportVTable; +use vortex_arrow::ArrowSession; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::FromArrowArray; use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_session::registry::CachedId; @@ -156,9 +156,9 @@ mod tests { use vortex_array::VortexSessionExecute; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::VarBinArray; - use vortex_array::arrow::ArrowSessionExt; use vortex_array::dtype::Nullability; use vortex_array::dtype::extension::ExtDType; + use vortex_arrow::ArrowSessionExt; use vortex_error::VortexExpect; use vortex_error::VortexResult; diff --git a/vortex-json/src/lib.rs b/vortex-json/src/lib.rs index 21f86ca9a7c..7a6dc8a780e 100644 --- a/vortex-json/src/lib.rs +++ b/vortex-json/src/lib.rs @@ -20,9 +20,9 @@ pub use json_to_variant::JsonToVariant; pub use json_to_variant::JsonToVariantOptions; pub use json_to_variant::ShreddingSpec; pub use json_to_variant::json_to_variant; -use vortex_array::arrow::ArrowSessionExt; use vortex_array::dtype::session::DTypeSessionExt; use vortex_array::scalar_fn::session::ScalarFnSessionExt; +use vortex_arrow::ArrowSessionExt; use vortex_session::VortexSession; /// Register JSON extension support with a session. diff --git a/vortex-layout/Cargo.toml b/vortex-layout/Cargo.toml index daa96d4e138..f772b9ab639 100644 --- a/vortex-layout/Cargo.toml +++ b/vortex-layout/Cargo.toml @@ -40,6 +40,7 @@ termtree = { workspace = true } tokio = { workspace = true, features = ["rt"], optional = true } tracing = { workspace = true } vortex-array = { workspace = true } +vortex-arrow = { workspace = true } vortex-btrblocks = { workspace = true } vortex-buffer = { workspace = true } vortex-error = { workspace = true } diff --git a/vortex-layout/src/scan/arrow.rs b/vortex-layout/src/scan/arrow.rs index 485c2685934..663b29d9501 100644 --- a/vortex-layout/src/scan/arrow.rs +++ b/vortex-layout/src/scan/arrow.rs @@ -14,7 +14,7 @@ use futures::TryStreamExt; use vortex_array::ArrayRef; use vortex_array::ExecutionCtx; use vortex_array::VortexSessionExecute; -use vortex_array::arrow::ArrowSessionExt; +use vortex_arrow::ArrowSessionExt; use vortex_error::VortexResult; use vortex_io::runtime::BlockingRuntime; @@ -128,7 +128,7 @@ mod tests { use arrow_schema::Schema; use vortex_array::ArrayRef; use vortex_array::VortexSessionExecute; - use vortex_array::arrow::FromArrowArray; + use vortex_arrow::FromArrowArray; use vortex_error::VortexResult; use super::*; diff --git a/vortex-python/Cargo.toml b/vortex-python/Cargo.toml index 491bff86e6a..a87548d69a9 100644 --- a/vortex-python/Cargo.toml +++ b/vortex-python/Cargo.toml @@ -52,6 +52,7 @@ pyo3-object_store = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"], optional = true } url = { workspace = true } vortex = { workspace = true, features = ["object_store"] } +vortex-arrow = { workspace = true } vortex-python-abi = { path = "../vortex-python-abi" } vortex-tui = { workspace = true, optional = true } diff --git a/vortex-python/src/arrays/from_arrow.rs b/vortex-python/src/arrays/from_arrow.rs index e8c9ca04d60..3964742626e 100644 --- a/vortex-python/src/arrays/from_arrow.rs +++ b/vortex-python/src/arrays/from_arrow.rs @@ -13,11 +13,11 @@ use pyo3::prelude::*; use vortex::array::ArrayRef; use vortex::array::IntoArray; use vortex::array::arrays::ChunkedArray; -use vortex::array::arrow::FromArrowArray; use vortex::dtype::DType; -use vortex::dtype::arrow::TryFromArrowType; use vortex::error::VortexError; use vortex::error::VortexResult; +use vortex_arrow::FromArrowArray; +use vortex_arrow::TryFromArrowType; use crate::arrays::PyArrayRef; use crate::arrow::FromPyArrow; diff --git a/vortex-python/src/arrays/mod.rs b/vortex-python/src/arrays/mod.rs index e22e8d95955..ad6575fdbe0 100644 --- a/vortex-python/src/arrays/mod.rs +++ b/vortex-python/src/arrays/mod.rs @@ -39,7 +39,6 @@ use vortex::array::arrays::BoolArray; use vortex::array::arrays::Chunked; use vortex::array::arrays::bool::BoolArrayExt; use vortex::array::arrays::chunked::ChunkedArrayExt; -use vortex::array::arrow::ArrowSessionExt; use vortex::array::buffer::BufferHandle; use vortex::array::builtins::ArrayBuiltins; use vortex::array::match_each_integer_ptype; @@ -52,6 +51,7 @@ use vortex::flatbuffers::WriteFlatBufferExt; use vortex::ipc::messages::EncoderMessage; use vortex::ipc::messages::MessageEncoder; use vortex::scalar_fn::fns::operators::Operator; +use vortex_arrow::ArrowSessionExt; use vortex_python_abi::BUFFER_EXPORT_CAPSULE_NAME; use vortex_python_abi::VORTEX_BUFFER_EXPORT_VERSION; use vortex_python_abi::VORTEX_BUFFER_HOST; diff --git a/vortex-python/src/dataset.rs b/vortex-python/src/dataset.rs index acb8285e5a0..504ed05aeee 100644 --- a/vortex-python/src/dataset.rs +++ b/vortex-python/src/dataset.rs @@ -25,6 +25,7 @@ use vortex::file::OpenOptionsSessionExt; use vortex::file::VortexFile; use vortex::io::runtime::BlockingRuntime; use vortex::layout::scan::split_by::SplitBy; +use vortex_arrow::ToArrowType; use crate::RUNTIME; use crate::arrays::PyArrayRef; diff --git a/vortex-python/src/dtype/mod.rs b/vortex-python/src/dtype/mod.rs index bea8f7d7fda..ce18ceb6d83 100644 --- a/vortex-python/src/dtype/mod.rs +++ b/vortex-python/src/dtype/mod.rs @@ -33,9 +33,10 @@ use pyo3::pyclass; use pyo3::pymethods; use pyo3::types::PyType; use pyo3::wrap_pyfunction; -use vortex::array::arrow::ArrowSessionExt; use vortex::dtype::DType; -use vortex::dtype::arrow::TryFromArrowType; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::ToArrowType; +use vortex_arrow::TryFromArrowType; use crate::arrow::FromPyArrow; use crate::arrow::ToPyArrow; diff --git a/vortex-python/src/file.rs b/vortex-python/src/file.rs index f6bd1ed9cda..9550067b956 100644 --- a/vortex-python/src/file.rs +++ b/vortex-python/src/file.rs @@ -27,6 +27,7 @@ use vortex::io::runtime::BlockingRuntime; use vortex::layout::scan::scan_builder::ScanBuilder; use vortex::layout::scan::split_by::SplitBy; use vortex::layout::segments::MokaSegmentCache; +use vortex_arrow::ToArrowType; use crate::RUNTIME; use crate::arrays::PyArrayRef; diff --git a/vortex-python/src/io.rs b/vortex-python/src/io.rs index a8d61edfc18..182ab7fbe74 100644 --- a/vortex-python/src/io.rs +++ b/vortex-python/src/io.rs @@ -12,13 +12,11 @@ use pyo3_object_store::PyObjectStore; use vortex::array::ArrayRef; use vortex::array::Canonical; use vortex::array::IntoArray; -use vortex::array::arrow::FromArrowArray; use vortex::array::iter::ArrayIterator; use vortex::array::iter::ArrayIteratorAdapter; use vortex::array::iter::ArrayIteratorExt; use vortex::compressor::BtrBlocksCompressorBuilder; use vortex::dtype::DType; -use vortex::dtype::arrow::TryFromArrowType; use vortex::error::VortexError; use vortex::error::VortexResult; use vortex::file::WriteOptionsSessionExt; @@ -26,6 +24,8 @@ use vortex::file::WriteStrategyBuilder; use vortex::io::VortexWrite; use vortex::io::object_store::ObjectStoreWrite; use vortex::io::runtime::BlockingRuntime; +use vortex_arrow::FromArrowArray; +use vortex_arrow::TryFromArrowType; use crate::PyVortex; use crate::RUNTIME; diff --git a/vortex-python/src/iter/mod.rs b/vortex-python/src/iter/mod.rs index e03bd36d609..757f9910ad6 100644 --- a/vortex-python/src/iter/mod.rs +++ b/vortex-python/src/iter/mod.rs @@ -21,11 +21,12 @@ use pyo3::types::PyIterator; use vortex::array::Canonical; use vortex::array::IntoArray; use vortex::array::VortexSessionExecute; -use vortex::array::arrow::ArrowSessionExt; use vortex::array::iter::ArrayIterator; use vortex::array::iter::ArrayIteratorAdapter; use vortex::array::iter::ArrayIteratorExt; use vortex::dtype::DType; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::ToArrowType; use crate::arrays::PyArrayRef; use crate::arrow::IntoPyArrow; diff --git a/vortex-tensor/Cargo.toml b/vortex-tensor/Cargo.toml index 7429d71521d..a6782eea309 100644 --- a/vortex-tensor/Cargo.toml +++ b/vortex-tensor/Cargo.toml @@ -18,6 +18,7 @@ workspace = true [dependencies] vortex-array = { workspace = true } +vortex-arrow = { workspace = true } vortex-buffer = { workspace = true } vortex-compressor = { workspace = true } vortex-error = { workspace = true } diff --git a/vortex-tensor/src/lib.rs b/vortex-tensor/src/lib.rs index 17b13b1ba1e..6826f3ad191 100644 --- a/vortex-tensor/src/lib.rs +++ b/vortex-tensor/src/lib.rs @@ -13,10 +13,10 @@ use std::sync::Arc; use vortex_array::arrays::scalar_fn::plugin::ScalarFnArrayPlugin; -use vortex_array::arrow::ArrowSessionExt; use vortex_array::dtype::session::DTypeSessionExt; use vortex_array::scalar_fn::session::ScalarFnSessionExt; use vortex_array::session::ArraySessionExt; +use vortex_arrow::ArrowSessionExt; use vortex_session::VortexSession; use crate::scalar_fns::cosine_similarity::CosineSimilarity; diff --git a/vortex-tensor/src/types/vector/arrow.rs b/vortex-tensor/src/types/vector/arrow.rs index a011dc50c49..05c1890e7e1 100644 --- a/vortex-tensor/src/types/vector/arrow.rs +++ b/vortex-tensor/src/types/vector/arrow.rs @@ -20,17 +20,17 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::extension::ExtensionArrayExt; -use vortex_array::arrow::ArrowExport; -use vortex_array::arrow::ArrowExportVTable; -use vortex_array::arrow::ArrowImport; -use vortex_array::arrow::ArrowImportVTable; -use vortex_array::arrow::ArrowSession; -use vortex_array::arrow::ArrowSessionExt; -use vortex_array::arrow::FromArrowArray; use vortex_array::dtype::DType; -use vortex_array::dtype::arrow::FromArrowType; use vortex_array::dtype::extension::ExtDType; use vortex_array::dtype::extension::ExtVTable; +use vortex_arrow::ArrowExport; +use vortex_arrow::ArrowExportVTable; +use vortex_arrow::ArrowImport; +use vortex_arrow::ArrowImportVTable; +use vortex_arrow::ArrowSession; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::FromArrowArray; +use vortex_arrow::FromArrowType; use vortex_error::VortexResult; use vortex_session::registry::CachedId; use vortex_session::registry::Id; @@ -179,10 +179,6 @@ mod tests { use vortex_array::VortexSessionExecute; use vortex_array::arrays::FixedSizeListArray; use vortex_array::arrays::PrimitiveArray; - use vortex_array::arrow::ArrowExport; - use vortex_array::arrow::ArrowImport; - use vortex_array::arrow::ArrowSession; - use vortex_array::arrow::ArrowSessionExt; use vortex_array::dtype::DType; use vortex_array::dtype::FieldName; use vortex_array::dtype::Nullability; @@ -190,6 +186,10 @@ mod tests { use vortex_array::dtype::StructFields; use vortex_array::dtype::extension::ExtDType; use vortex_array::validity::Validity; + use vortex_arrow::ArrowExport; + use vortex_arrow::ArrowImport; + use vortex_arrow::ArrowSession; + use vortex_arrow::ArrowSessionExt; use vortex_buffer::buffer; use super::*; diff --git a/vortex-test/compat-gen/Cargo.toml b/vortex-test/compat-gen/Cargo.toml index dd8bec4703a..4fbd90a6273 100644 --- a/vortex-test/compat-gen/Cargo.toml +++ b/vortex-test/compat-gen/Cargo.toml @@ -23,6 +23,7 @@ path = "src/main.rs" # Vortex crates vortex = { workspace = true, features = ["files", "tokio", "zstd"] } vortex-array = { workspace = true, features = ["_test-harness"] } +vortex-arrow = { workspace = true } vortex-buffer = { workspace = true } vortex-error = { workspace = true } vortex-session = { workspace = true } diff --git a/vortex-test/compat-gen/src/fixtures/arrays/datasets/clickbench.rs b/vortex-test/compat-gen/src/fixtures/arrays/datasets/clickbench.rs index 7cae6944463..12d86c41241 100644 --- a/vortex-test/compat-gen/src/fixtures/arrays/datasets/clickbench.rs +++ b/vortex-test/compat-gen/src/fixtures/arrays/datasets/clickbench.rs @@ -11,7 +11,7 @@ use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; use vortex_array::ArrayRef; use vortex_array::IntoArray; use vortex_array::arrays::ChunkedArray; -use vortex_array::arrow::FromArrowArray; +use vortex_arrow::FromArrowArray; use vortex_error::VortexResult; use vortex_error::vortex_err; diff --git a/vortex-test/compat-gen/src/fixtures/arrays/datasets/tpch.rs b/vortex-test/compat-gen/src/fixtures/arrays/datasets/tpch.rs index 9d831bef143..59e97a7e587 100644 --- a/vortex-test/compat-gen/src/fixtures/arrays/datasets/tpch.rs +++ b/vortex-test/compat-gen/src/fixtures/arrays/datasets/tpch.rs @@ -8,7 +8,7 @@ use tpchgen_arrow::RecordBatchIterator; use vortex_array::ArrayRef; use vortex_array::IntoArray; use vortex_array::arrays::ChunkedArray; -use vortex_array::arrow::FromArrowArray; +use vortex_arrow::FromArrowArray; use vortex_error::VortexResult; use crate::fixtures::DatasetFixture; diff --git a/vortex-test/e2e-cuda/src/lib.rs b/vortex-test/e2e-cuda/src/lib.rs index 493d7c7d23c..1c6530ce612 100644 --- a/vortex-test/e2e-cuda/src/lib.rs +++ b/vortex-test/e2e-cuda/src/lib.rs @@ -45,9 +45,9 @@ use vortex::array::arrays::StructArray; use vortex::array::arrays::TemporalArray; use vortex::array::arrays::VarBinViewArray; use vortex::array::arrays::varbinview::BinaryView; -use vortex::array::arrow::ArrowSessionExt; use vortex::array::stream::ArrayStreamExt; use vortex::array::validity::Validity; +use vortex::arrow::ArrowSessionExt; use vortex::buffer::Buffer; use vortex::buffer::ByteBuffer; use vortex::dtype::DType; diff --git a/vortex-tui/Cargo.toml b/vortex-tui/Cargo.toml index 2c890758945..34b90ecfc05 100644 --- a/vortex-tui/Cargo.toml +++ b/vortex-tui/Cargo.toml @@ -55,6 +55,7 @@ taffy = { workspace = true } vortex = { version = "0.1.0", path = "../vortex", default-features = false, features = [ "files", ] } +vortex-arrow = { workspace = true } # Native-only dependencies (gated behind "native" feature) arrow-array = { workspace = true, optional = true } diff --git a/vortex-tui/src/convert.rs b/vortex-tui/src/convert.rs index f6798413422..17fd5dcbc96 100644 --- a/vortex-tui/src/convert.rs +++ b/vortex-tui/src/convert.rs @@ -13,16 +13,16 @@ use parquet::arrow::ParquetRecordBatchStreamBuilder; use tokio::fs::File; use tokio::io::AsyncWriteExt; use vortex::array::ArrayRef; -use vortex::array::arrow::FromArrowArray; use vortex::array::stream::ArrayStreamAdapter; use vortex::compressor::BtrBlocksCompressorBuilder; use vortex::dtype::DType; -use vortex::dtype::arrow::FromArrowType; use vortex::error::VortexExpect; use vortex::error::vortex_err; use vortex::file::WriteOptionsSessionExt; use vortex::file::WriteStrategyBuilder; use vortex::session::VortexSession; +use vortex_arrow::FromArrowArray; +use vortex_arrow::FromArrowType; /// Compression strategy to use when converting Parquet files to Vortex format. #[derive(Clone, Copy, Debug, Default, ValueEnum)] diff --git a/vortex-web/crate/Cargo.toml b/vortex-web/crate/Cargo.toml index 1b65fe832eb..b4de2003c28 100644 --- a/vortex-web/crate/Cargo.toml +++ b/vortex-web/crate/Cargo.toml @@ -19,6 +19,7 @@ serde_json = { workspace = true } vortex = { path = "../../vortex", default-features = false, features = [ "files", ] } +vortex-arrow = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] futures = { workspace = true } diff --git a/vortex-web/crate/src/wasm.rs b/vortex-web/crate/src/wasm.rs index 04abe8ee6d3..0323a98c271 100644 --- a/vortex-web/crate/src/wasm.rs +++ b/vortex-web/crate/src/wasm.rs @@ -20,7 +20,6 @@ use futures::future::BoxFuture; use serde::Serialize; use vortex::array::ArrayRef; use vortex::array::VortexSessionExecute; -use vortex::array::arrow::ArrowSessionExt; use vortex::array::buffer::BufferHandle; use vortex::array::dtype::DType; use vortex::array::serde::SerializedArray; @@ -40,6 +39,7 @@ use vortex::layout::layouts::flat::Flat; use vortex::layout::scan::scan_builder::ScanBuilder; use vortex::session::VortexSession; use vortex::session::registry::ReadContext; +use vortex_arrow::ArrowSessionExt; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; diff --git a/vortex/Cargo.toml b/vortex/Cargo.toml index 510ec5747f7..2511893d09e 100644 --- a/vortex/Cargo.toml +++ b/vortex/Cargo.toml @@ -25,6 +25,7 @@ workspace = true [dependencies] vortex-alp = { workspace = true } vortex-array = { workspace = true } +vortex-arrow = { workspace = true } vortex-btrblocks = { workspace = true } vortex-buffer = { workspace = true } vortex-bytebool = { workspace = true } @@ -42,7 +43,7 @@ vortex-mask = { workspace = true } vortex-metrics = { workspace = true } vortex-pco = { workspace = true } vortex-proto = { workspace = true, default-features = true } -vortex-runend = { workspace = true, features = ["arrow"] } +vortex-runend = { workspace = true } vortex-scan = { workspace = true } vortex-sequence = { workspace = true } vortex-session = { workspace = true } diff --git a/vortex/src/lib.rs b/vortex/src/lib.rs index 764c1f3d5fc..2703020906c 100644 --- a/vortex/src/lib.rs +++ b/vortex/src/lib.rs @@ -99,7 +99,6 @@ // vortex::compute is deprecated and will be ported over to expressions. pub use vortex_array::aggregate_fn; use vortex_array::aggregate_fn::session::AggregateFnSession; -use vortex_array::arrow::ArrowSession; pub use vortex_array::compute; use vortex_array::dtype::session::DTypeSession; // vortex::expr is in the process of having its dependencies inverted, and will eventually be @@ -127,6 +126,12 @@ pub mod array { // twice. } +/// Arrow interoperability: conversion between Vortex and Apache Arrow arrays, types, and +/// schemas. +pub mod arrow { + pub use vortex_arrow::*; +} + /// Aligned buffers and byte buffers used by arrays, layouts, IPC, and file IO. pub mod buffer { pub use vortex_buffer::*; @@ -297,9 +302,9 @@ impl VortexSessionDefault for VortexSession { .with::() .with::() .with::() - .with::() .with::() .with::(); + vortex_arrow::initialize(&session); // `MultiFileSession` holds a `moka` cache whose clock reads `std::time::Instant::now()` // when constructed. `Instant` is unsupported on `wasm32` and panics with "time not @@ -355,9 +360,9 @@ mod test { use arrow_array::RecordBatchReader; use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; use vortex::array::arrays::ChunkedArray; - use vortex::array::arrow::FromArrowArray; + use vortex::arrow::FromArrowArray; + use vortex::arrow::FromArrowType; use vortex::dtype::DType; - use vortex::dtype::arrow::FromArrowType; let reader = ParquetRecordBatchReaderBuilder::try_new(File::open( "../docs/_static/example.parquet", From 443b79b2b16a36781b01fed701b9b4c8a1ca2250 Mon Sep 17 00:00:00 2001 From: myrrc Date: Mon, 13 Jul 2026 16:35:08 +0100 Subject: [PATCH 067/104] New C++ api docs (#8737) Signed-off-by: Mikhail Kot --- docs/api/cpp/index.rst | 256 +++++++++++++++++++++++++++++++++++++++++ docs/api/index.md | 1 + 2 files changed, 257 insertions(+) create mode 100644 docs/api/cpp/index.rst diff --git a/docs/api/cpp/index.rst b/docs/api/cpp/index.rst new file mode 100644 index 00000000000..ea1c415ac93 --- /dev/null +++ b/docs/api/cpp/index.rst @@ -0,0 +1,256 @@ +C++ API +======= + +The Vortex C++ API allows you to read and write ``.vortex`` files directly or +via an Arrow compatibility layer like `nanoarrow +`_. The only dependency apart from Vortex +is ``nanoarrow``. + +.. note:: + C++ API is a work in progress. Please reach out to us if you are interested + in using Vortex from C++ or you want a feature not covered yet e.g. + extension support. + +Installation +------------ + +We don't provide prebuilt library files (yet) so you will need to build Vortex +from source, and for that you will need: + +- C++20, +- Rust toolchain, +- and CMake 3.10. + +.. code-block:: bash + + git clone --depth 1 https://github.com/vortex-data/vortex + cd vortex + cargo build --release -p vortex-ffi + cd vortex-cxx + mkdir build + + cmake -Bbuild -DCMAKE_BUILD_TYPE=Release + # To build the examples, pass -DBUILD_EXAMPLES=1 + # cmake -Bbuild -DBUILD_EXAMPLES=1 + + cmake --build build -j + +This produces a shared and a static library which you can use directly or via + +.. code-block:: cmake + + # static library + target_link_libraries(target PRIVATE vortex_cxx) + # shared library + target_link_libraries(target PRIVATE vortex_cxx_shared) + +Have a look at the `examples +`_ +directory as well. + +Reading files +------------- + +Full source code for this example is `reader.cpp +`_. +For brevity we omit ``main()``, system includes and ``using`` directives. + +Assuming you have Vortex files ``people0``, ``people1``, and ``me`` in a local folder, +each containing U8 column "age" and U16 column "height", this is how you +print all ages for specific heights: + +.. code-block:: cpp + + Session session; + DataSource ds = DataSource::open(session, {"people*.vortex", "me.vortex"}); + Scan scan = ds.scan({.filter = col("height") >= lit(50)}); + + for (Partition &partition : scan.partitions()) { + for (Array &array : partition.batches()) { + Array age = array.field("age"); + PrimitiveView age_view = age.values(session); + std::span age_values = age_view.values(); + for (uint8_t value : age_values) { + std::cout << int(value) << " "; + } + } + } + std::cout << "\n"; + +DataSource and Scan +^^^^^^^^^^^^^^^^^^^ + +First, you need to create a Vortex session, which does extension bookkeeping and +may also hold metadata like object store credentials (we'll get back to it +later). Further, a DataSource provides a view over multiple files which may also +be remote. You can specify globs for every item as it's shown. + +Once you have a DataSource, you can create Scans out of it. A Scan is a single +traversal of a DataSource which projects columns and filters on them. Our Scan +is consumed by following calls so it needs to be mutable. ScanOptions, which is +passed to Scan, is a simple C++ aggregate so you can initialize any fields you +want or avoid them altogether (``auto scan = ds.scan()``). + +Expressions +^^^^^^^^^^^ + +If you omit ``.projection`` all columns are read as part of a scan. If we wanted +to return only the "age" column we'd write ``.projection = col("age")``. We pass +an Expression to a filter which returns false for some "height" values, and the +scan filters them out. + +Two additional things to look for in ``.filter``: first, we allow overloading +Expression operators which produce Expressions themselves à la Eigen. This is +opt-in via ``using namespace vortex::expr::ops``. If you prefer, you can use +``eq(col("height"), lit(180))`` instead. The second thing is that we +explicitly pass the type to the ``lit`` expression, which creates a literal +constant. We don't do any type coercion in Vortex so if you were to write +``lit(180)``, C++ would likely deduce the type to ``int`` and fail at runtime. + +Once we're done with the scan, we need to consume the data it provides. Vortex +has Arrow interoperability, but now let's focus on Partitions and Arrays. + +Partitions and Arrays +^^^^^^^^^^^^^^^^^^^^^ + +A Partition is an independent unit of work. Assuming your Vortex file +processing will likely be multithreaded, each thread can pull Partitions from +a Scan and handle them in parallel. For simplicity we don't cover it here, but +look for the next sections! Each Partition produces Arrays which are batches of +rows and columns. + +.. note:: + Arrays, DataTypes, and Expressions are reference-counted so copying them is + cheap. + +Once we have an Array, we can get access to its values. First, we need to +extract the "age" field. + +.. note:: + Default projection returns the root Array which is a Struct so to get + a field from it we need to use ``field()``. If we were to use ``col()`` in + projection, the array we got would be "age" column already, so we wouldn't + need to use ``field()``. + + +Then we want to get access to the raw bytes. However, +Array likely references compressed data, so now we need to learn about +canonicalization. + +Canonicalization +^^^^^^^^^^^^^^^^ + +Vortex files hold trees of compressed data where each node is a specific +encoding (zstd, FSST, delta) over another node. This is good for performance +because we defer decompression and we can also pass compressed data directly to +other systems. Say, if a reader knows how to deal with bitpacked integers but +not RLE and we have ``RLE(Bitpacked(U64))``, we can decompress just RLE and +pass bitpacked array directly to the reader. + +In this example we want to remove all encodings and uncompress all data fully. +This form is called a canonical Array, and the process is canonicalization. + +When we request ``.values(session)``, we canonicalize the Array and get a +PrimitiveView because we already know the type of the column. As PrimitiveView +holds uncompressed data, we can get raw ``uint64_t`` numbers by calling +``.values()`` on the view. + +Writing files +------------- + +Now let's write the first files to be read by our previous example. +Source code for this example is `writer.cpp +`_. + +.. code-block:: cpp + + const Session session; + const DataType dtype = dtype::struct_({ + {"age", dtype::uint8()}, + {"height", dtype::uint16(Nullable)}, + }); + + constexpr size_t SAMPLE_ROWS = 100; + std::vector age_buffer(SAMPLE_ROWS); + std::vector height_buffer(SAMPLE_ROWS); + for (size_t i = 0; i < SAMPLE_ROWS; ++i) { + age_buffer[i] = static_cast(i); + height_buffer[i] = static_cast((i + 1) % 200); + } + + Array age = Array::primitive(age_buffer); + Array array = make_struct({ + {"age", age}, + {"height", Array::primitive(height_buffer, AllValid)}, + }); + + Expression age_gt_10 = expr::gt(expr::col("age"), expr::lit(10)); + Array validity_array = array.apply(age_gt_10); + + const Validity validity {ValidityType::Array, validity_array}; + Array array2 = make_struct({ + {"age", age}, + {"height", Array::primitive(height_buffer, validity)}, + }); + + Writer writer = Writer::open(session, argv[1], dtype); + writer.push(array); + writer.push(array2); + writer.finish(); + +DataType +^^^^^^^^ + +First, you want to create a schema. DataType is a logical representation of +Vortex's type system. "Logical" as opposed to "physical" means DataType doesn't +know what encodings the data use. As another example, Arrow and Parquet's types +are physical. There's a convenience function ``struct_`` which creates a +``DataTypeVariant::Struct`` with fields passed as a ``initializer_list``. + +Each DataType has a notion of nullability: whether some items in the row can be +invalid (Vortex uses the terms "null" and "invalid" interchangeably). DataTypes +are non-nullable by default, so "age" column is not nullable, but "height" is. + +Once we create Arrays for columns we can merge them into a Struct Array with +``make_struct``, and we need to say a word about Validity. + +Nullability and Validity +^^^^^^^^^^^^^^^^^^^^^^^^ + +Nullability (and ``Nullable`` flag) is a type-system note that we `may` have +invalid elements in the column. A non-``Nullable`` column can't have nulls in +it, but a ``Nullable`` column's items may be all valid as well. Validity, on the +other hand, tells us whether we `do` have nulls in a particular Array. + +"height" in the first Array is AllValid which means in this Array we don't have +invalid items. For the second Array we reuse age by copying it (which is a +reference clone so it's cheap). But for "height" we say Validity is determined +by another Array. This array consists of Bools and we obtained it by applying a +comparison Expression to the "age" column of the first array. So, "height" field +in ``array2`` will have null/invalid values if "age"'s item at the same index +was less than 11. + +Applying Expressions +^^^^^^^^^^^^^^^^^^^^ + +One important thing to know is that applying an Expression is a constant-time +operation, and the returned array contains the original values along with some +meta-information about the expression. Real application happens when you execute +the array. One example of executing the array is canonicalization, which in this +example happens later when we write both arrays to the file. + +Writing to a file +^^^^^^^^^^^^^^^^^ + +Once we're done with the arrays, we need to initialize the Writer and push +arrays into it. ``finish()`` call writes the file footer. If you don't call it and +let Writer go out of scope, the file will be visibly corrupted. + +Now you can build the example and read back the generated files: + +.. code-block:: + + ./build/examples/writer people0.vortex + ./build/examples/writer people1.vortex + ./build/examples/writer me.vortex + ./build/examples/reader diff --git a/docs/api/index.md b/docs/api/index.md index 343661f6717..c4fcf3fe182 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -11,5 +11,6 @@ maxdepth: 2 python/index Rust API c/index +cpp/index java/index ``` From 3d7817552fb687d765fdb066fee54196364318b5 Mon Sep 17 00:00:00 2001 From: myrrc Date: Mon, 13 Jul 2026 16:52:39 +0100 Subject: [PATCH 068/104] vx_expression_clone, remove vx_file_write_array (#8735) Preparatory changes split from new C++ API PR. - Add vx_expression_clone - Remove accidental const modifier from from vx_expression_not's output. - Fix a memory leak in vx_struct_fields_builder_add_field where a non-UTF8 field throws before DType was converted from a raw pointer to an Arc. - Make sink throw in push() if array's dtype doesn't match sink's dtype. - Allow passing null pointers to FFI's arc_wrapper! free() function. - Remove vx_file_write_array in favor of sink interface. Signed-off-by: Mikhail Kot --- vortex-ffi/Cargo.toml | 4 +- vortex-ffi/cinclude/vortex.h | 70 +++++++++++++++++---------------- vortex-ffi/src/dtype.rs | 2 +- vortex-ffi/src/expression.rs | 15 ++++++- vortex-ffi/src/file.rs | 45 --------------------- vortex-ffi/src/lib.rs | 1 - vortex-ffi/src/macros.rs | 32 +++++++-------- vortex-ffi/src/sink.rs | 36 +++++++++++++++-- vortex-ffi/src/struct_fields.rs | 8 ++-- 9 files changed, 102 insertions(+), 111 deletions(-) delete mode 100644 vortex-ffi/src/file.rs diff --git a/vortex-ffi/Cargo.toml b/vortex-ffi/Cargo.toml index bf9d6c1c342..a64aabac03f 100644 --- a/vortex-ffi/Cargo.toml +++ b/vortex-ffi/Cargo.toml @@ -20,14 +20,14 @@ categories = { workspace = true } all-features = true [dependencies] -arrow-array = { workspace = true, features = ["ffi"] } +arrow-array = { workspace = true } arrow-schema = { workspace = true } async-fs = { workspace = true } bytes = { workspace = true } futures = { workspace = true } mimalloc = { workspace = true, optional = true } paste = { workspace = true } -tracing = { workspace = true, features = ["std", "log"] } +tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter"] } vortex = { workspace = true, features = ["object_store"] } vortex-arrow = { workspace = true } diff --git a/vortex-ffi/cinclude/vortex.h b/vortex-ffi/cinclude/vortex.h index cbd9f7f1406..1c2caa6fad8 100644 --- a/vortex-ffi/cinclude/vortex.h +++ b/vortex-ffi/cinclude/vortex.h @@ -682,12 +682,12 @@ extern "C" { #endif // __cplusplus /** - * Clone a vx_array + * Increase reference count on vx_array */ const vx_array *vx_array_clone(const vx_array *ptr); /** - * Free an owned [`vx_array`] object. + * Decrease reference count on vx_array or free if there are no other references */ void vx_array_free(const vx_array *ptr); @@ -909,7 +909,7 @@ const void *vx_array_data_ptr_bool(const vx_array *array, size_t *bit_offset_out const vx_array *vx_array_apply(const vx_array *array, const vx_expression *expression, vx_error **error); /** - * Free an owned [`vx_array_iterator`] object. + * Free a vx_array_iterator */ void vx_array_iterator_free(const vx_array_iterator *ptr); @@ -924,12 +924,12 @@ void vx_array_iterator_free(const vx_array_iterator *ptr); const vx_array *vx_array_iterator_next(vx_array_iterator *iter, vx_error **error_out); /** - * Clone a vx_data_source. Returned handle must be release with vx_data_source_free + * Increase reference count on vx_data_source */ const vx_data_source *vx_data_source_clone(const vx_data_source *ptr); /** - * Free a vx_data_source + * Decrease reference count on vx_data_source or free if there are no other references */ void vx_data_source_free(const vx_data_source *ptr); @@ -966,12 +966,12 @@ const vx_dtype *vx_data_source_dtype(const vx_data_source *ds); void vx_data_source_get_row_count(const vx_data_source *ds, vx_estimate *row_count); /** - * Clone a vx_dtype + * Increase reference count on vx_dtype */ const vx_dtype *vx_dtype_clone(const vx_dtype *ptr); /** - * Free an owned [`vx_dtype`] object. + * Decrease reference count on vx_dtype or free if there are no other references */ void vx_dtype_free(const vx_dtype *ptr); @@ -1054,7 +1054,7 @@ int8_t vx_dtype_decimal_scale(const vx_dtype *dtype); /** * If "dtype" is DTYPE_STRUCT, return owned vx_struct_fields for this struct, * return NULL otherwise. Returned vx_struct_fields must be released with - * vx_dtype_free. + * vx_struct_fields_free. */ const vx_struct_fields *vx_dtype_struct_dtype(const vx_dtype *dtype); @@ -1123,7 +1123,7 @@ int vx_dtype_to_arrow_schema(const vx_dtype *dtype, FFI_ArrowSchema *schema, vx_ const vx_dtype *vx_dtype_from_arrow_schema(FFI_ArrowSchema *schema, vx_error **err); /** - * Free an owned [`vx_error`] object. + * Free a vx_error */ void vx_error_free(const vx_error *ptr); @@ -1139,7 +1139,7 @@ vx_view vx_error_message(const vx_error *error); vx_error_code vx_error_get_code(const vx_error *error); /** - * Free an owned [`vx_expression`] object. + * Free a vx_expression */ void vx_expression_free(const vx_expression *ptr); @@ -1161,6 +1161,11 @@ void vx_expression_free(const vx_expression *ptr); */ vx_expression *vx_expression_root(void); +/** + * Reference-clone a vx_expression + */ +vx_expression *vx_expression_clone(const vx_expression *ptr); + /** * Create a literal expression from a scalar. * @@ -1253,7 +1258,7 @@ vx_expression_binary(vx_binary_operator operator_, const vx_expression *lhs, con * * Returns the logical negation of the input boolean expression. */ -const vx_expression *vx_expression_not(const vx_expression *child); +vx_expression *vx_expression_not(const vx_expression *child); /** * Create an expression that checks for null values. @@ -1284,21 +1289,6 @@ vx_expression *vx_expression_get_item(vx_view item, const vx_expression *child); */ vx_expression *vx_expression_list_contains(const vx_expression *list, const vx_expression *value); -/** - * Clone a vx_file - */ -const vx_file *vx_file_clone(const vx_file *ptr); - -/** - * Free an owned [`vx_file`] object. - */ -void vx_file_free(const vx_file *ptr); - -void vx_file_write_array(const vx_session *session, - vx_view path, - const vx_array *array, - vx_error **error_out); - /** * Set the stderr logger to output at the specified level. * @@ -1307,7 +1297,7 @@ void vx_file_write_array(const vx_session *session, void vx_set_log_level(vx_log_level level); /** - * Free an owned [`vx_scalar`] object. + * Free a vx_scalar */ void vx_scalar_free(const vx_scalar *ptr); @@ -1526,12 +1516,12 @@ vx_scalar *vx_scalar_new_struct(const vx_dtype *struct_dtype, vx_error **err); /** - * Free an owned [`vx_scan`] object. + * Free a vx_scan */ void vx_scan_free(const vx_scan *ptr); /** - * Free an owned [`vx_partition`] object. + * Free a vx_partition */ void vx_partition_free(const vx_partition *ptr); @@ -1611,7 +1601,7 @@ int vx_partition_scan_arrow(const vx_session *session, const vx_array *vx_partition_next(vx_partition *partition, vx_error **err); /** - * Free an owned [`vx_session`] object. + * Free a vx_session */ void vx_session_free(const vx_session *ptr); @@ -1629,6 +1619,16 @@ vx_session *vx_session_new(void); */ vx_session *vx_session_clone(const vx_session *session); +/** + * Increase reference count on vx_file + */ +const vx_file *vx_file_clone(const vx_file *ptr); + +/** + * Decrease reference count on vx_file or free if there are no other references + */ +void vx_file_free(const vx_file *ptr); + /** * Opens a writable array stream, where sink is used to push values into the stream. * To close the stream close the sink with `vx_array_sink_close`. @@ -1639,7 +1639,9 @@ vx_array_sink_open_file(const vx_session *session, vx_view path, const vx_dtype /** * Push an array into a file sink. - * Does not take ownership of array + * Does not take ownership of array. + * + * Errors if array's DType doesn't match sink's DType. */ void vx_array_sink_push(vx_array_sink *sink, const vx_array *array, vx_error **error_out); @@ -1656,7 +1658,7 @@ void vx_array_sink_close(vx_array_sink *sink, vx_error **error_out); void vx_array_sink_abort(vx_array_sink *sink); /** - * Free an owned [`vx_struct_column_builder`] object. + * Free a vx_struct_column_builder */ void vx_struct_column_builder_free(const vx_struct_column_builder *ptr); @@ -1707,7 +1709,7 @@ void vx_struct_column_builder_add_field(vx_struct_column_builder *builder, const vx_array *vx_struct_column_builder_finalize(vx_struct_column_builder *builder, vx_error **error); /** - * Free an owned [`vx_struct_fields`] object. + * Free a vx_struct_fields */ void vx_struct_fields_free(const vx_struct_fields *ptr); @@ -1731,7 +1733,7 @@ vx_view vx_struct_fields_field_name(const vx_struct_fields *dtype, size_t idx); const vx_dtype *vx_struct_fields_field_dtype(const vx_struct_fields *dtype, size_t idx); /** - * Free an owned [`vx_struct_fields_builder`] object. + * Free a vx_struct_fields_builder */ void vx_struct_fields_builder_free(const vx_struct_fields_builder *ptr); diff --git a/vortex-ffi/src/dtype.rs b/vortex-ffi/src/dtype.rs index baeab5fd280..d38a8c62ec8 100644 --- a/vortex-ffi/src/dtype.rs +++ b/vortex-ffi/src/dtype.rs @@ -211,7 +211,7 @@ pub unsafe extern "C-unwind" fn vx_dtype_decimal_scale(dtype: *const vx_dtype) - /// If "dtype" is DTYPE_STRUCT, return owned vx_struct_fields for this struct, /// return NULL otherwise. Returned vx_struct_fields must be released with -/// vx_dtype_free. +/// vx_struct_fields_free. #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_dtype_struct_dtype( dtype: *const vx_dtype, diff --git a/vortex-ffi/src/expression.rs b/vortex-ffi/src/expression.rs index 87e15c5f843..e724ea08ce5 100644 --- a/vortex-ffi/src/expression.rs +++ b/vortex-ffi/src/expression.rs @@ -63,6 +63,17 @@ pub unsafe extern "C" fn vx_expression_root() -> *mut vx_expression { vx_expression::new(root()) } +/// Reference-clone a vx_expression +#[unsafe(no_mangle)] +pub unsafe extern "C-unwind" fn vx_expression_clone( + ptr: *const vx_expression, +) -> *mut vx_expression { + if ptr.is_null() { + return ptr::null_mut(); + } + vx_expression::new(vx_expression::as_ref(ptr).clone()) +} + /// Create a literal expression from a scalar. /// /// Literal expressions are useful for constants in expression trees, especially scan @@ -260,9 +271,9 @@ pub unsafe extern "C" fn vx_expression_binary( /// /// Returns the logical negation of the input boolean expression. #[unsafe(no_mangle)] -pub unsafe extern "C" fn vx_expression_not(child: *const vx_expression) -> *const vx_expression { +pub unsafe extern "C" fn vx_expression_not(child: *const vx_expression) -> *mut vx_expression { if child.is_null() { - return child; + return ptr::null_mut(); } vx_expression::new(not(vx_expression::as_ref(child).clone())) } diff --git a/vortex-ffi/src/file.rs b/vortex-ffi/src/file.rs deleted file mode 100644 index da962e5af3b..00000000000 --- a/vortex-ffi/src/file.rs +++ /dev/null @@ -1,45 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -use vortex::file::VortexFile; -use vortex::file::WriteOptionsSessionExt; -use vortex::io::runtime::BlockingRuntime; - -use crate::RUNTIME; -use crate::arc_wrapper; -use crate::array::vx_array; -use crate::error::try_or_default; -use crate::error::vx_error; -use crate::session::vx_session; -use crate::string::vx_view; - -arc_wrapper!( - /// A handle to a Vortex file encapsulating the footer and logic for instantiating a reader. - VortexFile, - vx_file -); - -#[unsafe(no_mangle)] -pub unsafe extern "C-unwind" fn vx_file_write_array( - session: *const vx_session, - path: vx_view, - array: *const vx_array, - error_out: *mut *mut vx_error, -) { - let session = vx_session::as_ref(session); - let options = session.write_options(); - let array = vx_array::as_ref(array); - try_or_default(error_out, || { - let path = unsafe { path.as_str() }?; - - RUNTIME.block_on(async move { - options - .write( - &mut async_fs::File::create(path).await?, - array.to_array_stream(), - ) - .await?; - Ok(()) - }) - }); -} diff --git a/vortex-ffi/src/lib.rs b/vortex-ffi/src/lib.rs index f50a853b8dc..ef493a27b03 100644 --- a/vortex-ffi/src/lib.rs +++ b/vortex-ffi/src/lib.rs @@ -11,7 +11,6 @@ mod data_source; mod dtype; mod error; mod expression; -mod file; mod log; mod macros; mod ptype; diff --git a/vortex-ffi/src/macros.rs b/vortex-ffi/src/macros.rs index 1e943c17ad9..09f751c2a9e 100644 --- a/vortex-ffi/src/macros.rs +++ b/vortex-ffi/src/macros.rs @@ -77,7 +77,7 @@ macro_rules! arc_dyn_wrapper { } } - #[doc = r" Clone a " $ffi_ident ". Returned handle must be release with " $ffi_ident "_free "] + #[doc = r" Increase reference count on " $ffi_ident] #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn [<$ffi_ident _clone>](ptr: *const $ffi_ident) -> *const $ffi_ident { if ptr.is_null() { @@ -87,13 +87,12 @@ macro_rules! arc_dyn_wrapper { $ffi_ident::new($ffi_ident::as_ref(ptr).clone()) } - #[doc = r" Free a " $ffi_ident] + #[doc = r" Decrease reference count on " $ffi_ident " or free if there are no other references"] #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn [<$ffi_ident _free>](ptr: *const $ffi_ident) { - if ptr.is_null() { - vortex::error::vortex_panic!("null pointer"); + if !ptr.is_null() { + drop($ffi_ident::into_arc(ptr)) } - drop($ffi_ident::into_arc(ptr)) } } }; @@ -133,7 +132,7 @@ macro_rules! arc_wrapper { } } - #[doc = r" Clone a " $ffi_ident] + #[doc = r" Increase reference count on " $ffi_ident] #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn [<$ffi_ident _clone>](ptr: *const $ffi_ident) -> *const $ffi_ident { if ptr.is_null() { @@ -143,13 +142,12 @@ macro_rules! arc_wrapper { ptr } - #[doc = r" Free an owned [`" $ffi_ident "`] object."] + #[doc = r" Decrease reference count on " $ffi_ident " or free if there are no other references"] #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn [<$ffi_ident _free>](ptr: *const $ffi_ident) { - if ptr.is_null() { - vortex::error::vortex_panic!("null pointer"); + if !ptr.is_null() { + unsafe { std::sync::Arc::decrement_strong_count(ptr) }; } - unsafe { std::sync::Arc::decrement_strong_count(ptr) }; } } }; @@ -193,13 +191,12 @@ macro_rules! box_dyn_wrapper { } } - #[doc = r" Free an owned [`" $ffi_ident "`] object."] + #[doc = r" Free a " $ffi_ident] #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn [<$ffi_ident _free>](ptr: *const $ffi_ident) { - if ptr.is_null() { - vortex::error::vortex_panic!("null pointer"); + if !ptr.is_null() { + drop($ffi_ident::into_box(ptr.cast_mut())) } - drop($ffi_ident::into_box(ptr.cast_mut())) } } }; @@ -253,17 +250,16 @@ macro_rules! box_wrapper { } } - #[doc = r" Free an owned [`" $ffi_ident "`] object."] + #[doc = r" Free a " $ffi_ident] // These allows only matter once the destructor is re-exported (e.g. `vx_error_free`): // its `# Safety` lives at the C boundary, and its doc links a private wrapper type. #[allow(clippy::missing_safety_doc)] #[allow(rustdoc::private_intra_doc_links)] #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn [<$ffi_ident _free>](ptr: *const $ffi_ident) { - if ptr.is_null() { - vortex::error::vortex_panic!("null pointer"); + if !ptr.is_null() { + std::mem::drop(unsafe { Box::from_raw(ptr.cast::<$T>().cast_mut()) }) } - std::mem::drop(unsafe { Box::from_raw(ptr.cast::<$T>().cast_mut()) }) } } }; diff --git a/vortex-ffi/src/sink.rs b/vortex-ffi/src/sink.rs index fa8dae19ed8..7ca149d0731 100644 --- a/vortex-ffi/src/sink.rs +++ b/vortex-ffi/src/sink.rs @@ -7,10 +7,12 @@ use futures::channel::mpsc; use futures::channel::mpsc::Sender; use vortex::array::ArrayRef; use vortex::array::stream::ArrayStreamAdapter; +use vortex::dtype::DType; use vortex::error::VortexResult; use vortex::error::vortex_bail; use vortex::error::vortex_ensure; use vortex::error::vortex_err; +use vortex::file::VortexFile; use vortex::file::WriteOptionsSessionExt; use vortex::file::WriteSummary; use vortex::io::runtime::BlockingRuntime; @@ -18,6 +20,7 @@ use vortex::io::runtime::Task; use vortex::io::session::RuntimeSessionExt; use crate::RUNTIME; +use crate::arc_wrapper; use crate::array::vx_array; use crate::dtype::vx_dtype; use crate::error::try_or_default; @@ -25,6 +28,12 @@ use crate::error::vx_error; use crate::session::vx_session; use crate::string::vx_view; +arc_wrapper!( + /// A handle to a Vortex file encapsulating the footer and logic for instantiating a reader. + VortexFile, + vx_file +); + #[expect(non_camel_case_types)] /// The `sink` interface is used to collect array chunks and place them into a resource /// (e.g. an array stream or file (`vx_array_sink_open_file`)). @@ -42,6 +51,7 @@ use crate::string::vx_view; pub struct vx_array_sink { sink: Sender>, writer: Task>, + dtype: DType, } /// Opens a writable array stream, where sink is used to push values into the stream. @@ -65,19 +75,26 @@ pub unsafe extern "C-unwind" fn vx_array_sink_open_file( let file_dtype = vx_dtype::as_ref(dtype); // The channel size 32 was chosen arbitrarily. let (sink, rx) = mpsc::channel(32); - let array_stream = ArrayStreamAdapter::new(file_dtype.clone(), rx.into_stream()); + let dtype = file_dtype.clone(); + let array_stream = ArrayStreamAdapter::new(dtype.clone(), rx.into_stream()); let writer = session.handle().spawn(async move { let mut file = async_fs::File::create(path).await?; session.write_options().write(&mut file, array_stream).await }); - Ok(Box::into_raw(Box::new(vx_array_sink { sink, writer }))) + Ok(Box::into_raw(Box::new(vx_array_sink { + sink, + writer, + dtype, + }))) }) } /// Push an array into a file sink. -/// Does not take ownership of array +/// Does not take ownership of array. +/// +/// Errors if array's DType doesn't match sink's DType. #[unsafe(no_mangle)] pub unsafe extern "C-unwind" fn vx_array_sink_push( sink: *mut vx_array_sink, @@ -90,6 +107,13 @@ pub unsafe extern "C-unwind" fn vx_array_sink_push( let array = vx_array::as_ref(array); let sink = unsafe { &mut *sink }; + + vortex_ensure!( + *array.dtype() == sink.dtype, + "array dtype {} does not match sink dtype {}", + array.dtype(), + sink.dtype + ); RUNTIME .block_on(sink.sink.send(Ok(array.clone()))) .map_err(|e| vortex_err!("Send error: {e}")) @@ -104,7 +128,11 @@ pub unsafe extern "C-unwind" fn vx_array_sink_close( error_out: *mut *mut vx_error, ) { try_or_default(error_out, || { - let vx_array_sink { sink, writer } = *unsafe { Box::from_raw(sink) }; + let vx_array_sink { + sink, + writer, + dtype: _, + } = *unsafe { Box::from_raw(sink) }; drop(sink); RUNTIME.block_on(async { diff --git a/vortex-ffi/src/struct_fields.rs b/vortex-ffi/src/struct_fields.rs index 6950b62a302..4cb22bdb521 100644 --- a/vortex-ffi/src/struct_fields.rs +++ b/vortex-ffi/src/struct_fields.rs @@ -103,10 +103,10 @@ pub unsafe extern "C-unwind" fn vx_struct_fields_builder_add_field( ) { try_or_default(error_out, || { let builder = vx_struct_fields_builder::as_mut(builder); - builder.names.push(Arc::from(unsafe { name.as_str() }?)); - builder - .fields - .push(vx_dtype::into_arc(dtype).deref().clone()); + let field = vx_dtype::into_arc(dtype).deref().clone(); + let name = Arc::from(unsafe { name.as_str() }?); + builder.fields.push(field); + builder.names.push(name); Ok(()) }) } From 002e40afe2be0564c2f20f689c5d052e21cc387b Mon Sep 17 00:00:00 2001 From: Nemo Yu <83347615+HarukiMoriarty@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:07:29 -0400 Subject: [PATCH 069/104] ST_Intersects pushdown: vortex.geo.intersects kernel, DuckDB lowering, spatial function overrides (#8704) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Rationale for this change `ST_Intersects` over native geometry columns currently evaluates inside DuckDB, exporting every row as `GEOMETRY` just to be tested. This adds a native `vortex.geo.intersects` kernel and pushes the filter into the scan: on SpatialBench SF1 (6M points vs a literal polygon), 406ms (DuckDB filter) / 36.6ms (SPATIAL_JOIN) -> 12.4ms pushed. Part of the native geospatial lane, following the `ST_Distance`/`ST_DWithin` pushdown. ## What changes are included in this PR? - `vortex-geo`: `GeoIntersects` kernel. - `vortex-duckdb`: lower `st_intersects(native column, GEOMETRY literal)` like `st_distance`. - `vortex-duckdb`: shadow spatial's `ST_Intersects` with a non-fallible copy — DuckDB won't push can-throw filters through the projection every view-registered table has. The `ST_DWithin` override generalizes into a table-driven registry (`spatial_overrides.hpp`) shared by registration and the join-condition restore pass. Note: drop `GeoDistance`'s columnar point fast paths. `geo` is row-oriented, so kernels materialize rows regardless; hand-rolled columnar paths belong in a future columnar geometry compute library (pushed Q1: 5.4ms -> 12.6ms, still far ahead of unpushed). --------- Signed-off-by: Nemo Yu --- benchmarks/duckdb-bench/src/lib.rs | 9 +- vortex-duckdb/build.rs | 6 +- vortex-duckdb/cpp/expr.cpp | 59 ---- vortex-duckdb/cpp/include/expr.h | 5 - .../cpp/include/scalar_fn_pushdown.hpp | 2 - vortex-duckdb/cpp/include/spatial_overrides.h | 19 + .../cpp/include/spatial_overrides.hpp | 32 ++ vortex-duckdb/cpp/scalar_fn_pushdown.cpp | 67 ---- vortex-duckdb/cpp/spatial_overrides.cpp | 157 ++++++++ vortex-duckdb/cpp/vortex_duckdb.cpp | 3 +- vortex-duckdb/src/convert/expr.rs | 70 ++-- vortex-duckdb/src/duckdb/database.rs | 11 +- .../src/e2e_test/geo_pushdown_test.rs | 160 +++++++++ vortex-duckdb/src/e2e_test/mod.rs | 2 + vortex-geo/Cargo.toml | 1 + vortex-geo/src/extension/mod.rs | 30 ++ vortex-geo/src/lib.rs | 4 + vortex-geo/src/scalar_fn/contains.rs | 307 ++++++++++++++++ vortex-geo/src/scalar_fn/distance.rs | 123 ++----- vortex-geo/src/scalar_fn/intersects.rs | 334 ++++++++++++++++++ vortex-geo/src/scalar_fn/mod.rs | 4 +- 21 files changed, 1146 insertions(+), 259 deletions(-) create mode 100644 vortex-duckdb/cpp/include/spatial_overrides.h create mode 100644 vortex-duckdb/cpp/include/spatial_overrides.hpp create mode 100644 vortex-duckdb/cpp/spatial_overrides.cpp create mode 100644 vortex-duckdb/src/e2e_test/geo_pushdown_test.rs create mode 100644 vortex-geo/src/scalar_fn/contains.rs create mode 100644 vortex-geo/src/scalar_fn/intersects.rs diff --git a/benchmarks/duckdb-bench/src/lib.rs b/benchmarks/duckdb-bench/src/lib.rs index 11be0dd80e5..4f3bdd388fc 100644 --- a/benchmarks/duckdb-bench/src/lib.rs +++ b/benchmarks/duckdb-bench/src/lib.rs @@ -78,11 +78,12 @@ impl DuckClient { for stmt in &statements { self.connection().query(stmt)?; } - // After `LOAD spatial`, shadow `ST_DWithin` so radius filters push. No-op without it. + // After `LOAD spatial`, shadow the overridden spatial functions so that their filters + // push down. No-op without it. self.db .as_ref() .vortex_expect("DuckClient database accessed after close") - .register_st_dwithin_override()?; + .register_spatial_overrides()?; self.init_sql = statements; Ok(()) } @@ -132,11 +133,11 @@ impl DuckClient { .vortex_expect("connection just opened") .query(stmt)?; } - // Re-shadow `ST_DWithin` against the fresh instance. + // Re-shadow the overridden spatial functions against the fresh instance. self.db .as_ref() .vortex_expect("database just opened") - .register_st_dwithin_override()?; + .register_spatial_overrides()?; Ok(()) } diff --git a/vortex-duckdb/build.rs b/vortex-duckdb/build.rs index c6bc02f95de..c15009a92c3 100644 --- a/vortex-duckdb/build.rs +++ b/vortex-duckdb/build.rs @@ -27,12 +27,13 @@ const DEFAULT_DUCKDB_VERSION: &str = "1.5.3"; const BUILD_ARTIFACTS: [&str; 3] = ["libduckdb.dylib", "libduckdb.so", "libduckdb_static.a"]; -const SOURCE_FILES: [&str; 10] = [ +const SOURCE_FILES: [&str; 11] = [ "cpp/vortex_duckdb.cpp", "cpp/copy_function.cpp", "cpp/expr.cpp", "cpp/optimizer.cpp", "cpp/scalar_fn_pushdown.cpp", + "cpp/spatial_overrides.cpp", "cpp/cast_pushdown.cpp", "cpp/aggregate_fn_pushdown.cpp", "cpp/table_filter.cpp", @@ -178,9 +179,10 @@ const DUCKDB_C_API_FUNCTIONS: [&str; 133] = [ "duckdb_vector_size", ]; -const DUCKDB_C_API_HEADERS: [&str; 6] = [ +const DUCKDB_C_API_HEADERS: [&str; 7] = [ "cpp/include/vortex_duckdb.h", "cpp/include/expr.h", + "cpp/include/spatial_overrides.h", "cpp/include/table_filter.h", "cpp/include/vector.h", "cpp/include/copy_function.h", diff --git a/vortex-duckdb/cpp/expr.cpp b/vortex-duckdb/cpp/expr.cpp index 819133f7873..54ef37cd959 100644 --- a/vortex-duckdb/cpp/expr.cpp +++ b/vortex-duckdb/cpp/expr.cpp @@ -14,19 +14,6 @@ #include "duckdb/planner/expression/bound_operator_expression.hpp" #include "duckdb/planner/expression/bound_conjunction_expression.hpp" -#include "duckdb/catalog/catalog.hpp" -#include "duckdb/catalog/catalog_entry/scalar_function_catalog_entry.hpp" -#include "duckdb/common/error_data.hpp" -#include "duckdb/logging/logger.hpp" -#include "duckdb/main/capi/capi_internal.hpp" -#include "duckdb/main/client_context.hpp" -#include "duckdb/main/connection.hpp" -#include "duckdb/main/database_manager.hpp" -#include "duckdb/parser/parsed_data/create_scalar_function_info.hpp" -#include "duckdb/transaction/meta_transaction.hpp" - -#include - using namespace duckdb; extern "C" const char *duckdb_vx_sfunc_name(duckdb_vx_sfunc ffi_func) { @@ -37,52 +24,6 @@ extern "C" const char *duckdb_vx_sfunc_name(duckdb_vx_sfunc ffi_func) { return func->name.c_str(); } -extern "C" duckdb_state duckdb_vx_register_st_dwithin_override(duckdb_database ffi_db) { - if (!ffi_db) { - return DuckDBError; - } - const DatabaseWrapper &wrapper = *reinterpret_cast(ffi_db); - DatabaseInstance &db = *wrapper.database->instance; - try { - Connection conn(db); - ClientContext &context = *conn.context; - context.RunFunctionInTransaction([&]() { - auto &system = Catalog::GetSystemCatalog(context); - auto entry = system.GetEntry(context, - DEFAULT_SCHEMA, - "st_dwithin", - OnEntryNotFound::RETURN_NULL); - if (!entry) { - // No `spatial` loaded, so there is no `ST_DWithin` to override. - return; - } - ScalarFunctionSet set("st_dwithin"); - for (const auto &overload : entry->functions.functions) { - ScalarFunction copy = overload; - // Keep the radius as children[2]; spatial's bind folds it into private bind data. - copy.bind = nullptr; - set.AddFunction(copy); - } - CreateScalarFunctionInfo info(std::move(set)); - info.on_conflict = OnCreateConflict::REPLACE_ON_CONFLICT; - // `internal` entries are only accepted by the system catalog. - info.internal = false; - // The user catalog binds ahead of the system catalog, shadowing spatial's entry; - // `RestoreStDWithin` rebinds unpushed calls through the original. - auto &catalog = Catalog::GetCatalog(context, DatabaseManager::GetDefaultDatabase(context)); - // Durable catalogs require the modified mark; scalar function entries are never - // persisted, so this is metadata-only. - MetaTransaction::Get(context).ModifyDatabase(catalog.GetAttached(), DatabaseModificationType()); - catalog.CreateFunction(context, info); - }); - } catch (const std::exception &e) { - ErrorData data(e); - DUCKDB_LOG_ERROR(db, "Failed to register the ST_DWithin override:\t" + data.Message()); - return DuckDBError; - } - return DuckDBSuccess; -} - extern "C" const char *duckdb_vx_agg_func_name(duckdb_vx_agg_func ffi) { D_ASSERT(ffi); return reinterpret_cast(ffi)->name.c_str(); diff --git a/vortex-duckdb/cpp/include/expr.h b/vortex-duckdb/cpp/include/expr.h index 886c1d21170..704c8406c90 100644 --- a/vortex-duckdb/cpp/include/expr.h +++ b/vortex-duckdb/cpp/include/expr.h @@ -14,11 +14,6 @@ typedef struct duckdb_vx_agg_func_ *duckdb_vx_agg_func; const char *duckdb_vx_sfunc_name(duckdb_vx_sfunc ffi_func); -/// Shadow `ST_DWithin` with a copy that keeps the radius as the third argument, so -/// radius filters can push into Vortex scans. See `RestoreStDWithin` in -/// scalar_fn_pushdown.hpp for the override/restore example. -duckdb_state duckdb_vx_register_st_dwithin_override(duckdb_database ffi_db); - typedef struct duckdb_vx_expr_ *duckdb_vx_expr; const char *duckdb_vx_agg_func_name(duckdb_vx_agg_func func); diff --git a/vortex-duckdb/cpp/include/scalar_fn_pushdown.hpp b/vortex-duckdb/cpp/include/scalar_fn_pushdown.hpp index c94fab80408..19bad361cb0 100644 --- a/vortex-duckdb/cpp/include/scalar_fn_pushdown.hpp +++ b/vortex-duckdb/cpp/include/scalar_fn_pushdown.hpp @@ -35,5 +35,3 @@ struct ScalarFnReplace final : LogicalOperatorVisitor { ExpressionPtr VisitReplace(BoundColumnRefExpression &expr, ExpressionPtr *ptr) override; ExpressionPtr VisitReplace(BoundFunctionExpression &expr, ExpressionPtr *ptr) override; }; - -void RestoreStDWithin(ClientContext &context, LogicalOperator &plan); diff --git a/vortex-duckdb/cpp/include/spatial_overrides.h b/vortex-duckdb/cpp/include/spatial_overrides.h new file mode 100644 index 00000000000..e5bbe4dde04 --- /dev/null +++ b/vortex-duckdb/cpp/include/spatial_overrides.h @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#pragma once + +#include "duckdb.h" + +#ifdef __cplusplus /* If compiled as C++, use C ABI */ +extern "C" { +#endif + +/// Shadow the spatial functions that block filter pushdown with pushable copies; see +/// `SPATIAL_OVERRIDES` in spatial_overrides.cpp for the list. Call after `LOAD spatial`; +/// does nothing when spatial is not loaded. +duckdb_state duckdb_vx_register_spatial_overrides(duckdb_database ffi_db); + +#ifdef __cplusplus +} +#endif diff --git a/vortex-duckdb/cpp/include/spatial_overrides.hpp b/vortex-duckdb/cpp/include/spatial_overrides.hpp new file mode 100644 index 00000000000..ce49d82fef8 --- /dev/null +++ b/vortex-duckdb/cpp/include/spatial_overrides.hpp @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once +#include "duckdb/main/client_context.hpp" +#include "duckdb/planner/expression/bound_function_expression.hpp" +#include "duckdb/planner/logical_operator_visitor.hpp" + +using namespace duckdb; + +/* + * Vortex shadows some spatial (`ST_*`) scalar functions with tweaked copies, because as + * spatial registers them their filters can never push into Vortex scans. + * + * `duckdb_vx_register_spatial_overrides` (spatial_overrides.h) installs the copies after + * `LOAD spatial`. `RestoreSpatialOverrides` below hands join conditions back to spatial's + * originals: Vortex never pushes join conditions, and spatial's join machinery expects its + * own functions. + */ + +// Rebinds overridden spatial calls in join conditions back to spatial's original, so spatial's +// own machinery handles joins. Filters are left untouched: they keep the override and push to +// Vortex scans. +struct SpatialOverrideRestore final : LogicalOperatorVisitor { + ClientContext &context; + + explicit SpatialOverrideRestore(ClientContext &context); + void VisitOperator(LogicalOperator &op) override; + unique_ptr VisitReplace(BoundFunctionExpression &expr, unique_ptr *ptr) override; +}; + +/// Rebind overridden spatial calls in join conditions to spatial's originals. +void RestoreSpatialOverrides(ClientContext &context, LogicalOperator &plan); diff --git a/vortex-duckdb/cpp/scalar_fn_pushdown.cpp b/vortex-duckdb/cpp/scalar_fn_pushdown.cpp index b3986ef49cc..bcf23496443 100644 --- a/vortex-duckdb/cpp/scalar_fn_pushdown.cpp +++ b/vortex-duckdb/cpp/scalar_fn_pushdown.cpp @@ -2,9 +2,6 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors #include "scalar_fn_pushdown.hpp" -#include "duckdb/catalog/catalog.hpp" -#include "duckdb/catalog/catalog_entry/scalar_function_catalog_entry.hpp" -#include "duckdb/function/function_binder.hpp" #include "duckdb/planner/operator/logical_projection.hpp" #include "duckdb/planner/operator/logical_get.hpp" @@ -122,67 +119,3 @@ ScalarFnCollect::ScalarFnCollect(Analyses &analyses, const Projections &projecti ScalarFnReplace::ScalarFnReplace(Analyses &analyses, const Projections &projections) : analyses(analyses), projections(projections) { } - -namespace { - -// See RestoreStDWithin: rebinding through spatial's own entry lets spatial build its own bind -// data, so nothing here depends on its internals. -class StDWithinRestore final : public LogicalOperatorVisitor { -public: - explicit StDWithinRestore(ClientContext &context) : context(context) { - } - - // Restore join conditions, filters must keep the radius visible so - // DuckDB's filter pushdown can offer them to Vortex scans. - void VisitOperator(LogicalOperator &op) override { - using enum LogicalOperatorType; - switch (op.type) { - case LOGICAL_COMPARISON_JOIN: - case LOGICAL_ANY_JOIN: - case LOGICAL_DELIM_JOIN: - case LOGICAL_ASOF_JOIN: - VisitOperatorExpressions(op); - break; - default: - break; - } - VisitOperatorChildren(op); - } - - ExpressionPtr VisitReplace(BoundFunctionExpression &expr, ExpressionPtr *) override { - if (expr.children.size() != 3 || expr.function.name != "st_dwithin") { - return nullptr; // Not the override's shape: keep it and descend into its children. - } - // The system catalog holds spatial's original; the user-catalog override cannot shadow - // this lookup. - auto original = Catalog::GetSystemCatalog(context).GetEntry( - context, - DEFAULT_SCHEMA, - "st_dwithin", - OnEntryNotFound::RETURN_NULL); - if (!original) { - return nullptr; - } - vector children; - children.reserve(expr.children.size()); - for (const auto &child : expr.children) { - children.push_back(child->Copy()); - } - ErrorData error; - FunctionBinder binder(context); - auto bound = binder.BindScalarFunction(*original, std::move(children), error); - if (!bound) { - return nullptr; // No matching overload: keep the executable 3-argument form. - } - return bound; - } - -private: - ClientContext &context; -}; - -} // namespace - -void RestoreStDWithin(ClientContext &context, LogicalOperator &plan) { - StDWithinRestore(context).VisitOperator(plan); -} diff --git a/vortex-duckdb/cpp/spatial_overrides.cpp b/vortex-duckdb/cpp/spatial_overrides.cpp new file mode 100644 index 00000000000..28f095a7b89 --- /dev/null +++ b/vortex-duckdb/cpp/spatial_overrides.cpp @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#include "spatial_overrides.hpp" + +#include "spatial_overrides.h" + +#include "duckdb/catalog/catalog.hpp" +#include "duckdb/catalog/catalog_entry/scalar_function_catalog_entry.hpp" +#include "duckdb/common/error_data.hpp" +#include "duckdb/function/function_binder.hpp" +#include "duckdb/function/scalar_function.hpp" +#include "duckdb/logging/logger.hpp" +#include "duckdb/main/capi/capi_internal.hpp" +#include "duckdb/main/connection.hpp" +#include "duckdb/main/database_manager.hpp" +#include "duckdb/parser/parsed_data/create_scalar_function_info.hpp" +#include "duckdb/planner/expression/bound_function_expression.hpp" +#include "duckdb/planner/logical_operator_visitor.hpp" +#include "duckdb/transaction/meta_transaction.hpp" + +#include +#include + +/// A spatial function Vortex shadows so that its filters can push into Vortex scans. +struct SpatialOverride { + const char *name; + idx_t arity; + void (*tweak)(ScalarFunction &); +}; + +/// Drop spatial's bind so the filter keeps the radius visible as `children[2]`. +static void DropBind(ScalarFunction &fn) { + fn.bind = nullptr; +} + +/// Clear the error mode so the filter pushes through view projections. +static void ClearErrorMode(ScalarFunction &fn) { + fn.SetErrorMode(FunctionErrors::CANNOT_ERROR); +} + +static constexpr SpatialOverride SPATIAL_OVERRIDES[] = { + {"st_dwithin", 3, DropBind}, + {"st_intersects", 2, ClearErrorMode}, + {"st_contains", 2, ClearErrorMode}, + {"st_within", 2, ClearErrorMode}, +}; + +/// Apply one override, later calls to the function bind to the pushable copy instead of +/// spatial's original. +static void RegisterSpatialOverride(ClientContext &context, const SpatialOverride &fn_override) { + auto &system = Catalog::GetSystemCatalog(context); + auto entry = system.GetEntry(context, + DEFAULT_SCHEMA, + fn_override.name, + OnEntryNotFound::RETURN_NULL); + if (!entry) { + return; + } + ScalarFunctionSet set(fn_override.name); + for (const auto &overload : entry->functions.functions) { + ScalarFunction copy = overload; + fn_override.tweak(copy); + set.AddFunction(copy); + } + CreateScalarFunctionInfo info(std::move(set)); + info.on_conflict = OnCreateConflict::REPLACE_ON_CONFLICT; + + info.internal = false; + // Register in the default database's catalog: unqualified calls resolve there before the + // system catalog, so the copy shadows spatial's entry. + auto &catalog = Catalog::GetCatalog(context, DatabaseManager::GetDefaultDatabase(context)); + // A file-backed catalog rejects writes unless the database is marked modified; the mark is + // harmless here because function entries are never persisted to disk. + MetaTransaction::Get(context).ModifyDatabase(catalog.GetAttached(), DatabaseModificationType()); + catalog.CreateFunction(context, info); +} + +/// Apply every override in `SPATIAL_OVERRIDES` in one transaction. +extern "C" duckdb_state duckdb_vx_register_spatial_overrides(duckdb_database ffi_db) { + if (!ffi_db) { + return DuckDBError; + } + const DatabaseWrapper &wrapper = *reinterpret_cast(ffi_db); + DatabaseInstance &db = *wrapper.database->instance; + try { + Connection conn(db); + ClientContext &context = *conn.context; + context.RunFunctionInTransaction([&]() { + for (const auto &fn_override : SPATIAL_OVERRIDES) { + RegisterSpatialOverride(context, fn_override); + } + }); + } catch (const std::exception &e) { + ErrorData data(e); + DUCKDB_LOG_ERROR(db, "Failed to register the spatial overrides:\t" + data.Message()); + return DuckDBError; + } + return DuckDBSuccess; +} + +SpatialOverrideRestore::SpatialOverrideRestore(ClientContext &context) : context(context) { +} + +void SpatialOverrideRestore::VisitOperator(LogicalOperator &op) { + using enum LogicalOperatorType; + switch (op.type) { + case LOGICAL_COMPARISON_JOIN: + case LOGICAL_ANY_JOIN: + case LOGICAL_DELIM_JOIN: + case LOGICAL_ASOF_JOIN: + VisitOperatorExpressions(op); + break; + default: + break; + } + VisitOperatorChildren(op); +} + +unique_ptr SpatialOverrideRestore::VisitReplace(BoundFunctionExpression &expr, + unique_ptr *) { + const bool overridden = + std::any_of(std::begin(SPATIAL_OVERRIDES), + std::end(SPATIAL_OVERRIDES), + [&](const SpatialOverride &o) { + return expr.function.name == o.name && expr.children.size() == o.arity; + }); + if (!overridden) { + return nullptr; // Not an overridden call: leave it as is. + } + // Spatial's original lives in the system catalog, where the override cannot shadow it. + auto original = + Catalog::GetSystemCatalog(context).GetEntry(context, + DEFAULT_SCHEMA, + expr.function.name, + OnEntryNotFound::RETURN_NULL); + if (!original) { + return nullptr; + } + // Rebind a copy of the call's arguments through the original function. + vector> children; + children.reserve(expr.children.size()); + for (const auto &child : expr.children) { + children.push_back(child->Copy()); + } + ErrorData error; + FunctionBinder binder(context); + auto bound = binder.BindScalarFunction(*original, std::move(children), error); + if (!bound) { + return nullptr; // No matching overload: the override call still executes, keep it. + } + return bound; +} + +void RestoreSpatialOverrides(ClientContext &context, LogicalOperator &plan) { + SpatialOverrideRestore(context).VisitOperator(plan); +} diff --git a/vortex-duckdb/cpp/vortex_duckdb.cpp b/vortex-duckdb/cpp/vortex_duckdb.cpp index 8b023c960dd..05ee9098583 100644 --- a/vortex-duckdb/cpp/vortex_duckdb.cpp +++ b/vortex-duckdb/cpp/vortex_duckdb.cpp @@ -5,6 +5,7 @@ #include "data.hpp" #include "error.hpp" #include "scalar_fn_pushdown.hpp" +#include "spatial_overrides.hpp" #include "cast_pushdown.hpp" #include "vortex_duckdb.h" @@ -276,7 +277,7 @@ static void VortexOptimizeFunction(OptimizerExtensionInput &input, unique_ptr &plan) { - RestoreStDWithin(input.context, *plan); + RestoreSpatialOverrides(input.context, *plan); } struct VortexOptimizerExtension final : OptimizerExtension { diff --git a/vortex-duckdb/src/convert/expr.rs b/vortex-duckdb/src/convert/expr.rs index 9acd763bf30..c8c40e20af6 100644 --- a/vortex-duckdb/src/convert/expr.rs +++ b/vortex-duckdb/src/convert/expr.rs @@ -60,7 +60,9 @@ use vortex_geo::extension::Point; use vortex_geo::extension::Polygon; use vortex_geo::extension::WellKnownBinary; use vortex_geo::extension::native_geometry_scalar_from_wkb; +use vortex_geo::scalar_fn::contains::GeoContains; use vortex_geo::scalar_fn::distance::GeoDistance; +use vortex_geo::scalar_fn::intersects::GeoIntersects; use crate::convert::dtype::FromLogicalType; use crate::cpp::DUCKDB_TYPE; @@ -116,13 +118,14 @@ struct ConvertCtx<'a> { fields: Option<&'a [DuckdbField]>, } -/// Whether `name` is a native geometry column of the scan. The pushed `GeoDistance` cannot -/// evaluate `vortex.geo.wkb` columns, which also surface to DuckDB as `GEOMETRY`. +/// Whether `name` is a non-nullable native geometry column of the scan. The pushed geo kernels +/// reject nullable operands and cannot evaluate `vortex.geo.wkb` columns, which also surface to +/// DuckDB as `GEOMETRY`. fn is_native_geo_column(fields: Option<&[DuckdbField]>, name: &str) -> bool { fields .into_iter() .flatten() - .filter(|field| field.name == name) + .filter(|field| field.name == name && !field.dtype.is_nullable()) .any(|field| match field.dtype.as_extension_opt() { Some(ext) => { ext.is::() @@ -166,48 +169,72 @@ fn geo_operand( } } +/// Lower all geometry operands of a geo function. Returns `None`, skipping the push, when any +/// operand is neither a constant geometry nor a native geometry column. +fn geo_operands( + children: &[&duckdb::ExpressionRef], + ctx: ConvertCtx<'_>, +) -> VortexResult>> { + children + .iter() + .map(|child| geo_operand(child, ctx)) + .collect() +} + /// Lower geo UDFs to native Vortex geo ops so the work runs in the scan. `None` otherwise. fn try_from_geo_function( name: &str, func: &BoundFunction, ctx: ConvertCtx<'_>, ) -> VortexResult> { - // Catch-all for every bound function: reject non-geo names before touching the children. - if !is_geo_function(name) { - return Ok(None); - } let children: Vec<_> = func.children().collect(); let expr = match name.to_ascii_lowercase().as_str() { - // The Vortex override keeps the radius as `children[2]`; see - // `duckdb_vx_register_st_dwithin_override`. + // Spatial's own st_dwithin folds the radius into bind data; the override + // (cpp/spatial_overrides.cpp) keeps it visible here as `children[2]`. "st_dwithin" => { if children.len() != 3 { return Ok(None); } - let Some(a) = geo_operand(children[0], ctx)? else { - return Ok(None); - }; - let Some(b) = geo_operand(children[1], ctx)? else { + let Some(operands) = geo_operands(&children[..2], ctx)? else { return Ok(None); }; // A non-constant radius is left for DuckDB to evaluate. let Some(distance) = from_bound_f64(children[2])? else { return Ok(None); }; - let geo_distance = GeoDistance.new_expr(ScalarEmptyOptions, [a, b]); + let geo_distance = GeoDistance.new_expr(ScalarEmptyOptions, operands); Binary.new_expr(Operator::Lte, [geo_distance, lit(distance)]) } "st_distance" => { if children.len() != 2 { return Ok(None); } - let Some(a) = geo_operand(children[0], ctx)? else { + let Some(operands) = geo_operands(&children, ctx)? else { return Ok(None); }; - let Some(b) = geo_operand(children[1], ctx)? else { + GeoDistance.new_expr(ScalarEmptyOptions, operands) + } + "st_intersects" => { + if children.len() != 2 { + return Ok(None); + } + let Some(operands) = geo_operands(&children, ctx)? else { return Ok(None); }; - GeoDistance.new_expr(ScalarEmptyOptions, [a, b]) + GeoIntersects.new_expr(ScalarEmptyOptions, operands) + } + containment @ ("st_contains" | "st_within") => { + if children.len() != 2 { + return Ok(None); + } + let Some(mut operands) = geo_operands(&children, ctx)? else { + return Ok(None); + }; + // `st_within(a, b)` is `st_contains(b, a)`; both lower to the contains kernel. + if containment == "st_within" { + operands.swap(0, 1); + } + GeoContains.new_expr(ScalarEmptyOptions, operands) } _ => return Ok(None), }; @@ -215,15 +242,6 @@ fn try_from_geo_function( Ok(Some(expr)) } -/// Geo UDFs that `try_from_geo_function` lowers - shared with `can_push_expression` so the pushable -/// and lowered sets can't drift. -fn is_geo_function(name: &str) -> bool { - matches!( - name.to_ascii_lowercase().as_str(), - "st_distance" | "st_dwithin" - ) -} - fn try_from_bound_function( func: &BoundFunction, ctx: ConvertCtx<'_>, diff --git a/vortex-duckdb/src/duckdb/database.rs b/vortex-duckdb/src/duckdb/database.rs index e7033b75ee2..1d258f05751 100644 --- a/vortex-duckdb/src/duckdb/database.rs +++ b/vortex-duckdb/src/duckdb/database.rs @@ -91,12 +91,13 @@ impl DatabaseRef { Ok(()) } - /// Shadow `ST_DWithin` with a radius-visible copy so its filters push into the Vortex scan - /// (see `duckdb_vx_register_st_dwithin_override`). No-op when `spatial` is not loaded. - pub fn register_st_dwithin_override(&self) -> VortexResult<()> { + /// Shadow the spatial functions that block filter pushdown with pushable copies; see + /// cpp/spatial_overrides.cpp for the list. Call after `LOAD spatial`; does nothing when + /// spatial is not loaded. + pub fn register_spatial_overrides(&self) -> VortexResult<()> { duckdb_try!( - unsafe { cpp::duckdb_vx_register_st_dwithin_override(self.as_ptr()) }, - "Failed to register the ST_DWithin override" + unsafe { cpp::duckdb_vx_register_spatial_overrides(self.as_ptr()) }, + "Failed to register the spatial function overrides" ); Ok(()) } diff --git a/vortex-duckdb/src/e2e_test/geo_pushdown_test.rs b/vortex-duckdb/src/e2e_test/geo_pushdown_test.rs new file mode 100644 index 00000000000..f058de20d47 --- /dev/null +++ b/vortex-duckdb/src/e2e_test/geo_pushdown_test.rs @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Pushdown tests for the geo scalar functions: every lowered filter must reach the Vortex +//! scan on a direct file scan and through a view. + +use num_traits::AsPrimitive; +use rstest::rstest; +use tempfile::NamedTempFile; +use vortex::array::IntoArray; +use vortex::array::arrays::PrimitiveArray; +use vortex::array::arrays::StructArray; +use vortex::file::WriteOptionsSessionExt; +use vortex::io::runtime::BlockingRuntime; +use vortex_array::arrays::ExtensionArray; +use vortex_array::dtype::extension::ExtDType; +use vortex_geo::extension::GeoMetadata; +use vortex_geo::extension::Point; + +use crate::RUNTIME; +use crate::SESSION; +use crate::cpp::duckdb_string_t; +use crate::duckdb::Connection; +use crate::duckdb::Database; + +/// The test points; the filters below state how many of them they match. +const POINTS: [(f64, f64); 5] = [ + (1.0, 1.0), + (4.0, 4.0), + (10.0, 10.0), + (-1.0, 5.0), + (2.0, 3.0), +]; + +/// Matches three of [`POINTS`]: two inside the polygon and one on its boundary. +const ST_INTERSECTS_FILTER: &str = + "ST_Intersects(geometry, ST_GeomFromText('POLYGON((0 0, 4 0, 4 4, 0 4, 0 0))'))"; + +/// Matches two of [`POINTS`]: those closer than `3` to `(1, 1)`. +const ST_DISTANCE_FILTER: &str = "ST_Distance(geometry, ST_GeomFromText('POINT (1 1)')) < 3.0"; + +/// Matches the same two points as [`ST_DISTANCE_FILTER`], phrased as `ST_DWithin`. +const ST_DWITHIN_FILTER: &str = "ST_DWithin(geometry, ST_GeomFromText('POINT (1 1)'), 3.0)"; + +/// Matches two of [`POINTS`]: the boundary point of [`ST_INTERSECTS_FILTER`]'s polygon is not +/// contained (OGC contains excludes the boundary). +const ST_CONTAINS_FILTER: &str = + "ST_Contains(ST_GeomFromText('POLYGON((0 0, 4 0, 4 4, 0 4, 0 0))'), geometry)"; + +/// Matches the same two points as [`ST_CONTAINS_FILTER`], phrased as `ST_Within`. +const ST_WITHIN_FILTER: &str = + "ST_Within(geometry, ST_GeomFromText('POLYGON((0 0, 4 0, 4 4, 0 4, 0 0))'))"; + +/// A vortex file whose single column `geometry` holds [`POINTS`] as native `Point`s. +fn native_point_file() -> NamedTempFile { + RUNTIME.block_on(async { + let xs = PrimitiveArray::from_iter(POINTS.map(|(x, _)| x)).into_array(); + let ys = PrimitiveArray::from_iter(POINTS.map(|(_, y)| y)).into_array(); + let storage = StructArray::from_fields(&[("x", xs), ("y", ys)]) + .unwrap() + .into_array(); + let dtype = + ExtDType::::try_new(GeoMetadata { crs: None }, storage.dtype().clone()).unwrap(); + let points = ExtensionArray::new(dtype.erased(), storage).into_array(); + + let file = NamedTempFile::with_suffix(".vortex").unwrap(); + let table = StructArray::from_fields(&[("geometry", points)]).unwrap(); + let mut writer = async_fs::File::create(&file).await.unwrap(); + SESSION + .write_options() + .write(&mut writer, table.into_array().to_array_stream()) + .await + .unwrap(); + file + }) +} + +/// An in-memory database with the Vortex extension initialized, `spatial` loaded, and the +/// spatial overrides registered. +fn spatial_database() -> (Database, Connection) { + let db = Database::open_in_memory().unwrap(); + db.register_vortex_scan_replacement().unwrap(); + crate::initialize(&db).unwrap(); + let conn = db.connect().unwrap(); + conn.query("INSTALL spatial; LOAD spatial;").unwrap(); + // Must follow `LOAD spatial`: the overrides copy spatial's catalog entries. + db.register_spatial_overrides().unwrap(); + (db, conn) +} + +/// Read back the single `i64` of a one-row, one-column query. +fn query_i64(conn: &Connection, query: &str) -> i64 { + let result = conn.query(query).unwrap(); + let chunk = result.into_iter().next().unwrap(); + chunk + .get_vector(0) + .as_slice_with_len::(chunk.len().as_())[0] +} + +/// The `EXPLAIN` physical plan of `query` as one string. +fn explain_plan(conn: &Connection, query: &str) -> String { + let explain = conn.query(&format!("EXPLAIN {query}")).unwrap(); + let mut plan = String::new(); + for mut chunk in explain { + let len = chunk.len().as_(); + let vec = chunk.get_vector_mut(1); + for value in unsafe { vec.as_slice_mut::(len) } { + let slice: &[u8] = unsafe { + std::slice::from_raw_parts( + crate::cpp::duckdb_string_t_data(&raw mut *value) as _, + crate::cpp::duckdb_string_t_length(*value) as usize, + ) + }; + plan.push_str(&String::from_utf8_lossy(slice)); + } + } + plan +} + +/// Assert that filtering `table` with `filter` counts `expected` points and leaves no `FILTER` +/// operator in the plan, i.e. the predicate ran inside the scan. +fn assert_pushed(conn: &Connection, table: &str, filter: &str, expected: i64) { + let query = format!("SELECT count(*) FROM {table} WHERE {filter}"); + assert_eq!(query_i64(conn, &query), expected); + let plan = explain_plan(conn, &query); + assert!(!plan.contains("FILTER"), "filter was not pushed:\n{plan}"); +} + +/// Every lowered geo filter pushes on a direct file scan. +#[rstest] +#[case::st_intersects(ST_INTERSECTS_FILTER, 3)] +#[case::st_distance(ST_DISTANCE_FILTER, 2)] +#[case::st_dwithin(ST_DWITHIN_FILTER, 2)] +#[case::st_contains(ST_CONTAINS_FILTER, 2)] +#[case::st_within(ST_WITHIN_FILTER, 2)] +fn geo_filter_pushes_on_file_scan(#[case] filter: &str, #[case] expected: i64) { + let file = native_point_file(); + let (_db, conn) = spatial_database(); + let table = format!("'{}'", file.path().to_string_lossy()); + assert_pushed(&conn, &table, filter, expected); +} + +/// Every lowered geo filter pushes through a view; without the overrides, DuckDB would keep +/// `ST_Intersects` (can-throw) above the view's projection and hide `ST_DWithin`'s radius. +#[rstest] +#[case::st_intersects(ST_INTERSECTS_FILTER, 3)] +#[case::st_distance(ST_DISTANCE_FILTER, 2)] +#[case::st_dwithin(ST_DWITHIN_FILTER, 2)] +#[case::st_contains(ST_CONTAINS_FILTER, 2)] +#[case::st_within(ST_WITHIN_FILTER, 2)] +fn geo_filter_pushes_through_view(#[case] filter: &str, #[case] expected: i64) { + let file = native_point_file(); + let (_db, conn) = spatial_database(); + conn.query(&format!( + "CREATE VIEW points_v AS SELECT * FROM read_vortex('{}')", + file.path().to_string_lossy() + )) + .unwrap(); + assert_pushed(&conn, "points_v", filter, expected); +} diff --git a/vortex-duckdb/src/e2e_test/mod.rs b/vortex-duckdb/src/e2e_test/mod.rs index 34182bd133d..5d41df51220 100644 --- a/vortex-duckdb/src/e2e_test/mod.rs +++ b/vortex-duckdb/src/e2e_test/mod.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +#[cfg(test)] +mod geo_pushdown_test; #[cfg(test)] mod vortex_scan_test; diff --git a/vortex-geo/Cargo.toml b/vortex-geo/Cargo.toml index bfb6a639742..36a26bfd835 100644 --- a/vortex-geo/Cargo.toml +++ b/vortex-geo/Cargo.toml @@ -30,6 +30,7 @@ wkb = { workspace = true } [dev-dependencies] rstest = { workspace = true } +vortex-array = { workspace = true, features = ["_test-harness"] } [lints] workspace = true diff --git a/vortex-geo/src/extension/mod.rs b/vortex-geo/src/extension/mod.rs index 73f6b6317b0..663d10820e0 100644 --- a/vortex-geo/src/extension/mod.rs +++ b/vortex-geo/src/extension/mod.rs @@ -43,15 +43,45 @@ use vortex_array::IntoArray; use vortex_array::arrays::ConstantArray; use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::extension::ExtensionArrayExt; +use vortex_array::dtype::DType; use vortex_array::dtype::extension::ExtDType; use vortex_array::dtype::extension::ExtVTable; use vortex_array::scalar::Scalar; use vortex_arrow::FromArrowArray; use vortex_error::VortexResult; use vortex_error::vortex_bail; +use vortex_error::vortex_ensure; use vortex_error::vortex_err; pub use wkb::*; +/// Whether `dtype` is one of the native geometry extension types the geo kernels operate on. +fn is_native_geometry(dtype: &DType) -> bool { + dtype.as_extension_opt().is_some_and(|ext| { + ext.is::() + || ext.is::() + || ext.is::() + || ext.is::() + || ext.is::() + || ext.is::() + }) +} + +/// Validate the operands of a geo scalar function: each must be a native geometry type (so the +/// kernel can decode it) and non-nullable (geometry arrays never carry nulls). +pub(crate) fn validate_geometry_operands(dtypes: &[DType]) -> VortexResult<()> { + for dtype in dtypes { + vortex_ensure!( + is_native_geometry(dtype), + "geo: operand {dtype} is not a native geometry type" + ); + vortex_ensure!( + !dtype.is_nullable(), + "geo: nullable operand {dtype} is unsupported" + ); + } + Ok(()) +} + /// Decode a native geometry column to `geo_types`. A non-geometry operand is an error. pub(crate) fn geometries( array: &ArrayRef, diff --git a/vortex-geo/src/lib.rs b/vortex-geo/src/lib.rs index 4ddb8c25c3b..ad0c3c503e2 100644 --- a/vortex-geo/src/lib.rs +++ b/vortex-geo/src/lib.rs @@ -15,7 +15,9 @@ use crate::extension::MultiPolygon; use crate::extension::Point; use crate::extension::Polygon; use crate::extension::WellKnownBinary; +use crate::scalar_fn::contains::GeoContains; use crate::scalar_fn::distance::GeoDistance; +use crate::scalar_fn::intersects::GeoIntersects; pub mod extension; pub mod scalar_fn; @@ -50,5 +52,7 @@ pub fn initialize(session: &VortexSession) { session.arrow().register_importer(Arc::new(MultiPolygon)); // Register the geometry scalar functions. + session.scalar_fns().register(GeoContains); session.scalar_fns().register(GeoDistance); + session.scalar_fns().register(GeoIntersects); } diff --git a/vortex-geo/src/scalar_fn/contains.rs b/vortex-geo/src/scalar_fn/contains.rs new file mode 100644 index 00000000000..5638c224e20 --- /dev/null +++ b/vortex-geo/src/scalar_fn/contains.rs @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! `ST_Contains`: OGC containment test between two native geometries. + +use geo::Contains; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::Constant; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::ScalarFnArray; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::scalar::Scalar; +use vortex_array::scalar_fn::Arity; +use vortex_array::scalar_fn::ChildName; +use vortex_array::scalar_fn::EmptyOptions; +use vortex_array::scalar_fn::ExecutionArgs; +use vortex_array::scalar_fn::ScalarFnId; +use vortex_array::scalar_fn::ScalarFnVTable; +use vortex_array::scalar_fn::TypedScalarFnInstance; +use vortex_error::VortexResult; +use vortex_error::vortex_ensure_eq; +use vortex_session::VortexSession; +use vortex_session::registry::CachedId; + +use crate::extension::geometries; +use crate::extension::single_geometry; +use crate::extension::validate_geometry_operands; + +/// OGC `ST_Contains` between two native geometry operands, each a column or a constant +/// literal: true where operand `b` lies completely inside operand `a` (boundary contact alone +/// does not count). Containment is not symmetric; the operand order is significant. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct GeoContains; + +impl GeoContains { + /// A lazy `ScalarFnArray` computing per-row whether operand `a` contains operand `b`; + /// either may be constant. The output length is taken from `a`. + pub fn try_new_array(a: ArrayRef, b: ArrayRef) -> VortexResult { + ScalarFnArray::try_new( + TypedScalarFnInstance::new(GeoContains, EmptyOptions).erased(), + vec![a, b], + ) + } +} + +impl ScalarFnVTable for GeoContains { + type Options = EmptyOptions; + + fn id(&self) -> ScalarFnId { + static ID: CachedId = CachedId::new("vortex.geo.contains"); + *ID + } + + fn serialize(&self, _: &Self::Options) -> VortexResult>> { + Ok(Some(vec![])) + } + + fn deserialize(&self, _: &[u8], _: &VortexSession) -> VortexResult { + Ok(EmptyOptions) + } + + fn arity(&self, _: &Self::Options) -> Arity { + Arity::Exact(2) + } + + fn child_name(&self, _: &Self::Options, child_idx: usize) -> ChildName { + match child_idx { + 0 => ChildName::from("a"), + 1 => ChildName::from("b"), + _ => unreachable!("contains has exactly two children"), + } + } + + fn return_dtype(&self, _: &Self::Options, dtypes: &[DType]) -> VortexResult { + validate_geometry_operands(dtypes)?; + Ok(DType::Bool(Nullability::NonNullable)) + } + + fn execute( + &self, + _: &Self::Options, + args: &dyn ExecutionArgs, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + let a = args.get(0)?; + let b = args.get(1)?; + // Containment is not symmetric: `a` is always the container and `b` the contained. + match (a.as_opt::(), b.as_opt::()) { + (Some(qa), Some(qb)) => { + let ga = single_geometry(qa.scalar(), ctx)?; + let gb = single_geometry(qb.scalar(), ctx)?; + Ok(ConstantArray::new( + Scalar::bool(ga.contains(&gb), Nullability::NonNullable), + a.len(), + ) + .into_array()) + } + (Some(qa), None) => constant_contains_column(qa.scalar(), &b, ctx), + (None, Some(qb)) => column_contains_constant(&a, qb.scalar(), ctx), + (None, None) => { + vortex_ensure_eq!( + a.len(), + b.len(), + "geo contains: operand length mismatch {} vs {}", + a.len(), + b.len() + ); + let ag = geometries(&a, ctx)?; + let bg = geometries(&b, ctx)?; + let hits = ag.iter().zip(&bg).map(|(x, y)| x.contains(y)); + Ok(BoolArray::from_iter(hits).into_array()) + } + } + } +} + +/// Whether the constant `container` contains each row of `contained`. +fn constant_contains_column( + container: &Scalar, + contained: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let container = single_geometry(container, ctx)?; + let geoms = geometries(contained, ctx)?; + let hits = geoms.iter().map(|g| container.contains(g)); + Ok(BoolArray::from_iter(hits).into_array()) +} + +/// Whether each row of `container` contains the constant `contained`. +fn column_contains_constant( + container: &ArrayRef, + contained: &Scalar, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let contained = single_geometry(contained, ctx)?; + let geoms = geometries(container, ctx)?; + let hits = geoms.iter().map(|g| g.contains(&contained)); + Ok(BoolArray::from_iter(hits).into_array()) +} + +#[cfg(test)] +mod tests { + use geo_types::Geometry; + use geo_types::LineString; + use geo_types::Point; + use geo_types::Polygon; + use rstest::rstest; + use vortex_array::ArrayRef; + use vortex_array::Canonical; + use vortex_array::ExecutionCtx; + use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::arrays::BoolArray; + use vortex_array::arrays::ConstantArray; + use vortex_array::assert_arrays_eq; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::scalar_fn::EmptyOptions; + use vortex_array::scalar_fn::ScalarFnVTable; + use vortex_error::VortexResult; + use vortex_error::vortex_err; + use wkb::writer::WriteOptions; + + use super::GeoContains; + use crate::test_harness::point_column; + + /// A rectangle polygon with corners `(x0, y0)` and `(x1, y1)`, no holes. + fn rect_polygon(x0: f64, y0: f64, x1: f64, y1: f64) -> Polygon { + Polygon::new( + LineString::from(vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)]), + vec![], + ) + } + + /// A constant column of length `len`, every row the native form of `geometry`. + fn geometry_constant(geometry: &Geometry, len: usize) -> VortexResult { + let mut buf = Vec::new(); + wkb::writer::write_geometry(&mut buf, geometry, &WriteOptions::default()) + .map_err(|e| vortex_err!("writing WKB failed: {e}"))?; + let scalar = crate::extension::native_geometry_scalar_from_wkb(&buf)? + .ok_or_else(|| vortex_err!("unsupported geometry type"))?; + Ok(ConstantArray::new(scalar, len).into_array()) + } + + /// Materialize `array` so it is no longer a `Constant`, forcing the non-constant kernel + /// paths. + fn materialize(array: ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + Ok(array.execute::(ctx)?.into_array()) + } + + /// Execute `GeoContains(a, b)` and assert the per-row verdicts equal `expected`. + fn assert_contains( + a: ArrayRef, + b: ArrayRef, + expected: impl IntoIterator, + ) -> VortexResult<()> { + let session = vortex_array::array_session(); + let mut ctx = session.create_execution_ctx(); + let contains = GeoContains::try_new_array(a, b)?.into_array(); + assert_arrays_eq!(contains, BoolArray::from_iter(expected), &mut ctx); + Ok(()) + } + + // The tests cover each `execute` dispatch arm in match order, then the edge cases. + + /// Constant vs constant: a polygon contains a nested polygon but not a partially + /// overlapping or disjoint one; every output row carries the same verdict. + #[rstest] + #[case::nested(rect_polygon(1.0, 1.0, 3.0, 3.0), true)] + #[case::overlapping(rect_polygon(2.0, 2.0, 6.0, 6.0), false)] + #[case::disjoint(rect_polygon(20.0, 20.0, 24.0, 24.0), false)] + fn constant_vs_constant_polygons( + #[case] other: Polygon, + #[case] expected: bool, + ) -> VortexResult<()> { + let container = geometry_constant(&Geometry::Polygon(rect_polygon(0.0, 0.0, 4.0, 4.0)), 3)?; + let other = geometry_constant(&Geometry::Polygon(other), 3)?; + assert_contains(container, other, [expected; 3]) + } + + /// Partially overlapping polygons contain each other in neither direction. + #[test] + fn overlapping_polygons_contain_neither_way() -> VortexResult<()> { + let a = geometry_constant(&Geometry::Polygon(rect_polygon(0.0, 0.0, 4.0, 4.0)), 2)?; + let b = geometry_constant(&Geometry::Polygon(rect_polygon(2.0, 2.0, 6.0, 6.0)), 2)?; + assert_contains(a.clone(), b.clone(), [false; 2])?; + assert_contains(b, a, [false; 2]) + } + + /// Containment is not symmetric: a polygon contains an interior point, but the point does + /// not contain the polygon. + #[test] + fn contains_is_asymmetric() -> VortexResult<()> { + let polygon = geometry_constant(&Geometry::Polygon(rect_polygon(0.0, 0.0, 4.0, 4.0)), 2)?; + let point = geometry_constant(&Geometry::Point(Point::new(2.0, 2.0)), 2)?; + assert_contains(polygon.clone(), point.clone(), [true; 2])?; + assert_contains(point, polygon, [false; 2]) + } + + /// Constant polygon vs point column: a strictly interior point is contained; points outside + /// or exactly on the boundary are not (OGC contains excludes the boundary). + #[test] + fn constant_polygon_vs_point_column() -> VortexResult<()> { + let container = geometry_constant(&Geometry::Polygon(rect_polygon(0.0, 0.0, 4.0, 4.0)), 3)?; + let points = point_column(vec![2.0, 10.0, 0.0], vec![2.0, 10.0, 2.0])?; + assert_contains(container, points, [true, false, false]) + } + + /// Polygon column vs constant point: only the polygon around the point contains it. + #[test] + fn polygon_column_vs_constant_point() -> VortexResult<()> { + let session = vortex_array::array_session(); + let mut ctx = session.create_execution_ctx(); + + let around = materialize( + geometry_constant(&Geometry::Polygon(rect_polygon(0.0, 0.0, 4.0, 4.0)), 2)?, + &mut ctx, + )?; + let away = materialize( + geometry_constant(&Geometry::Polygon(rect_polygon(20.0, 20.0, 24.0, 24.0)), 2)?, + &mut ctx, + )?; + let point = geometry_constant(&Geometry::Point(Point::new(2.0, 2.0)), 2)?; + + assert_contains(around, point.clone(), [true; 2])?; + assert_contains(away, point, [false; 2]) + } + + /// Column vs column pairs rows: each polygon row is tested against the point row at the + /// same position. + #[test] + fn polygon_column_vs_point_column() -> VortexResult<()> { + let session = vortex_array::array_session(); + let mut ctx = session.create_execution_ctx(); + + let polygons = materialize( + geometry_constant(&Geometry::Polygon(rect_polygon(0.0, 0.0, 4.0, 4.0)), 2)?, + &mut ctx, + )?; + let points = point_column(vec![2.0, 10.0], vec![2.0, 10.0])?; + assert_contains(polygons, points, [true, false]) + } + + /// Geometry arrays are never nullable, so a nullable operand dtype is rejected. + #[test] + fn nullable_operand_is_rejected() -> VortexResult<()> { + let dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + let result = GeoContains.return_dtype(&EmptyOptions, &[dtype.as_nullable(), dtype]); + assert!(result.is_err()); + Ok(()) + } + + /// A non-geometry operand dtype is rejected up front, before execution. + #[test] + fn non_geometry_operand_is_rejected() -> VortexResult<()> { + let geo = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + let numeric = DType::Primitive(PType::I32, Nullability::NonNullable); + let result = GeoContains.return_dtype(&EmptyOptions, &[geo, numeric]); + assert!(result.is_err()); + Ok(()) + } +} diff --git a/vortex-geo/src/scalar_fn/distance.rs b/vortex-geo/src/scalar_fn/distance.rs index b894946eecb..2e196706ec2 100644 --- a/vortex-geo/src/scalar_fn/distance.rs +++ b/vortex-geo/src/scalar_fn/distance.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -//! `ST_Distance`: planar (Euclidean) distance between two native geometries via the `geo` crate. +//! `ST_Distance`: planar (Euclidean) distance between two native geometries. use geo::Distance; use geo::Euclidean; @@ -10,12 +10,8 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::Constant; use vortex_array::arrays::ConstantArray; -use vortex_array::arrays::ExtensionArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::ScalarFnArray; -use vortex_array::arrays::StructArray; -use vortex_array::arrays::extension::ExtensionArrayExt; -use vortex_array::arrays::struct_::StructArrayExt; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; @@ -28,17 +24,16 @@ use vortex_array::scalar_fn::ScalarFnId; use vortex_array::scalar_fn::ScalarFnVTable; use vortex_array::scalar_fn::TypedScalarFnInstance; use vortex_error::VortexResult; -use vortex_error::vortex_ensure; +use vortex_error::vortex_ensure_eq; use vortex_session::VortexSession; use vortex_session::registry::CachedId; -use crate::extension::Point; -use crate::extension::coordinate::coordinate_from_struct; use crate::extension::geometries; use crate::extension::single_geometry; +use crate::extension::validate_geometry_operands; -/// Planar (Euclidean) `ST_Distance` (no geodesic correction) between two native geometry operands. -/// Each is a column or a constant literal; `geo` computes the distance between each pair. +/// Planar (Euclidean) `ST_Distance` (no geodesic correction) between two native geometry +/// operands, each a column or a constant literal. #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] pub struct GeoDistance; @@ -81,7 +76,8 @@ impl ScalarFnVTable for GeoDistance { } } - fn return_dtype(&self, _: &Self::Options, _: &[DType]) -> VortexResult { + fn return_dtype(&self, _: &Self::Options, dtypes: &[DType]) -> VortexResult { + validate_geometry_operands(dtypes)?; Ok(DType::Primitive(PType::F64, Nullability::NonNullable)) } @@ -107,23 +103,13 @@ impl ScalarFnVTable for GeoDistance { (Some(query), None) => distances_to_constant(&b, query.scalar(), ctx), (None, Some(query)) => distances_to_constant(&a, query.scalar(), ctx), (None, None) => { - vortex_ensure!( - a.len() == b.len(), + vortex_ensure_eq!( + a.len(), + b.len(), "geo distance: operand length mismatch {} vs {}", a.len(), b.len() ); - // Fast path: two Point columns, distance straight over their `x`/`y` f64 buffers. - if is_nonnull_point(a.dtype()) && is_nonnull_point(b.dtype()) { - let (xa, ya) = point_xy(&a, ctx)?; - let (xb, yb) = point_xy(&b, ctx)?; - return Ok(point_distances( - xa.as_slice::().iter().copied(), - ya.as_slice::().iter().copied(), - xb.as_slice::().iter().copied(), - yb.as_slice::().iter().copied(), - )); - } let ag = geometries(&a, ctx)?; let bg = geometries(&b, ctx)?; let distances = ag.iter().zip(&bg).map(|(x, y)| Euclidean.distance(x, y)); @@ -133,80 +119,19 @@ impl ScalarFnVTable for GeoDistance { } } -/// Distance from each row of `operand` to a constant `query` geometry, decoded once and broadcast. -/// Distance is symmetric, so this serves a constant on either side. +/// Distance from each row of `operand` to the constant `query` geometry. Distance is symmetric, +/// so this serves a constant on either side. fn distances_to_constant( operand: &ArrayRef, query: &Scalar, ctx: &mut ExecutionCtx, ) -> VortexResult { - // Fast path: Point column vs constant Point, `x`/`y` f64 buffers, broadcasting the constant. - if is_nonnull_point(operand.dtype()) && is_point(query.dtype()) { - let q = coordinate_from_struct(&query.as_extension().to_storage_scalar())?; - let (xs, ys) = point_xy(operand, ctx)?; - return Ok(point_distances( - xs.as_slice::().iter().copied(), - ys.as_slice::().iter().copied(), - std::iter::repeat(q.x), - std::iter::repeat(q.y), - )); - } - let query = single_geometry(query, ctx)?; let geoms = geometries(operand, ctx)?; let distances = geoms.iter().map(|g| Euclidean.distance(g, &query)); Ok(PrimitiveArray::from_iter(distances).into_array()) } -/// Extract the `x` and `y` `f64` columns from a native `Point` operand, for the columnar fast paths. -fn point_xy( - operand: &ArrayRef, - ctx: &mut ExecutionCtx, -) -> VortexResult<(PrimitiveArray, PrimitiveArray)> { - let storage = operand - .clone() - .execute::(ctx)? - .storage_array() - .clone() - .execute::(ctx)?; - let xs = storage - .unmasked_field_by_name("x")? - .clone() - .execute::(ctx)?; - let ys = storage - .unmasked_field_by_name("y")? - .clone() - .execute::(ctx)?; - Ok((xs, ys)) -} - -/// Per-row planar distance `sqrt(dx^2 + dy^2)` over two `(x, y)` f64 streams; a constant side is fed -/// as `repeat(c)`. -fn point_distances( - xa: impl Iterator, - ya: impl Iterator, - xb: impl Iterator, - yb: impl Iterator, -) -> ArrayRef { - let distances = xa.zip(ya).zip(xb.zip(yb)).map(|((xa, ya), (xb, yb))| { - let (dx, dy) = (xa - xb, ya - yb); - (dx * dx + dy * dy).sqrt() - }); - PrimitiveArray::from_iter(distances).into_array() -} - -/// Whether `dtype` is the native `Point` extension (eligible for the columnar fast path). -fn is_point(dtype: &DType) -> bool { - dtype - .as_extension_opt() - .is_some_and(|ext| ext.is::()) -} - -/// A non-nullable native `Point`, a column operand the fast path can read straight from `x`/`y`. -fn is_nonnull_point(dtype: &DType) -> bool { - is_point(dtype) && !dtype.is_nullable() -} - #[cfg(test)] mod tests { use vortex_array::ArrayRef; @@ -215,6 +140,11 @@ mod tests { use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; use vortex_array::arrays::ConstantArray; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::scalar_fn::EmptyOptions; + use vortex_array::scalar_fn::ScalarFnVTable; use vortex_error::VortexResult; use super::GeoDistance; @@ -296,4 +226,23 @@ mod tests { assert_eq!(distances(distance, &mut ctx)?, vec![5.0, 5.0, 5.0]); Ok(()) } + + /// Geometry arrays are never nullable, so a nullable operand dtype is rejected. + #[test] + fn nullable_operand_is_rejected() -> VortexResult<()> { + let dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + let result = GeoDistance.return_dtype(&EmptyOptions, &[dtype.as_nullable(), dtype]); + assert!(result.is_err()); + Ok(()) + } + + /// A non-geometry operand dtype is rejected up front, before execution. + #[test] + fn non_geometry_operand_is_rejected() -> VortexResult<()> { + let geo = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + let numeric = DType::Primitive(PType::I32, Nullability::NonNullable); + let result = GeoDistance.return_dtype(&EmptyOptions, &[geo, numeric]); + assert!(result.is_err()); + Ok(()) + } } diff --git a/vortex-geo/src/scalar_fn/intersects.rs b/vortex-geo/src/scalar_fn/intersects.rs new file mode 100644 index 00000000000..801745dc277 --- /dev/null +++ b/vortex-geo/src/scalar_fn/intersects.rs @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! `ST_Intersects`: OGC intersection test between two native geometries. + +use geo::Intersects; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::Constant; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::ScalarFnArray; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::scalar::Scalar; +use vortex_array::scalar_fn::Arity; +use vortex_array::scalar_fn::ChildName; +use vortex_array::scalar_fn::EmptyOptions; +use vortex_array::scalar_fn::ExecutionArgs; +use vortex_array::scalar_fn::ScalarFnId; +use vortex_array::scalar_fn::ScalarFnVTable; +use vortex_array::scalar_fn::TypedScalarFnInstance; +use vortex_error::VortexResult; +use vortex_error::vortex_ensure_eq; +use vortex_session::VortexSession; +use vortex_session::registry::CachedId; + +use crate::extension::geometries; +use crate::extension::single_geometry; +use crate::extension::validate_geometry_operands; + +/// OGC `ST_Intersects` (not disjoint; boundary contact counts) between two native geometry +/// operands, each a column or a constant literal. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct GeoIntersects; + +impl GeoIntersects { + /// A lazy `ScalarFnArray` computing per-row whether operands `a` and `b` intersect; either may + /// be constant. The output length is taken from `a`. + pub fn try_new_array(a: ArrayRef, b: ArrayRef) -> VortexResult { + ScalarFnArray::try_new( + TypedScalarFnInstance::new(GeoIntersects, EmptyOptions).erased(), + vec![a, b], + ) + } +} + +impl ScalarFnVTable for GeoIntersects { + type Options = EmptyOptions; + + fn id(&self) -> ScalarFnId { + static ID: CachedId = CachedId::new("vortex.geo.intersects"); + *ID + } + + fn serialize(&self, _: &Self::Options) -> VortexResult>> { + Ok(Some(vec![])) + } + + fn deserialize(&self, _: &[u8], _: &VortexSession) -> VortexResult { + Ok(EmptyOptions) + } + + fn arity(&self, _: &Self::Options) -> Arity { + Arity::Exact(2) + } + + fn child_name(&self, _: &Self::Options, child_idx: usize) -> ChildName { + match child_idx { + 0 => ChildName::from("a"), + 1 => ChildName::from("b"), + _ => unreachable!("intersects has exactly two children"), + } + } + + fn return_dtype(&self, _: &Self::Options, dtypes: &[DType]) -> VortexResult { + validate_geometry_operands(dtypes)?; + Ok(DType::Bool(Nullability::NonNullable)) + } + + fn execute( + &self, + _: &Self::Options, + args: &dyn ExecutionArgs, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + let a = args.get(0)?; + let b = args.get(1)?; + match (a.as_opt::(), b.as_opt::()) { + (Some(qa), Some(qb)) => { + let ga = single_geometry(qa.scalar(), ctx)?; + let gb = single_geometry(qb.scalar(), ctx)?; + Ok(ConstantArray::new( + Scalar::bool(ga.intersects(&gb), Nullability::NonNullable), + a.len(), + ) + .into_array()) + } + (Some(query), None) => intersects_constant(&b, query.scalar(), ctx), + (None, Some(query)) => intersects_constant(&a, query.scalar(), ctx), + (None, None) => { + vortex_ensure_eq!( + a.len(), + b.len(), + "geo intersects: operand length mismatch {} vs {}", + a.len(), + b.len() + ); + let ag = geometries(&a, ctx)?; + let bg = geometries(&b, ctx)?; + let hits = ag.iter().zip(&bg).map(|(x, y)| x.intersects(y)); + Ok(BoolArray::from_iter(hits).into_array()) + } + } + } +} + +/// Whether each row of `operand` intersects the constant `query` geometry. Intersection is +/// symmetric, so this serves a constant on either side. +fn intersects_constant( + operand: &ArrayRef, + query: &Scalar, + ctx: &mut ExecutionCtx, +) -> VortexResult { + let query = single_geometry(query, ctx)?; + let geoms = geometries(operand, ctx)?; + let hits = geoms.iter().map(|g| g.intersects(&query)); + Ok(BoolArray::from_iter(hits).into_array()) +} + +#[cfg(test)] +mod tests { + use geo_types::Coord; + use geo_types::Geometry; + use geo_types::LineString; + use geo_types::MultiPolygon; + use geo_types::Polygon; + use rstest::rstest; + use vortex_array::ArrayRef; + use vortex_array::Canonical; + use vortex_array::ExecutionCtx; + use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::arrays::BoolArray; + use vortex_array::arrays::ConstantArray; + use vortex_array::assert_arrays_eq; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::scalar_fn::EmptyOptions; + use vortex_array::scalar_fn::ScalarFnVTable; + use vortex_error::VortexResult; + use vortex_error::vortex_err; + use wkb::writer::WriteOptions; + + use super::GeoIntersects; + use crate::test_harness::point_column; + + /// A rectangle polygon with corners `(x0, y0)` and `(x1, y1)`, no holes. + fn rect_polygon(x0: f64, y0: f64, x1: f64, y1: f64) -> Polygon { + Polygon::new( + LineString::from(vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)]), + vec![], + ) + } + + /// The query region shared by the point tests: a `10x10` square with a `4..6` square hole. + fn donut() -> Geometry { + Geometry::Polygon(Polygon::new( + LineString::from(vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]), + vec![LineString::from(vec![ + (4.0, 4.0), + (6.0, 4.0), + (6.0, 6.0), + (4.0, 6.0), + ])], + )) + } + + /// Probes for [`donut`], one per verdict class: interior, exterior, on the outer boundary, + /// and inside the hole. + fn donut_probes() -> VortexResult { + point_column(vec![2.0, 20.0, 0.0, 5.0], vec![2.0, 20.0, 5.0, 5.0]) + } + + /// [`donut_probes`]'s verdicts: interior and boundary contact intersect; exterior and + /// in-hole do not. + const DONUT_EXPECTED: [bool; 4] = [true, false, true, false]; + + /// A constant column of length `len`, every row the native form of `geometry`. + fn geometry_constant(geometry: &Geometry, len: usize) -> VortexResult { + let mut buf = Vec::new(); + wkb::writer::write_geometry(&mut buf, geometry, &WriteOptions::default()) + .map_err(|e| vortex_err!("writing WKB failed: {e}"))?; + let scalar = crate::extension::native_geometry_scalar_from_wkb(&buf)? + .ok_or_else(|| vortex_err!("unsupported geometry type"))?; + Ok(ConstantArray::new(scalar, len).into_array()) + } + + /// Materialize `array` so it is no longer a `Constant`, forcing the non-constant kernel paths. + fn materialize(array: ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + Ok(array.execute::(ctx)?.into_array()) + } + + /// Execute `GeoIntersects(a, b)` and assert the per-row verdicts equal `expected`. + fn assert_intersects( + a: ArrayRef, + b: ArrayRef, + expected: impl IntoIterator, + ) -> VortexResult<()> { + let session = vortex_array::array_session(); + let mut ctx = session.create_execution_ctx(); + let intersects = GeoIntersects::try_new_array(a, b)?.into_array(); + assert_arrays_eq!(intersects, BoolArray::from_iter(expected), &mut ctx); + Ok(()) + } + + // The tests cover each `execute` dispatch arm in match order, then the edge cases. + + /// Constant vs constant: overlapping and touching (edge or corner) polygons intersect, + /// disjoint ones do not; every output row carries the same verdict. + #[rstest] + #[case::overlapping(rect_polygon(2.0, 2.0, 6.0, 6.0), true)] + #[case::touching_edge(rect_polygon(4.0, 0.0, 8.0, 4.0), true)] + #[case::touching_corner(rect_polygon(4.0, 4.0, 8.0, 8.0), true)] + #[case::disjoint(rect_polygon(20.0, 20.0, 24.0, 24.0), false)] + fn constant_vs_constant_polygons( + #[case] other: Polygon, + #[case] expected: bool, + ) -> VortexResult<()> { + let a = geometry_constant(&Geometry::Polygon(rect_polygon(0.0, 0.0, 4.0, 4.0)), 3)?; + let b = geometry_constant(&Geometry::Polygon(other), 3)?; + assert_intersects(a, b, [expected; 3]) + } + + /// Point column vs constant polygon, with the constant on either side since intersection + /// is symmetric: the [`DONUT_EXPECTED`] verdicts. + #[test] + fn point_column_vs_constant_polygon() -> VortexResult<()> { + assert_intersects( + donut_probes()?, + geometry_constant(&donut(), 4)?, + DONUT_EXPECTED, + )?; + assert_intersects( + geometry_constant(&donut(), 4)?, + donut_probes()?, + DONUT_EXPECTED, + ) + } + + /// Point column vs constant multipolygon: a point in either part intersects, a point in + /// neither does not. + #[test] + fn point_column_vs_constant_multipolygon() -> VortexResult<()> { + let query = Geometry::MultiPolygon(MultiPolygon::new(vec![ + rect_polygon(0.0, 0.0, 2.0, 2.0), + rect_polygon(10.0, 10.0, 12.0, 12.0), + ])); + let points = point_column(vec![1.0, 11.0, 5.0], vec![1.0, 11.0, 5.0])?; + assert_intersects(points, geometry_constant(&query, 3)?, [true, true, false]) + } + + /// Polygon column vs constant: the same pairs and verdicts as + /// [`constant_vs_constant_polygons`]. + #[rstest] + #[case::overlapping(rect_polygon(2.0, 2.0, 6.0, 6.0), true)] + #[case::touching_edge(rect_polygon(4.0, 0.0, 8.0, 4.0), true)] + #[case::disjoint(rect_polygon(20.0, 20.0, 24.0, 24.0), false)] + fn polygon_column_vs_constant( + #[case] other: Polygon, + #[case] expected: bool, + ) -> VortexResult<()> { + let session = vortex_array::array_session(); + let mut ctx = session.create_execution_ctx(); + let column = materialize(geometry_constant(&Geometry::Polygon(other), 2)?, &mut ctx)?; + let query = geometry_constant(&Geometry::Polygon(rect_polygon(0.0, 0.0, 4.0, 4.0)), 2)?; + assert_intersects(column, query, [expected; 2]) + } + + /// Point column vs point column: rows intersect exactly at equal coordinates. + #[test] + fn point_column_vs_point_column() -> VortexResult<()> { + let a = point_column(vec![0.0, 1.0], vec![0.0, 1.0])?; + let b = point_column(vec![0.0, 2.0], vec![0.0, 1.0])?; + assert_intersects(a, b, [true, false]) + } + + /// Point column vs polygon column pairwise: agrees with point column vs constant on the + /// same data. + #[test] + fn columns_agree_with_constant() -> VortexResult<()> { + let session = vortex_array::array_session(); + let mut ctx = session.create_execution_ctx(); + + let constant = geometry_constant(&donut(), 4)?; + let column = materialize(constant.clone(), &mut ctx)?; + + let against_constant = + GeoIntersects::try_new_array(donut_probes()?, constant)?.into_array(); + let pairwise = GeoIntersects::try_new_array(donut_probes()?, column)?.into_array(); + + assert_arrays_eq!(against_constant, pairwise, &mut ctx); + Ok(()) + } + + /// An empty constant geometry intersects nothing. + #[test] + fn empty_constant_intersects_nothing() -> VortexResult<()> { + let empty = Geometry::LineString(LineString::new(Vec::::new())); + let points = point_column(vec![0.0, 1.0], vec![0.0, 1.0])?; + assert_intersects(points, geometry_constant(&empty, 2)?, [false, false]) + } + + /// Geometry arrays are never nullable, so a nullable operand dtype is rejected. + #[test] + fn nullable_operand_is_rejected() -> VortexResult<()> { + let dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + let result = GeoIntersects.return_dtype(&EmptyOptions, &[dtype.as_nullable(), dtype]); + assert!(result.is_err()); + Ok(()) + } + + /// A non-geometry operand dtype is rejected up front, before execution. + #[test] + fn non_geometry_operand_is_rejected() -> VortexResult<()> { + let geo = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + let numeric = DType::Primitive(PType::I32, Nullability::NonNullable); + let result = GeoIntersects.return_dtype(&EmptyOptions, &[geo, numeric]); + assert!(result.is_err()); + Ok(()) + } +} diff --git a/vortex-geo/src/scalar_fn/mod.rs b/vortex-geo/src/scalar_fn/mod.rs index 385208f1991..2246f82621b 100644 --- a/vortex-geo/src/scalar_fn/mod.rs +++ b/vortex-geo/src/scalar_fn/mod.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -//! Geometry scalar functions over the [`Point`](crate::extension::Point) type. +//! Geometry scalar functions over the native geometry extension types. +pub mod contains; pub mod distance; +pub mod intersects; From 0aa35f45ec8f22513b30a3ea03ddd87bdd3773f1 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Mon, 13 Jul 2026 16:34:19 -0400 Subject: [PATCH 070/104] Reject negative Union type IDs (#8738) ## Summary Tracking issue: https://github.com/vortex-data/vortex/issues/7882 - reject negative `UnionVariants` type IDs during shape validation - document the Arrow-compatible `0..=127` range - add regression coverage for negative tags ## Why Arrow-rs rejects negative Union type IDs. Enforcing the same range before the scalar and canonical array APIs harden avoids accepting schemas that cannot round-trip through Arrow. Extracted as a prerequisite from the Union scalar work tracked by #7882. Signed-off-by: "Connor Tsui" --- vortex-array/src/dtype/union.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/vortex-array/src/dtype/union.rs b/vortex-array/src/dtype/union.rs index c2097d3994c..faec75751c5 100644 --- a/vortex-array/src/dtype/union.rs +++ b/vortex-array/src/dtype/union.rs @@ -25,6 +25,8 @@ use crate::dtype::Nullability; /// Per Arrow's spec, the per-row type tag is an `int8`. By default, tag `i` selects the child at /// offset `i` (`type_ids = [0, 1, ..., N-1]`). /// +/// Type IDs are restricted to the Arrow-compatible range `0..=127`; negative tags are rejected. +/// /// Schemas may also use non-consecutive tags (e.g. `[0, 5, 7]`), in which case the value of /// `type_ids[i]` is the tag used in the data to select the child at offset `i`. Supporting /// non-consecutive tags lets the schema remove children without renumbering the remaining tags. @@ -179,6 +181,11 @@ impl UnionVariants { "type_ids must be distinct, got {:?}", type_ids ); + vortex_ensure!( + type_ids.iter().all(|type_id| *type_id >= 0), + "type_ids must be non-negative, got {:?}", + type_ids + ); vortex_ensure!( names.iter().all_unique(), "union variant names must be distinct, got {:?}", @@ -193,7 +200,7 @@ impl UnionVariants { /// # Errors /// /// Returns an error if names, dtypes, or type IDs do not all have the same length, or if there - /// are any duplicate names or type ids. + /// are any duplicate names or type ids, or if a type ID is negative. pub fn try_new(names: FieldNames, dtypes: Vec, type_ids: Vec) -> VortexResult { Self::validate_shape(&names, dtypes.len(), &type_ids)?; @@ -237,7 +244,7 @@ impl UnionVariants { /// # Errors /// /// Returns an error if names, dtypes, or type IDs do not all have the same length, or if there - /// are any duplicate names or type ids. + /// are any duplicate names or type ids, or if a type ID is negative. pub(crate) fn try_from_fields( names: FieldNames, dtypes: Vec, @@ -429,6 +436,17 @@ mod tests { assert!(result.unwrap_err().to_string().contains("distinct")); } + #[test] + fn test_negative_type_ids_rejected() { + let result = UnionVariants::try_new( + ["negative"].into(), + vec![DType::Primitive(PType::I32, Nullability::NonNullable)], + vec![-1], + ); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("non-negative")); + } + #[test] fn test_length_mismatch_rejected() { let result = UnionVariants::try_new( From 2f18a4cb680ea16f1541a42e229622489aada916 Mon Sep 17 00:00:00 2001 From: Nemo Yu <83347615+HarukiMoriarty@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:29:43 -0400 Subject: [PATCH 071/104] feat(vortex-geo): add native geoarrow.box bounding-box type (#8746) ## Rationale for this change Groundwork for spatial chunk-pruning: the upcoming geometry-bounds zone-map needs to store bounding boxes as a first-class geometry so the pruning rules can reuse the existing `ST_Distance` / `ST_Intersects` / `ST_Contains` kernels on them instead of hand-rolled box math. ## What changes are included in this PR? - New `Rect` extension type (`vortex.geo.box`, GeoArrow's `geoarrow.box`): an axis-aligned box stored as `Struct` of non-nullable `f64`. --------- Signed-off-by: Nemo Yu --- vortex-geo/src/extension/mod.rs | 5 + vortex-geo/src/extension/rect.rs | 392 +++++++++++++++++++++++++++++++ vortex-geo/src/lib.rs | 4 + vortex-geo/src/test_harness.rs | 27 ++- vortex-geo/src/tests/mod.rs | 1 + vortex-geo/src/tests/rect.rs | 146 ++++++++++++ 6 files changed, 572 insertions(+), 3 deletions(-) create mode 100644 vortex-geo/src/extension/rect.rs create mode 100644 vortex-geo/src/tests/rect.rs diff --git a/vortex-geo/src/extension/mod.rs b/vortex-geo/src/extension/mod.rs index 663d10820e0..e5f551a04e8 100644 --- a/vortex-geo/src/extension/mod.rs +++ b/vortex-geo/src/extension/mod.rs @@ -8,6 +8,7 @@ mod multipoint; mod multipolygon; mod point; mod polygon; +mod rect; mod wkb; use std::fmt::Display; @@ -37,6 +38,7 @@ pub use multipoint::*; pub use multipolygon::*; pub use point::*; pub use polygon::*; +pub use rect::*; use vortex_array::ArrayRef; use vortex_array::ExecutionCtx; use vortex_array::IntoArray; @@ -63,6 +65,7 @@ fn is_native_geometry(dtype: &DType) -> bool { || ext.is::() || ext.is::() || ext.is::() + || ext.is::() }) } @@ -110,6 +113,8 @@ pub(crate) fn geometries( multilinestring_geometries(&storage, ctx) } else if ext.is::() { multipolygon_geometries(&storage, ctx) + } else if ext.is::() { + rect_geometries(&storage, ctx) } else { vortex_bail!("geo: unsupported geometry extension {}", array.dtype()) } diff --git a/vortex-geo/src/extension/rect.rs b/vortex-geo/src/extension/rect.rs new file mode 100644 index 00000000000..d2dddf51e79 --- /dev/null +++ b/vortex-geo/src/extension/rect.rs @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! The [`Rect`] bounding-box extension type (`vortex.geo.box`): an axis-aligned envelope stored +//! columnarly as `Struct` of non-nullable +//! `f64` — the lower corner's ordinates followed by the upper corner's — tagged with +//! [`GeoMetadata`] (CRS). Its GeoArrow wire type is `geoarrow.box`. +//! +//! Decoding to `geo_types` yields a 2D [`Geometry::Rect`]; any `z`/`m` +//! bounds are dropped, as for the other geometry types. + +use std::sync::Arc; + +use arrow_array::ArrayRef as ArrowArrayRef; +use arrow_schema::DataType; +use arrow_schema::Field; +use arrow_schema::extension::ExtensionType; +use geo_traits::to_geo::ToGeoGeometry; +use geo_types::Geometry; +use geoarrow::array::GeoArrowArrayAccessor; +use geoarrow::array::IntoArrow; +use geoarrow::array::RectArray; +use geoarrow::datatypes::BoxType; +use prost::Message; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::arrays::ExtensionArray; +use vortex_array::arrays::extension::ExtensionArrayExt; +use vortex_array::dtype::DType; +use vortex_array::dtype::FieldNames; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::PType; +use vortex_array::dtype::StructFields; +use vortex_array::dtype::extension::ExtDType; +use vortex_array::dtype::extension::ExtId; +use vortex_array::dtype::extension::ExtVTable; +use vortex_array::scalar::ScalarValue; +use vortex_arrow::ArrowExport; +use vortex_arrow::ArrowExportVTable; +use vortex_arrow::ArrowImport; +use vortex_arrow::ArrowImportVTable; +use vortex_arrow::ArrowSession; +use vortex_arrow::ArrowSessionExt; +use vortex_arrow::FromArrowArray; +use vortex_arrow::FromArrowType; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; +use vortex_error::vortex_ensure; +use vortex_error::vortex_err; +use vortex_session::registry::CachedId; +use vortex_session::registry::Id; + +use super::GeoMetadata; +use super::coordinate::Dimension; +use super::geo_metadata_from_arrow; +use super::geoarrow_metadata; + +/// A bounding box (`geoarrow.box`), stored as `Struct`. +// Named `Rect`, not `Box`: matches `geo::Rect` / geoarrow-rs `RectArray`, and `Box` is a std name. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct Rect; + +impl ExtVTable for Rect { + type Metadata = GeoMetadata; + // No cheap owned value; expose the raw storage scalar like `Polygon`. + type NativeValue<'a> = &'a ScalarValue; + + fn id(&self) -> ExtId { + static ID: CachedId = CachedId::new("vortex.geo.box"); + *ID + } + + fn serialize_metadata(&self, metadata: &Self::Metadata) -> VortexResult> { + Ok(metadata.encode_to_vec()) + } + + fn deserialize_metadata(&self, metadata: &[u8]) -> VortexResult { + Ok(GeoMetadata::decode(metadata)?) + } + + fn validate_dtype(ext_dtype: &ExtDType) -> VortexResult<()> { + box_dimension(ext_dtype.storage_dtype()).map(|_| ()) + } + + fn unpack_native<'a>( + _ext_dtype: &'a ExtDType, + storage_value: &'a ScalarValue, + ) -> VortexResult<&'a ScalarValue> { + Ok(storage_value) + } +} + +/// The `geoarrow.box` storage field names for `dim`: the lower corner's ordinates followed by the +/// upper corner's. +fn box_field_names(dim: Dimension) -> &'static [&'static str] { + match dim { + Dimension::Xy => &["xmin", "ymin", "xmax", "ymax"], + Dimension::Xyz => &["xmin", "ymin", "zmin", "xmax", "ymax", "zmax"], + Dimension::Xym => &["xmin", "ymin", "mmin", "xmax", "ymax", "mmax"], + Dimension::Xyzm => &[ + "xmin", "ymin", "zmin", "mmin", "xmax", "ymax", "zmax", "mmax", + ], + } +} + +/// The dimension whose box field names match `names`, or `None` if none do. +fn box_dimension_from_names(names: &FieldNames) -> Option { + [ + Dimension::Xy, + Dimension::Xyz, + Dimension::Xym, + Dimension::Xyzm, + ] + .into_iter() + .find(|&dim| { + names + .iter() + .map(AsRef::as_ref) + .eq(box_field_names(dim).iter().copied()) + }) +} + +/// Canonical box storage: a `Struct` of non-nullable `f64` min/max fields, with `nullability` at +/// the struct (per-box) level. +pub(crate) fn box_storage_dtype(dim: Dimension, nullability: Nullability) -> DType { + let names = box_field_names(dim); + let fields = std::iter::repeat_n( + DType::Primitive(PType::F64, Nullability::NonNullable), + names.len(), + ) + .collect::>(); + DType::Struct( + StructFields::new(FieldNames::from(names), fields), + nullability, + ) +} + +/// Validate `dtype` is a box `Struct` of non-nullable `f64` min/max fields, returning its +/// [`Dimension`]. +pub(crate) fn box_dimension(dtype: &DType) -> VortexResult { + let DType::Struct(fields, _) = dtype else { + vortex_bail!("box storage must be a Struct, was {dtype}"); + }; + for (name, field) in fields.names().iter().zip(fields.fields()) { + vortex_ensure!( + matches!( + field, + DType::Primitive(PType::F64, Nullability::NonNullable) + ), + "box field {name} must be non-nullable f64, was {field}" + ); + } + box_dimension_from_names(fields.names()) + .ok_or_else(|| vortex_err!("not a valid geoarrow.box dimension: {:?}", fields.names())) +} + +static ARROW_BOX: CachedId = CachedId::new(BoxType::NAME); + +/// The `geoarrow.box` extension type for `dimension`. +fn box_type(geo_metadata: &GeoMetadata, dimension: Dimension) -> BoxType { + BoxType::new(dimension.into(), geoarrow_metadata(geo_metadata)) +} + +/// Build a geoarrow `RectArray` from a `Rect`'s box `Struct` storage. +fn rect_array(storage: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult { + let box_type = box_type(&GeoMetadata::default(), box_dimension(storage.dtype())?); + let session = ctx.session().clone(); + let arrow = session.arrow().execute_arrow(storage.clone(), None, ctx)?; + RectArray::try_from((arrow.as_ref(), box_type)) + .map_err(|e| vortex_err!("failed to construct RectArray: {e}")) +} + +/// Decode `Rect` storage to `geo_types` (2D [`Geometry::Rect`]), for the geo scalar functions. +pub(crate) fn rect_geometries( + storage: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult>> { + rect_array(storage, ctx)? + .iter() + .map(|geometry| -> VortexResult> { + Ok(geometry + .ok_or_else(|| vortex_err!("geo: null geometry is not supported"))? + .map_err(|e| vortex_err!("geo: geometry access failed: {e}"))? + .to_geometry()) + }) + .collect() +} + +impl ArrowExportVTable for Rect { + fn arrow_ext_id(&self) -> Id { + *ARROW_BOX + } + + fn vortex_id(&self) -> Id { + self.id() + } + + fn to_arrow_field( + &self, + name: &str, + dtype: &DType, + session: &ArrowSession, + ) -> VortexResult> { + let ext_type = dtype.as_extension(); + let geo_metadata = ext_type.metadata::(); + let dimension = box_dimension(ext_type.storage_dtype())?; + + let mut field = session.to_arrow_field(name, ext_type.storage_dtype())?; + field.try_with_extension_type(box_type(geo_metadata, dimension))?; + + Ok(Some(field)) + } + + fn execute_arrow( + &self, + array: ArrayRef, + target: &Field, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + let is_box = array + .dtype() + .as_extension_opt() + .map(|ext| ext.is::()) + .unwrap_or(false); + if !is_box { + return Ok(ArrowExport::Unsupported(array)); + } + + let Ok(box_meta) = target.try_extension_type::() else { + return Ok(ArrowExport::Unsupported(array)); + }; + + let executed = array.execute::(ctx)?; + let storage = executed.storage_array().clone(); + + let storage_field = Field::new( + String::new(), + target.data_type().clone(), + target.is_nullable(), + ); + let session = ctx.session().clone(); + let arrow_storage = session + .arrow() + .execute_arrow(storage, Some(&storage_field), ctx)?; + + // Round-trip through GeoArrow's rect array; `into_arrow` is concrete, so wrap in `Arc`. + let rects = RectArray::try_from((arrow_storage.as_ref(), box_meta)) + .map_err(|e| vortex_err!("failed to construct RectArray: {e}"))?; + + Ok(ArrowExport::Exported(Arc::new(rects.into_arrow()))) + } +} + +impl ArrowImportVTable for Rect { + fn arrow_ext_id(&self) -> Id { + *ARROW_BOX + } + + /// Import a `geoarrow.box` field as the [`Rect`] dtype. Keyed off the standard GeoArrow name, + /// so any producer resolves here. Accepts the full `BoxType` extension, or — for a + /// metadata-less literal — the name alone, inferring the dimension from the field names. + fn from_arrow_field(&self, field: &Field) -> VortexResult> { + let (dimension, metadata) = if let Ok(box_meta) = field.try_extension_type::() { + ( + box_meta.dimension().into(), + geo_metadata_from_arrow(box_meta.metadata()), + ) + } else { + if field.extension_type_name() != Some(BoxType::NAME) { + return Ok(None); + } + // Infer from field names, not the strict `box_dimension` check: a literal's fields may + // be nullable, which that check rejects. + let DType::Struct(fields, _) = DType::from_arrow(field) else { + return Ok(None); + }; + let Some(dimension) = box_dimension_from_names(fields.names()) else { + return Ok(None); + }; + (dimension, GeoMetadata::default()) + }; + + let storage_dtype = box_storage_dtype(dimension, field.is_nullable().into()); + Ok(Some(DType::Extension( + ExtDType::try_with_vtable(Rect, metadata, storage_dtype)?.erased(), + ))) + } + + fn from_arrow_array( + &self, + array: ArrowArrayRef, + field: &Field, + dtype: &DType, + ) -> VortexResult { + let Some(ext_dtype) = dtype.as_extension_opt() else { + return Ok(ArrowImport::Unsupported(array)); + }; + if !ext_dtype.is::() + || field.try_extension_type::().is_err() + || !matches!(array.data_type(), DataType::Struct(_)) + { + return Ok(ArrowImport::Unsupported(array)); + } + + let storage = ArrayRef::from_arrow(array.as_ref(), field.is_nullable())?; + Ok(ArrowImport::Imported( + ExtensionArray::try_new(ext_dtype.clone(), storage)?.into_array(), + )) + } +} + +#[cfg(test)] +mod tests { + use geo_types::Geometry; + use rstest::rstest; + use vortex_array::VortexSessionExecute; + use vortex_array::arrays::ExtensionArray; + use vortex_array::arrays::extension::ExtensionArrayExt; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::dtype::extension::ExtDType; + use vortex_error::VortexResult; + + use super::Rect; + use super::box_dimension; + use super::box_storage_dtype; + use super::rect_geometries; + use crate::extension::GeoMetadata; + use crate::extension::coordinate::Dimension; + + fn geo_meta() -> GeoMetadata { + GeoMetadata { + crs: Some("EPSG:4326".to_string()), + } + } + + /// `Rect` accepts the canonical box storage of every GeoArrow dimension, and the storage dtype + /// round-trips back to that dimension. + #[rstest] + #[case::xy(Dimension::Xy)] + #[case::xyz(Dimension::Xyz)] + #[case::xym(Dimension::Xym)] + #[case::xyzm(Dimension::Xyzm)] + fn box_validates_every_dimension(#[case] dim: Dimension) -> VortexResult<()> { + let storage = box_storage_dtype(dim, Nullability::NonNullable); + ExtDType::::try_new(geo_meta(), storage.clone())?; + assert_eq!(box_dimension(&storage)?, dim); + Ok(()) + } + + /// Invalid storage is rejected at dtype construction: non-struct storage, a struct with the + /// wrong field names, and a struct with a nullable field. + #[test] + fn box_rejects_invalid_storage() -> VortexResult<()> { + let primitive = DType::Primitive(PType::F64, Nullability::NonNullable); + assert!(ExtDType::::try_new(geo_meta(), primitive).is_err()); + + let point_like = box_storage_dtype(Dimension::Xy, Nullability::NonNullable); + let DType::Struct(fields, _) = &point_like else { + unreachable!("box storage is a struct"); + }; + // Rename a field so it no longer matches the geoarrow.box layout. + let renamed = DType::Struct( + vortex_array::dtype::StructFields::new( + ["x", "ymin", "xmax", "ymax"].into(), + fields.fields().collect(), + ), + Nullability::NonNullable, + ); + assert!(ExtDType::::try_new(geo_meta(), renamed).is_err()); + Ok(()) + } + + /// A `Rect` column decodes to 2D `geo_types` rectangles. + #[test] + fn box_decodes_to_geo_rect() -> VortexResult<()> { + let session = vortex_array::array_session(); + let mut ctx = session.create_execution_ctx(); + + let rects = crate::test_harness::rect_column(vec![(0.0, 0.0, 2.0, 3.0)])?; + let storage = rects + .execute::(&mut ctx)? + .storage_array() + .clone(); + + let geometries = rect_geometries(&storage, &mut ctx)?; + assert!(matches!(geometries.as_slice(), [Geometry::Rect(_)])); + Ok(()) + } +} diff --git a/vortex-geo/src/lib.rs b/vortex-geo/src/lib.rs index ad0c3c503e2..3e695dbba65 100644 --- a/vortex-geo/src/lib.rs +++ b/vortex-geo/src/lib.rs @@ -14,6 +14,7 @@ use crate::extension::MultiPoint; use crate::extension::MultiPolygon; use crate::extension::Point; use crate::extension::Polygon; +use crate::extension::Rect; use crate::extension::WellKnownBinary; use crate::scalar_fn::contains::GeoContains; use crate::scalar_fn::distance::GeoDistance; @@ -50,6 +51,9 @@ pub fn initialize(session: &VortexSession) { session.dtypes().register(MultiPolygon); session.arrow().register_exporter(Arc::new(MultiPolygon)); session.arrow().register_importer(Arc::new(MultiPolygon)); + session.dtypes().register(Rect); + session.arrow().register_exporter(Arc::new(Rect)); + session.arrow().register_importer(Arc::new(Rect)); // Register the geometry scalar functions. session.scalar_fns().register(GeoContains); diff --git a/vortex-geo/src/test_harness.rs b/vortex-geo/src/test_harness.rs index 2e9e7f43c27..4711e2e64db 100644 --- a/vortex-geo/src/test_harness.rs +++ b/vortex-geo/src/test_harness.rs @@ -14,9 +14,17 @@ use vortex_error::VortexResult; use crate::extension::GeoMetadata; use crate::extension::Point; +use crate::extension::Rect; use crate::extension::coordinate::Coordinate; use crate::extension::coordinate::coordinate_from_struct; +/// The WGS 84 (`EPSG:4326`) metadata tagged onto test geometry columns. +fn wgs84() -> GeoMetadata { + GeoMetadata { + crs: Some("EPSG:4326".to_string()), + } +} + /// A `Point` column (CRS `EPSG:4326`) over the given x/y coordinates. pub(crate) fn point_column(xs: Vec, ys: Vec) -> VortexResult { let storage = StructArray::from_fields(&[ @@ -24,10 +32,23 @@ pub(crate) fn point_column(xs: Vec, ys: Vec) -> VortexResult ("y", PrimitiveArray::from_iter(ys).into_array()), ])? .into_array(); - let metadata = GeoMetadata { - crs: Some("EPSG:4326".to_string()), + let dtype = ExtDType::::try_new(wgs84(), storage.dtype().clone())?; + Ok(ExtensionArray::new(dtype.erased(), storage).into_array()) +} + +/// A 2D `Rect` (`geoarrow.box`) column (CRS `EPSG:4326`) over `(xmin, ymin, xmax, ymax)` boxes. +pub(crate) fn rect_column(boxes: Vec<(f64, f64, f64, f64)>) -> VortexResult { + let field = |select: fn(&(f64, f64, f64, f64)) -> f64| { + PrimitiveArray::from_iter(boxes.iter().map(select)).into_array() }; - let dtype = ExtDType::::try_new(metadata, storage.dtype().clone())?; + let storage = StructArray::from_fields(&[ + ("xmin", field(|b| b.0)), + ("ymin", field(|b| b.1)), + ("xmax", field(|b| b.2)), + ("ymax", field(|b| b.3)), + ])? + .into_array(); + let dtype = ExtDType::::try_new(wgs84(), storage.dtype().clone())?; Ok(ExtensionArray::new(dtype.erased(), storage).into_array()) } diff --git a/vortex-geo/src/tests/mod.rs b/vortex-geo/src/tests/mod.rs index 0d4de3ae6a3..3708d5ad1fb 100644 --- a/vortex-geo/src/tests/mod.rs +++ b/vortex-geo/src/tests/mod.rs @@ -9,6 +9,7 @@ mod multilinestring; mod multipoint; mod multipolygon; mod point; +mod rect; mod wkb; use std::sync::LazyLock; diff --git a/vortex-geo/src/tests/rect.rs b/vortex-geo/src/tests/rect.rs new file mode 100644 index 00000000000..865892493e7 --- /dev/null +++ b/vortex-geo/src/tests/rect.rs @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Arrow interop for the `vortex.geo.box` extension type (`geoarrow.box`). + +use std::sync::Arc; + +use arrow_schema::DataType; +use arrow_schema::Field; +use arrow_schema::extension::ExtensionType as _; +use geoarrow::datatypes::BoxType; +use geoarrow::datatypes::Crs; +use geoarrow::datatypes::Dimension as GeoArrowDimension; +use geoarrow::datatypes::Metadata; +use vortex_array::VortexSessionExecute; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_arrow::ArrowSessionExt; +use vortex_error::VortexResult; +use vortex_error::vortex_err; + +use super::SESSION; +use crate::extension::Rect; +use crate::test_harness::rect_column; + +/// A `geoarrow.box` Arrow field of the given dimension. +fn box_field(name: &str, dim: GeoArrowDimension, nullable: bool, crs: Option<&str>) -> Field { + let crs = crs + .map(|crs| Crs::from_unknown_crs_type(crs.to_string())) + .unwrap_or_default(); + let metadata = Arc::new(Metadata::new(crs, None)); + BoxType::new(dim, metadata).to_field(name, nullable) +} + +/// The exported Arrow field carries the `geoarrow.box` extension over the `Struct` layout. +#[test] +fn export_field_carries_extension() -> VortexResult<()> { + let array = rect_column(vec![(0.0, 0.0, 1.0, 1.0)])?; + let field = SESSION.arrow().to_arrow_field("bbox", array.dtype())?; + + assert_eq!(field.extension_type_name(), Some(BoxType::NAME)); + let DataType::Struct(fields) = field.data_type() else { + panic!("expected Struct, got {}", field.data_type()); + }; + let names: Vec<&str> = fields.iter().map(|f| f.name().as_str()).collect(); + assert_eq!(names, vec!["xmin", "ymin", "xmax", "ymax"]); + Ok(()) +} + +/// An imported `geoarrow.box` field maps to the Rect extension dtype, recovering the CRS, +/// box field names, and nullability. +#[test] +fn import_field_recovers_extension() -> VortexResult<()> { + let field = box_field("bbox", GeoArrowDimension::XY, true, Some("EPSG:4326")); + let dtype = SESSION.arrow().from_arrow_field(&field)?; + + let DType::Extension(ext) = &dtype else { + panic!("expected Extension dtype, got {dtype}"); + }; + assert!(ext.is::()); + assert_eq!(ext.metadata::().crs.as_deref(), Some("EPSG:4326")); + + let DType::Struct(fields, nullability) = ext.storage_dtype() else { + panic!("expected Struct storage, got {}", ext.storage_dtype()); + }; + assert_eq!(*nullability, Nullability::Nullable); + let names: Vec<&str> = fields.names().iter().map(|n| n.as_ref()).collect(); + assert_eq!(names, vec!["xmin", "ymin", "xmax", "ymax"]); + Ok(()) +} + +/// A `Rect` column exported to Arrow and imported back is unchanged, including the CRS and the +/// box corners. +#[test] +fn roundtrips_through_arrow() -> VortexResult<()> { + let mut ctx = SESSION.create_execution_ctx(); + let original = rect_column(vec![(0.0, 1.0, 2.0, 3.0), (-5.0, -5.0, 5.0, 5.0)])?; + + let target = box_field("bbox", GeoArrowDimension::XY, false, Some("EPSG:4326")); + let exported = SESSION + .arrow() + .execute_arrow(original, Some(&target), &mut ctx)?; + let reimported = SESSION.arrow().from_arrow_array(exported, &target)?; + + let ext = reimported + .dtype() + .as_extension_opt() + .ok_or_else(|| vortex_err!("expected Extension dtype"))?; + assert!(ext.is::()); + assert_eq!(ext.metadata::().crs.as_deref(), Some("EPSG:4326")); + + let mut corner = |row: usize, name: &str| -> VortexResult { + let scalar = reimported.execute_scalar(row, &mut ctx)?; + let storage = scalar + .as_extension_opt() + .ok_or_else(|| vortex_err!("expected extension scalar"))? + .to_storage_scalar(); + f64::try_from( + &storage + .as_struct() + .field(name) + .ok_or_else(|| vortex_err!("missing {name}"))?, + ) + }; + assert_eq!(corner(0, "xmin")?, 0.0); + assert_eq!(corner(0, "ymax")?, 3.0); + assert_eq!(corner(1, "xmin")?, -5.0); + assert_eq!(corner(1, "ymax")?, 5.0); + Ok(()) +} + +/// The existing geo scalar functions run on a `Rect` operand via the shared `geometries()` decode, +/// producing the same results as the equivalent polygon: a box `(0,0)-(10,10)` against interior +/// point `(5,5)` and exterior point `(20,20)`. +#[test] +fn scalar_functions_run_on_rect() -> VortexResult<()> { + use vortex_array::Canonical; + use vortex_array::IntoArray; + use vortex_array::arrays::BoolArray; + use vortex_array::assert_arrays_eq; + + use crate::scalar_fn::contains::GeoContains; + use crate::scalar_fn::distance::GeoDistance; + use crate::scalar_fn::intersects::GeoIntersects; + use crate::test_harness::point_column; + + let mut ctx = SESSION.create_execution_ctx(); + let bbox = rect_column(vec![(0.0, 0.0, 10.0, 10.0), (0.0, 0.0, 10.0, 10.0)])?; + let points = point_column(vec![5.0, 20.0], vec![5.0, 20.0])?; + + // Distance: 0 to the interior point, >0 to the exterior point. + let distance = GeoDistance::try_new_array(bbox.clone(), points.clone())?.into_array(); + let distance = distance.execute::(&mut ctx)?.into_primitive(); + let distances = distance.as_slice::(); + assert_eq!(distances[0], 0.0); + assert!(distances[1] > 0.0); + + // Intersects / Contains: true for the interior point, false for the exterior one. + let intersects = GeoIntersects::try_new_array(bbox.clone(), points.clone())?.into_array(); + assert_arrays_eq!(intersects, BoolArray::from_iter([true, false]), &mut ctx); + + let contains = GeoContains::try_new_array(bbox, points)?.into_array(); + assert_arrays_eq!(contains, BoolArray::from_iter([true, false]), &mut ctx); + Ok(()) +} From 5abaf9823dee973dde7295a6a36234935f08d060 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Mon, 13 Jul 2026 20:24:25 -0400 Subject: [PATCH 072/104] Fix flaky CodSpeed microbenchmarks (#8742) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small pool of microbenchmarks flips ±10-35% between two fixed values on unrelated PRs (including docs-only and lockfile-only changes), spamming every CodSpeed report. This PR fixes them (and removes only 1 benchmark). No `#[cfg(codspeed)]` gating; CI and local runs stay identical. One commit per benchmark. ## Changes - **mimalloc as global allocator** in the 5 flaky `vortex-array` bench files: `chunk_array_builder`, `dict_compress`, `varbinview_compact`, `compare`, `binary_ops` - **`bitwise_not_vortex_buffer_mut`**: drop the 128/1024/2048 sizes (measured only harness overhead) - **`slice_empty_vortex`**: rewrite as a 1024-iteration tight loop, renamed `slice_empty_tight_loop_vortex` - **`rebuild_naive` (vortex-zstd)**: the one benchmark removed instead of fixed — its cost is dominated by zstd-internal copies (glibc `ifunc`-resolved `memcpy`) that no bench-level change can stabilize, its fixture is degenerate (a 4-string dictionary with all-zero offsets), and ListView rebuild is already benchmarked across element types and list shapes in `vortex-array/benches/listview_rebuild.rs`. The crate's now-unused `divan` dev-dependency goes with it.
Which benchmarks were flaky, and the evidence Identified by reading the CodSpeed comments on the ~47 PRs merged since June 25 (post-#8490). The tell: the same benchmark flipping between the same two values, in both directions, on PRs that can't have affected it — including deny.toml-only (#8712, #8716), uv.lock-only (#8732), docs-only (#8737, #8728, #8685), and CI-YAML-only (#8660, #8683) changes. | Benchmark | Evidence | |---|---| | `bitwise_not_vortex_buffer_mut[128/1024/2048]` | ~half of all PRs — worst offender | | `chunked_varbinview_*` ×4 | ~20 PRs, both directions | | `chunked_bool_canonical_into[(1000,10)]` | ~2× flips (16µs ↔ 35µs) on 4 PRs | | `encode_varbin`, `encode_varbinview` | ~12 PRs; `encode_varbinview[(10000,4)]` also flipped on an earlier revision of this PR | | `compact`, `compact_sliced` (90%-utilization args) | ~8 PRs | | `compare_int_constant` | ±11.1% verbatim on ≥9 PRs | | `eq_i64_constant` | same ±11% signature incl. docs-only PR | | `slice_empty_vortex` | -14.66% verbatim on ~13 PRs | | `rebuild_naive` (vortex-zstd) | ~10 PRs, both directions | Watch list (left alone, below the ≥3-independent-sightings bar): `copy_nullable`/`copy_non_nullable[65536]` in `cast_decimal.rs`, `true_count_vortex_buffer[128]`.
Root causes and why each fix matches - **Allocation in the timed region** → glibc malloc's code differs across CodSpeed runner images, so alloc-heavy benchmarks trace differently for byte-identical Vortex code. Vendored mimalloc removes glibc malloc from the trace. Empirical support: the only three bench files already using mimalloc (`single_encoding_throughput`, `common_encoding_tree_throughput`, `row_encode`) are the most alloc-heavy suites in the repo and were never flagged once in the 47-PR window. - **Sub-microsecond work** → the measurement is fixed harness overhead plus binary code layout, which shifts with any unrelated change. Fix by making the operation dominate: the in-place NOT (no alloc, no copy) keeps only sizes where the loop dominates; the empty slice runs 1024× per iteration, mirroring the neighboring `slice_tight_loop_vortex`. - **Environment-bound and low-signal** → `rebuild_naive`, per the justification above: unfixable at the bench level and redundant with the vortex-array ListView rebuild suite, so removed.
Validation: A/A reruns and a stacked canary PR - **Expected one-time step changes on this PR**: swapping the allocator changes the trace of every benchmark in the touched files, so this PR's report shows a few ±10-20% level shifts (including on never-flaky `encode_primitives`, which just shares a file). These need a one-time acknowledgment on the CodSpeed dashboard; after merge every PR compares mimalloc-vs-mimalloc. - **A/A reruns** (same bench binaries measured three times, on separate runners and commits): every value reproduced exactly — 211.5µs, 137.1µs, 14.6µs, 26.3µs — with zero new flags across 1656 benchmarks. - **Canary #8743** (a #8681-style cold-string change stacked on this branch — the class of change that used to collect five false flags): **Performance Gate Passed**, `✅ 1660 untouched`, zero changes reported.
No public API changes; benchmark-only. https://claude.ai/code/session_01T6PPcdrqcNeUkfi1EGd4oC --------- Signed-off-by: Claude Co-authored-by: Claude --- Cargo.lock | 2 +- encodings/zstd/Cargo.toml | 5 -- encodings/zstd/benches/listview_rebuild.rs | 58 --------------------- vortex-array/Cargo.toml | 1 + vortex-array/benches/binary_ops.rs | 4 ++ vortex-array/benches/chunk_array_builder.rs | 4 ++ vortex-array/benches/compare.rs | 4 ++ vortex-array/benches/dict_compress.rs | 4 ++ vortex-array/benches/varbinview_compact.rs | 4 ++ vortex-buffer/benches/vortex_bitbuffer.rs | 7 ++- vortex-buffer/benches/vortex_buffer.rs | 11 +++- 11 files changed, 37 insertions(+), 67 deletions(-) delete mode 100644 encodings/zstd/benches/listview_rebuild.rs diff --git a/Cargo.lock b/Cargo.lock index 881744baa91..74236675f9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9680,6 +9680,7 @@ dependencies = [ "itertools 0.14.0", "jiff", "memchr", + "mimalloc", "num-traits", "num_enum", "parking_lot", @@ -10759,7 +10760,6 @@ dependencies = [ name = "vortex-zstd" version = "0.1.0" dependencies = [ - "codspeed-divan-compat", "itertools 0.14.0", "prost 0.14.4", "rstest", diff --git a/encodings/zstd/Cargo.toml b/encodings/zstd/Cargo.toml index e5e415544a2..1b2cb4f2dd0 100644 --- a/encodings/zstd/Cargo.toml +++ b/encodings/zstd/Cargo.toml @@ -34,10 +34,5 @@ vortex-session = { workspace = true } zstd = { workspace = true } [dev-dependencies] -divan = { workspace = true } rstest = { workspace = true } vortex-array = { workspace = true, features = ["_test-harness"] } - -[[bench]] -name = "listview_rebuild" -harness = false diff --git a/encodings/zstd/benches/listview_rebuild.rs b/encodings/zstd/benches/listview_rebuild.rs deleted file mode 100644 index e3c0e6d5dd1..00000000000 --- a/encodings/zstd/benches/listview_rebuild.rs +++ /dev/null @@ -1,58 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#![expect(clippy::unwrap_used)] - -use std::sync::LazyLock; - -use divan::Bencher; -use vortex_array::IntoArray; -use vortex_array::VortexSessionExecute; -use vortex_array::arrays::ListViewArray; -use vortex_array::arrays::VarBinViewArray; -use vortex_array::arrays::listview::ListViewRebuildMode; -use vortex_array::validity::Validity; -use vortex_buffer::Buffer; -use vortex_session::VortexSession; -use vortex_zstd::Zstd; -use vortex_zstd::ZstdData; - -/// A shared session for the `ListView` rebuild benchmark, used to create execution contexts. -static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); - -#[divan::bench(sample_size = 1000)] -fn rebuild_naive(bencher: Bencher) { - let dudes = VarBinViewArray::from_iter_str(["Washington", "Adams", "Jefferson", "Madison"]) - .into_array(); - let dtype = dudes.dtype().clone(); - let validity = dudes.validity().unwrap(); - let dudes = Zstd::try_new( - dtype, - ZstdData::from_array(dudes, 9, 1024, &mut SESSION.create_execution_ctx()).unwrap(), - validity, - ) - .unwrap() - .into_array(); - - let offsets = std::iter::repeat_n(0u32, 1024) - .collect::>() - .into_array(); - let sizes = [0u64, 1, 2, 3, 4] - .into_iter() - .cycle() - .take(1024) - .collect::>() - .into_array(); - - let list_view = ListViewArray::new(dudes, offsets, sizes, Validity::NonNullable); - - bencher - .with_inputs(|| (&list_view, SESSION.create_execution_ctx())) - .bench_refs(|(list_view, ctx)| { - list_view.rebuild(ListViewRebuildMode::MakeZeroCopyToList, ctx) - }) -} - -fn main() { - divan::main() -} diff --git a/vortex-array/Cargo.toml b/vortex-array/Cargo.toml index dcd8af6d76a..a1aa5b047f8 100644 --- a/vortex-array/Cargo.toml +++ b/vortex-array/Cargo.toml @@ -85,6 +85,7 @@ serde = ["dep:serde", "vortex-buffer/serde", "vortex-mask/serde"] divan = { workspace = true } futures = { workspace = true, features = ["executor"] } insta = { workspace = true } +mimalloc = { workspace = true } rand_distr = { workspace = true } rstest = { workspace = true } serde_json = { workspace = true } diff --git a/vortex-array/benches/binary_ops.rs b/vortex-array/benches/binary_ops.rs index d1f664f8645..0740eac43f0 100644 --- a/vortex-array/benches/binary_ops.rs +++ b/vortex-array/benches/binary_ops.rs @@ -11,6 +11,7 @@ use std::sync::LazyLock; use divan::Bencher; use divan::counter::ItemsCount; +use mimalloc::MiMalloc; use vortex_array::ArrayRef; use vortex_array::Executable; use vortex_array::IntoArray; @@ -23,6 +24,9 @@ use vortex_array::builtins::ArrayBuiltins; use vortex_array::scalar_fn::fns::operators::Operator; use vortex_session::VortexSession; +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; + fn main() { LazyLock::force(&SESSION); divan::main(); diff --git a/vortex-array/benches/chunk_array_builder.rs b/vortex-array/benches/chunk_array_builder.rs index 124db6e0f61..c2dedc6ef46 100644 --- a/vortex-array/benches/chunk_array_builder.rs +++ b/vortex-array/benches/chunk_array_builder.rs @@ -4,6 +4,7 @@ use std::sync::LazyLock; use divan::Bencher; +use mimalloc::MiMalloc; use rand::RngExt; use rand::SeedableRng; use rand::prelude::StdRng; @@ -22,6 +23,9 @@ use vortex_array::dtype::DType; use vortex_error::VortexExpect; use vortex_session::VortexSession; +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; + fn main() { LazyLock::force(&SESSION); divan::main(); diff --git a/vortex-array/benches/compare.rs b/vortex-array/benches/compare.rs index aaeda5ab3b6..4a399760dc2 100644 --- a/vortex-array/benches/compare.rs +++ b/vortex-array/benches/compare.rs @@ -4,6 +4,7 @@ #![expect(clippy::unwrap_used)] use divan::Bencher; +use mimalloc::MiMalloc; use rand::RngExt; use rand::SeedableRng; use rand::distr::Uniform; @@ -25,6 +26,9 @@ use vortex_array::scalar_fn::fns::operators::Operator; use vortex_array::validity::Validity; use vortex_buffer::Buffer; +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; + fn main() { divan::main(); } diff --git a/vortex-array/benches/dict_compress.rs b/vortex-array/benches/dict_compress.rs index 6dd8150dc02..fa53dae8c43 100644 --- a/vortex-array/benches/dict_compress.rs +++ b/vortex-array/benches/dict_compress.rs @@ -6,6 +6,7 @@ use std::sync::LazyLock; use divan::Bencher; +use mimalloc::MiMalloc; use rand::distr::Distribution; use rand::distr::StandardUniform; use vortex_array::Canonical; @@ -20,6 +21,9 @@ use vortex_array::builders::dict::dict_encode; use vortex_array::dtype::NativePType; use vortex_session::VortexSession; +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; + fn main() { LazyLock::force(&SESSION); divan::main(); diff --git a/vortex-array/benches/varbinview_compact.rs b/vortex-array/benches/varbinview_compact.rs index d68420a264f..80f7e18ec30 100644 --- a/vortex-array/benches/varbinview_compact.rs +++ b/vortex-array/benches/varbinview_compact.rs @@ -4,6 +4,7 @@ use std::sync::LazyLock; use divan::Bencher; +use mimalloc::MiMalloc; use rand::RngExt; use rand::SeedableRng; use rand::rngs::StdRng; @@ -19,6 +20,9 @@ use vortex_buffer::Buffer; use vortex_error::VortexExpect; use vortex_session::VortexSession; +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; + fn main() { divan::main(); } diff --git a/vortex-buffer/benches/vortex_bitbuffer.rs b/vortex-buffer/benches/vortex_bitbuffer.rs index 7efc6c6a114..923ef2aa98e 100644 --- a/vortex-buffer/benches/vortex_bitbuffer.rs +++ b/vortex-buffer/benches/vortex_bitbuffer.rs @@ -246,7 +246,12 @@ fn bitwise_not_vortex_buffer(bencher: Bencher, length: usize) { .bench_values(|buffer| !&buffer); } -#[divan::bench(args = INPUT_SIZE)] +/// The in-place NOT on an owned `BitBufferMut` performs no allocation and no copy, so below a +/// few thousand bits the measurement is fixed harness overhead and binary code layout rather +/// than the loop itself. Only the sizes where the loop dominates are worth measuring. +const NOT_MUT_INPUT_SIZE: &[usize] = &[16_384, 65_536]; + +#[divan::bench(args = NOT_MUT_INPUT_SIZE)] fn bitwise_not_vortex_buffer_mut(bencher: Bencher, length: usize) { bencher .with_inputs(|| BitBufferMut::from_iter((0..length).map(|i| i % 2 == 0))) diff --git a/vortex-buffer/benches/vortex_buffer.rs b/vortex-buffer/benches/vortex_buffer.rs index 74a402887f0..e1e6683e505 100644 --- a/vortex-buffer/benches/vortex_buffer.rs +++ b/vortex-buffer/benches/vortex_buffer.rs @@ -170,10 +170,17 @@ fn slice_tight_loop_arrow(bencher: Bencher, len: usize) { }); } +/// Loops like `slice_tight_loop_vortex` above: a single empty slice is a few nanoseconds, far +/// below the measurement noise floor, so the loop amortizes fixed overhead until the slice +/// itself dominates. #[divan::bench] -fn slice_empty_vortex(bencher: Bencher) { +fn slice_empty_tight_loop_vortex(bencher: Bencher) { let buf = Buffer::::from_iter((0..1024).map(|i| i % i32::MAX)); - bencher.bench(|| divan::black_box(buf.slice(8..8))); + bencher.bench(|| { + for _ in 0..1024 { + divan::black_box(buf.slice(8..8)); + } + }); } #[divan::bench(args = INPUT_SIZE)] From e519a02c430b9158243babfa0bede565b6e0deab Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Tue, 14 Jul 2026 10:24:21 -0400 Subject: [PATCH 073/104] Reorganize the compressor (#8745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Rationale for this change Tracking issue: https://github.com/vortex-data/vortex/issues/7697 I'd like to make some improvements to the compressor logic, and having a better structured file system makes that a bit easier. This is a purely cosmetic change (except for some paths changing, namely `vortex_compressor::ctx::*` and `vortex_compressor::estimate::*`). ## What changes are included in this PR? Moving around a lot of stuff, the tree is now: ``` └── src ├── builtins │ ├── dict │ │ ├── ... │ └── mod.rs ├── compressor │ ├── cascade.rs │ ├── constant.rs │ ├── mod.rs │ ├── sample.rs │ ├── select.rs │ ├── structural.rs │ └── tests.rs ├── lib.rs ├── scheme │ ├── ctx.rs │ ├── estimate.rs │ ├── exclusion.rs │ └── mod.rs ├── stats │ ├── ... └── trace.rs ``` Signed-off-by: Connor Tsui --- vortex-btrblocks/src/lib.rs | 4 +- vortex-btrblocks/src/schemes/binary/zstd.rs | 4 +- .../src/schemes/binary/zstd_buffers.rs | 4 +- vortex-btrblocks/src/schemes/decimal.rs | 4 +- vortex-btrblocks/src/schemes/float/alp.rs | 6 +- vortex-btrblocks/src/schemes/float/alprd.rs | 6 +- vortex-btrblocks/src/schemes/float/pco.rs | 4 +- vortex-btrblocks/src/schemes/float/rle.rs | 6 +- vortex-btrblocks/src/schemes/float/sparse.rs | 4 +- .../src/schemes/integer/bitpacking.rs | 6 +- vortex-btrblocks/src/schemes/integer/delta.rs | 8 +- vortex-btrblocks/src/schemes/integer/for_.rs | 4 +- vortex-btrblocks/src/schemes/integer/pco.rs | 6 +- vortex-btrblocks/src/schemes/integer/rle.rs | 6 +- .../src/schemes/integer/runend.rs | 6 +- .../src/schemes/integer/sequence.rs | 8 +- .../src/schemes/integer/sparse.rs | 4 +- .../src/schemes/integer/zigzag.rs | 6 +- vortex-btrblocks/src/schemes/string/fsst.rs | 4 +- vortex-btrblocks/src/schemes/string/onpair.rs | 4 +- vortex-btrblocks/src/schemes/string/sparse.rs | 4 +- vortex-btrblocks/src/schemes/string/zstd.rs | 4 +- .../src/schemes/string/zstd_buffers.rs | 4 +- vortex-btrblocks/src/schemes/temporal.rs | 4 +- vortex-compressor/src/builtins/dict/binary.rs | 8 +- vortex-compressor/src/builtins/dict/float.rs | 8 +- .../src/builtins/dict/integer.rs | 6 +- vortex-compressor/src/builtins/dict/string.rs | 8 +- vortex-compressor/src/compressor.rs | 1369 ----------------- vortex-compressor/src/compressor/cascade.rs | 324 ++++ .../src/{ => compressor}/constant.rs | 0 vortex-compressor/src/compressor/mod.rs | 73 + .../src/{ => compressor}/sample.rs | 59 + vortex-compressor/src/compressor/select.rs | 187 +++ .../src/compressor/structural.rs | 128 ++ vortex-compressor/src/compressor/tests.rs | 706 +++++++++ vortex-compressor/src/lib.rs | 8 +- vortex-compressor/src/{ => scheme}/ctx.rs | 8 +- .../src/{ => scheme}/estimate.rs | 102 +- vortex-compressor/src/scheme/exclusion.rs | 54 + .../src/{scheme.rs => scheme/mod.rs} | 71 +- vortex-compressor/src/trace.rs | 2 +- vortex-tensor/src/encodings/l2_denorm.rs | 6 +- 43 files changed, 1644 insertions(+), 1603 deletions(-) delete mode 100644 vortex-compressor/src/compressor.rs create mode 100644 vortex-compressor/src/compressor/cascade.rs rename vortex-compressor/src/{ => compressor}/constant.rs (100%) create mode 100644 vortex-compressor/src/compressor/mod.rs rename vortex-compressor/src/{ => compressor}/sample.rs (74%) create mode 100644 vortex-compressor/src/compressor/select.rs create mode 100644 vortex-compressor/src/compressor/structural.rs create mode 100644 vortex-compressor/src/compressor/tests.rs rename vortex-compressor/src/{ => scheme}/ctx.rs (95%) rename vortex-compressor/src/{ => scheme}/estimate.rs (64%) create mode 100644 vortex-compressor/src/scheme/exclusion.rs rename vortex-compressor/src/{scheme.rs => scheme/mod.rs} (80%) diff --git a/vortex-btrblocks/src/lib.rs b/vortex-btrblocks/src/lib.rs index 69ddb59738c..37915b427b6 100644 --- a/vortex-btrblocks/src/lib.rs +++ b/vortex-btrblocks/src/lib.rs @@ -78,8 +78,8 @@ pub use builder::BtrBlocksCompressorBuilder; pub use canonical_compressor::BtrBlocksCompressor; pub use schemes::patches::compress_patches; pub use vortex_compressor::CascadingCompressor; -pub use vortex_compressor::ctx::CompressorContext; -pub use vortex_compressor::ctx::MAX_CASCADE; +pub use vortex_compressor::scheme::CompressorContext; +pub use vortex_compressor::scheme::MAX_CASCADE; pub use vortex_compressor::scheme::Scheme; pub use vortex_compressor::scheme::SchemeExt; pub use vortex_compressor::scheme::SchemeId; diff --git a/vortex-btrblocks/src/schemes/binary/zstd.rs b/vortex-btrblocks/src/schemes/binary/zstd.rs index b63049f36e7..83c09d24e94 100644 --- a/vortex-btrblocks/src/schemes/binary/zstd.rs +++ b/vortex-btrblocks/src/schemes/binary/zstd.rs @@ -7,8 +7,8 @@ use vortex_array::ArrayRef; use vortex_array::Canonical; use vortex_array::ExecutionCtx; use vortex_array::IntoArray; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; use vortex_error::VortexResult; use crate::ArrayAndStats; diff --git a/vortex-btrblocks/src/schemes/binary/zstd_buffers.rs b/vortex-btrblocks/src/schemes/binary/zstd_buffers.rs index 269665866a2..a84672ef860 100644 --- a/vortex-btrblocks/src/schemes/binary/zstd_buffers.rs +++ b/vortex-btrblocks/src/schemes/binary/zstd_buffers.rs @@ -7,8 +7,8 @@ use vortex_array::ArrayRef; use vortex_array::Canonical; use vortex_array::ExecutionCtx; use vortex_array::IntoArray; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; use vortex_error::VortexResult; use crate::ArrayAndStats; diff --git a/vortex-btrblocks/src/schemes/decimal.rs b/vortex-btrblocks/src/schemes/decimal.rs index d60e5c6b175..20c5cd80a3c 100644 --- a/vortex-btrblocks/src/schemes/decimal.rs +++ b/vortex-btrblocks/src/schemes/decimal.rs @@ -11,8 +11,8 @@ use vortex_array::arrays::DecimalArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::decimal::narrowed_decimal; use vortex_array::dtype::DecimalType; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::EstimateVerdict; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::EstimateVerdict; use vortex_decimal_byte_parts::DecimalByteParts; use vortex_error::VortexResult; diff --git a/vortex-btrblocks/src/schemes/float/alp.rs b/vortex-btrblocks/src/schemes/float/alp.rs index ba8d97f676c..b8a26abf348 100644 --- a/vortex-btrblocks/src/schemes/float/alp.rs +++ b/vortex-btrblocks/src/schemes/float/alp.rs @@ -15,9 +15,9 @@ use vortex_array::arrays::Patched; use vortex_array::arrays::patched::use_experimental_patches; use vortex_array::arrays::primitive::PrimitiveArrayExt; use vortex_array::dtype::PType; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; -use vortex_compressor::estimate::EstimateVerdict; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; +use vortex_compressor::scheme::EstimateVerdict; use vortex_error::VortexResult; use crate::ArrayAndStats; diff --git a/vortex-btrblocks/src/schemes/float/alprd.rs b/vortex-btrblocks/src/schemes/float/alprd.rs index 969afabad49..662a47a1d9c 100644 --- a/vortex-btrblocks/src/schemes/float/alprd.rs +++ b/vortex-btrblocks/src/schemes/float/alprd.rs @@ -12,9 +12,9 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::primitive::PrimitiveArrayExt; use vortex_array::dtype::PType; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; -use vortex_compressor::estimate::EstimateVerdict; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; +use vortex_compressor::scheme::EstimateVerdict; use vortex_error::VortexResult; use vortex_error::vortex_panic; diff --git a/vortex-btrblocks/src/schemes/float/pco.rs b/vortex-btrblocks/src/schemes/float/pco.rs index 34147f3dfdc..dc9a96133d5 100644 --- a/vortex-btrblocks/src/schemes/float/pco.rs +++ b/vortex-btrblocks/src/schemes/float/pco.rs @@ -7,8 +7,8 @@ use vortex_array::ArrayRef; use vortex_array::Canonical; use vortex_array::ExecutionCtx; use vortex_array::IntoArray; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; use vortex_error::VortexResult; use crate::ArrayAndStats; diff --git a/vortex-btrblocks/src/schemes/float/rle.rs b/vortex-btrblocks/src/schemes/float/rle.rs index 54406fcb9fd..c2683ed131c 100644 --- a/vortex-btrblocks/src/schemes/float/rle.rs +++ b/vortex-btrblocks/src/schemes/float/rle.rs @@ -6,11 +6,11 @@ use vortex_array::ArrayRef; use vortex_array::Canonical; use vortex_array::ExecutionCtx; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; -use vortex_compressor::estimate::EstimateVerdict; use vortex_compressor::scheme::AncestorExclusion; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; use vortex_compressor::scheme::DescendantExclusion; +use vortex_compressor::scheme::EstimateVerdict; use vortex_error::VortexResult; use crate::ArrayAndStats; diff --git a/vortex-btrblocks/src/schemes/float/sparse.rs b/vortex-btrblocks/src/schemes/float/sparse.rs index 8f41143c971..42d09e323c5 100644 --- a/vortex-btrblocks/src/schemes/float/sparse.rs +++ b/vortex-btrblocks/src/schemes/float/sparse.rs @@ -9,10 +9,10 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::primitive::PrimitiveArrayExt; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::EstimateVerdict; use vortex_compressor::scheme::ChildSelection; +use vortex_compressor::scheme::CompressionEstimate; use vortex_compressor::scheme::DescendantExclusion; +use vortex_compressor::scheme::EstimateVerdict; use vortex_error::VortexResult; use vortex_sparse::Sparse; use vortex_sparse::SparseExt as _; diff --git a/vortex-btrblocks/src/schemes/integer/bitpacking.rs b/vortex-btrblocks/src/schemes/integer/bitpacking.rs index f742f8deac1..2424402649e 100644 --- a/vortex-btrblocks/src/schemes/integer/bitpacking.rs +++ b/vortex-btrblocks/src/schemes/integer/bitpacking.rs @@ -10,9 +10,9 @@ use vortex_array::IntoArray; use vortex_array::arrays::Patched; use vortex_array::arrays::patched::use_experimental_patches; use vortex_array::arrays::primitive::PrimitiveArrayExt; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; -use vortex_compressor::estimate::EstimateVerdict; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; +use vortex_compressor::scheme::EstimateVerdict; use vortex_error::VortexResult; use vortex_fastlanes::BitPacked; use vortex_fastlanes::bitpack_compress::bit_width_histogram; diff --git a/vortex-btrblocks/src/schemes/integer/delta.rs b/vortex-btrblocks/src/schemes/integer/delta.rs index f25bc6ce466..ed087f5d845 100644 --- a/vortex-btrblocks/src/schemes/integer/delta.rs +++ b/vortex-btrblocks/src/schemes/integer/delta.rs @@ -12,13 +12,13 @@ use vortex_compressor::builtins::BinaryDictScheme; use vortex_compressor::builtins::FloatDictScheme; use vortex_compressor::builtins::IntDictScheme; use vortex_compressor::builtins::StringDictScheme; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; -use vortex_compressor::estimate::EstimateScore; -use vortex_compressor::estimate::EstimateVerdict; use vortex_compressor::scheme::AncestorExclusion; use vortex_compressor::scheme::ChildSelection; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; use vortex_compressor::scheme::DescendantExclusion; +use vortex_compressor::scheme::EstimateScore; +use vortex_compressor::scheme::EstimateVerdict; use vortex_error::VortexResult; use vortex_fastlanes::Delta; diff --git a/vortex-btrblocks/src/schemes/integer/for_.rs b/vortex-btrblocks/src/schemes/integer/for_.rs index ec23f1114db..832ed4819fd 100644 --- a/vortex-btrblocks/src/schemes/integer/for_.rs +++ b/vortex-btrblocks/src/schemes/integer/for_.rs @@ -12,10 +12,10 @@ use vortex_compressor::builtins::BinaryDictScheme; use vortex_compressor::builtins::FloatDictScheme; use vortex_compressor::builtins::IntDictScheme; use vortex_compressor::builtins::StringDictScheme; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::EstimateVerdict; use vortex_compressor::scheme::AncestorExclusion; use vortex_compressor::scheme::ChildSelection; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::EstimateVerdict; use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_fastlanes::FoR; diff --git a/vortex-btrblocks/src/schemes/integer/pco.rs b/vortex-btrblocks/src/schemes/integer/pco.rs index 63e5917a996..d7f182f588e 100644 --- a/vortex-btrblocks/src/schemes/integer/pco.rs +++ b/vortex-btrblocks/src/schemes/integer/pco.rs @@ -7,9 +7,9 @@ use vortex_array::ArrayRef; use vortex_array::Canonical; use vortex_array::ExecutionCtx; use vortex_array::IntoArray; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; -use vortex_compressor::estimate::EstimateVerdict; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; +use vortex_compressor::scheme::EstimateVerdict; use vortex_error::VortexResult; use crate::ArrayAndStats; diff --git a/vortex-btrblocks/src/schemes/integer/rle.rs b/vortex-btrblocks/src/schemes/integer/rle.rs index df0f0183780..5bc4e5296f1 100644 --- a/vortex-btrblocks/src/schemes/integer/rle.rs +++ b/vortex-btrblocks/src/schemes/integer/rle.rs @@ -9,11 +9,11 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::primitive::PrimitiveArrayExt; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; -use vortex_compressor::estimate::EstimateVerdict; use vortex_compressor::scheme::AncestorExclusion; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; use vortex_compressor::scheme::DescendantExclusion; +use vortex_compressor::scheme::EstimateVerdict; #[cfg(feature = "unstable_encodings")] use vortex_compressor::scheme::SchemeId; use vortex_error::VortexResult; diff --git a/vortex-btrblocks/src/schemes/integer/runend.rs b/vortex-btrblocks/src/schemes/integer/runend.rs index b68567d789c..175f33e7406 100644 --- a/vortex-btrblocks/src/schemes/integer/runend.rs +++ b/vortex-btrblocks/src/schemes/integer/runend.rs @@ -12,12 +12,12 @@ use vortex_compressor::builtins::BinaryDictScheme; use vortex_compressor::builtins::FloatDictScheme; use vortex_compressor::builtins::IntDictScheme; use vortex_compressor::builtins::StringDictScheme; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; -use vortex_compressor::estimate::EstimateVerdict; use vortex_compressor::scheme::AncestorExclusion; use vortex_compressor::scheme::ChildSelection; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; use vortex_compressor::scheme::DescendantExclusion; +use vortex_compressor::scheme::EstimateVerdict; use vortex_error::VortexResult; use vortex_runend::RunEnd; use vortex_runend::compress::runend_encode; diff --git a/vortex-btrblocks/src/schemes/integer/sequence.rs b/vortex-btrblocks/src/schemes/integer/sequence.rs index 604d7567d00..fda9995b510 100644 --- a/vortex-btrblocks/src/schemes/integer/sequence.rs +++ b/vortex-btrblocks/src/schemes/integer/sequence.rs @@ -10,12 +10,12 @@ use vortex_compressor::builtins::BinaryDictScheme; use vortex_compressor::builtins::FloatDictScheme; use vortex_compressor::builtins::IntDictScheme; use vortex_compressor::builtins::StringDictScheme; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; -use vortex_compressor::estimate::EstimateScore; -use vortex_compressor::estimate::EstimateVerdict; use vortex_compressor::scheme::AncestorExclusion; use vortex_compressor::scheme::ChildSelection; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; +use vortex_compressor::scheme::EstimateScore; +use vortex_compressor::scheme::EstimateVerdict; use vortex_error::VortexResult; use vortex_error::vortex_bail; use vortex_error::vortex_err; diff --git a/vortex-btrblocks/src/schemes/integer/sparse.rs b/vortex-btrblocks/src/schemes/integer/sparse.rs index 609a258d495..e843d9d7999 100644 --- a/vortex-btrblocks/src/schemes/integer/sparse.rs +++ b/vortex-btrblocks/src/schemes/integer/sparse.rs @@ -12,10 +12,10 @@ use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::primitive::PrimitiveArrayExt; use vortex_array::scalar::Scalar; use vortex_compressor::builtins::IntDictScheme; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::EstimateVerdict; use vortex_compressor::scheme::ChildSelection; +use vortex_compressor::scheme::CompressionEstimate; use vortex_compressor::scheme::DescendantExclusion; +use vortex_compressor::scheme::EstimateVerdict; use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_sparse::Sparse; diff --git a/vortex-btrblocks/src/schemes/integer/zigzag.rs b/vortex-btrblocks/src/schemes/integer/zigzag.rs index 87fb38e02b8..959889dabac 100644 --- a/vortex-btrblocks/src/schemes/integer/zigzag.rs +++ b/vortex-btrblocks/src/schemes/integer/zigzag.rs @@ -12,12 +12,12 @@ use vortex_compressor::builtins::BinaryDictScheme; use vortex_compressor::builtins::FloatDictScheme; use vortex_compressor::builtins::IntDictScheme; use vortex_compressor::builtins::StringDictScheme; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; -use vortex_compressor::estimate::EstimateVerdict; use vortex_compressor::scheme::AncestorExclusion; use vortex_compressor::scheme::ChildSelection; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; use vortex_compressor::scheme::DescendantExclusion; +use vortex_compressor::scheme::EstimateVerdict; use vortex_error::VortexResult; use vortex_zigzag::ZigZag; use vortex_zigzag::ZigZagArrayExt; diff --git a/vortex-btrblocks/src/schemes/string/fsst.rs b/vortex-btrblocks/src/schemes/string/fsst.rs index 5e6baf73933..bd5bd010396 100644 --- a/vortex-btrblocks/src/schemes/string/fsst.rs +++ b/vortex-btrblocks/src/schemes/string/fsst.rs @@ -11,8 +11,8 @@ use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::VarBinArray; use vortex_array::arrays::primitive::PrimitiveArrayExt; use vortex_array::arrays::varbin::VarBinArrayExt; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; use vortex_error::VortexResult; use vortex_fsst::FSST; use vortex_fsst::FSSTArrayExt; diff --git a/vortex-btrblocks/src/schemes/string/onpair.rs b/vortex-btrblocks/src/schemes/string/onpair.rs index 7bbe480d059..f0a8966089c 100644 --- a/vortex-btrblocks/src/schemes/string/onpair.rs +++ b/vortex-btrblocks/src/schemes/string/onpair.rs @@ -9,8 +9,8 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::primitive::PrimitiveArrayExt; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; use vortex_compressor::scheme::SchemeId; use vortex_error::VortexResult; use vortex_onpair::DEFAULT_DICT12_CONFIG; diff --git a/vortex-btrblocks/src/schemes/string/sparse.rs b/vortex-btrblocks/src/schemes/string/sparse.rs index 37f6c4b05cf..750dd978030 100644 --- a/vortex-btrblocks/src/schemes/string/sparse.rs +++ b/vortex-btrblocks/src/schemes/string/sparse.rs @@ -9,10 +9,10 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::primitive::PrimitiveArrayExt; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::EstimateVerdict; use vortex_compressor::scheme::ChildSelection; +use vortex_compressor::scheme::CompressionEstimate; use vortex_compressor::scheme::DescendantExclusion; +use vortex_compressor::scheme::EstimateVerdict; use vortex_error::VortexResult; use vortex_sparse::Sparse; use vortex_sparse::SparseExt as _; diff --git a/vortex-btrblocks/src/schemes/string/zstd.rs b/vortex-btrblocks/src/schemes/string/zstd.rs index c799b64e359..177acfa1543 100644 --- a/vortex-btrblocks/src/schemes/string/zstd.rs +++ b/vortex-btrblocks/src/schemes/string/zstd.rs @@ -7,8 +7,8 @@ use vortex_array::ArrayRef; use vortex_array::Canonical; use vortex_array::ExecutionCtx; use vortex_array::IntoArray; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; use vortex_error::VortexResult; use crate::ArrayAndStats; diff --git a/vortex-btrblocks/src/schemes/string/zstd_buffers.rs b/vortex-btrblocks/src/schemes/string/zstd_buffers.rs index 0456bbc4204..a8d16cc837f 100644 --- a/vortex-btrblocks/src/schemes/string/zstd_buffers.rs +++ b/vortex-btrblocks/src/schemes/string/zstd_buffers.rs @@ -7,8 +7,8 @@ use vortex_array::ArrayRef; use vortex_array::Canonical; use vortex_array::ExecutionCtx; use vortex_array::IntoArray; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::DeferredEstimate; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::DeferredEstimate; use vortex_error::VortexResult; use crate::ArrayAndStats; diff --git a/vortex-btrblocks/src/schemes/temporal.rs b/vortex-btrblocks/src/schemes/temporal.rs index 3ea4aaf3654..6989b6a2aba 100644 --- a/vortex-btrblocks/src/schemes/temporal.rs +++ b/vortex-btrblocks/src/schemes/temporal.rs @@ -15,8 +15,8 @@ use vortex_array::arrays::primitive::PrimitiveArrayExt; use vortex_array::dtype::extension::Matcher; use vortex_array::extension::datetime::AnyTemporal; use vortex_array::extension::datetime::TemporalMetadata; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::EstimateVerdict; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::EstimateVerdict; use vortex_datetime_parts::DateTimeParts; use vortex_datetime_parts::TemporalParts; use vortex_datetime_parts::split_temporal; diff --git a/vortex-compressor/src/builtins/dict/binary.rs b/vortex-compressor/src/builtins/dict/binary.rs index bd194aa1121..72b5f01141a 100644 --- a/vortex-compressor/src/builtins/dict/binary.rs +++ b/vortex-compressor/src/builtins/dict/binary.rs @@ -21,12 +21,12 @@ use vortex_error::VortexResult; use crate::CascadingCompressor; use crate::builtins::IntDictScheme; -use crate::ctx::CompressorContext; -use crate::estimate::CompressionEstimate; -use crate::estimate::DeferredEstimate; -use crate::estimate::EstimateVerdict; use crate::scheme::ChildSelection; +use crate::scheme::CompressionEstimate; +use crate::scheme::CompressorContext; +use crate::scheme::DeferredEstimate; use crate::scheme::DescendantExclusion; +use crate::scheme::EstimateVerdict; use crate::scheme::Scheme; use crate::scheme::SchemeExt; use crate::stats::ArrayAndStats; diff --git a/vortex-compressor/src/builtins/dict/float.rs b/vortex-compressor/src/builtins/dict/float.rs index 5288637a609..074d3ce5e07 100644 --- a/vortex-compressor/src/builtins/dict/float.rs +++ b/vortex-compressor/src/builtins/dict/float.rs @@ -25,12 +25,12 @@ use vortex_error::VortexResult; use crate::CascadingCompressor; use crate::builtins::IntDictScheme; -use crate::ctx::CompressorContext; -use crate::estimate::CompressionEstimate; -use crate::estimate::DeferredEstimate; -use crate::estimate::EstimateVerdict; use crate::scheme::ChildSelection; +use crate::scheme::CompressionEstimate; +use crate::scheme::CompressorContext; +use crate::scheme::DeferredEstimate; use crate::scheme::DescendantExclusion; +use crate::scheme::EstimateVerdict; use crate::scheme::Scheme; use crate::scheme::SchemeExt; use crate::stats::ArrayAndStats; diff --git a/vortex-compressor/src/builtins/dict/integer.rs b/vortex-compressor/src/builtins/dict/integer.rs index 8ec3bf53345..4e91bace3ac 100644 --- a/vortex-compressor/src/builtins/dict/integer.rs +++ b/vortex-compressor/src/builtins/dict/integer.rs @@ -23,9 +23,9 @@ use vortex_error::VortexExpect; use vortex_error::VortexResult; use crate::CascadingCompressor; -use crate::ctx::CompressorContext; -use crate::estimate::CompressionEstimate; -use crate::estimate::EstimateVerdict; +use crate::scheme::CompressionEstimate; +use crate::scheme::CompressorContext; +use crate::scheme::EstimateVerdict; use crate::scheme::Scheme; use crate::scheme::SchemeExt; use crate::stats::ArrayAndStats; diff --git a/vortex-compressor/src/builtins/dict/string.rs b/vortex-compressor/src/builtins/dict/string.rs index 72db72c2cc5..64ed469dde9 100644 --- a/vortex-compressor/src/builtins/dict/string.rs +++ b/vortex-compressor/src/builtins/dict/string.rs @@ -21,12 +21,12 @@ use vortex_error::VortexResult; use crate::CascadingCompressor; use crate::builtins::IntDictScheme; -use crate::ctx::CompressorContext; -use crate::estimate::CompressionEstimate; -use crate::estimate::DeferredEstimate; -use crate::estimate::EstimateVerdict; use crate::scheme::ChildSelection; +use crate::scheme::CompressionEstimate; +use crate::scheme::CompressorContext; +use crate::scheme::DeferredEstimate; use crate::scheme::DescendantExclusion; +use crate::scheme::EstimateVerdict; use crate::scheme::Scheme; use crate::scheme::SchemeExt; use crate::stats::ArrayAndStats; diff --git a/vortex-compressor/src/compressor.rs b/vortex-compressor/src/compressor.rs deleted file mode 100644 index 727aa4ed98b..00000000000 --- a/vortex-compressor/src/compressor.rs +++ /dev/null @@ -1,1369 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -//! Cascading array compression implementation. - -use vortex_array::ArrayRef; -use vortex_array::ArraySlots; -use vortex_array::Canonical; -use vortex_array::CanonicalValidity; -use vortex_array::ExecutionCtx; -use vortex_array::IntoArray; -use vortex_array::arrays::Constant; -use vortex_array::arrays::ConstantArray; -use vortex_array::arrays::ExtensionArray; -use vortex_array::arrays::FixedSizeListArray; -use vortex_array::arrays::ListArray; -use vortex_array::arrays::ListViewArray; -use vortex_array::arrays::Masked; -use vortex_array::arrays::PrimitiveArray; -use vortex_array::arrays::StructArray; -use vortex_array::arrays::Variant; -use vortex_array::arrays::VariantArray; -use vortex_array::arrays::extension::ExtensionArrayExt; -use vortex_array::arrays::fixed_size_list::FixedSizeListArrayExt; -use vortex_array::arrays::list::ListArrayExt; -use vortex_array::arrays::listview::ListViewArrayExt; -use vortex_array::arrays::listview::list_from_list_view; -use vortex_array::arrays::masked::MaskedArraySlotsExt; -use vortex_array::arrays::primitive::PrimitiveArrayExt; -use vortex_array::arrays::scalar_fn::AnyScalarFn; -use vortex_array::arrays::struct_::StructArrayExt; -use vortex_array::arrays::variant::VariantArrayExt; -use vortex_array::scalar::Scalar; -use vortex_error::VortexResult; - -use crate::builtins::IntDictScheme; -use crate::constant; -use crate::ctx::CompressorContext; -use crate::estimate::CompressionEstimate; -use crate::estimate::DeferredEstimate; -use crate::estimate::EstimateScore; -use crate::estimate::EstimateVerdict; -use crate::estimate::WinnerEstimate; -use crate::estimate::estimate_compression_ratio_with_sampling; -use crate::estimate::is_better_score; -use crate::scheme::ChildSelection; -use crate::scheme::DescendantExclusion; -use crate::scheme::Scheme; -use crate::scheme::SchemeExt; -use crate::scheme::SchemeId; -use crate::stats::ArrayAndStats; -use crate::stats::GenerateStatsOptions; -use crate::trace; - -/// Synthetic scheme ID used for the compressor's own root-level cascading. -pub(crate) const ROOT_SCHEME_ID: SchemeId = SchemeId { - name: "vortex.compressor.root", -}; - -/// Child indices for the compressor's list/listview compression. -mod root_list_children { - /// List/ListView offsets child. - pub const OFFSETS: usize = 1; - /// ListView sizes child. - pub const SIZES: usize = 2; -} - -/// The main compressor type implementing cascading adaptive compression. -/// -/// This compressor applies adaptive compression [`Scheme`]s to arrays based on their data types and -/// characteristics. It recursively compresses nested structures like structs and lists, and chooses -/// optimal compression schemes for leaf types. -/// -/// The compressor works by: -/// 1. Canonicalizing input arrays to a standard representation. -/// 2. Pre-filtering schemes by [`Scheme::matches`] and exclusion rules. -/// 3. Evaluating each matching scheme's compression estimate and resolving deferred work. -/// 4. Compressing with the best scheme and verifying the result is smaller. -/// -/// No scheme may appear twice in a cascade chain. The compressor enforces this automatically -/// along with push/pull exclusion rules declared by each scheme. -/// -/// Downstream crates usually wrap this type with a preconfigured scheme set. Use it directly when -/// embedding a custom fixed scheme list or testing scheme interactions. -#[derive(Debug, Clone)] -pub struct CascadingCompressor { - /// The enabled compression schemes. - schemes: Vec<&'static dyn Scheme>, - - /// Descendant exclusion rules for the compressor's own cascading (e.g. excluding Dict from - /// list offsets). - root_exclusions: Vec, -} - -impl CascadingCompressor { - /// Creates a new compressor with the given schemes. - /// - /// Root-level exclusion rules (e.g. excluding Dict from list offsets) are built - /// automatically. - pub fn new(schemes: Vec<&'static dyn Scheme>) -> Self { - // Root exclusion: exclude IntDict from list/listview offsets (monotonically - // increasing data where dictionary encoding is wasteful). - let root_exclusions = vec![DescendantExclusion { - excluded: IntDictScheme.id(), - children: ChildSelection::One(root_list_children::OFFSETS), - }]; - Self { - schemes, - root_exclusions, - } - } - - /// Compresses an array using cascading adaptive compression. - /// - /// First canonicalizes and compacts the array, then applies optimal compression schemes. - /// - /// # Errors - /// - /// Returns an error if canonicalization or compression fails. - pub fn compress( - &self, - array: &ArrayRef, - exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - let before_nbytes = array.nbytes(); - let span = trace::compress_span(array.len(), array.dtype(), before_nbytes); - let _enter = span.enter(); - - let canonical = array.clone().execute::(exec_ctx)?.0; - let compact = canonical.compact(exec_ctx)?; - let compressed = self.compress_canonical(compact, CompressorContext::new(), exec_ctx)?; - - trace::record_compress_outcome(&span, before_nbytes, compressed.nbytes()); - - Ok(compressed) - } - - /// Compresses a child array produced by a cascading scheme. - /// - /// If the cascade budget is exhausted, the canonical array is returned as-is. Otherwise, the - /// child context is created by descending and recording the parent scheme + child index, and - /// compression proceeds normally. - /// - /// # Errors - /// - /// Returns an error if compression fails. - pub fn compress_child( - &self, - child: &ArrayRef, - parent_ctx: &CompressorContext, - parent_id: SchemeId, - child_index: usize, - exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - if parent_ctx.finished_cascading() { - trace::cascade_exhausted(parent_id, child_index); - return Ok(child.clone()); - } - - let canonical = child.clone().execute::(exec_ctx)?.0; - let compact = canonical.compact(exec_ctx)?; - - let child_ctx = parent_ctx - .clone() - .descend_with_scheme(parent_id, child_index); - self.compress_canonical(compact, child_ctx, exec_ctx) - } - - /// Compresses a canonical array by dispatching to type-specific logic. - /// - /// # Errors - /// - /// Returns an error if compression of any sub-array fails. - fn compress_canonical( - &self, - array: Canonical, - compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - match array { - Canonical::Null(null_array) => Ok(null_array.into_array()), - Canonical::Bool(bool_array) => { - self.choose_and_compress(Canonical::Bool(bool_array), compress_ctx, exec_ctx) - } - Canonical::Primitive(primitive) => { - self.choose_and_compress(Canonical::Primitive(primitive), compress_ctx, exec_ctx) - } - Canonical::Decimal(decimal) => { - self.choose_and_compress(Canonical::Decimal(decimal), compress_ctx, exec_ctx) - } - Canonical::Struct(struct_array) => { - let fields = struct_array - .iter_unmasked_fields() - .map(|field| self.compress(field, exec_ctx)) - .collect::, _>>()?; - - Ok(StructArray::try_new( - struct_array.names().clone(), - fields, - struct_array.len(), - struct_array.validity()?, - )? - .into_array()) - } - Canonical::List(list_view_array) => { - if list_view_array.is_zero_copy_to_list() || list_view_array.elements().is_empty() { - let list_array = list_from_list_view(list_view_array, exec_ctx)?; - self.compress_list_array(list_array, compress_ctx, exec_ctx) - } else { - self.compress_list_view_array(list_view_array, compress_ctx, exec_ctx) - } - } - Canonical::FixedSizeList(fsl_array) => { - let compressed_elems = self.compress(fsl_array.elements(), exec_ctx)?; - - Ok(FixedSizeListArray::try_new( - compressed_elems, - fsl_array.list_size(), - fsl_array.validity()?, - fsl_array.len(), - )? - .into_array()) - } - Canonical::VarBinView(varbinview) => { - self.choose_and_compress(Canonical::VarBinView(varbinview), compress_ctx, exec_ctx) - } - Canonical::Extension(ext_array) => { - // Try scheme-based compression first. - let scheme_compressed = self.choose_and_compress( - Canonical::Extension(ext_array.clone()), - compress_ctx, - exec_ctx, - )?; - // TODO(connor): HACK TO SUPPORT L2 DENORMALIZATION!!! - if scheme_compressed.is::() { - return Ok(scheme_compressed); - } - - // A constant extension array (that might be masked) is already in its terminal - // representation, and compressing the storage separately cannot do better. - if scheme_compressed.is::() { - return Ok(scheme_compressed); - } - if let Some(masked) = scheme_compressed.as_opt::() - && masked.child().is::() - { - return Ok(scheme_compressed); - } - - // Also compress the underlying storage array. Some extension schemes can beat the - // extension storage but still lose to ordinary storage compression. - let compressed_storage = self.compress(ext_array.storage_array(), exec_ctx)?; - let storage_compressed = - ExtensionArray::new(ext_array.ext_dtype().clone(), compressed_storage) - .into_array(); - - if scheme_compressed.nbytes() < storage_compressed.nbytes() { - Ok(scheme_compressed) - } else { - Ok(storage_compressed) - } - } - Canonical::Variant(variant_array) => { - let core_storage = - self.compress_physical_slots(variant_array.core_storage(), exec_ctx)?; - let shredded = variant_array - .shredded() - .map(|arr| { - // Avoid stack-overflow for variant shredded values - if arr.is::() { - self.compress_physical_slots(arr, exec_ctx) - } else { - self.compress(arr, exec_ctx) - } - }) - .transpose()?; - - Ok(VariantArray::try_new(core_storage, shredded)?.into_array()) - } - } - } - - /// The main scheme-selection entry point for a single leaf array. - /// - /// Filters allowed schemes by [`matches`] and exclusion rules, merges their [`stats_options`] - /// into a single [`GenerateStatsOptions`], and picks the winner by estimated compression - /// ratio. - /// - /// If a winner is found and its compressed output is actually smaller, that output is - /// returned. Otherwise, the original array is returned unchanged. - /// - /// Empty, all-null, and constant arrays are handled by the compressor itself before any - /// scheme evaluation (constant detection is skipped while compressing samples). - /// - /// [`matches`]: Scheme::matches - /// [`stats_options`]: Scheme::stats_options - fn choose_and_compress( - &self, - canonical: Canonical, - compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - let eligible_schemes: Vec<&'static dyn Scheme> = self - .schemes - .iter() - .copied() - .filter(|s| s.matches(&canonical) && !self.is_excluded(*s, &compress_ctx)) - .collect(); - - let array: ArrayRef = canonical.into(); - - if array.is_empty() { - return Ok(array); - } - - if array.all_invalid(exec_ctx)? { - return Ok( - ConstantArray::new(Scalar::null(array.dtype().clone()), array.len()).into_array(), - ); - } - - let before_nbytes = array.nbytes(); - - let merged_opts = eligible_schemes - .iter() - .fold(GenerateStatsOptions::default(), |acc, s| { - acc.merge(s.stats_options()) - }); - let compress_ctx = compress_ctx.with_merged_stats_options(merged_opts); - - let data = ArrayAndStats::new(array, merged_opts); - - // Constant detection is built into the compressor: a constant leaf always short-circuits - // scheme selection. Samples are exempt because a constant sample does not imply that the - // full array is constant. - if !compress_ctx.is_sample() && constant::is_constant_for_compression(&data, exec_ctx)? { - let _winner_span = - trace::winner_compress_span(constant::CONSTANT_SCHEME_ID, before_nbytes).entered(); - let compressed = constant::compress_constant(data.array(), exec_ctx)?; - - let after_nbytes = compressed.nbytes(); - let actual_ratio = - (after_nbytes != 0).then(|| before_nbytes as f64 / after_nbytes as f64); - let accepted = after_nbytes < before_nbytes; - trace::record_winner_compress_result(after_nbytes, None, actual_ratio, accepted); - - return if accepted { - Ok(compressed) - } else { - Ok(data.into_array()) - }; - } - - if eligible_schemes.is_empty() { - return Ok(data.into_array()); - } - - let Some((winner, winner_estimate)) = - self.choose_best_scheme(&eligible_schemes, &data, compress_ctx.clone(), exec_ctx)? - else { - return Ok(data.into_array()); - }; - - // Run the winning scheme's `compress`. On failure, emit an ERROR event carrying the - // scheme name and cascade history before propagating. - let error_ctx = trace::enabled_error_context(&compress_ctx); - let _winner_span = trace::winner_compress_span(winner.id(), before_nbytes).entered(); - let compressed = winner - .compress(self, &data, compress_ctx, exec_ctx) - .inspect_err(|err| { - // NB: this is the only way we can tell which scheme panicked / bailed on their - // data, especially for third-party schemes where the error site may not carry any - // compressor context. - trace::scheme_compress_failed(winner.id(), before_nbytes, error_ctx.as_ref(), err); - })?; - - let after_nbytes = compressed.nbytes(); - let actual_ratio = (after_nbytes != 0).then(|| before_nbytes as f64 / after_nbytes as f64); - - // TODO(connor): HACK TO SUPPORT L2 DENORMALIZATION!!! - let accepted = after_nbytes < before_nbytes || compressed.is::(); - - trace::record_winner_compress_result( - after_nbytes, - winner_estimate.trace_ratio(), - actual_ratio, - accepted, - ); - - if accepted { - Ok(compressed) - } else { - Ok(data.into_array()) - } - } - - /// Calls [`expected_compression_ratio`] on each candidate and returns the winning scheme along - /// with its resolved winner estimate, or `None` if no scheme beats the canonical encoding. - /// - /// Selection runs in two passes. Pass 1 evaluates every immediate - /// [`CompressionEstimate::Verdict`] and tracks the running best. [`Scheme`]s returning - /// [`CompressionEstimate::Deferred`] are stashed for pass 2 so that we do not make any - /// expensive computations if we don't have to. - /// - /// Pass 2 evaluates the deferred work and, for each [`DeferredEstimate::Callback`], passes the - /// current best [`EstimateScore`] as an early-exit hint so the callback can return - /// [`EstimateVerdict::Skip`] without doing expensive work when it cannot beat the threshold. - /// - /// Ties are broken by registration order within each pass. - /// - /// [`expected_compression_ratio`]: Scheme::expected_compression_ratio - fn choose_best_scheme( - &self, - schemes: &[&'static dyn Scheme], - data: &ArrayAndStats, - compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, - ) -> VortexResult> { - let mut best: Option<(&'static dyn Scheme, EstimateScore)> = None; - let mut deferred: Vec<(&'static dyn Scheme, DeferredEstimate)> = Vec::new(); - - // Pass 1: evaluate every immediate verdict. Stash deferred work for pass 2. - { - let _verdict_pass = trace::verdict_pass_span().entered(); - for &scheme in schemes { - match scheme.expected_compression_ratio(data, compress_ctx.clone(), exec_ctx) { - CompressionEstimate::Verdict(EstimateVerdict::Skip) => {} - CompressionEstimate::Verdict(EstimateVerdict::AlwaysUse) => { - return Ok(Some((scheme, WinnerEstimate::AlwaysUse))); - } - CompressionEstimate::Verdict(EstimateVerdict::Ratio(ratio)) => { - let score = EstimateScore::FiniteCompression(ratio); - - if is_better_score(score, best.as_ref()) { - best = Some((scheme, score)); - } - } - CompressionEstimate::Deferred(deferred_estimate) => { - deferred.push((scheme, deferred_estimate)); - } - } - } - } - - // Pass 2: run deferred work. Callbacks receive the current best as a threshold so they can - // short-circuit with `Skip` when they cannot beat it. - for (scheme, deferred_estimate) in deferred { - let _span = trace::scheme_eval_span(scheme.id()).entered(); - let threshold: Option = best.map(|(_, score)| score); - match deferred_estimate { - DeferredEstimate::Sample => { - let score = estimate_compression_ratio_with_sampling( - self, - scheme, - data.array(), - compress_ctx.clone(), - exec_ctx, - )?; - - if is_better_score(score, best.as_ref()) { - best = Some((scheme, score)); - } - } - DeferredEstimate::Callback(callback) => { - match callback(self, data, threshold, compress_ctx.clone(), exec_ctx)? { - EstimateVerdict::Skip => {} - EstimateVerdict::AlwaysUse => { - return Ok(Some((scheme, WinnerEstimate::AlwaysUse))); - } - EstimateVerdict::Ratio(ratio) => { - let score = EstimateScore::FiniteCompression(ratio); - - if is_better_score(score, best.as_ref()) { - best = Some((scheme, score)); - } - } - } - } - } - } - - Ok(best.map(|(scheme, score)| (scheme, WinnerEstimate::Score(score)))) - } - - // TODO(connor): Lots of room for optimization here. - /// Returns `true` if the candidate scheme should be excluded based on the cascade history and - /// exclusion rules. - fn is_excluded(&self, candidate: &dyn Scheme, ctx: &CompressorContext) -> bool { - let id = candidate.id(); - let history = ctx.cascade_history(); - - // Self-exclusion: no scheme appears twice in any chain. - if history.iter().any(|&(sid, _)| sid == id) { - return true; - } - - let mut iter = history.iter().copied().peekable(); - - // The root entry is always first in the history (if present). Check if the root has - // excluded us. - if let Some((_, child_idx)) = iter.next_if(|&(sid, _)| sid == ROOT_SCHEME_ID) - && self - .root_exclusions - .iter() - .any(|rule| rule.excluded == id && rule.children.contains(child_idx)) - { - return true; - } - - // Push rules: Check if any of our ancestors have excluded us. - for (ancestor_id, child_idx) in iter { - if let Some(ancestor) = self.schemes.iter().find(|s| s.id() == ancestor_id) - && ancestor - .descendant_exclusions() - .iter() - .any(|rule| rule.excluded == id && rule.children.contains(child_idx)) - { - return true; - } - } - - // Pull rules: Check if we have excluded ourselves because of our ancestors. - for rule in candidate.ancestor_exclusions() { - if history - .iter() - .any(|(sid, cidx)| *sid == rule.ancestor && rule.children.contains(*cidx)) - { - return true; - } - } - - false - } - - /// Compresses a [`ListArray`] by narrowing offsets and recursively compressing elements. - fn compress_list_array( - &self, - list_array: ListArray, - compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - let list_array = list_array.reset_offsets(true, exec_ctx)?; - - let compressed_elems = self.compress(list_array.elements(), exec_ctx)?; - - // Record the root scheme with the offsets child index so root exclusion rules apply. - let offset_ctx = - compress_ctx.descend_with_scheme(ROOT_SCHEME_ID, root_list_children::OFFSETS); - let list_offsets_primitive = list_array - .offsets() - .clone() - .execute::(exec_ctx)? - .narrow(exec_ctx)?; - let compressed_offsets = self.compress_canonical( - Canonical::Primitive(list_offsets_primitive), - offset_ctx, - exec_ctx, - )?; - - Ok( - ListArray::try_new(compressed_elems, compressed_offsets, list_array.validity()?)? - .into_array(), - ) - } - - /// Compresses a [`ListViewArray`] by narrowing offsets/sizes and recursively compressing - /// elements. - fn compress_list_view_array( - &self, - list_view: ListViewArray, - compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - let compressed_elems = self.compress(list_view.elements(), exec_ctx)?; - - let offset_ctx = compress_ctx - .clone() - .descend_with_scheme(ROOT_SCHEME_ID, root_list_children::OFFSETS); - let list_view_offsets_primitive = list_view - .offsets() - .clone() - .execute::(exec_ctx)? - .narrow(exec_ctx)?; - let compressed_offsets = self.compress_canonical( - Canonical::Primitive(list_view_offsets_primitive), - offset_ctx, - exec_ctx, - )?; - - let sizes_ctx = compress_ctx.descend_with_scheme(ROOT_SCHEME_ID, root_list_children::SIZES); - let list_view_sizes_primitive = list_view - .sizes() - .clone() - .execute::(exec_ctx)? - .narrow(exec_ctx)?; - let compressed_sizes = self.compress_canonical( - Canonical::Primitive(list_view_sizes_primitive), - sizes_ctx, - exec_ctx, - )?; - - Ok(ListViewArray::try_new( - compressed_elems, - compressed_offsets, - compressed_sizes, - list_view.validity()?, - )? - .into_array()) - } - - /// Compress very child slot of the array, then re-build it from them. - fn compress_physical_slots( - &self, - array: &ArrayRef, - exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - let slots = array - .slots() - .iter() - .map(|slot| { - slot.as_ref() - .map(|child| self.compress(child, exec_ctx)) - .transpose() - }) - .collect::>()?; - - // SAFETY: compression rewrites each child slot to an equivalent physical representation, - // preserving the parent array's logical values and statistics. - unsafe { array.clone().with_slots(slots) } - } -} - -#[cfg(test)] -mod tests { - use std::sync::LazyLock; - - use parking_lot::Mutex; - use vortex_array::ArrayRef; - use vortex_array::Canonical; - use vortex_array::VortexSessionExecute; - use vortex_array::arrays::BoolArray; - use vortex_array::arrays::Constant; - use vortex_array::arrays::NullArray; - use vortex_array::arrays::PrimitiveArray; - use vortex_array::validity::Validity; - use vortex_buffer::buffer; - use vortex_session::VortexSession; - - use super::*; - use crate::builtins::FloatDictScheme; - use crate::builtins::IntDictScheme; - use crate::builtins::StringDictScheme; - use crate::ctx::CompressorContext; - use crate::estimate::CompressionEstimate; - use crate::estimate::DeferredEstimate; - use crate::estimate::EstimateScore; - use crate::estimate::EstimateVerdict; - use crate::estimate::WinnerEstimate; - use crate::scheme::SchemeExt; - - static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); - - fn compressor() -> CascadingCompressor { - CascadingCompressor::new(vec![&IntDictScheme, &FloatDictScheme, &StringDictScheme]) - } - - fn estimate_test_data() -> ArrayAndStats { - let array = PrimitiveArray::new(buffer![1i32, 2, 3, 4], Validity::NonNullable).into_array(); - ArrayAndStats::new(array, GenerateStatsOptions::default()) - } - - fn matches_integer_primitive(canonical: &Canonical) -> bool { - matches!(canonical, Canonical::Primitive(primitive) if primitive.ptype().is_int()) - } - - #[derive(Debug)] - struct DirectRatioScheme; - - impl Scheme for DirectRatioScheme { - fn scheme_name(&self) -> &'static str { - "test.direct_ratio" - } - - fn matches(&self, canonical: &Canonical) -> bool { - matches_integer_primitive(canonical) - } - - fn expected_compression_ratio( - &self, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> CompressionEstimate { - CompressionEstimate::Verdict(EstimateVerdict::Ratio(2.0)) - } - - fn compress( - &self, - _compressor: &CascadingCompressor, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - unreachable!("test helper should never be selected for compression") - } - } - - #[derive(Debug)] - struct ImmediateAlwaysUseScheme; - - impl Scheme for ImmediateAlwaysUseScheme { - fn scheme_name(&self) -> &'static str { - "test.immediate_always_use" - } - - fn matches(&self, canonical: &Canonical) -> bool { - matches_integer_primitive(canonical) - } - - fn expected_compression_ratio( - &self, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> CompressionEstimate { - CompressionEstimate::Verdict(EstimateVerdict::AlwaysUse) - } - - fn compress( - &self, - _compressor: &CascadingCompressor, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - unreachable!("test helper should never be selected for compression") - } - } - - #[derive(Debug)] - struct CallbackAlwaysUseScheme; - - impl Scheme for CallbackAlwaysUseScheme { - fn scheme_name(&self) -> &'static str { - "test.callback_always_use" - } - - fn matches(&self, canonical: &Canonical) -> bool { - matches_integer_primitive(canonical) - } - - fn expected_compression_ratio( - &self, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> CompressionEstimate { - CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new( - |_compressor, _data, _ctx, _exec_ctx, _best_so_far| Ok(EstimateVerdict::AlwaysUse), - ))) - } - - fn compress( - &self, - _compressor: &CascadingCompressor, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - unreachable!("test helper should never be selected for compression") - } - } - - #[derive(Debug)] - struct CallbackSkipScheme; - - impl Scheme for CallbackSkipScheme { - fn scheme_name(&self) -> &'static str { - "test.callback_skip" - } - - fn matches(&self, canonical: &Canonical) -> bool { - matches_integer_primitive(canonical) - } - - fn expected_compression_ratio( - &self, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> CompressionEstimate { - CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new( - |_compressor, _data, _ctx, _exec_ctx, _best_so_far| Ok(EstimateVerdict::Skip), - ))) - } - - fn compress( - &self, - _compressor: &CascadingCompressor, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - unreachable!("test helper should never be selected for compression") - } - } - - #[derive(Debug)] - struct CallbackRatioScheme; - - impl Scheme for CallbackRatioScheme { - fn scheme_name(&self) -> &'static str { - "test.callback_ratio" - } - - fn matches(&self, canonical: &Canonical) -> bool { - matches_integer_primitive(canonical) - } - - fn expected_compression_ratio( - &self, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> CompressionEstimate { - CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new( - |_compressor, _data, _ctx, _exec_ctx, _best_so_far| Ok(EstimateVerdict::Ratio(3.0)), - ))) - } - - fn compress( - &self, - _compressor: &CascadingCompressor, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - unreachable!("test helper should never be selected for compression") - } - } - - #[derive(Debug)] - struct HugeRatioScheme; - - impl Scheme for HugeRatioScheme { - fn scheme_name(&self) -> &'static str { - "test.huge_ratio" - } - - fn matches(&self, canonical: &Canonical) -> bool { - matches_integer_primitive(canonical) - } - - fn expected_compression_ratio( - &self, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> CompressionEstimate { - CompressionEstimate::Verdict(EstimateVerdict::Ratio(100.0)) - } - - fn compress( - &self, - _compressor: &CascadingCompressor, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - unreachable!("test helper should never be selected for compression") - } - } - - #[derive(Debug)] - struct ZeroBytesSamplingScheme; - - impl Scheme for ZeroBytesSamplingScheme { - fn scheme_name(&self) -> &'static str { - "test.zero_bytes_sampling" - } - - fn matches(&self, canonical: &Canonical) -> bool { - matches_integer_primitive(canonical) - } - - fn expected_compression_ratio( - &self, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> CompressionEstimate { - CompressionEstimate::Deferred(DeferredEstimate::Sample) - } - - fn compress( - &self, - _compressor: &CascadingCompressor, - data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - Ok(NullArray::new(data.array().len()).into_array()) - } - } - - #[test] - fn test_self_exclusion() { - let c = compressor(); - let ctx = CompressorContext::default().descend_with_scheme(IntDictScheme.id(), 0); - - // IntDictScheme is in the history, so it should be excluded. - assert!(c.is_excluded(&IntDictScheme, &ctx)); - } - - #[test] - fn test_root_exclusion_list_offsets() { - let c = compressor(); - let ctx = CompressorContext::default() - .descend_with_scheme(ROOT_SCHEME_ID, root_list_children::OFFSETS); - - // IntDict should be excluded for list offsets. - assert!(c.is_excluded(&IntDictScheme, &ctx)); - } - - #[test] - fn test_push_rule_float_dict_excludes_int_dict_from_codes() { - let c = compressor(); - // FloatDict cascading through codes (child 1). - let ctx = CompressorContext::default().descend_with_scheme(FloatDictScheme.id(), 1); - - // IntDict should be excluded from FloatDict's codes child. - assert!(c.is_excluded(&IntDictScheme, &ctx)); - } - - #[test] - fn test_push_rule_float_dict_excludes_int_dict_from_values() { - let c = compressor(); - // FloatDict cascading through values (child 0). - let ctx = CompressorContext::default().descend_with_scheme(FloatDictScheme.id(), 0); - - // IntDict should also be excluded from FloatDict's values child (ALP propagation - // replacement). - assert!(c.is_excluded(&IntDictScheme, &ctx)); - } - - #[test] - fn test_no_exclusion_without_history() { - let c = compressor(); - let ctx = CompressorContext::default(); - - // No history means no exclusions. - assert!(!c.is_excluded(&IntDictScheme, &ctx)); - } - - #[test] - fn immediate_always_use_wins_immediately() -> VortexResult<()> { - let compressor = - CascadingCompressor::new(vec![&DirectRatioScheme, &ImmediateAlwaysUseScheme]); - let schemes: [&'static dyn Scheme; 2] = [&DirectRatioScheme, &ImmediateAlwaysUseScheme]; - let data = estimate_test_data(); - let mut exec_ctx = SESSION.create_execution_ctx(); - - let winner = compressor.choose_best_scheme( - &schemes, - &data, - CompressorContext::new(), - &mut exec_ctx, - )?; - - assert!(matches!( - winner, - Some((scheme, WinnerEstimate::AlwaysUse)) - if scheme.id() == ImmediateAlwaysUseScheme.id() - )); - Ok(()) - } - - #[test] - fn callback_always_use_wins_immediately() -> VortexResult<()> { - let compressor = - CascadingCompressor::new(vec![&DirectRatioScheme, &CallbackAlwaysUseScheme]); - let schemes: [&'static dyn Scheme; 2] = [&DirectRatioScheme, &CallbackAlwaysUseScheme]; - let data = estimate_test_data(); - let mut exec_ctx = SESSION.create_execution_ctx(); - - let winner = compressor.choose_best_scheme( - &schemes, - &data, - CompressorContext::new(), - &mut exec_ctx, - )?; - - assert!(matches!( - winner, - Some((scheme, WinnerEstimate::AlwaysUse)) - if scheme.id() == CallbackAlwaysUseScheme.id() - )); - Ok(()) - } - - #[test] - fn callback_skip_is_ignored() -> VortexResult<()> { - let compressor = CascadingCompressor::new(vec![&CallbackSkipScheme, &DirectRatioScheme]); - let schemes: [&'static dyn Scheme; 2] = [&CallbackSkipScheme, &DirectRatioScheme]; - let data = estimate_test_data(); - let mut exec_ctx = SESSION.create_execution_ctx(); - - let winner = compressor.choose_best_scheme( - &schemes, - &data, - CompressorContext::new(), - &mut exec_ctx, - )?; - - assert!(matches!( - winner, - Some((scheme, WinnerEstimate::Score(EstimateScore::FiniteCompression(2.0)))) - if scheme.id() == DirectRatioScheme.id() - )); - Ok(()) - } - - #[test] - fn callback_ratio_competes_numerically() -> VortexResult<()> { - let compressor = CascadingCompressor::new(vec![&DirectRatioScheme, &CallbackRatioScheme]); - let schemes: [&'static dyn Scheme; 2] = [&DirectRatioScheme, &CallbackRatioScheme]; - let data = estimate_test_data(); - let mut exec_ctx = SESSION.create_execution_ctx(); - - let winner = compressor.choose_best_scheme( - &schemes, - &data, - CompressorContext::new(), - &mut exec_ctx, - )?; - - assert!(matches!( - winner, - Some((scheme, WinnerEstimate::Score(EstimateScore::FiniteCompression(3.0)))) - if scheme.id() == CallbackRatioScheme.id() - )); - Ok(()) - } - - #[test] - fn zero_byte_sample_loses_to_finite_ratio() -> VortexResult<()> { - let compressor = CascadingCompressor::new(vec![&HugeRatioScheme, &ZeroBytesSamplingScheme]); - let schemes: [&'static dyn Scheme; 2] = [&HugeRatioScheme, &ZeroBytesSamplingScheme]; - let data = estimate_test_data(); - let mut exec_ctx = SESSION.create_execution_ctx(); - - let winner = compressor.choose_best_scheme( - &schemes, - &data, - CompressorContext::new(), - &mut exec_ctx, - )?; - - assert!(matches!( - winner, - Some((scheme, WinnerEstimate::Score(EstimateScore::FiniteCompression(100.0)))) - if scheme.id() == HugeRatioScheme.id() - )); - Ok(()) - } - - #[test] - fn finite_ratio_displaces_zero_byte_sample() -> VortexResult<()> { - let compressor = CascadingCompressor::new(vec![&ZeroBytesSamplingScheme, &HugeRatioScheme]); - let schemes: [&'static dyn Scheme; 2] = [&ZeroBytesSamplingScheme, &HugeRatioScheme]; - let data = estimate_test_data(); - let mut exec_ctx = SESSION.create_execution_ctx(); - - let winner = compressor.choose_best_scheme( - &schemes, - &data, - CompressorContext::new(), - &mut exec_ctx, - )?; - - assert!(matches!( - winner, - Some((scheme, WinnerEstimate::Score(EstimateScore::FiniteCompression(100.0)))) - if scheme.id() == HugeRatioScheme.id() - )); - Ok(()) - } - - #[test] - fn zero_byte_sample_alone_selects_no_scheme() -> VortexResult<()> { - let compressor = CascadingCompressor::new(vec![&ZeroBytesSamplingScheme]); - let schemes: [&'static dyn Scheme; 1] = [&ZeroBytesSamplingScheme]; - let data = estimate_test_data(); - let mut exec_ctx = SESSION.create_execution_ctx(); - - let winner = compressor.choose_best_scheme( - &schemes, - &data, - CompressorContext::new(), - &mut exec_ctx, - )?; - - assert!(winner.is_none()); - Ok(()) - } - - // Observer helper used by threshold-related tests. Captures the `best_so_far` value the - // compressor passes to its deferred callback. `OBSERVER_LOCK` serializes tests that share - // `OBSERVED_THRESHOLD` so they do not race. - static OBSERVER_LOCK: Mutex<()> = Mutex::new(()); - static OBSERVED_THRESHOLD: Mutex>> = Mutex::new(None); - - #[derive(Debug)] - struct ThresholdObservingScheme; - - impl Scheme for ThresholdObservingScheme { - fn scheme_name(&self) -> &'static str { - "test.threshold_observing" - } - - fn matches(&self, canonical: &Canonical) -> bool { - matches_integer_primitive(canonical) - } - - fn expected_compression_ratio( - &self, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> CompressionEstimate { - CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new( - |_compressor, _data, best_so_far, _ctx, _exec_ctx| { - *OBSERVED_THRESHOLD.lock() = Some(best_so_far); - Ok(EstimateVerdict::Skip) - }, - ))) - } - - fn compress( - &self, - _compressor: &CascadingCompressor, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - unreachable!("test helper should never be selected for compression") - } - } - - #[derive(Debug)] - struct CallbackMatchingRatioScheme; - - impl Scheme for CallbackMatchingRatioScheme { - fn scheme_name(&self) -> &'static str { - "test.callback_matching_ratio" - } - - fn matches(&self, canonical: &Canonical) -> bool { - matches_integer_primitive(canonical) - } - - fn expected_compression_ratio( - &self, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> CompressionEstimate { - CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new( - |_compressor, _data, _ctx, _exec_ctx, _best_so_far| Ok(EstimateVerdict::Ratio(2.0)), - ))) - } - - fn compress( - &self, - _compressor: &CascadingCompressor, - _data: &ArrayAndStats, - _compress_ctx: CompressorContext, - _exec_ctx: &mut ExecutionCtx, - ) -> VortexResult { - unreachable!("test helper should never be selected for compression") - } - } - - #[test] - fn callback_always_use_overrides_pass_one_best() -> VortexResult<()> { - // `HugeRatioScheme` returns an immediate `Ratio(100.0)` in pass 1; - // `CallbackAlwaysUseScheme` returns `AlwaysUse` from its deferred callback in pass 2. - // The deferred `AlwaysUse` must still win. - let compressor = CascadingCompressor::new(vec![&HugeRatioScheme, &CallbackAlwaysUseScheme]); - let schemes: [&'static dyn Scheme; 2] = [&HugeRatioScheme, &CallbackAlwaysUseScheme]; - let data = estimate_test_data(); - let mut exec_ctx = SESSION.create_execution_ctx(); - - let winner = compressor.choose_best_scheme( - &schemes, - &data, - CompressorContext::new(), - &mut exec_ctx, - )?; - - assert!(matches!( - winner, - Some((scheme, WinnerEstimate::AlwaysUse)) - if scheme.id() == CallbackAlwaysUseScheme.id() - )); - Ok(()) - } - - #[test] - fn threshold_reflects_pass_one_best() -> VortexResult<()> { - let _guard = OBSERVER_LOCK.lock(); - *OBSERVED_THRESHOLD.lock() = None; - - let compressor = - CascadingCompressor::new(vec![&DirectRatioScheme, &ThresholdObservingScheme]); - let schemes: [&'static dyn Scheme; 2] = [&DirectRatioScheme, &ThresholdObservingScheme]; - let data = estimate_test_data(); - let mut exec_ctx = SESSION.create_execution_ctx(); - - compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; - - let observed = *OBSERVED_THRESHOLD.lock(); - assert!(matches!( - observed, - Some(Some(EstimateScore::FiniteCompression(r))) if r == 2.0 - )); - Ok(()) - } - - #[test] - fn threshold_is_none_when_only_prior_is_zero_bytes() -> VortexResult<()> { - let _guard = OBSERVER_LOCK.lock(); - *OBSERVED_THRESHOLD.lock() = None; - - let compressor = - CascadingCompressor::new(vec![&ZeroBytesSamplingScheme, &ThresholdObservingScheme]); - let schemes: [&'static dyn Scheme; 2] = - [&ZeroBytesSamplingScheme, &ThresholdObservingScheme]; - let data = estimate_test_data(); - let mut exec_ctx = SESSION.create_execution_ctx(); - - compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; - - // The observing callback was invoked (outer `Some`) and `best_so_far` was `None` (inner - // `None`) because the zero-byte sample is never stored as the best. - let observed = *OBSERVED_THRESHOLD.lock(); - assert_eq!(observed, Some(None)); - Ok(()) - } - - #[test] - fn threshold_is_none_when_no_prior_scheme() -> VortexResult<()> { - let _guard = OBSERVER_LOCK.lock(); - *OBSERVED_THRESHOLD.lock() = None; - - let compressor = CascadingCompressor::new(vec![&ThresholdObservingScheme]); - let schemes: [&'static dyn Scheme; 1] = [&ThresholdObservingScheme]; - let data = estimate_test_data(); - let mut exec_ctx = SESSION.create_execution_ctx(); - - compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; - - let observed = *OBSERVED_THRESHOLD.lock(); - assert_eq!(observed, Some(None)); - Ok(()) - } - - #[test] - fn threshold_updates_from_earlier_deferred_callback() -> VortexResult<()> { - let _guard = OBSERVER_LOCK.lock(); - *OBSERVED_THRESHOLD.lock() = None; - - // Both schemes are deferred. The first callback registers `Ratio(3.0)`; the second - // callback must observe it as its threshold. - let compressor = - CascadingCompressor::new(vec![&CallbackRatioScheme, &ThresholdObservingScheme]); - let schemes: [&'static dyn Scheme; 2] = [&CallbackRatioScheme, &ThresholdObservingScheme]; - let data = estimate_test_data(); - let mut exec_ctx = SESSION.create_execution_ctx(); - - compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; - - let observed = *OBSERVED_THRESHOLD.lock(); - assert!(matches!( - observed, - Some(Some(EstimateScore::FiniteCompression(r))) if r == 3.0 - )); - Ok(()) - } - - #[test] - fn ratio_tie_between_immediate_and_deferred_favors_immediate() -> VortexResult<()> { - // Both schemes produce the same `Ratio(2.0)`, one from pass 1 (immediate) and one from - // pass 2 (deferred callback). Pass 1 locks in first, and strict `>` tie-breaking means - // the deferred callback's equal ratio cannot displace it. - let compressor = - CascadingCompressor::new(vec![&CallbackMatchingRatioScheme, &DirectRatioScheme]); - let schemes: [&'static dyn Scheme; 2] = [&CallbackMatchingRatioScheme, &DirectRatioScheme]; - let data = estimate_test_data(); - let mut exec_ctx = SESSION.create_execution_ctx(); - - let winner = compressor.choose_best_scheme( - &schemes, - &data, - CompressorContext::new(), - &mut exec_ctx, - )?; - - assert!(matches!( - winner, - Some((scheme, WinnerEstimate::Score(EstimateScore::FiniteCompression(r)))) - if scheme.id() == DirectRatioScheme.id() && r == 2.0 - )); - Ok(()) - } - - #[test] - fn all_null_array_compresses_to_constant() -> VortexResult<()> { - let array = PrimitiveArray::new( - buffer![0i32, 0, 0, 0, 0], - Validity::Array(BoolArray::from_iter([false, false, false, false, false]).into_array()), - ) - .into_array(); - - // The compressor should produce a `ConstantArray` for an all-null array regardless of - // which schemes are registered. - let compressor = CascadingCompressor::new(vec![&IntDictScheme]); - let mut exec_ctx = SESSION.create_execution_ctx(); - let compressed = compressor.compress(&array, &mut exec_ctx)?; - assert!(compressed.is::()); - Ok(()) - } - - /// Regression test for . - /// - /// `estimate_compression_ratio_with_sampling` must use the *scheme's* stats options - /// (which request distinct-value counting) rather than the context's stats options - /// (which may not). With the old code this panicked inside `dictionary_encode` because - /// distinct values were never computed for the sample. - #[test] - fn sampling_uses_scheme_stats_options() -> VortexResult<()> { - // Low-cardinality float array so FloatDictScheme considers it compressible. - let array = PrimitiveArray::new( - buffer![1.0f32, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0], - Validity::NonNullable, - ) - .into_array(); - - let compressor = CascadingCompressor::new(vec![&FloatDictScheme]); - - // A context with default stats_options (count_distinct_values = false) and - // marked as a sample so the function skips the sampling step and compresses - // the array directly. - let ctx = CompressorContext::new().with_sampling(); - - // Before the fix this panicked with: - // "this must be present since `DictScheme` declared that we need distinct values" - let mut exec_ctx = SESSION.create_execution_ctx(); - let score = estimate_compression_ratio_with_sampling( - &compressor, - &FloatDictScheme, - &array, - ctx, - &mut exec_ctx, - )?; - assert!(matches!(score, EstimateScore::FiniteCompression(ratio) if ratio.is_finite())); - Ok(()) - } -} diff --git a/vortex-compressor/src/compressor/cascade.rs b/vortex-compressor/src/compressor/cascade.rs new file mode 100644 index 00000000000..9dbbc611448 --- /dev/null +++ b/vortex-compressor/src/compressor/cascade.rs @@ -0,0 +1,324 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Core cascading compression flow. + +use vortex_array::ArrayRef; +use vortex_array::Canonical; +use vortex_array::CanonicalValidity; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::arrays::Constant; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::ExtensionArray; +use vortex_array::arrays::FixedSizeListArray; +use vortex_array::arrays::Masked; +use vortex_array::arrays::StructArray; +use vortex_array::arrays::Variant; +use vortex_array::arrays::VariantArray; +use vortex_array::arrays::extension::ExtensionArrayExt; +use vortex_array::arrays::fixed_size_list::FixedSizeListArrayExt; +use vortex_array::arrays::listview::ListViewArrayExt; +use vortex_array::arrays::listview::list_from_list_view; +use vortex_array::arrays::masked::MaskedArraySlotsExt; +use vortex_array::arrays::scalar_fn::AnyScalarFn; +use vortex_array::arrays::struct_::StructArrayExt; +use vortex_array::arrays::variant::VariantArrayExt; +use vortex_array::scalar::Scalar; +use vortex_error::VortexResult; + +use super::CascadingCompressor; +use super::constant; +use crate::scheme::CompressorContext; +use crate::scheme::Scheme; +use crate::scheme::SchemeExt; +use crate::scheme::SchemeId; +use crate::stats::ArrayAndStats; +use crate::stats::GenerateStatsOptions; +use crate::trace; + +impl CascadingCompressor { + /// Compresses an array using cascading adaptive compression. + /// + /// First canonicalizes and compacts the array, then applies optimal compression schemes. + /// + /// # Errors + /// + /// Returns an error if canonicalization or compression fails. + pub fn compress( + &self, + array: &ArrayRef, + exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + let before_nbytes = array.nbytes(); + let span = trace::compress_span(array.len(), array.dtype(), before_nbytes); + let _enter = span.enter(); + + let canonical = array.clone().execute::(exec_ctx)?.0; + let compact = canonical.compact(exec_ctx)?; + let compressed = self.compress_canonical(compact, CompressorContext::new(), exec_ctx)?; + + trace::record_compress_outcome(&span, before_nbytes, compressed.nbytes()); + + Ok(compressed) + } + + /// Compresses a child array produced by a cascading scheme. + /// + /// If the cascade budget is exhausted, the canonical array is returned as-is. Otherwise, the + /// child context is created by descending and recording the parent scheme + child index, and + /// compression proceeds normally. + /// + /// # Errors + /// + /// Returns an error if compression fails. + pub fn compress_child( + &self, + child: &ArrayRef, + parent_ctx: &CompressorContext, + parent_id: SchemeId, + child_index: usize, + exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + if parent_ctx.finished_cascading() { + trace::cascade_exhausted(parent_id, child_index); + return Ok(child.clone()); + } + + let canonical = child.clone().execute::(exec_ctx)?.0; + let compact = canonical.compact(exec_ctx)?; + + let child_ctx = parent_ctx + .clone() + .descend_with_scheme(parent_id, child_index); + self.compress_canonical(compact, child_ctx, exec_ctx) + } + + /// Compresses a canonical array by dispatching to type-specific logic. + /// + /// # Errors + /// + /// Returns an error if compression of any sub-array fails. + pub(super) fn compress_canonical( + &self, + array: Canonical, + compress_ctx: CompressorContext, + exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + match array { + Canonical::Null(null_array) => Ok(null_array.into_array()), + Canonical::Bool(bool_array) => { + self.choose_and_compress(Canonical::Bool(bool_array), compress_ctx, exec_ctx) + } + Canonical::Primitive(primitive) => { + self.choose_and_compress(Canonical::Primitive(primitive), compress_ctx, exec_ctx) + } + Canonical::Decimal(decimal) => { + self.choose_and_compress(Canonical::Decimal(decimal), compress_ctx, exec_ctx) + } + Canonical::Struct(struct_array) => { + let fields = struct_array + .iter_unmasked_fields() + .map(|field| self.compress(field, exec_ctx)) + .collect::, _>>()?; + + Ok(StructArray::try_new( + struct_array.names().clone(), + fields, + struct_array.len(), + struct_array.validity()?, + )? + .into_array()) + } + Canonical::List(list_view_array) => { + if list_view_array.is_zero_copy_to_list() || list_view_array.elements().is_empty() { + let list_array = list_from_list_view(list_view_array, exec_ctx)?; + self.compress_list_array(list_array, compress_ctx, exec_ctx) + } else { + self.compress_list_view_array(list_view_array, compress_ctx, exec_ctx) + } + } + Canonical::FixedSizeList(fsl_array) => { + let compressed_elems = self.compress(fsl_array.elements(), exec_ctx)?; + + Ok(FixedSizeListArray::try_new( + compressed_elems, + fsl_array.list_size(), + fsl_array.validity()?, + fsl_array.len(), + )? + .into_array()) + } + Canonical::VarBinView(varbinview) => { + self.choose_and_compress(Canonical::VarBinView(varbinview), compress_ctx, exec_ctx) + } + Canonical::Extension(ext_array) => { + // Try scheme-based compression first. + let scheme_compressed = self.choose_and_compress( + Canonical::Extension(ext_array.clone()), + compress_ctx, + exec_ctx, + )?; + // TODO(connor): HACK TO SUPPORT L2 DENORMALIZATION!!! + if scheme_compressed.is::() { + return Ok(scheme_compressed); + } + + // A constant extension array (that might be masked) is already in its terminal + // representation, and compressing the storage separately cannot do better. + if scheme_compressed.is::() { + return Ok(scheme_compressed); + } + if let Some(masked) = scheme_compressed.as_opt::() + && masked.child().is::() + { + return Ok(scheme_compressed); + } + + // Also compress the underlying storage array. Some extension schemes can beat the + // extension storage but still lose to ordinary storage compression. + let compressed_storage = self.compress(ext_array.storage_array(), exec_ctx)?; + let storage_compressed = + ExtensionArray::new(ext_array.ext_dtype().clone(), compressed_storage) + .into_array(); + + if scheme_compressed.nbytes() < storage_compressed.nbytes() { + Ok(scheme_compressed) + } else { + Ok(storage_compressed) + } + } + Canonical::Variant(variant_array) => { + let core_storage = + self.compress_physical_slots(variant_array.core_storage(), exec_ctx)?; + let shredded = variant_array + .shredded() + .map(|arr| { + // Avoid stack-overflow for variant shredded values + if arr.is::() { + self.compress_physical_slots(arr, exec_ctx) + } else { + self.compress(arr, exec_ctx) + } + }) + .transpose()?; + + Ok(VariantArray::try_new(core_storage, shredded)?.into_array()) + } + } + } + + /// The main scheme-selection entry point for a single leaf array. + /// + /// Filters allowed schemes by [`matches`] and exclusion rules, merges their [`stats_options`] + /// into a single [`GenerateStatsOptions`], and picks the winner by estimated compression + /// ratio. + /// + /// If a winner is found and its compressed output is actually smaller, that output is + /// returned. Otherwise, the original array is returned unchanged. + /// + /// Empty, all-null, and constant arrays are handled by the compressor itself before any + /// scheme evaluation (constant detection is skipped while compressing samples). + /// + /// [`matches`]: Scheme::matches + /// [`stats_options`]: Scheme::stats_options + fn choose_and_compress( + &self, + canonical: Canonical, + compress_ctx: CompressorContext, + exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + let eligible_schemes: Vec<&'static dyn Scheme> = self + .schemes + .iter() + .copied() + .filter(|s| s.matches(&canonical) && !self.is_excluded(*s, &compress_ctx)) + .collect(); + + let array: ArrayRef = canonical.into(); + + if array.is_empty() { + return Ok(array); + } + + if array.all_invalid(exec_ctx)? { + return Ok( + ConstantArray::new(Scalar::null(array.dtype().clone()), array.len()).into_array(), + ); + } + + let before_nbytes = array.nbytes(); + + let merged_opts = eligible_schemes + .iter() + .fold(GenerateStatsOptions::default(), |acc, s| { + acc.merge(s.stats_options()) + }); + let compress_ctx = compress_ctx.with_merged_stats_options(merged_opts); + + let data = ArrayAndStats::new(array, merged_opts); + + // Constant detection is built into the compressor: a constant leaf always short-circuits + // scheme selection. Samples are exempt because a constant sample does not imply that the + // full array is constant. + if !compress_ctx.is_sample() && constant::is_constant_for_compression(&data, exec_ctx)? { + let _winner_span = + trace::winner_compress_span(constant::CONSTANT_SCHEME_ID, before_nbytes).entered(); + let compressed = constant::compress_constant(data.array(), exec_ctx)?; + + let after_nbytes = compressed.nbytes(); + let actual_ratio = + (after_nbytes != 0).then(|| before_nbytes as f64 / after_nbytes as f64); + let accepted = after_nbytes < before_nbytes; + trace::record_winner_compress_result(after_nbytes, None, actual_ratio, accepted); + + return if accepted { + Ok(compressed) + } else { + Ok(data.into_array()) + }; + } + + if eligible_schemes.is_empty() { + return Ok(data.into_array()); + } + + let Some((winner, winner_estimate)) = + self.choose_best_scheme(&eligible_schemes, &data, compress_ctx.clone(), exec_ctx)? + else { + return Ok(data.into_array()); + }; + + // Run the winning scheme's `compress`. On failure, emit an ERROR event carrying the + // scheme name and cascade history before propagating. + let error_ctx = trace::enabled_error_context(&compress_ctx); + let _winner_span = trace::winner_compress_span(winner.id(), before_nbytes).entered(); + let compressed = winner + .compress(self, &data, compress_ctx, exec_ctx) + .inspect_err(|err| { + // NB: this is the only way we can tell which scheme panicked / bailed on their + // data, especially for third-party schemes where the error site may not carry any + // compressor context. + trace::scheme_compress_failed(winner.id(), before_nbytes, error_ctx.as_ref(), err); + })?; + + let after_nbytes = compressed.nbytes(); + let actual_ratio = (after_nbytes != 0).then(|| before_nbytes as f64 / after_nbytes as f64); + + // TODO(connor): HACK TO SUPPORT L2 DENORMALIZATION!!! + let accepted = after_nbytes < before_nbytes || compressed.is::(); + + trace::record_winner_compress_result( + after_nbytes, + winner_estimate.trace_ratio(), + actual_ratio, + accepted, + ); + + if accepted { + Ok(compressed) + } else { + Ok(data.into_array()) + } + } +} diff --git a/vortex-compressor/src/constant.rs b/vortex-compressor/src/compressor/constant.rs similarity index 100% rename from vortex-compressor/src/constant.rs rename to vortex-compressor/src/compressor/constant.rs diff --git a/vortex-compressor/src/compressor/mod.rs b/vortex-compressor/src/compressor/mod.rs new file mode 100644 index 00000000000..a661970950c --- /dev/null +++ b/vortex-compressor/src/compressor/mod.rs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Cascading array compression implementation. + +mod cascade; +mod constant; +mod sample; +mod select; +mod structural; + +use crate::builtins::IntDictScheme; +use crate::scheme::ChildSelection; +use crate::scheme::DescendantExclusion; +use crate::scheme::Scheme; +use crate::scheme::SchemeExt; +use crate::scheme::SchemeId; + +/// Synthetic scheme ID used for the compressor's own root-level cascading. +pub(crate) const ROOT_SCHEME_ID: SchemeId = SchemeId { + name: "vortex.compressor.root", +}; + +/// The main compressor type implementing cascading adaptive compression. +/// +/// This compressor applies adaptive compression [`Scheme`]s to arrays based on their data types and +/// characteristics. It recursively compresses nested structures like structs and lists, and chooses +/// optimal compression schemes for leaf types. +/// +/// The compressor works by: +/// 1. Canonicalizing input arrays to a standard representation. +/// 2. Pre-filtering schemes by [`Scheme::matches`] and exclusion rules. +/// 3. Evaluating each matching scheme's compression estimate and resolving deferred work. +/// 4. Compressing with the best scheme and verifying the result is smaller. +/// +/// No scheme may appear twice in a cascade chain. The compressor enforces this automatically +/// along with push/pull exclusion rules declared by each scheme. +/// +/// Downstream crates usually wrap this type with a preconfigured scheme set. Use it directly when +/// embedding a custom fixed scheme list or testing scheme interactions. +#[derive(Debug, Clone)] +pub struct CascadingCompressor { + /// The enabled compression schemes. + schemes: Vec<&'static dyn Scheme>, + + /// Descendant exclusion rules for the compressor's own cascading (e.g. excluding Dict from + /// list offsets). + root_exclusions: Vec, +} + +impl CascadingCompressor { + /// Creates a new compressor with the given schemes. + /// + /// Root-level exclusion rules (e.g. excluding Dict from list offsets) are built automatically. + pub fn new(schemes: Vec<&'static dyn Scheme>) -> Self { + // Root exclusion: exclude IntDict from list/listview offsets (monotonically + // increasing data where dictionary encoding is wasteful). + let root_exclusions = vec![DescendantExclusion { + excluded: IntDictScheme.id(), + children: ChildSelection::One(structural::root_list_children::OFFSETS), + }]; + + Self { + schemes, + root_exclusions, + } + } +} + +// NB: Cascading compression logic is located in `vortex-compressor/src/compressor/cascade.rs`. + +#[cfg(test)] +mod tests; diff --git a/vortex-compressor/src/sample.rs b/vortex-compressor/src/compressor/sample.rs similarity index 74% rename from vortex-compressor/src/sample.rs rename to vortex-compressor/src/compressor/sample.rs index deb5bbe6f37..ba1271d4a6d 100644 --- a/vortex-compressor/src/sample.rs +++ b/vortex-compressor/src/compressor/sample.rs @@ -7,9 +7,20 @@ use rand::RngExt; use rand::SeedableRng; use rand::prelude::StdRng; use vortex_array::ArrayRef; +use vortex_array::Canonical; +use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::ChunkedArray; use vortex_error::VortexExpect; +use vortex_error::VortexResult; + +use crate::CascadingCompressor; +use crate::scheme::CompressorContext; +use crate::scheme::EstimateScore; +use crate::scheme::Scheme; +use crate::scheme::SchemeExt; +use crate::stats::ArrayAndStats; +use crate::trace; /// The size of each sampled run. pub const SAMPLE_SIZE: u32 = 64; @@ -130,6 +141,54 @@ fn partition_indices(length: usize, num_partitions: u32) -> Vec<(usize, usize)> .collect() } +/// Estimates compression ratio by compressing a ~1% sample of the data. +/// +/// Creates a new [`ArrayAndStats`] for the sample so that stats are generated from the sample, not +/// the full array. +/// +/// # Errors +/// +/// Returns an error if sample compression fails. +pub(super) fn estimate_compression_ratio_with_sampling( + compressor: &CascadingCompressor, + scheme: &S, + array: &ArrayRef, + compress_ctx: CompressorContext, + exec_ctx: &mut ExecutionCtx, +) -> VortexResult { + let sample_array = if compress_ctx.is_sample() { + array.clone() + } else { + let sample_count = sample_count_approx_one_percent(array.len()); + // `ArrayAndStats` expects a canonical array (so that it can easily compute lazy stats). + let canonical: Canonical = sample(array, SAMPLE_SIZE, sample_count).execute(exec_ctx)?; + canonical.into_array() + }; + + let sample_data = ArrayAndStats::new(sample_array, scheme.stats_options()); + let error_ctx = trace::enabled_error_context(&compress_ctx); + let sample_ctx = compress_ctx.with_sampling(); + + let compressed = match scheme.compress(compressor, &sample_data, sample_ctx, exec_ctx) { + Ok(compressed) => compressed, + Err(err) => { + trace::sample_compress_failed(scheme.id(), error_ctx.as_ref(), &err); + return Err(err); + } + }; + + let after = compressed.nbytes(); + let before = sample_data.array().nbytes(); + + let score = EstimateScore::from_sample_sizes(before, after); + + if matches!(score, EstimateScore::ZeroBytes) { + trace::zero_byte_sample_result(scheme.id(), before); + } + + Ok(score) +} + #[cfg(test)] mod tests { use vortex_array::IntoArray; diff --git a/vortex-compressor/src/compressor/select.rs b/vortex-compressor/src/compressor/select.rs new file mode 100644 index 00000000000..3c73d2d4cdb --- /dev/null +++ b/vortex-compressor/src/compressor/select.rs @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Scheme selection: estimating each eligible scheme and choosing the winner. + +use vortex_array::ExecutionCtx; +use vortex_error::VortexResult; + +use super::ROOT_SCHEME_ID; +use super::sample::estimate_compression_ratio_with_sampling; +use crate::CascadingCompressor; +use crate::scheme::CompressionEstimate; +use crate::scheme::CompressorContext; +use crate::scheme::DeferredEstimate; +use crate::scheme::EstimateScore; +use crate::scheme::EstimateVerdict; +use crate::scheme::Scheme; +use crate::scheme::SchemeExt; +use crate::stats::ArrayAndStats; +use crate::trace; + +/// Winner estimate carried from scheme selection into result tracing. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) enum WinnerEstimate { + /// The scheme must be used immediately. + AlwaysUse, + /// The scheme won by a ranked estimate. + Score(EstimateScore), +} + +impl WinnerEstimate { + /// Returns the traceable numeric ratio for the winning estimate. + pub(super) fn trace_ratio(self) -> Option { + match self { + Self::AlwaysUse => None, + Self::Score(score) => score.finite_ratio(), + } + } +} + +/// Returns `true` if `score` beats the current best estimate. +fn is_better_score( + score: EstimateScore, + best: Option<&(&'static dyn Scheme, EstimateScore)>, +) -> bool { + score.is_valid() && best.is_none_or(|(_, best_score)| score.beats(*best_score)) +} + +impl CascadingCompressor { + /// Calls [`expected_compression_ratio`] on each candidate and returns the winning scheme along + /// with its resolved winner estimate, or `None` if no scheme beats the canonical encoding. + /// + /// Selection runs in two passes. Pass 1 evaluates every immediate + /// [`CompressionEstimate::Verdict`] and tracks the running best. [`Scheme`]s returning + /// [`CompressionEstimate::Deferred`] are stashed for pass 2 so that we do not make any + /// expensive computations if we don't have to. + /// + /// Pass 2 evaluates the deferred work and, for each [`DeferredEstimate::Callback`], passes the + /// current best [`EstimateScore`] as an early-exit hint so the callback can return + /// [`EstimateVerdict::Skip`] without doing expensive work when it cannot beat the threshold. + /// + /// Ties are broken by registration order within each pass. + /// + /// [`expected_compression_ratio`]: Scheme::expected_compression_ratio + pub(super) fn choose_best_scheme( + &self, + schemes: &[&'static dyn Scheme], + data: &ArrayAndStats, + compress_ctx: CompressorContext, + exec_ctx: &mut ExecutionCtx, + ) -> VortexResult> { + let mut best: Option<(&'static dyn Scheme, EstimateScore)> = None; + let mut deferred: Vec<(&'static dyn Scheme, DeferredEstimate)> = Vec::new(); + + // Pass 1: evaluate every immediate verdict. Stash deferred work for pass 2. + { + let _verdict_pass = trace::verdict_pass_span().entered(); + for &scheme in schemes { + match scheme.expected_compression_ratio(data, compress_ctx.clone(), exec_ctx) { + CompressionEstimate::Verdict(EstimateVerdict::Skip) => {} + CompressionEstimate::Verdict(EstimateVerdict::AlwaysUse) => { + return Ok(Some((scheme, WinnerEstimate::AlwaysUse))); + } + CompressionEstimate::Verdict(EstimateVerdict::Ratio(ratio)) => { + let score = EstimateScore::FiniteCompression(ratio); + + if is_better_score(score, best.as_ref()) { + best = Some((scheme, score)); + } + } + CompressionEstimate::Deferred(deferred_estimate) => { + deferred.push((scheme, deferred_estimate)); + } + } + } + } + + // Pass 2: run deferred work. Callbacks receive the current best as a threshold so they can + // short-circuit with `Skip` when they cannot beat it. + for (scheme, deferred_estimate) in deferred { + let _span = trace::scheme_eval_span(scheme.id()).entered(); + let threshold: Option = best.map(|(_, score)| score); + match deferred_estimate { + DeferredEstimate::Sample => { + let score = estimate_compression_ratio_with_sampling( + self, + scheme, + data.array(), + compress_ctx.clone(), + exec_ctx, + )?; + + if is_better_score(score, best.as_ref()) { + best = Some((scheme, score)); + } + } + DeferredEstimate::Callback(callback) => { + match callback(self, data, threshold, compress_ctx.clone(), exec_ctx)? { + EstimateVerdict::Skip => {} + EstimateVerdict::AlwaysUse => { + return Ok(Some((scheme, WinnerEstimate::AlwaysUse))); + } + EstimateVerdict::Ratio(ratio) => { + let score = EstimateScore::FiniteCompression(ratio); + + if is_better_score(score, best.as_ref()) { + best = Some((scheme, score)); + } + } + } + } + } + } + + Ok(best.map(|(scheme, score)| (scheme, WinnerEstimate::Score(score)))) + } + + // TODO(connor): Lots of room for optimization here. + /// Returns `true` if the candidate scheme should be excluded based on the cascade history and + /// exclusion rules. + pub(super) fn is_excluded(&self, candidate: &dyn Scheme, ctx: &CompressorContext) -> bool { + let id = candidate.id(); + let history = ctx.cascade_history(); + + // Self-exclusion: no scheme appears twice in any chain. + if history.iter().any(|&(sid, _)| sid == id) { + return true; + } + + let mut iter = history.iter().copied().peekable(); + + // The root entry is always first in the history (if present). Check if the root has + // excluded us. + if let Some((_, child_idx)) = iter.next_if(|&(sid, _)| sid == ROOT_SCHEME_ID) + && self + .root_exclusions + .iter() + .any(|rule| rule.excluded == id && rule.children.contains(child_idx)) + { + return true; + } + + // Push rules: Check if any of our ancestors have excluded us. + for (ancestor_id, child_idx) in iter { + if let Some(ancestor) = self.schemes.iter().find(|s| s.id() == ancestor_id) + && ancestor + .descendant_exclusions() + .iter() + .any(|rule| rule.excluded == id && rule.children.contains(child_idx)) + { + return true; + } + } + + // Pull rules: Check if we have excluded ourselves because of our ancestors. + for rule in candidate.ancestor_exclusions() { + if history + .iter() + .any(|(sid, cidx)| *sid == rule.ancestor && rule.children.contains(*cidx)) + { + return true; + } + } + + false + } +} diff --git a/vortex-compressor/src/compressor/structural.rs b/vortex-compressor/src/compressor/structural.rs new file mode 100644 index 00000000000..ec867c1c1dc --- /dev/null +++ b/vortex-compressor/src/compressor/structural.rs @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Recursive compression of structural arrays: lists, list views, and physical slots. + +use vortex_array::ArrayRef; +use vortex_array::ArraySlots; +use vortex_array::Canonical; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::arrays::ListArray; +use vortex_array::arrays::ListViewArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::arrays::list::ListArrayExt; +use vortex_array::arrays::listview::ListViewArrayExt; +use vortex_array::arrays::primitive::PrimitiveArrayExt; +use vortex_error::VortexResult; + +use super::ROOT_SCHEME_ID; +use crate::CascadingCompressor; +use crate::scheme::CompressorContext; + +/// Child indices for the compressor's list/listview compression. +pub(super) mod root_list_children { + /// List/ListView offsets child. + pub const OFFSETS: usize = 1; + /// ListView sizes child. + pub const SIZES: usize = 2; +} + +impl CascadingCompressor { + /// Compresses a [`ListArray`] by narrowing offsets and recursively compressing elements. + pub(super) fn compress_list_array( + &self, + list_array: ListArray, + compress_ctx: CompressorContext, + exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + let list_array = list_array.reset_offsets(true, exec_ctx)?; + + let compressed_elems = self.compress(list_array.elements(), exec_ctx)?; + + // Record the root scheme with the offsets child index so root exclusion rules apply. + let offset_ctx = + compress_ctx.descend_with_scheme(ROOT_SCHEME_ID, root_list_children::OFFSETS); + let list_offsets_primitive = list_array + .offsets() + .clone() + .execute::(exec_ctx)? + .narrow(exec_ctx)?; + let compressed_offsets = self.compress_canonical( + Canonical::Primitive(list_offsets_primitive), + offset_ctx, + exec_ctx, + )?; + + Ok( + ListArray::try_new(compressed_elems, compressed_offsets, list_array.validity()?)? + .into_array(), + ) + } + + /// Compresses a [`ListViewArray`] by narrowing offsets/sizes and recursively compressing + /// elements. + pub(super) fn compress_list_view_array( + &self, + list_view: ListViewArray, + compress_ctx: CompressorContext, + exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + let compressed_elems = self.compress(list_view.elements(), exec_ctx)?; + + let offset_ctx = compress_ctx + .clone() + .descend_with_scheme(ROOT_SCHEME_ID, root_list_children::OFFSETS); + let list_view_offsets_primitive = list_view + .offsets() + .clone() + .execute::(exec_ctx)? + .narrow(exec_ctx)?; + let compressed_offsets = self.compress_canonical( + Canonical::Primitive(list_view_offsets_primitive), + offset_ctx, + exec_ctx, + )?; + + let sizes_ctx = compress_ctx.descend_with_scheme(ROOT_SCHEME_ID, root_list_children::SIZES); + let list_view_sizes_primitive = list_view + .sizes() + .clone() + .execute::(exec_ctx)? + .narrow(exec_ctx)?; + let compressed_sizes = self.compress_canonical( + Canonical::Primitive(list_view_sizes_primitive), + sizes_ctx, + exec_ctx, + )?; + + Ok(ListViewArray::try_new( + compressed_elems, + compressed_offsets, + compressed_sizes, + list_view.validity()?, + )? + .into_array()) + } + + /// Compress very child slot of the array, then re-build it from them. + pub(super) fn compress_physical_slots( + &self, + array: &ArrayRef, + exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + let slots = array + .slots() + .iter() + .map(|slot| { + slot.as_ref() + .map(|child| self.compress(child, exec_ctx)) + .transpose() + }) + .collect::>()?; + + // SAFETY: compression rewrites each child slot to an equivalent physical representation, + // preserving the parent array's logical values and statistics. + unsafe { array.clone().with_slots(slots) } + } +} diff --git a/vortex-compressor/src/compressor/tests.rs b/vortex-compressor/src/compressor/tests.rs new file mode 100644 index 00000000000..08eafa369de --- /dev/null +++ b/vortex-compressor/src/compressor/tests.rs @@ -0,0 +1,706 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use std::sync::LazyLock; + +use parking_lot::Mutex; +use vortex_array::ArrayRef; +use vortex_array::Canonical; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::Constant; +use vortex_array::arrays::NullArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::validity::Validity; +use vortex_buffer::buffer; +use vortex_error::VortexResult; +use vortex_session::VortexSession; + +use super::CascadingCompressor; +use super::ROOT_SCHEME_ID; +use super::sample::estimate_compression_ratio_with_sampling; +use super::select::WinnerEstimate; +use super::structural; +use crate::builtins::FloatDictScheme; +use crate::builtins::IntDictScheme; +use crate::builtins::StringDictScheme; +use crate::scheme::CompressionEstimate; +use crate::scheme::CompressorContext; +use crate::scheme::DeferredEstimate; +use crate::scheme::EstimateScore; +use crate::scheme::EstimateVerdict; +use crate::scheme::Scheme; +use crate::scheme::SchemeExt; +use crate::stats::ArrayAndStats; +use crate::stats::GenerateStatsOptions; + +static SESSION: LazyLock = LazyLock::new(vortex_array::array_session); + +fn compressor() -> CascadingCompressor { + CascadingCompressor::new(vec![&IntDictScheme, &FloatDictScheme, &StringDictScheme]) +} + +fn estimate_test_data() -> ArrayAndStats { + let array = PrimitiveArray::new(buffer![1i32, 2, 3, 4], Validity::NonNullable).into_array(); + ArrayAndStats::new(array, GenerateStatsOptions::default()) +} + +fn matches_integer_primitive(canonical: &Canonical) -> bool { + matches!(canonical, Canonical::Primitive(primitive) if primitive.ptype().is_int()) +} + +#[derive(Debug)] +struct DirectRatioScheme; + +impl Scheme for DirectRatioScheme { + fn scheme_name(&self) -> &'static str { + "test.direct_ratio" + } + + fn matches(&self, canonical: &Canonical) -> bool { + matches_integer_primitive(canonical) + } + + fn expected_compression_ratio( + &self, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> CompressionEstimate { + CompressionEstimate::Verdict(EstimateVerdict::Ratio(2.0)) + } + + fn compress( + &self, + _compressor: &CascadingCompressor, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + unreachable!("test helper should never be selected for compression") + } +} + +#[derive(Debug)] +struct ImmediateAlwaysUseScheme; + +impl Scheme for ImmediateAlwaysUseScheme { + fn scheme_name(&self) -> &'static str { + "test.immediate_always_use" + } + + fn matches(&self, canonical: &Canonical) -> bool { + matches_integer_primitive(canonical) + } + + fn expected_compression_ratio( + &self, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> CompressionEstimate { + CompressionEstimate::Verdict(EstimateVerdict::AlwaysUse) + } + + fn compress( + &self, + _compressor: &CascadingCompressor, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + unreachable!("test helper should never be selected for compression") + } +} + +#[derive(Debug)] +struct CallbackAlwaysUseScheme; + +impl Scheme for CallbackAlwaysUseScheme { + fn scheme_name(&self) -> &'static str { + "test.callback_always_use" + } + + fn matches(&self, canonical: &Canonical) -> bool { + matches_integer_primitive(canonical) + } + + fn expected_compression_ratio( + &self, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> CompressionEstimate { + CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new( + |_compressor, _data, _ctx, _exec_ctx, _best_so_far| Ok(EstimateVerdict::AlwaysUse), + ))) + } + + fn compress( + &self, + _compressor: &CascadingCompressor, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + unreachable!("test helper should never be selected for compression") + } +} + +#[derive(Debug)] +struct CallbackSkipScheme; + +impl Scheme for CallbackSkipScheme { + fn scheme_name(&self) -> &'static str { + "test.callback_skip" + } + + fn matches(&self, canonical: &Canonical) -> bool { + matches_integer_primitive(canonical) + } + + fn expected_compression_ratio( + &self, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> CompressionEstimate { + CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new( + |_compressor, _data, _ctx, _exec_ctx, _best_so_far| Ok(EstimateVerdict::Skip), + ))) + } + + fn compress( + &self, + _compressor: &CascadingCompressor, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + unreachable!("test helper should never be selected for compression") + } +} + +#[derive(Debug)] +struct CallbackRatioScheme; + +impl Scheme for CallbackRatioScheme { + fn scheme_name(&self) -> &'static str { + "test.callback_ratio" + } + + fn matches(&self, canonical: &Canonical) -> bool { + matches_integer_primitive(canonical) + } + + fn expected_compression_ratio( + &self, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> CompressionEstimate { + CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new( + |_compressor, _data, _ctx, _exec_ctx, _best_so_far| Ok(EstimateVerdict::Ratio(3.0)), + ))) + } + + fn compress( + &self, + _compressor: &CascadingCompressor, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + unreachable!("test helper should never be selected for compression") + } +} + +#[derive(Debug)] +struct HugeRatioScheme; + +impl Scheme for HugeRatioScheme { + fn scheme_name(&self) -> &'static str { + "test.huge_ratio" + } + + fn matches(&self, canonical: &Canonical) -> bool { + matches_integer_primitive(canonical) + } + + fn expected_compression_ratio( + &self, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> CompressionEstimate { + CompressionEstimate::Verdict(EstimateVerdict::Ratio(100.0)) + } + + fn compress( + &self, + _compressor: &CascadingCompressor, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + unreachable!("test helper should never be selected for compression") + } +} + +#[derive(Debug)] +struct ZeroBytesSamplingScheme; + +impl Scheme for ZeroBytesSamplingScheme { + fn scheme_name(&self) -> &'static str { + "test.zero_bytes_sampling" + } + + fn matches(&self, canonical: &Canonical) -> bool { + matches_integer_primitive(canonical) + } + + fn expected_compression_ratio( + &self, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> CompressionEstimate { + CompressionEstimate::Deferred(DeferredEstimate::Sample) + } + + fn compress( + &self, + _compressor: &CascadingCompressor, + data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + Ok(NullArray::new(data.array().len()).into_array()) + } +} + +#[test] +fn test_self_exclusion() { + let c = compressor(); + let ctx = CompressorContext::default().descend_with_scheme(IntDictScheme.id(), 0); + + // IntDictScheme is in the history, so it should be excluded. + assert!(c.is_excluded(&IntDictScheme, &ctx)); +} + +#[test] +fn test_root_exclusion_list_offsets() { + let c = compressor(); + let ctx = CompressorContext::default() + .descend_with_scheme(ROOT_SCHEME_ID, structural::root_list_children::OFFSETS); + + // IntDict should be excluded for list offsets. + assert!(c.is_excluded(&IntDictScheme, &ctx)); +} + +#[test] +fn test_push_rule_float_dict_excludes_int_dict_from_codes() { + let c = compressor(); + // FloatDict cascading through codes (child 1). + let ctx = CompressorContext::default().descend_with_scheme(FloatDictScheme.id(), 1); + + // IntDict should be excluded from FloatDict's codes child. + assert!(c.is_excluded(&IntDictScheme, &ctx)); +} + +#[test] +fn test_push_rule_float_dict_excludes_int_dict_from_values() { + let c = compressor(); + // FloatDict cascading through values (child 0). + let ctx = CompressorContext::default().descend_with_scheme(FloatDictScheme.id(), 0); + + // IntDict should also be excluded from FloatDict's values child (ALP propagation + // replacement). + assert!(c.is_excluded(&IntDictScheme, &ctx)); +} + +#[test] +fn test_no_exclusion_without_history() { + let c = compressor(); + let ctx = CompressorContext::default(); + + // No history means no exclusions. + assert!(!c.is_excluded(&IntDictScheme, &ctx)); +} + +#[test] +fn immediate_always_use_wins_immediately() -> VortexResult<()> { + let compressor = CascadingCompressor::new(vec![&DirectRatioScheme, &ImmediateAlwaysUseScheme]); + let schemes: [&'static dyn Scheme; 2] = [&DirectRatioScheme, &ImmediateAlwaysUseScheme]; + let data = estimate_test_data(); + let mut exec_ctx = SESSION.create_execution_ctx(); + + let winner = + compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; + + assert!(matches!( + winner, + Some((scheme, WinnerEstimate::AlwaysUse)) + if scheme.id() == ImmediateAlwaysUseScheme.id() + )); + Ok(()) +} + +#[test] +fn callback_always_use_wins_immediately() -> VortexResult<()> { + let compressor = CascadingCompressor::new(vec![&DirectRatioScheme, &CallbackAlwaysUseScheme]); + let schemes: [&'static dyn Scheme; 2] = [&DirectRatioScheme, &CallbackAlwaysUseScheme]; + let data = estimate_test_data(); + let mut exec_ctx = SESSION.create_execution_ctx(); + + let winner = + compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; + + assert!(matches!( + winner, + Some((scheme, WinnerEstimate::AlwaysUse)) + if scheme.id() == CallbackAlwaysUseScheme.id() + )); + Ok(()) +} + +#[test] +fn callback_skip_is_ignored() -> VortexResult<()> { + let compressor = CascadingCompressor::new(vec![&CallbackSkipScheme, &DirectRatioScheme]); + let schemes: [&'static dyn Scheme; 2] = [&CallbackSkipScheme, &DirectRatioScheme]; + let data = estimate_test_data(); + let mut exec_ctx = SESSION.create_execution_ctx(); + + let winner = + compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; + + assert!(matches!( + winner, + Some((scheme, WinnerEstimate::Score(EstimateScore::FiniteCompression(2.0)))) + if scheme.id() == DirectRatioScheme.id() + )); + Ok(()) +} + +#[test] +fn callback_ratio_competes_numerically() -> VortexResult<()> { + let compressor = CascadingCompressor::new(vec![&DirectRatioScheme, &CallbackRatioScheme]); + let schemes: [&'static dyn Scheme; 2] = [&DirectRatioScheme, &CallbackRatioScheme]; + let data = estimate_test_data(); + let mut exec_ctx = SESSION.create_execution_ctx(); + + let winner = + compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; + + assert!(matches!( + winner, + Some((scheme, WinnerEstimate::Score(EstimateScore::FiniteCompression(3.0)))) + if scheme.id() == CallbackRatioScheme.id() + )); + Ok(()) +} + +#[test] +fn zero_byte_sample_loses_to_finite_ratio() -> VortexResult<()> { + let compressor = CascadingCompressor::new(vec![&HugeRatioScheme, &ZeroBytesSamplingScheme]); + let schemes: [&'static dyn Scheme; 2] = [&HugeRatioScheme, &ZeroBytesSamplingScheme]; + let data = estimate_test_data(); + let mut exec_ctx = SESSION.create_execution_ctx(); + + let winner = + compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; + + assert!(matches!( + winner, + Some((scheme, WinnerEstimate::Score(EstimateScore::FiniteCompression(100.0)))) + if scheme.id() == HugeRatioScheme.id() + )); + Ok(()) +} + +#[test] +fn finite_ratio_displaces_zero_byte_sample() -> VortexResult<()> { + let compressor = CascadingCompressor::new(vec![&ZeroBytesSamplingScheme, &HugeRatioScheme]); + let schemes: [&'static dyn Scheme; 2] = [&ZeroBytesSamplingScheme, &HugeRatioScheme]; + let data = estimate_test_data(); + let mut exec_ctx = SESSION.create_execution_ctx(); + + let winner = + compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; + + assert!(matches!( + winner, + Some((scheme, WinnerEstimate::Score(EstimateScore::FiniteCompression(100.0)))) + if scheme.id() == HugeRatioScheme.id() + )); + Ok(()) +} + +#[test] +fn zero_byte_sample_alone_selects_no_scheme() -> VortexResult<()> { + let compressor = CascadingCompressor::new(vec![&ZeroBytesSamplingScheme]); + let schemes: [&'static dyn Scheme; 1] = [&ZeroBytesSamplingScheme]; + let data = estimate_test_data(); + let mut exec_ctx = SESSION.create_execution_ctx(); + + let winner = + compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; + + assert!(winner.is_none()); + Ok(()) +} + +// Observer helper used by threshold-related tests. Captures the `best_so_far` value the +// compressor passes to its deferred callback. `OBSERVER_LOCK` serializes tests that share +// `OBSERVED_THRESHOLD` so they do not race. +static OBSERVER_LOCK: Mutex<()> = Mutex::new(()); +static OBSERVED_THRESHOLD: Mutex>> = Mutex::new(None); + +#[derive(Debug)] +struct ThresholdObservingScheme; + +impl Scheme for ThresholdObservingScheme { + fn scheme_name(&self) -> &'static str { + "test.threshold_observing" + } + + fn matches(&self, canonical: &Canonical) -> bool { + matches_integer_primitive(canonical) + } + + fn expected_compression_ratio( + &self, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> CompressionEstimate { + CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new( + |_compressor, _data, best_so_far, _ctx, _exec_ctx| { + *OBSERVED_THRESHOLD.lock() = Some(best_so_far); + Ok(EstimateVerdict::Skip) + }, + ))) + } + + fn compress( + &self, + _compressor: &CascadingCompressor, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + unreachable!("test helper should never be selected for compression") + } +} + +#[derive(Debug)] +struct CallbackMatchingRatioScheme; + +impl Scheme for CallbackMatchingRatioScheme { + fn scheme_name(&self) -> &'static str { + "test.callback_matching_ratio" + } + + fn matches(&self, canonical: &Canonical) -> bool { + matches_integer_primitive(canonical) + } + + fn expected_compression_ratio( + &self, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> CompressionEstimate { + CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new( + |_compressor, _data, _ctx, _exec_ctx, _best_so_far| Ok(EstimateVerdict::Ratio(2.0)), + ))) + } + + fn compress( + &self, + _compressor: &CascadingCompressor, + _data: &ArrayAndStats, + _compress_ctx: CompressorContext, + _exec_ctx: &mut ExecutionCtx, + ) -> VortexResult { + unreachable!("test helper should never be selected for compression") + } +} + +#[test] +fn callback_always_use_overrides_pass_one_best() -> VortexResult<()> { + // `HugeRatioScheme` returns an immediate `Ratio(100.0)` in pass 1; + // `CallbackAlwaysUseScheme` returns `AlwaysUse` from its deferred callback in pass 2. + // The deferred `AlwaysUse` must still win. + let compressor = CascadingCompressor::new(vec![&HugeRatioScheme, &CallbackAlwaysUseScheme]); + let schemes: [&'static dyn Scheme; 2] = [&HugeRatioScheme, &CallbackAlwaysUseScheme]; + let data = estimate_test_data(); + let mut exec_ctx = SESSION.create_execution_ctx(); + + let winner = + compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; + + assert!(matches!( + winner, + Some((scheme, WinnerEstimate::AlwaysUse)) + if scheme.id() == CallbackAlwaysUseScheme.id() + )); + Ok(()) +} + +#[test] +fn threshold_reflects_pass_one_best() -> VortexResult<()> { + let _guard = OBSERVER_LOCK.lock(); + *OBSERVED_THRESHOLD.lock() = None; + + let compressor = CascadingCompressor::new(vec![&DirectRatioScheme, &ThresholdObservingScheme]); + let schemes: [&'static dyn Scheme; 2] = [&DirectRatioScheme, &ThresholdObservingScheme]; + let data = estimate_test_data(); + let mut exec_ctx = SESSION.create_execution_ctx(); + + compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; + + let observed = *OBSERVED_THRESHOLD.lock(); + assert!(matches!( + observed, + Some(Some(EstimateScore::FiniteCompression(r))) if r == 2.0 + )); + Ok(()) +} + +#[test] +fn threshold_is_none_when_only_prior_is_zero_bytes() -> VortexResult<()> { + let _guard = OBSERVER_LOCK.lock(); + *OBSERVED_THRESHOLD.lock() = None; + + let compressor = + CascadingCompressor::new(vec![&ZeroBytesSamplingScheme, &ThresholdObservingScheme]); + let schemes: [&'static dyn Scheme; 2] = [&ZeroBytesSamplingScheme, &ThresholdObservingScheme]; + let data = estimate_test_data(); + let mut exec_ctx = SESSION.create_execution_ctx(); + + compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; + + // The observing callback was invoked (outer `Some`) and `best_so_far` was `None` (inner + // `None`) because the zero-byte sample is never stored as the best. + let observed = *OBSERVED_THRESHOLD.lock(); + assert_eq!(observed, Some(None)); + Ok(()) +} + +#[test] +fn threshold_is_none_when_no_prior_scheme() -> VortexResult<()> { + let _guard = OBSERVER_LOCK.lock(); + *OBSERVED_THRESHOLD.lock() = None; + + let compressor = CascadingCompressor::new(vec![&ThresholdObservingScheme]); + let schemes: [&'static dyn Scheme; 1] = [&ThresholdObservingScheme]; + let data = estimate_test_data(); + let mut exec_ctx = SESSION.create_execution_ctx(); + + compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; + + let observed = *OBSERVED_THRESHOLD.lock(); + assert_eq!(observed, Some(None)); + Ok(()) +} + +#[test] +fn threshold_updates_from_earlier_deferred_callback() -> VortexResult<()> { + let _guard = OBSERVER_LOCK.lock(); + *OBSERVED_THRESHOLD.lock() = None; + + // Both schemes are deferred. The first callback registers `Ratio(3.0)`; the second + // callback must observe it as its threshold. + let compressor = + CascadingCompressor::new(vec![&CallbackRatioScheme, &ThresholdObservingScheme]); + let schemes: [&'static dyn Scheme; 2] = [&CallbackRatioScheme, &ThresholdObservingScheme]; + let data = estimate_test_data(); + let mut exec_ctx = SESSION.create_execution_ctx(); + + compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; + + let observed = *OBSERVED_THRESHOLD.lock(); + assert!(matches!( + observed, + Some(Some(EstimateScore::FiniteCompression(r))) if r == 3.0 + )); + Ok(()) +} + +#[test] +fn ratio_tie_between_immediate_and_deferred_favors_immediate() -> VortexResult<()> { + // Both schemes produce the same `Ratio(2.0)`, one from pass 1 (immediate) and one from + // pass 2 (deferred callback). Pass 1 locks in first, and strict `>` tie-breaking means + // the deferred callback's equal ratio cannot displace it. + let compressor = + CascadingCompressor::new(vec![&CallbackMatchingRatioScheme, &DirectRatioScheme]); + let schemes: [&'static dyn Scheme; 2] = [&CallbackMatchingRatioScheme, &DirectRatioScheme]; + let data = estimate_test_data(); + let mut exec_ctx = SESSION.create_execution_ctx(); + + let winner = + compressor.choose_best_scheme(&schemes, &data, CompressorContext::new(), &mut exec_ctx)?; + + assert!(matches!( + winner, + Some((scheme, WinnerEstimate::Score(EstimateScore::FiniteCompression(r)))) + if scheme.id() == DirectRatioScheme.id() && r == 2.0 + )); + Ok(()) +} + +#[test] +fn all_null_array_compresses_to_constant() -> VortexResult<()> { + let array = PrimitiveArray::new( + buffer![0i32, 0, 0, 0, 0], + Validity::Array(BoolArray::from_iter([false, false, false, false, false]).into_array()), + ) + .into_array(); + + // The compressor should produce a `ConstantArray` for an all-null array regardless of + // which schemes are registered. + let compressor = CascadingCompressor::new(vec![&IntDictScheme]); + let mut exec_ctx = SESSION.create_execution_ctx(); + let compressed = compressor.compress(&array, &mut exec_ctx)?; + assert!(compressed.is::()); + Ok(()) +} + +/// Regression test for . +/// +/// `estimate_compression_ratio_with_sampling` must use the *scheme's* stats options +/// (which request distinct-value counting) rather than the context's stats options +/// (which may not). With the old code this panicked inside `dictionary_encode` because +/// distinct values were never computed for the sample. +#[test] +fn sampling_uses_scheme_stats_options() -> VortexResult<()> { + // Low-cardinality float array so FloatDictScheme considers it compressible. + let array = PrimitiveArray::new( + buffer![1.0f32, 2.0, 1.0, 2.0, 1.0, 2.0, 1.0, 2.0], + Validity::NonNullable, + ) + .into_array(); + + let compressor = CascadingCompressor::new(vec![&FloatDictScheme]); + + // A context with default stats_options (count_distinct_values = false) and + // marked as a sample so the function skips the sampling step and compresses + // the array directly. + let ctx = CompressorContext::new().with_sampling(); + + // Before the fix this panicked with: + // "this must be present since `DictScheme` declared that we need distinct values" + let mut exec_ctx = SESSION.create_execution_ctx(); + let score = estimate_compression_ratio_with_sampling( + &compressor, + &FloatDictScheme, + &array, + ctx, + &mut exec_ctx, + )?; + assert!(matches!(score, EstimateScore::FiniteCompression(ratio) if ratio.is_finite())); + Ok(()) +} diff --git a/vortex-compressor/src/lib.rs b/vortex-compressor/src/lib.rs index 86df4fc4cba..55bb9b188f6 100644 --- a/vortex-compressor/src/lib.rs +++ b/vortex-compressor/src/lib.rs @@ -63,14 +63,10 @@ //! with a short `jq` query. pub mod builtins; -pub mod ctx; -pub mod estimate; pub mod scheme; pub mod stats; -mod constant; -mod sample; - mod compressor; -mod trace; pub use compressor::CascadingCompressor; + +mod trace; diff --git a/vortex-compressor/src/ctx.rs b/vortex-compressor/src/scheme/ctx.rs similarity index 95% rename from vortex-compressor/src/ctx.rs rename to vortex-compressor/src/scheme/ctx.rs index 4e0619ff8ee..4eed7538daa 100644 --- a/vortex-compressor/src/ctx.rs +++ b/vortex-compressor/src/scheme/ctx.rs @@ -44,7 +44,7 @@ impl CompressorContext { /// Creates a new `CompressorContext`. /// /// This should **only** be created by the compressor. - pub(super) fn new() -> Self { + pub(crate) fn new() -> Self { Self { is_sample: false, allowed_cascading: MAX_CASCADE, @@ -102,13 +102,13 @@ impl CompressorContext { } /// Returns a context with the given stats options. - pub(super) fn with_merged_stats_options(mut self, opts: GenerateStatsOptions) -> Self { + pub(crate) fn with_merged_stats_options(mut self, opts: GenerateStatsOptions) -> Self { self.merged_stats_options = opts; self } /// Returns a context marked as sample compression. - pub(super) fn with_sampling(mut self) -> Self { + pub(crate) fn with_sampling(mut self) -> Self { self.is_sample = true; self } @@ -118,7 +118,7 @@ impl CompressorContext { /// /// The `child_index` identifies which child of the scheme is being compressed (e.g. for /// Dict: values=0, codes=1). - pub(super) fn descend_with_scheme(mut self, id: SchemeId, child_index: usize) -> Self { + pub(crate) fn descend_with_scheme(mut self, id: SchemeId, child_index: usize) -> Self { self.allowed_cascading = self .allowed_cascading .checked_sub(1) diff --git a/vortex-compressor/src/estimate.rs b/vortex-compressor/src/scheme/estimate.rs similarity index 64% rename from vortex-compressor/src/estimate.rs rename to vortex-compressor/src/scheme/estimate.rs index 82d021353d2..20b9dc77e9b 100644 --- a/vortex-compressor/src/estimate.rs +++ b/vortex-compressor/src/scheme/estimate.rs @@ -1,25 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -//! Compression ratio estimation types and sampling-based estimation. +//! Compression ratio estimation types returned by schemes. use std::fmt; -use vortex_array::ArrayRef; -use vortex_array::Canonical; use vortex_array::ExecutionCtx; -use vortex_array::IntoArray; use vortex_error::VortexResult; use crate::CascadingCompressor; -use crate::ctx::CompressorContext; -use crate::sample::SAMPLE_SIZE; -use crate::sample::sample; -use crate::sample::sample_count_approx_one_percent; -use crate::scheme::Scheme; -use crate::scheme::SchemeExt; +use crate::scheme::CompressorContext; use crate::stats::ArrayAndStats; -use crate::trace; /// Closure type for [`DeferredEstimate::Callback`]. /// @@ -44,10 +35,12 @@ pub type EstimateFn = dyn FnOnce( + Send + Sync; -/// The result of a [`Scheme`]'s compression ratio estimation. +/// The result of a [`Scheme`](crate::scheme::Scheme)'s compression ratio estimation. /// -/// This type is returned by [`Scheme::expected_compression_ratio`] to tell the compressor how -/// promising this scheme is for a given array without performing any expensive work. +/// This type is returned by +/// [`Scheme::expected_compression_ratio`](crate::scheme::Scheme::expected_compression_ratio) to +/// tell the compressor how promising this scheme is for a given array without performing any +/// expensive work. /// /// [`CompressionEstimate::Verdict`] means the scheme already knows the terminal answer. /// [`CompressionEstimate::Deferred`] means the compressor must do extra work before the scheme can @@ -119,7 +112,7 @@ pub enum EstimateScore { impl EstimateScore { /// Converts measured sample sizes into a ranked estimate. - pub(super) fn from_sample_sizes(before_nbytes: u64, after_nbytes: u64) -> Self { + pub(crate) fn from_sample_sizes(before_nbytes: u64, after_nbytes: u64) -> Self { if after_nbytes == 0 { Self::ZeroBytes } else { @@ -139,7 +132,7 @@ impl EstimateScore { } /// Returns whether this estimate is eligible to compete. - fn is_valid(self) -> bool { + pub(crate) fn is_valid(self) -> bool { match self { Self::FiniteCompression(ratio) => { ratio.is_finite() && !ratio.is_subnormal() && ratio > 1.0 @@ -149,7 +142,7 @@ impl EstimateScore { } /// Returns whether this estimate beats another valid estimate. - fn beats(self, other: Self) -> bool { + pub(crate) fn beats(self, other: Self) -> bool { match (self, other) { (Self::ZeroBytes, _) => false, (Self::FiniteCompression(_), Self::ZeroBytes) => true, @@ -160,81 +153,6 @@ impl EstimateScore { } } -/// Winner estimate carried from scheme selection into result tracing. -#[derive(Debug, Clone, Copy, PartialEq)] -pub(super) enum WinnerEstimate { - /// The scheme must be used immediately. - AlwaysUse, - /// The scheme won by a ranked estimate. - Score(EstimateScore), -} - -impl WinnerEstimate { - /// Returns the traceable numeric ratio for the winning estimate. - pub(super) fn trace_ratio(self) -> Option { - match self { - Self::AlwaysUse => None, - Self::Score(score) => score.finite_ratio(), - } - } -} - -/// Returns `true` if `score` beats the current best estimate. -pub(super) fn is_better_score( - score: EstimateScore, - best: Option<&(&'static dyn Scheme, EstimateScore)>, -) -> bool { - score.is_valid() && best.is_none_or(|(_, best_score)| score.beats(*best_score)) -} - -/// Estimates compression ratio by compressing a ~1% sample of the data. -/// -/// Creates a new [`ArrayAndStats`] for the sample so that stats are generated from the sample, not -/// the full array. -/// -/// # Errors -/// -/// Returns an error if sample compression fails. -pub(super) fn estimate_compression_ratio_with_sampling( - compressor: &CascadingCompressor, - scheme: &S, - array: &ArrayRef, - compress_ctx: CompressorContext, - exec_ctx: &mut ExecutionCtx, -) -> VortexResult { - let sample_array = if compress_ctx.is_sample() { - array.clone() - } else { - let sample_count = sample_count_approx_one_percent(array.len()); - // `ArrayAndStats` expects a canonical array (so that it can easily compute lazy stats). - let canonical: Canonical = sample(array, SAMPLE_SIZE, sample_count).execute(exec_ctx)?; - canonical.into_array() - }; - - let sample_data = ArrayAndStats::new(sample_array, scheme.stats_options()); - let error_ctx = trace::enabled_error_context(&compress_ctx); - let sample_ctx = compress_ctx.with_sampling(); - - let compressed = match scheme.compress(compressor, &sample_data, sample_ctx, exec_ctx) { - Ok(compressed) => compressed, - Err(err) => { - trace::sample_compress_failed(scheme.id(), error_ctx.as_ref(), &err); - return Err(err); - } - }; - - let after = compressed.nbytes(); - let before = sample_data.array().nbytes(); - - let score = EstimateScore::from_sample_sizes(before, after); - - if matches!(score, EstimateScore::ZeroBytes) { - trace::zero_byte_sample_result(scheme.id(), before); - } - - Ok(score) -} - impl fmt::Debug for DeferredEstimate { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/vortex-compressor/src/scheme/exclusion.rs b/vortex-compressor/src/scheme/exclusion.rs new file mode 100644 index 00000000000..2dba6b85046 --- /dev/null +++ b/vortex-compressor/src/scheme/exclusion.rs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Exclusion rules that keep incompatible schemes out of a cascade chain. + +use crate::scheme::SchemeId; + +/// Selects which children of a cascading scheme a rule applies to. +#[derive(Debug, Clone, Copy)] +pub enum ChildSelection { + /// Rule applies to all children. + All, + /// Rule applies to a single child. + One(usize), + /// Rule applies to multiple specific children. + Many(&'static [usize]), +} + +impl ChildSelection { + /// Returns `true` if this selection includes the given child index. + pub fn contains(&self, child_index: usize) -> bool { + match self { + ChildSelection::All => true, + ChildSelection::One(idx) => *idx == child_index, + ChildSelection::Many(indices) => indices.contains(&child_index), + } + } +} + +/// Push rule: declared by a cascading scheme to exclude another scheme from the subtree +/// rooted at the specified children. +/// +/// Use this when the declaring scheme (the ancestor) knows about the excluded scheme. For example, +/// `ZigZag` excludes `Dict` from all its children. +#[derive(Debug, Clone, Copy)] +pub struct DescendantExclusion { + /// The scheme to exclude from descendants. + pub excluded: SchemeId, + /// Which children of the declaring scheme this rule applies to. + pub children: ChildSelection, +} + +/// Pull rule: declared by a scheme to exclude itself when the specified ancestor is in the +/// cascade chain. +/// +/// Use this when the excluded scheme (the descendant) knows about the ancestor. For example, +/// `Sequence` excludes itself when `IntDict` is an ancestor on its codes child. +#[derive(Debug, Clone, Copy)] +pub struct AncestorExclusion { + /// The ancestor scheme that makes the declaring scheme ineligible. + pub ancestor: SchemeId, + /// Which children of the ancestor this rule applies to. + pub children: ChildSelection, +} diff --git a/vortex-compressor/src/scheme.rs b/vortex-compressor/src/scheme/mod.rs similarity index 80% rename from vortex-compressor/src/scheme.rs rename to vortex-compressor/src/scheme/mod.rs index 57ebf9f2406..341acc8acbf 100644 --- a/vortex-compressor/src/scheme.rs +++ b/vortex-compressor/src/scheme/mod.rs @@ -1,21 +1,34 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -//! Unified compression scheme trait and exclusion rules. +//! Everything a scheme author implements or receives: the [`Scheme`] trait, exclusion rules, +//! compression estimates, and the compression context. +mod ctx; +pub use ctx::CompressorContext; +pub use ctx::MAX_CASCADE; + +pub(crate) mod estimate; +mod exclusion; use std::fmt; use std::fmt::Debug; use std::hash::Hash; use std::hash::Hasher; +pub use estimate::CompressionEstimate; +pub use estimate::DeferredEstimate; +pub use estimate::EstimateFn; +pub use estimate::EstimateScore; +pub use estimate::EstimateVerdict; +pub use exclusion::AncestorExclusion; +pub use exclusion::ChildSelection; +pub use exclusion::DescendantExclusion; use vortex_array::ArrayRef; use vortex_array::Canonical; use vortex_array::ExecutionCtx; use vortex_error::VortexResult; use crate::CascadingCompressor; -use crate::ctx::CompressorContext; -use crate::estimate::CompressionEstimate; use crate::stats::ArrayAndStats; use crate::stats::GenerateStatsOptions; @@ -39,54 +52,6 @@ impl fmt::Display for SchemeId { } } -/// Selects which children of a cascading scheme a rule applies to. -#[derive(Debug, Clone, Copy)] -pub enum ChildSelection { - /// Rule applies to all children. - All, - /// Rule applies to a single child. - One(usize), - /// Rule applies to multiple specific children. - Many(&'static [usize]), -} - -impl ChildSelection { - /// Returns `true` if this selection includes the given child index. - pub fn contains(&self, child_index: usize) -> bool { - match self { - ChildSelection::All => true, - ChildSelection::One(idx) => *idx == child_index, - ChildSelection::Many(indices) => indices.contains(&child_index), - } - } -} - -/// Push rule: declared by a cascading scheme to exclude another scheme from the subtree -/// rooted at the specified children. -/// -/// Use this when the declaring scheme (the ancestor) knows about the excluded scheme. For example, -/// `ZigZag` excludes `Dict` from all its children. -#[derive(Debug, Clone, Copy)] -pub struct DescendantExclusion { - /// The scheme to exclude from descendants. - pub excluded: SchemeId, - /// Which children of the declaring scheme this rule applies to. - pub children: ChildSelection, -} - -/// Pull rule: declared by a scheme to exclude itself when the specified ancestor is in the -/// cascade chain. -/// -/// Use this when the excluded scheme (the descendant) knows about the ancestor. For example, -/// `Sequence` excludes itself when `IntDict` is an ancestor on its codes child. -#[derive(Debug, Clone, Copy)] -pub struct AncestorExclusion { - /// The ancestor scheme that makes the declaring scheme ineligible. - pub ancestor: SchemeId, - /// Which children of the ancestor this rule applies to. - pub children: ChildSelection, -} - // TODO(connor): Remove all default implemented methods. /// A single compression encoding that the [`CascadingCompressor`] can select from. /// @@ -199,10 +164,10 @@ pub trait Scheme: Debug + Send + Sync { /// entire array. /// /// [`CompressionEstimate::Verdict`] means the scheme already knows the terminal - /// [`crate::estimate::EstimateVerdict`]. `CompressionEstimate::Deferred(DeferredEstimate::Sample)` + /// [`crate::scheme::EstimateVerdict`]. `CompressionEstimate::Deferred(DeferredEstimate::Sample)` /// asks the compressor to sample. `CompressionEstimate::Deferred(DeferredEstimate::Callback(...))` /// asks the compressor to run custom deferred work. Deferred callbacks must return a - /// [`crate::estimate::EstimateVerdict`] directly, never another deferred request. + /// [`crate::scheme::EstimateVerdict`] directly, never another deferred request. /// /// Note that the compressor will also use this method when compressing samples, so some /// statistics that might hold for the samples may not hold for the entire array (e.g., diff --git a/vortex-compressor/src/trace.rs b/vortex-compressor/src/trace.rs index 84027272b31..ee594621199 100644 --- a/vortex-compressor/src/trace.rs +++ b/vortex-compressor/src/trace.rs @@ -5,7 +5,7 @@ use std::fmt; -use crate::ctx::CompressorContext; +use crate::scheme::CompressorContext; use crate::scheme::SchemeId; /// Shared tracing target for compressor decisions and coarse cascade structure. diff --git a/vortex-tensor/src/encodings/l2_denorm.rs b/vortex-tensor/src/encodings/l2_denorm.rs index 6cb4fcb0626..d07734bc6ff 100644 --- a/vortex-tensor/src/encodings/l2_denorm.rs +++ b/vortex-tensor/src/encodings/l2_denorm.rs @@ -7,9 +7,9 @@ use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::extension::ExtensionArrayExt; use vortex_compressor::CascadingCompressor; -use vortex_compressor::ctx::CompressorContext; -use vortex_compressor::estimate::CompressionEstimate; -use vortex_compressor::estimate::EstimateVerdict; +use vortex_compressor::scheme::CompressionEstimate; +use vortex_compressor::scheme::CompressorContext; +use vortex_compressor::scheme::EstimateVerdict; use vortex_compressor::scheme::Scheme; use vortex_compressor::stats::ArrayAndStats; use vortex_error::VortexResult; From 8ea5dcd2ba9a8419760319c4141f0698a5b66357 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:34:47 +0000 Subject: [PATCH 074/104] Update dependency setuptools to v83 [SECURITY] (#8752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [setuptools](https://redirect.github.com/pypa/setuptools) ([changelog](https://setuptools.pypa.io/en/stable/history.html)) | `80.9.0` → `83.0.0` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/setuptools/83.0.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/setuptools/80.9.0/83.0.0?slim=true) | --- > [!WARNING] > Some dependencies could not be looked up. Check the [Dependency Dashboard](../issues/357) for more information. --- ### BIT-setuptools-2026-59890 / [CVE-2026-59890](https://nvd.nist.gov/vuln/detail/CVE-2026-59890) / [GHSA-h35f-9h28-mq5c](https://redirect.github.com/advisories/GHSA-h35f-9h28-mq5c) / PYSEC-2026-3447
More information #### Details setuptools is a package that allows users to download, build, install, upgrade, and uninstall Python packages. Prior to 83.0.0, FileList applied MANIFEST.in exclude, global-exclude, recursive-exclude, and prune directives by matching compiled glob patterns against on-disk file names without Unicode normalization, so on macOS APFS or HFS+ an NFD file name could bypass an NFC exclusion rule and be packed into a source distribution. This issue is fixed in version 83.0.0. #### Severity - CVSS Score: 6.1 / 10 (Medium) - Vector String: `CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:L/A:N` #### References - [https://github.com/pypa/setuptools/releases/tag/v83.0.0](https://redirect.github.com/pypa/setuptools/releases/tag/v83.0.0) - [https://github.com/pypa/setuptools/commit/dd9f436a36486b4cb8a4c70a2321548b0be09b8f](https://redirect.github.com/pypa/setuptools/commit/dd9f436a36486b4cb8a4c70a2321548b0be09b8f) - [https://github.com/pypa/setuptools/security/advisories/GHSA-h35f-9h28-mq5c](https://redirect.github.com/pypa/setuptools/security/advisories/GHSA-h35f-9h28-mq5c) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3447) and the [PyPI Advisory Database](https://redirect.github.com/pypa/advisory-database) ([CC-BY 4.0](https://redirect.github.com/pypa/advisory-database/blob/main/LICENSE)).
--- ### Release Notes
pypa/setuptools (setuptools) ### [`v83.0.0`](https://redirect.github.com/pypa/setuptools/compare/v82.0.1...v83.0.0) [Compare Source](https://redirect.github.com/pypa/setuptools/compare/v82.0.1...v83.0.0) ### [`v82.0.1`](https://redirect.github.com/pypa/setuptools/compare/v82.0.0...v82.0.1) [Compare Source](https://redirect.github.com/pypa/setuptools/compare/v82.0.0...v82.0.1) ### [`v82.0.0`](https://redirect.github.com/pypa/setuptools/compare/v81.0.0...v82.0.0) [Compare Source](https://redirect.github.com/pypa/setuptools/compare/v81.0.0...v82.0.0) ### [`v81.0.0`](https://redirect.github.com/pypa/setuptools/compare/v80.10.2...v81.0.0) [Compare Source](https://redirect.github.com/pypa/setuptools/compare/v80.10.2...v81.0.0) ### [`v80.10.2`](https://redirect.github.com/pypa/setuptools/compare/v80.10.1...v80.10.2) [Compare Source](https://redirect.github.com/pypa/setuptools/compare/v80.10.1...v80.10.2) ### [`v80.10.1`](https://redirect.github.com/pypa/setuptools/compare/v80.9.0...v80.10.1) [Compare Source](https://redirect.github.com/pypa/setuptools/compare/v80.9.0...v80.10.1)
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/vortex-data/vortex). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index d74f7b0cf4e..645780c261a 100644 --- a/uv.lock +++ b/uv.lock @@ -1486,11 +1486,11 @@ wheels = [ [[package]] name = "setuptools" -version = "80.9.0" +version = "83.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] [[package]] From 56dffccabbed9d960893f8b13384ff8d7444d197 Mon Sep 17 00:00:00 2001 From: myrrc Date: Tue, 14 Jul 2026 17:16:57 +0100 Subject: [PATCH 075/104] New C++ API (#8741) Add new C++ API code. Tests and examples will be added in the next PR --------- Signed-off-by: Mikhail Kot --- .clang-format | 2 +- .github/workflows/ci.yml | 36 ++- docs/Doxyfile.cpp | 28 ++ docs/api/cpp/index.rst | 2 +- docs/conf.py | 27 -- lang/cpp/CMakeLists.txt | 124 ++++++++ lang/cpp/README.md | 37 +++ lang/cpp/include/vortex/array.hpp | 401 ++++++++++++++++++++++++ lang/cpp/include/vortex/common.hpp | 98 ++++++ lang/cpp/include/vortex/data_source.hpp | 60 ++++ lang/cpp/include/vortex/dtype.hpp | 156 +++++++++ lang/cpp/include/vortex/error.hpp | 55 ++++ lang/cpp/include/vortex/estimate.hpp | 47 +++ lang/cpp/include/vortex/expression.hpp | 182 +++++++++++ lang/cpp/include/vortex/scalar.hpp | 93 ++++++ lang/cpp/include/vortex/scan.hpp | 238 ++++++++++++++ lang/cpp/include/vortex/session.hpp | 35 +++ lang/cpp/include/vortex/writer.hpp | 52 +++ lang/cpp/src/array.cpp | 341 ++++++++++++++++++++ lang/cpp/src/data_source.cpp | 112 +++++++ lang/cpp/src/dtype.cpp | 210 +++++++++++++ lang/cpp/src/expression.cpp | 159 ++++++++++ lang/cpp/src/f16.cpp | 40 +++ lang/cpp/src/scalar.cpp | 111 +++++++ lang/cpp/src/scan.cpp | 98 ++++++ lang/cpp/src/session.cpp | 27 ++ lang/cpp/src/writer.cpp | 50 +++ vortex-ffi/src/array.rs | 7 +- vortex-ffi/src/sink.rs | 2 +- 29 files changed, 2798 insertions(+), 32 deletions(-) create mode 100644 docs/Doxyfile.cpp create mode 100644 lang/cpp/CMakeLists.txt create mode 100644 lang/cpp/README.md create mode 100644 lang/cpp/include/vortex/array.hpp create mode 100644 lang/cpp/include/vortex/common.hpp create mode 100644 lang/cpp/include/vortex/data_source.hpp create mode 100644 lang/cpp/include/vortex/dtype.hpp create mode 100644 lang/cpp/include/vortex/error.hpp create mode 100644 lang/cpp/include/vortex/estimate.hpp create mode 100644 lang/cpp/include/vortex/expression.hpp create mode 100644 lang/cpp/include/vortex/scalar.hpp create mode 100644 lang/cpp/include/vortex/scan.hpp create mode 100644 lang/cpp/include/vortex/session.hpp create mode 100644 lang/cpp/include/vortex/writer.hpp create mode 100644 lang/cpp/src/array.cpp create mode 100644 lang/cpp/src/data_source.cpp create mode 100644 lang/cpp/src/dtype.cpp create mode 100644 lang/cpp/src/expression.cpp create mode 100644 lang/cpp/src/f16.cpp create mode 100644 lang/cpp/src/scalar.cpp create mode 100644 lang/cpp/src/scan.cpp create mode 100644 lang/cpp/src/session.cpp create mode 100644 lang/cpp/src/writer.cpp diff --git a/.clang-format b/.clang-format index 587f746da78..bb77abc1478 100644 --- a/.clang-format +++ b/.clang-format @@ -25,7 +25,7 @@ DerivePointerAlignment: false IncludeBlocks: Regroup InsertBraces: true Language: Cpp -NamespaceIndentation: Inner +NamespaceIndentation: None PointerAlignment: Right SpaceBeforeCpp11BracedList: true SpaceBeforeCtorInitializerColon: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b694bf364d..e7aae5dc647 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -355,7 +355,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: C/C++ Lint - clang-format run: | - git ls-files vortex-cuda vortex-duckdb vortex-ffi \ + git ls-files lang/cpp vortex-cuda vortex-duckdb vortex-ffi \ | grep -E '\.(cpp|hpp|cu|cuh|h)$' \ | grep -v 'arrow/reference/arrow_c_device\.h$' \ | grep -v 'kernels/src/bit_unpack_.*\.cu$' \ @@ -522,6 +522,40 @@ jobs: with: command: check ${{ matrix.checks }} + cxx-api: + name: "C++ API" + timeout-minutes: 5 + runs-on: >- + ${{ github.repository == 'vortex-data/vortex' + && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=cxx-build', github.run_id) + || 'ubuntu-latest' }} + steps: + - uses: runs-on/action@v2 + if: github.repository == 'vortex-data/vortex' + with: + sccache: s3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" + - name: Install Rust nightly toolchain + run: | + rustup toolchain install $NIGHTLY_TOOLCHAIN + rustup component add --toolchain $NIGHTLY_TOOLCHAIN rust-src rustfmt clippy llvm-tools-preview + - name: Build FFI library (asan) + run: | + RUSTFLAGS="-A warnings -Cunsafe-allow-abi-mismatch=sanitizer \ + -C debuginfo=2 -C opt-level=0 -C strip=none -Zexternal-clangrt \ + -Zsanitizer=address,leak" \ + cargo +$NIGHTLY_TOOLCHAIN build --locked --no-default-features \ + --target x86_64-unknown-linux-gnu -Zbuild-std \ + -p vortex-ffi + - name: Build C++ library (asan) + run: | + mkdir -p build + cmake -S lang/cpp -B build -DSANITIZER=asan -DTARGET_TRIPLE="x86_64-unknown-linux-gnu" + cmake --build build --parallel $(nproc) + sqllogic-test: name: "SQL logic tests" needs: duckdb-ready diff --git a/docs/Doxyfile.cpp b/docs/Doxyfile.cpp new file mode 100644 index 00000000000..23072f7479a --- /dev/null +++ b/docs/Doxyfile.cpp @@ -0,0 +1,28 @@ +# Doxygen configuration for Vortex C++ API documentation. +# XML output is consumed by Sphinx via the Breathe extension. + +PROJECT_NAME = "Vortex C++" +OUTPUT_DIRECTORY = _build/doxygen-cpp + +# Input sources +INPUT = ../lang/cpp/include/vortex +FILE_PATTERNS = *.hpp +RECURSIVE = NO + +# We only care about XML output for Breathe +GENERATE_XML = YES +GENERATE_HTML = NO +GENERATE_LATEX = NO +XML_PROGRAMLISTING = YES + +# Extract everything, even if not fully documented yet +EXTRACT_ALL = YES +EXTRACT_PRIVATE = NO +EXTRACT_STATIC = YES + +# Preprocessing — resolve includes but don't expand macros +ENABLE_PREPROCESSING = YES +MACRO_EXPANSION = NO + +# Suppress warnings about undocumented members (WIP API) +WARN_IF_UNDOCUMENTED = NO diff --git a/docs/api/cpp/index.rst b/docs/api/cpp/index.rst index ea1c415ac93..6d1f33b177d 100644 --- a/docs/api/cpp/index.rst +++ b/docs/api/cpp/index.rst @@ -187,7 +187,7 @@ Source code for this example is `writer.cpp Expression age_gt_10 = expr::gt(expr::col("age"), expr::lit(10)); Array validity_array = array.apply(age_gt_10); - const Validity validity {ValidityType::Array, validity_array}; + const Validity validity = Validity::from_array(validity_array); Array array2 = make_struct({ {"age", age}, {"height", Array::primitive(height_buffer, validity)}, diff --git a/docs/conf.py b/docs/conf.py index f795fea0d6b..a2cadbb255f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -125,33 +125,6 @@ breathe_projects = {"vortex-cpp": _doxygen_xml_dir} breathe_default_project = "vortex-cpp" -# C++ types from cxx bridge and standard library that Sphinx cannot resolve. -nitpick_ignore += [ - ("cpp:identifier", t) - for t in [ - "vortex", - "rust", - "ffi", - "uint8_t", - "uint16_t", - "uint32_t", - "uint64_t", - "int8_t", - "int16_t", - "int32_t", - "int64_t", - "size_t", - "std::size_t", - ] -] -nitpick_ignore_regex = [ - # cxx bridge internals that will never be resolvable in Sphinx. - (r"cpp:identifier", r"rust::.*"), - (r"cpp:identifier", r"ffi::.*"), - # Doxygen file-level labels (e.g. "dtype_8hpp") that we don't generate pages for. - (r"ref", r".*_8hpp"), -] - # -- Options for hawkmoth C API gen ---------------------------- hawkmoth_root = str(git_root / "vortex-ffi/cinclude") diff --git a/lang/cpp/CMakeLists.txt b/lang/cpp/CMakeLists.txt new file mode 100644 index 00000000000..28099a44baa --- /dev/null +++ b/lang/cpp/CMakeLists.txt @@ -0,0 +1,124 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + +cmake_minimum_required(VERSION 3.10) +project(VortexCXX + VERSION 0.0.1 + LANGUAGES CXX) + +include(FetchContent) + +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_program(SCCACHE_PROGRAM sccache) +if (SCCACHE_PROGRAM) + set(CMAKE_C_COMPILER_LAUNCHER "${SCCACHE_PROGRAM}") + set(CMAKE_CXX_COMPILER_LAUNCHER "${SCCACHE_PROGRAM}") + message(STATUS "Sccache found: ${SCCACHE_PROGRAM}") +else () + message(STATUS "Sccache not found") +endif () + +set(SANITIZER "" CACHE STRING "Build with sanitizers") +set(TARGET_TRIPLE "" CACHE STRING "Rust target triple for FFI library") +set(RUST_BUILD_PROFILE "" CACHE STRING "Cargo profile name for Rust FFI library") + +if (NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Debug) +endif() + +if (NOT SANITIZER STREQUAL "") + message(NOTICE "Sanitizer: ${SANITIZER}") + if (NOT CMAKE_BUILD_TYPE STREQUAL "Debug") + message(FATAL_ERROR "Only debug build is supported for sanitizer builds") + endif() + + if (SANITIZER STREQUAL "asan") + set(SANITIZER_FLAGS "-fsanitize=address,undefined,leak") + elseif (SANITIZER STREQUAL "tsan") + set(SANITIZER_FLAGS "-fsanitize=thread") + else() + message(FATAL_ERROR "Unknown sanitizer ${SANITIZER}") + endif() + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${SANITIZER_FLAGS}") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${SANITIZER_FLAGS}") + set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} ${SANITIZER_FLAGS}") +endif() + +if (RUST_BUILD_PROFILE STREQUAL "") + if (CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo") + set(RUST_BUILD_PROFILE "release_debug") + else() + string(TOLOWER "${CMAKE_BUILD_TYPE}" RUST_BUILD_PROFILE) + endif() +endif() + +set(VORTEX_FFI_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../vortex-ffi") +set(VORTEX_FFI_LIB_DIR "${VORTEX_FFI_DIR}/../target/${TARGET_TRIPLE}/${RUST_BUILD_PROFILE}") +set(VORTEX_FFI_HEADERS "${VORTEX_FFI_DIR}/cinclude") + +if(APPLE) + set(VORTEX_FFI_STATIC "${VORTEX_FFI_LIB_DIR}/libvortex_ffi.a") + set(VORTEX_FFI_SHARED "${VORTEX_FFI_LIB_DIR}/libvortex_ffi.dylib") +elseif(WIN32) + set(VORTEX_FFI_STATIC "${VORTEX_FFI_LIB_DIR}/libvortex_ffi.lib") + set(VORTEX_FFI_SHARED "${VORTEX_FFI_LIB_DIR}/libvortex_ffi.dll") +else() + set(VORTEX_FFI_STATIC "${VORTEX_FFI_LIB_DIR}/libvortex_ffi.a") + set(VORTEX_FFI_SHARED "${VORTEX_FFI_LIB_DIR}/libvortex_ffi.so") +endif() + +if(NOT EXISTS "${VORTEX_FFI_STATIC}") + message(FATAL_ERROR + "vortex-ffi static library not found at ${VORTEX_FFI_STATIC}. " + "Run: cargo build --profile -p vortex-ffi") +endif() + +add_library(vortex_ffi STATIC IMPORTED) +set_target_properties(vortex_ffi PROPERTIES + IMPORTED_LOCATION "${VORTEX_FFI_STATIC}" + INTERFACE_INCLUDE_DIRECTORIES "${VORTEX_FFI_HEADERS}") + +if(EXISTS "${VORTEX_FFI_SHARED}") + add_library(vortex_ffi_shared SHARED IMPORTED) + set_target_properties(vortex_ffi_shared PROPERTIES + IMPORTED_LOCATION "${VORTEX_FFI_SHARED}" + INTERFACE_INCLUDE_DIRECTORIES "${VORTEX_FFI_HEADERS}" + INTERFACE_LINK_OPTIONS "LINKER:-rpath,${VORTEX_FFI_LIB_DIR}") +endif() + +file(GLOB VORTEX_CXX_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp") +add_library(vortex_cxx STATIC ${VORTEX_CXX_SOURCES}) +target_include_directories(vortex_cxx PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/../vortex-ffi/cinclude +) +target_link_libraries(vortex_cxx PUBLIC vortex_ffi) +add_library(vortex::cxx ALIAS vortex_cxx) +target_compile_features(vortex_cxx PUBLIC cxx_std_20) + +set(APPLE_LINK_FLAGS "-framework CoreFoundation -framework Security") + +if(APPLE) + target_link_libraries(vortex_cxx PRIVATE ${APPLE_LINK_FLAGS}) +endif() + +if(TARGET vortex_ffi_shared) + add_library(vortex_cxx_shared SHARED ${VORTEX_CXX_SOURCES}) + set_target_properties(vortex_cxx_shared PROPERTIES POSITION_INDEPENDENT_CODE ON) + target_include_directories(vortex_cxx_shared PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/../vortex-ffi/cinclude + ) + target_link_libraries(vortex_cxx_shared PUBLIC vortex_ffi_shared) + add_library(vortex::cxx_shared ALIAS vortex_cxx_shared) + target_compile_features(vortex_cxx_shared PUBLIC cxx_std_20) + + if(APPLE) + target_link_libraries(vortex_cxx_shared PRIVATE ${APPLE_LINK_FLAGS}) + endif() +endif() diff --git a/lang/cpp/README.md b/lang/cpp/README.md new file mode 100644 index 00000000000..a0b92f21835 --- /dev/null +++ b/lang/cpp/README.md @@ -0,0 +1,37 @@ +# Vortex C++ bindings + +For a usage guide, see docs/api/cpp/index.rst. + +## Requirements + +- CMake 3.10+ +- C++20 compiler (C++23 compiler for tests). +- Rust toolchain for building `vortex-ffi`. + +## Build + +```sh +cargo build --release -p vortex-ffi +cmake -Bbuild -DCMAKE_BUILD_TYPE=Release +cmake --build build -j +``` + +This will generate `libvortex_cxx` shared and static libraries. +You can use `vortex_cxx` and `vortex_cxx_shared` CMake targets. + +## Test + +```sh +cargo build --release -p vortex-ffi +cmake -Bbuild -DBUILD_TESTS=ON +cmake --build build -j +ctest --test-dir build -j "$(nproc)" +``` + +## Run examples + +```sh +cmake -Bbuild -DBUILD_EXAMPLES=ON +cmake --build build -j +./build/examples/hello-vortex +``` diff --git a/lang/cpp/include/vortex/array.hpp b/lang/cpp/include/vortex/array.hpp new file mode 100644 index 00000000000..82c1b5d747d --- /dev/null +++ b/lang/cpp/include/vortex/array.hpp @@ -0,0 +1,401 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once + +#include "vortex/common.hpp" +#include "vortex/dtype.hpp" +#include "vortex/error.hpp" +#include "vortex/expression.hpp" +#include "vortex/session.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace vortex { + +// Types that a PrimitiveView can hold +template +concept primitive_view = primitive_type || std::is_same_v; + +template +class PrimitiveView; + +class Array; +class StringView; +class BytesView; + +/* + * Validity type tells us whether there are null/invalid values in an Array. + */ +enum class ValidityType { + // Items can't be null + NonNullable = VX_VALIDITY_NON_NULLABLE, + // All items are valid + AllValid = VX_VALIDITY_ALL_VALID, + // All items are invalid + AllInvalid = VX_VALIDITY_ALL_INVALID, + // Item validity is set in a boolean array: true = valid, false = invalid + Array = VX_VALIDITY_ARRAY, +}; + +/** + * Array per-element validity of type ValidityType. + * If ValidityType is ValidityType::Array, holds a boolean array + * with validity items. + * + * You can use shortcut constants NonNullable/AllValid/AllInvalid and + * function ValidityArray(validity_bools); + */ +class Validity { +public: + // NonNullable/AllValid/AllInvalid constructor + // NOLINTNEXTLINE(google-explicit-constructor) + Validity(ValidityType type); + // Validity determined by a boolean array, true = valid, false = invalid. + static Validity from_array(const Array &bools); + + Validity(const Validity &other); + Validity(Validity &&other) noexcept; + Validity &operator=(const Validity &other); + Validity &operator=(Validity &&other) noexcept; + ~Validity(); + + ValidityType type() const { + return type_; + } + + // Boolean validity array. Throws if type() != ValidityType::Array. + Array array() const; + +private: + friend struct detail::Access; + friend Validity ValidityArray(const Array &bools); + Validity(ValidityType type, const vx_array *owned) : type_(type), array_(owned) { + } + + ValidityType type_; + const vx_array *array_; +}; + +namespace detail { +// Validity bitmap for typed views. Owns the arrays that back the bits +class ValidityBits { +public: + ValidityBits(ValidityBits &&other) noexcept; + ValidityBits &operator=(ValidityBits &&other) noexcept; + ValidityBits(const ValidityBits &) = delete; + ValidityBits &operator=(const ValidityBits &) = delete; + ~ValidityBits(); + + bool is_null(size_t index) const; + +private: + friend class vortex::Array; + // Materialize validity of "canonical" + ValidityBits(const Session &session, const vx_array *canonical); + + const vx_array *owner_ = nullptr; + const uint8_t *bits_ = nullptr; + size_t bit_offset_ = 0; + bool all_invalid_ = false; +}; +} // namespace detail + +// A reference-counted handle to columnar data in some encoding +class Array { +public: + Array(const Array &other); + Array(Array &&) noexcept = default; + Array &operator=(const Array &other); + Array &operator=(Array &&) noexcept = default; + + // An all-null array with DataType Null. + static Array null(size_t len); + + /** + * A Primitive array copied from a typed buffer. + * + * Example: + * + * std::array buffer = {0, 1, 2}; + * auto array = Array::primitive(buffer); + */ + template + static Array primitive(std::span data, const Validity &validity = ValidityType::NonNullable) { + return primitive_raw(detail::to_ptype(), data.data(), data.size(), validity); + } + + /** + * Import an Arrow array. Consumes both "array" and "schema", do not use + * or release them afterwards. For a record batch pass nullable = false. + */ + static Array from_arrow(ArrowArray *array, ArrowSchema *schema, bool nullable); + + size_t size() const; + bool nullable() const; + bool has_dtype(DataTypeVariant variant) const; + bool is_primitive(PType ptype) const; + DataType dtype() const; + Validity validity() const; + + // Number of null/invalid elements in Array + size_t null_count() const; + + /** + * Get a Struct field by index. Throws if Array is not a Struct or if index + * is out of bounds. + */ + Array field(size_t index) const; + + /** + * Get a Struct field by name. Throws if Array is not a Struct or doesn't + * have this named field. + */ + Array field(std::string_view name) const; + + /* + * Create a new Array slicing [begin; end) rows from original. + * Doesn't copy the original buffer or sliced buffer. + * + * Example: + * + * std::array buffer = {0, 1, 2}; + * Array array = Array::primitive(buffer); + * Array sliced = array.slice(1, 2); + */ + Array slice(size_t begin, size_t end) const; + + /** + * Apply an expression to an array. + * + * This function operates in constant time and doesn't execute the result + * array. To execute the array, canonicalise it. + * + * Example: + * + * using namespace vortex::expr::ops; + * + * std::array buffer = {0, 1, 2}; + * Array array = Array::primitive(buffer); + * Expression expr = expr::root() > expr::lit(0); + * Array result = array.apply(expr); + */ + Array apply(const Expression &expr) const; + + /** + * Bulk view over values. Canonicalizes the array. + * Throws if T does not match Array's ptype. + * + * Example: + * + * Session session; + * std::array buffer = {0, 1, 2}; + * Array array = Array::primitive(buffer); + * auto view = array.values(session); + */ + template + PrimitiveView values(const Session &session) const; + + // Bulk view over Bool values + PrimitiveView bools(const Session &session) const; + + // Bulk view over Utf8 values. + StringView strings(const Session &session) const; + + // Bulk view over Binary values. + BytesView bytes(const Session &session) const; + +private: + friend struct detail::Access; + friend class StringView; + friend class BytesView; + template + friend class PrimitiveView; + + explicit Array(const vx_array *owned) : handle_(owned) { + } + const vx_array *release() && { + return handle_.release(); + } + + static Array primitive_raw(vx_ptype ptype, const void *data, size_t len, const Validity &validity); + Array canonicalize(const Session &session) const; + + struct Deleter { + void operator()(const vx_array *ptr) const noexcept; + }; + std::unique_ptr handle_; +}; + +// Column field of a Struct Array +struct ColumnField { + std::string name; + Array column; +}; + +/** + * Create a Struct array from named columns of equal length. + * + * Example: + * + * using enum ValidityType; + * std::array age_buffer = {0, 1, 2}; + * std::array height_buffer = {0, 1, 2}; + * Array ages = Array::primitive(age_buffer); + * Array heights = Array::primitive(height_buffer); + * Array result = make_struct( + * {{"age", ages}, {"height", heights}}, + * NonNullable); + */ +Array make_struct(std::span fields, const Validity &validity = ValidityType::NonNullable); +Array make_struct(std::initializer_list fields, + const Validity &validity = ValidityType::NonNullable); + +/** + * Typed read-only view over a Primitive array. + * + * Owns a canonicalized copy of Array. values() and anything derived from + * it are valid as long as the view lives. + */ +template +class PrimitiveView { +public: + /* + * Get raw values from this view. Values at null/invalid positions are + * unspecified. + */ + std::span values() const { + return {data_, size_}; + } + bool is_null(size_t index) const { + return validity_.is_null(index); + } + size_t size() const { + return size_; + } + +private: + friend class Array; + PrimitiveView(Array canonical, detail::ValidityBits validity, const T *data, size_t size) + : canonical_(std::move(canonical)), validity_(std::move(validity)), data_(data), size_(size) { + } + + Array canonical_; + detail::ValidityBits validity_; + const T *data_; + size_t size_; +}; + +/** + * Read-only view over a Bool array. As Bool values are bit-packed, there's no + * span. Read individual values with value(i). + */ +template <> +class PrimitiveView { +public: + /* + * Get raw value from this view. Values at null/invalid positions are + * unspecified. + */ + bool value(size_t index) const; + bool is_null(size_t index) const { + return validity_.is_null(index); + } + size_t size() const { + return size_; + } + +private: + friend class Array; + PrimitiveView(Array canonical, detail::ValidityBits validity, size_t size) + : canonical_(std::move(canonical)), validity_(std::move(validity)), size_(size) { + } + + Array canonical_; + detail::ValidityBits validity_; + size_t size_; +}; + +/** + * Read-only view over a Utf8 array. + * + * operator[] is O(1) and borrows from the view's canonical copy. Returned + * string_views are valid as long as the view lives. + */ +class StringView { +public: + /* + * Get raw value from this view. Values at null/invalid positions are + * unspecified. + */ + std::string_view operator[](size_t index) const; + bool is_null(size_t index) const { + return validity_.is_null(index); + } + size_t size() const { + return size_; + } + +private: + friend class Array; + StringView(Array canonical, detail::ValidityBits validity, size_t size) + : canonical_(std::move(canonical)), validity_(std::move(validity)), size_(size) { + } + + Array canonical_; + detail::ValidityBits validity_; + size_t size_; +}; + +/** + * Read-only view over a Bytes array. + * + * Byte spans borrow from the view's canonical copy and are valid as long as + * the view lives. + */ +class BytesView { +public: + /* + * Get raw value from this view. Values at null/invalid positions are + * unspecified. + */ + BinaryView operator[](size_t index) const; + bool is_null(size_t index) const { + return validity_.is_null(index); + } + size_t size() const { + return size_; + } + +private: + friend class Array; + BytesView(Array canonical, detail::ValidityBits validity, size_t size) + : canonical_(std::move(canonical)), validity_(std::move(validity)), size_(size) { + } + + Array canonical_; + detail::ValidityBits validity_; + size_t size_; +}; + +template +PrimitiveView Array::values(const Session &session) const { + Array canonical = canonicalize(session); + const vx_array *raw = detail::Access::c_ptr(canonical); + if (!vx_array_is_primitive(raw, detail::to_ptype())) { + throw VortexException("values: T does not match the array's ptype", ErrorCode::MismatchedTypes); + } + vx_error *error = nullptr; + const void *data = vx_array_data_ptr_primitive(raw, &error); + detail::throw_on_error(error); + detail::ValidityBits validity(session, raw); + const size_t n = vx_array_len(raw); + return PrimitiveView(std::move(canonical), std::move(validity), static_cast(data), n); +} +} // namespace vortex diff --git a/lang/cpp/include/vortex/common.hpp b/lang/cpp/include/vortex/common.hpp new file mode 100644 index 00000000000..1248f6d53bd --- /dev/null +++ b/lang/cpp/include/vortex/common.hpp @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once + +#include +#include +#include +#include +#include + +#if __STDCPP_FLOAT16_T__ != 1 + +namespace vortex { +struct float16_t { + uint16_t bits; + friend bool operator==(float16_t, float16_t) = default; + // NOLINTNEXTLINE + operator float() const; +}; +static_assert(sizeof(float16_t) == 2 && std::is_trivially_copyable_v); +} // namespace vortex + +#else + +#include + +namespace vortex { +using float16_t = ::std::float16_t; +} // namespace vortex + +#endif + +namespace vortex::detail { +struct Access { + template + static T adopt(Args &&...args) { + return T(std::forward(args)...); + } + template + static auto release(T &&t) { + return std::forward(t).release(); + } + template + static auto c_ptr(const T &t) { + return t.handle_.get(); + } +}; + +template +constexpr vx_ptype to_ptype() { + if constexpr (std::is_same_v) { + return PTYPE_U8; + } else if constexpr (std::is_same_v) { + return PTYPE_U16; + } else if constexpr (std::is_same_v) { + return PTYPE_U32; + } else if constexpr (std::is_same_v) { + return PTYPE_U64; + } else if constexpr (std::is_same_v) { + return PTYPE_I8; + } else if constexpr (std::is_same_v) { + return PTYPE_I16; + } else if constexpr (std::is_same_v) { + return PTYPE_I32; + } else if constexpr (std::is_same_v) { + return PTYPE_I64; + } else if constexpr (std::is_same_v) { + return PTYPE_F16; + } else if constexpr (std::is_same_v) { + return PTYPE_F32; + } else { + static_assert(std::is_same_v); + return PTYPE_F64; + } +} + +template +inline constexpr bool is_numeric_element = + std::is_same_v || std::is_same_v || std::is_same_v || + std::is_same_v || std::is_same_v || std::is_same_v || + std::is_same_v || std::is_same_v || std::is_same_v || + std::is_same_v || std::is_same_v; +} // namespace vortex::detail + +namespace vortex { + +// View over a single Binary byte range. +using BinaryView = std::span; + +// Types that can be stored in a Primitive array +template +concept primitive_type = detail::is_numeric_element; + +// Types constructible as scalars or literals. +template +concept element_type = primitive_type || std::is_same_v || std::is_same_v || + std::is_same_v; +} // namespace vortex diff --git a/lang/cpp/include/vortex/data_source.hpp b/lang/cpp/include/vortex/data_source.hpp new file mode 100644 index 00000000000..f3221f37068 --- /dev/null +++ b/lang/cpp/include/vortex/data_source.hpp @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once + +#include "vortex/common.hpp" +#include "vortex/estimate.hpp" +#include "vortex/scan.hpp" +#include "vortex/session.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace vortex { + +/** + * A reference to one or more (possibly remote, possibly glob) paths. + * + * Creating a DataSource opens the first matched path to read the schema. All + * other IO is deferred until a scan is requested. Multiple scans may be + * requested from one data source. + */ +class DataSource { +public: + static DataSource open(const Session &session, std::initializer_list paths); + static DataSource open(const Session &session, std::span paths); + static DataSource open(const Session &session, std::span paths); + + /** + * Create a DataSource from an in-memory Vortex file. Borrows the buffer: + * caller must keep it alive and unmodified while DataSource, Scans, + * Partitions, and Arrays obtained from this buffer are alive. + */ + static DataSource from_buffer(const Session &session, std::span data); + + DataSource(const DataSource &other); + DataSource(DataSource &&) noexcept = default; + DataSource &operator=(const DataSource &other); + DataSource &operator=(DataSource &&) noexcept = default; + + Estimate row_count() const; + DataType dtype() const; + Scan scan(const ScanOptions &options = {}) const; + +private: + friend struct detail::Access; + DataSource(const vx_data_source *owned, Session session) : handle_(owned), session_(std::move(session)) { + } + + struct Deleter { + void operator()(const vx_data_source *ptr) const noexcept; + }; + std::unique_ptr handle_; + Session session_; +}; +} // namespace vortex diff --git a/lang/cpp/include/vortex/dtype.hpp b/lang/cpp/include/vortex/dtype.hpp new file mode 100644 index 00000000000..1be5007c6d6 --- /dev/null +++ b/lang/cpp/include/vortex/dtype.hpp @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once + +#include "vortex/common.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace vortex { + +struct StructField; + +enum class DataTypeVariant { + Null = DTYPE_NULL, + Bool = DTYPE_BOOL, + // Primitives e.g., u8, i16, f32 + Primitive = DTYPE_PRIMITIVE, + // Variable-length UTF-8 string + Utf8 = DTYPE_UTF8, + // Variable-length binary + Binary = DTYPE_BINARY, + // Nested struct + Struct = DTYPE_STRUCT, + // Nested list + List = DTYPE_LIST, + // User-defined extension + Extension = DTYPE_EXTENSION, + // Decimal with fixed precision and scale + Decimal = DTYPE_DECIMAL, + // Nested fixed-size list + FixedSizeList = DTYPE_FIXED_SIZE_LIST, +}; + +// Primitive type +enum class PType { + U8 = PTYPE_U8, + U16 = PTYPE_U16, + U32 = PTYPE_U32, + U64 = PTYPE_U64, + I8 = PTYPE_I8, + I16 = PTYPE_I16, + I32 = PTYPE_I32, + I64 = PTYPE_I64, + F16 = PTYPE_F16, + F32 = PTYPE_F32, + F64 = PTYPE_F64, +}; + +/** + * A Vortex data type. Data types are logical: they say nothing about physical + * representation. + */ +class DataType { +public: + DataType(const DataType &other); + DataType(DataType &&) noexcept = default; + DataType &operator=(const DataType &other); + DataType &operator=(DataType &&) noexcept = default; + + /** + * Consume an ArrowSchema and convert it into a DataType. The schema must + * not be used after this call. + */ + static DataType from_arrow(ArrowSchema *schema); + + /** + * Convert dtype to an Arrow C schema. Caller is responsible for invoking + * schema's release() callback. + */ + ArrowSchema to_arrow() const; + + DataTypeVariant variant() const; + bool nullable() const; + + PType primitive_type() const; + uint8_t decimal_precision() const; + int8_t decimal_scale() const; + + /** + * For a Struct dtype, return its fields in order. + * Throws if DataType is not Struct. + */ + std::vector fields() const; + + // List accessors. Valid only on List and FixedSizeList dtypes + + DataType list_element() const; + DataType fixed_size_list_element() const; + uint32_t fixed_size_list_size() const; + +private: + friend struct detail::Access; + explicit DataType(const vx_dtype *owned); + const vx_dtype *release() && { + return handle_.release(); + } + + struct Deleter { + void operator()(const vx_dtype *ptr) const noexcept; + }; + std::unique_ptr handle_; +}; + +// Field of a Struct DataType. +struct StructField { + std::string name; + DataType dtype; +}; + +namespace dtype { + +inline constexpr bool Nullable = true; + +DataType null(); +DataType boolean(bool nullable = false); +DataType primitive(PType ptype, bool nullable = false); +DataType int8(bool nullable = false); +DataType int16(bool nullable = false); +DataType int32(bool nullable = false); +DataType int64(bool nullable = false); +DataType uint8(bool nullable = false); +DataType uint16(bool nullable = false); +DataType uint32(bool nullable = false); +DataType uint64(bool nullable = false); +DataType float16(bool nullable = false); +DataType float32(bool nullable = false); +DataType float64(bool nullable = false); +DataType utf8(bool nullable = false); +DataType binary(bool nullable = false); +DataType decimal(uint8_t precision, int8_t scale, bool nullable = false); +DataType list(DataType element, bool nullable = false); +DataType fixed_size_list(DataType element, uint32_t size, bool nullable = false); + +/** + * Create a DataTypeVariant::Struct from a field list. + * + * Example: + * + * using dtype::Nullable; + * DataType dtype = dtype::struct_({ + * {"age", dtype::uint8()}, + * {"height", dtype::uint16(Nullable)}} + * ); + */ +DataType struct_(std::span fields, bool nullable = false); +DataType struct_(std::initializer_list fields, bool nullable = false); +} // namespace dtype +} // namespace vortex diff --git a/lang/cpp/include/vortex/error.hpp b/lang/cpp/include/vortex/error.hpp new file mode 100644 index 00000000000..8793be76dbb --- /dev/null +++ b/lang/cpp/include/vortex/error.hpp @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once + +#include + +#include +#include +#include + +namespace vortex { +enum class ErrorCode { + Other = VX_ERROR_CODE_OTHER, + OutOfBounds = VX_ERROR_CODE_OUT_OF_BOUNDS, + Compute = VX_ERROR_CODE_COMPUTE, + InvalidArgument = VX_ERROR_CODE_INVALID_ARGUMENT, + Serialization = VX_ERROR_CODE_SERIALIZATION, + NotImplemented = VX_ERROR_CODE_NOT_IMPLEMENTED, + MismatchedTypes = VX_ERROR_CODE_MISMATCHED_TYPES, + AssertionFailed = VX_ERROR_CODE_ASSERTION_FAILED, + Io = VX_ERROR_CODE_IO, + Panic = VX_ERROR_CODE_PANIC, +}; + +class VortexException : public std::runtime_error { +public: + VortexException(const std::string &message, ErrorCode code) : std::runtime_error(message), code_(code) { + } + + ErrorCode code() const { + return code_; + } + +private: + ErrorCode code_; +}; + +namespace detail { +// Throw VortexException and free "error" if it is non-nullptr. +inline void throw_on_error(vx_error *error) { + if (error == nullptr) { + return; + } + const vx_view str = vx_error_message(error); + const std::string message {str.ptr, str.len}; + const auto code = static_cast(vx_error_get_code(error)); + vx_error_free(error); + throw VortexException(message, code); +} + +inline vx_view to_view(std::string_view view) { + return {view.data(), view.size()}; +} +} // namespace detail +} // namespace vortex diff --git a/lang/cpp/include/vortex/estimate.hpp b/lang/cpp/include/vortex/estimate.hpp new file mode 100644 index 00000000000..1b412555b0f --- /dev/null +++ b/lang/cpp/include/vortex/estimate.hpp @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once + +#include "vortex/error.hpp" +#include + +#include + +namespace vortex { + +enum class EstimateType { + Unknown = VX_ESTIMATE_UNKNOWN, + Exact = VX_ESTIMATE_EXACT, + Inexact = VX_ESTIMATE_INEXACT, +}; + +// Estimated count (rows in a partition, partitions in a scan) +class Estimate { +public: + explicit Estimate(vx_estimate raw) : raw_(raw) { + } + + inline EstimateType type() const { + return static_cast(raw_.type); + } + + /** + * Estimated count. Throws if type() is Unknown. For inexact estimates this + * is an upper bound. + */ + inline uint64_t value() const { + if (type() == EstimateType::Unknown) { + throw VortexException("estimate is unknown", ErrorCode::InvalidArgument); + } + return raw_.estimate; + } + + inline uint64_t value_or(uint64_t fallback) const { + return type() == EstimateType::Unknown ? fallback : raw_.estimate; + } + +private: + vx_estimate raw_; +}; + +} // namespace vortex diff --git a/lang/cpp/include/vortex/expression.hpp b/lang/cpp/include/vortex/expression.hpp new file mode 100644 index 00000000000..01547450ae5 --- /dev/null +++ b/lang/cpp/include/vortex/expression.hpp @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once + +#include "vortex/common.hpp" +#include "vortex/scalar.hpp" + +#include + +#include +#include +#include +#include + +namespace vortex { + +// A node in an expression tree used for scan filters and projections. +class Expression { +public: + Expression(const Expression &); + Expression &operator=(const Expression &); + Expression(Expression &&) noexcept = default; + Expression &operator=(Expression &&) noexcept = default; + + /** + * Extract field from a Struct. Output DataType is field's DataType. + * + * Errors at scan/apply time if field does not exist or if root() is + * not a Struct. + * + * Example: + * + * Expression field = root()["age"]["nested"]; + */ + Expression operator[](std::string_view field) const; + + Expression is_null() const; + + /* + * Extract fields from a Struct. Output DataType is a Struct. + * + * Errors at scan/apply time if any of fields does not exist or if root() + * is not a Struct. + */ + Expression select(std::span names) const; + Expression select(std::span names) const; + Expression select(std::initializer_list names) const; + +private: + friend struct detail::Access; + explicit Expression(const vx_expression *owned) : handle_(owned) { + } + const vx_expression *release() && { + return handle_.release(); + } + + struct Deleter { + void operator()(const vx_expression *ptr) const noexcept; + }; + std::unique_ptr handle_; +}; + +// Add, Sub, Mul, and Div error in runtime on overflow and underflow +enum class BinaryOperator { + // x == y + Eq = VX_OPERATOR_EQ, + // x != y + NotEq = VX_OPERATOR_NOT_EQ, + // x > y + Gt = VX_OPERATOR_GT, + // x >= y + Gte = VX_OPERATOR_GTE, + // x < y + Lt = VX_OPERATOR_LT, + // x <= y + Lte = VX_OPERATOR_LTE, + // boolean x AND y, Kleene semantics + KleeneAnd = VX_OPERATOR_KLEENE_AND, + // boolean x OR y, Kleene semantics + KleeneOr = VX_OPERATOR_KLEENE_OR, + // x + y + Add = VX_OPERATOR_ADD, + // x - y + Sub = VX_OPERATOR_SUB, + // x * y + Mul = VX_OPERATOR_MUL, + // x / y + Div = VX_OPERATOR_DIV, +}; + +namespace expr { + +// scanned/applied array. +Expression root(); + +// root()'s named column. +Expression col(std::string_view name); + +// Literal expression +Expression lit(const Scalar &value); + +/* + * Literal expression. + * + * Literal's DataType must match column it's compared against, otherwise scan + * fails at runtime. No type coercion is performed. + */ +template +Expression lit(T value) { + return lit(scalar::of(value)); +} + +Expression eq(const Expression &l, const Expression &r); +Expression neq(const Expression &l, const Expression &r); +Expression lt(const Expression &l, const Expression &r); +Expression lte(const Expression &l, const Expression &r); +Expression gt(const Expression &l, const Expression &r); +Expression gte(const Expression &l, const Expression &r); +Expression add(const Expression &l, const Expression &r); +Expression sub(const Expression &l, const Expression &r); +Expression mul(const Expression &l, const Expression &r); +Expression div(const Expression &l, const Expression &r); +Expression binary_op(BinaryOperator op, const Expression &l, const Expression &r); + +// Kleene AND of children. Errors on an empty list. +Expression and_all(std::span children); +// Kleene OR of children. Errors on an empty list. +Expression or_all(std::span children); + +Expression logical_not(const Expression &child); +Expression is_null(const Expression &child); +Expression list_contains(const Expression &list, const Expression &value); + +/** + * Opt-in operator overloads like in Eigen. + * Note && and || don't short-circuit. + */ +namespace ops { + +inline Expression operator==(const Expression &l, const Expression &r) { + return eq(l, r); +} +inline Expression operator!=(const Expression &l, const Expression &r) { + return neq(l, r); +} +inline Expression operator<(const Expression &l, const Expression &r) { + return lt(l, r); +} +inline Expression operator<=(const Expression &l, const Expression &r) { + return lte(l, r); +} +inline Expression operator>(const Expression &l, const Expression &r) { + return gt(l, r); +} +inline Expression operator>=(const Expression &l, const Expression &r) { + return gte(l, r); +} +inline Expression operator&&(const Expression &l, const Expression &r) { + return binary_op(BinaryOperator::KleeneAnd, l, r); +} +inline Expression operator||(const Expression &l, const Expression &r) { + return binary_op(BinaryOperator::KleeneOr, l, r); +} +inline Expression operator!(const Expression &e) { + return logical_not(e); +} +inline Expression operator+(const Expression &l, const Expression &r) { + return add(l, r); +} +inline Expression operator-(const Expression &l, const Expression &r) { + return sub(l, r); +} +inline Expression operator*(const Expression &l, const Expression &r) { + return mul(l, r); +} +inline Expression operator/(const Expression &l, const Expression &r) { + return div(l, r); +} + +} // namespace ops +} // namespace expr +} // namespace vortex diff --git a/lang/cpp/include/vortex/scalar.hpp b/lang/cpp/include/vortex/scalar.hpp new file mode 100644 index 00000000000..f454d918df3 --- /dev/null +++ b/lang/cpp/include/vortex/scalar.hpp @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once + +#include "vortex/common.hpp" +#include "vortex/dtype.hpp" +#include "vortex/error.hpp" + +#include + +#include +#include +#include +#include + +namespace vortex { + +// A single value with an associated DataType +class Scalar { +public: + Scalar(const Scalar &other); + Scalar(Scalar &&) noexcept = default; + Scalar &operator=(const Scalar &other); + Scalar &operator=(Scalar &&) noexcept = default; + + bool is_null() const; + DataType dtype() const; + +private: + friend struct detail::Access; + explicit Scalar(vx_scalar *owned) : handle_(owned) { + } + vx_scalar *release() && { + return handle_.release(); + } + + struct Deleter { + void operator()(vx_scalar *ptr) const noexcept; + }; + std::unique_ptr handle_; +}; + +namespace detail { +vx_scalar *make_bool(bool value, bool nullable); +vx_scalar *make_primitive(vx_ptype ptype, const void *value, bool nullable); +vx_scalar *make_utf8(std::string_view value, bool nullable); +vx_scalar *make_binary(BinaryView value, bool nullable); +Scalar adopt(vx_scalar *raw); +} // namespace detail + +namespace scalar { +/** + * A scalar of DataType selected by T: bool, primitive, string_view (utf8), + * or a BinaryView (binary). + * + * Bytes are copied for utf8 and binary scalars. + */ +template +Scalar of(T value, bool nullable = false) { + if constexpr (std::is_same_v) { + return detail::adopt(detail::make_bool(value, nullable)); + } else if constexpr (std::is_same_v) { + return detail::adopt(detail::make_utf8(value, nullable)); + } else if constexpr (std::is_same_v) { + return detail::adopt(detail::make_binary(value, nullable)); + } else { + return detail::adopt(detail::make_primitive(detail::to_ptype(), &value, nullable)); + } +} + +template +Scalar decimal(T value, uint8_t precision, int8_t scale, bool nullable = false) { + vx_error *error = nullptr; + vx_scalar *out = nullptr; + if constexpr (std::is_same_v) { + out = vx_scalar_new_decimal_i8(value, precision, scale, nullable, &error); + } else if constexpr (std::is_same_v) { + out = vx_scalar_new_decimal_i16(value, precision, scale, nullable, &error); + } else if constexpr (std::is_same_v) { + out = vx_scalar_new_decimal_i32(value, precision, scale, nullable, &error); + } else if constexpr (std::is_same_v) { + out = vx_scalar_new_decimal_i64(value, precision, scale, nullable, &error); + } else { + static_assert(false, "can't construct decimal of following scale"); + } + detail::throw_on_error(error); + return detail::Access::adopt(out); +} + +// A typed null of (a nullable copy of) a given DataType. +Scalar null(const DataType &dtype); +} // namespace scalar +} // namespace vortex diff --git a/lang/cpp/include/vortex/scan.hpp b/lang/cpp/include/vortex/scan.hpp new file mode 100644 index 00000000000..7175f494bef --- /dev/null +++ b/lang/cpp/include/vortex/scan.hpp @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once + +#include "vortex/array.hpp" +#include "vortex/common.hpp" +#include "vortex/estimate.hpp" +#include "vortex/expression.hpp" +#include "vortex/session.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +namespace vortex { + +namespace detail { +// range-for support for Scan and Partition +template +class PullRange { +public: + class iterator { + public: + using value_type = Item; + using difference_type = std::ptrdiff_t; + + iterator() = default; + iterator(Source *src, std::optional first) : src_(src), cur_(std::move(first)) { + } + + Item &operator*() const { + return *cur_; + } + iterator &operator++() { + cur_ = Next(src_); + return *this; + } + void operator++(int) { + ++*this; + } + bool operator==(std::default_sentinel_t) const { + return !cur_.has_value(); + } + + private: + Source *src_ = nullptr; + mutable std::optional cur_; + }; + + explicit PullRange(Source &src) : src_(&src) { + } + iterator begin() { + return iterator(src_, Next(src_)); + } + std::default_sentinel_t end() { + return std::default_sentinel; + } + +private: + Source *src_; +}; +} // namespace detail + +// Wrapper around ArrowArrayStream which releases stream in destructor +class ArrowStream { +public: + ArrowStream(const ArrowStream &) = delete; + ArrowStream &operator=(const ArrowStream &) = delete; + ArrowStream(ArrowStream &&other) noexcept; + ArrowStream &operator=(ArrowStream &&other) noexcept; + ~ArrowStream(); + + ArrowArrayStream *raw() { + return &stream_; + } + +private: + friend struct detail::Access; + ArrowStream(Session session, ArrowArrayStream stream) noexcept + : session_(std::move(session)), stream_(stream) { + } + + Session session_; + ArrowArrayStream stream_ {}; +}; + +/** + * An independent unit of scan work. + * + * Partition's methods are thread-unsafe: drive each partition from + * one worker thread. + * Calling methods of a moved-out Partition is UB. + */ +class Partition { +public: + Partition(const Partition &) = delete; + Partition &operator=(const Partition &) = delete; + Partition(Partition &&) noexcept = default; + Partition &operator=(Partition &&) noexcept = default; + + // Estimated row count. Throws if called after next(). + Estimate row_count() const; + + // Return next Array or nullopt when partition is exhausted. + std::optional next(); + + // range-for over Arrays + auto batches() & { + return detail::PullRange(*this); + } + auto batches() && = delete; + + /* + * Consume the partition into an Arrow stream. + * Blocks until partition is drained. + */ + ArrowStream into_arrow_stream() &&; + +private: + friend struct detail::Access; + Partition(vx_partition *owned, Session session) : handle_(owned), session_(std::move(session)) { + } + + struct Deleter { + void operator()(vx_partition *ptr) const noexcept; + }; + std::unique_ptr handle_; + Session session_; +}; + +/* + * Row range [begin; end) to apply over filtering. + * [0; 0) or convenience constant AllRows means "return all rows". + */ +struct RowRange { + uint64_t begin = 0; + uint64_t end = 0; +}; + +// Return all rows +constexpr RowRange AllRows = RowRange {0, 0}; + +struct Selection { + enum class Kind { + Include = VX_SELECTION_INCLUDE_RANGE, + Exclude = VX_SELECTION_EXCLUDE_RANGE, + }; + Kind kind = Kind::Include; + std::vector indices; +}; + +/** + * Scan configuration. Fields are append-only and must be set via designated + * initializers. + * Default fields have reasonable behaviour: default projection returns all + * fields, default filter, row_range, and selection don't filter etc. + * + * Example: + * + * DataSource ds = DataSource::open(session, {"file.vortex"}); + * Scan scan = ds.scan({.limit = 100}); + */ +struct ScanOptions { + std::optional projection; + std::optional filter; + /* + * Row range [begin; end) to apply over filtering. + * [0; 0) or convenience constant AllRows means "return all rows". + */ + std::optional row_range; + // Row-index filter applied after row_range. + std::optional selection; + /* + * Maximum number of rows to return. 0 means no limit. + * You can either pass a limit or a filter but not both. + */ + uint64_t limit = 0; + // If true, return rows in storage order. + bool ordered = false; +}; + +/** + * A single traversal of a DataSource. A scan can be consumed only once. + * + * next_partition() is internally synchronized, give each partition to its own + * worker thread. + * + * Calling methods of a moved-out Scan is UB. + */ +class Scan { +public: + Scan(const Scan &) = delete; + Scan &operator=(const Scan &) = delete; + Scan(Scan &&) noexcept = default; + Scan &operator=(Scan &&) noexcept = default; + + Estimate partition_count() const { + return estimate_; + } + + /** + * Scan's dtype. + * + * Throws if called after next_partition(). + * UB if called in parallel with next_partition(). + */ + DataType dtype() const; + + // Next partition or nullopt when the scan is exhausted. Thread-safe. + std::optional next_partition(); + + // range-for over partitions + auto partitions() & { + return detail::PullRange(*this); + } + auto partitions() && = delete; + +private: + friend struct detail::Access; + Scan(vx_scan *owned, Estimate estimate, Session session) + : handle_(owned), mutex_(std::make_unique()), estimate_(estimate), + session_(std::move(session)) { + } + + struct Deleter { + void operator()(vx_scan *ptr) const noexcept; + }; + std::unique_ptr handle_; + std::unique_ptr mutex_; + Estimate estimate_; + Session session_; +}; +} // namespace vortex diff --git a/lang/cpp/include/vortex/session.hpp b/lang/cpp/include/vortex/session.hpp new file mode 100644 index 00000000000..29522e7fd74 --- /dev/null +++ b/lang/cpp/include/vortex/session.hpp @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once + +#include "vortex/common.hpp" + +#include + +#include + +namespace vortex { + +/** + * A handle to a Vortex session, registry of encodings and compute kernels. + * Copying shares the underlying session. + */ +class Session { +public: + Session(); + + Session(const Session &other); + Session(Session &&) noexcept = default; + Session &operator=(const Session &other); + Session &operator=(Session &&) noexcept = default; + +private: + friend struct detail::Access; + + struct Deleter { + void operator()(vx_session *ptr) const noexcept; + }; + std::unique_ptr handle_; +}; + +} // namespace vortex diff --git a/lang/cpp/include/vortex/writer.hpp b/lang/cpp/include/vortex/writer.hpp new file mode 100644 index 00000000000..da761c334f9 --- /dev/null +++ b/lang/cpp/include/vortex/writer.hpp @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once + +#include "vortex/array.hpp" +#include "vortex/dtype.hpp" +#include "vortex/session.hpp" + +#include + +#include +#include + +namespace vortex { + +/** + * Writes arrays into a Vortex file. + * + * finish() writes the footer and finalizes the file. + * Not calling finish() leaves file corrupted. + */ +class Writer { +public: + static Writer open(const Session &session, std::string_view path, const DataType &dtype); + + Writer(const Writer &) = delete; + Writer &operator=(const Writer &) = delete; + Writer(Writer &&) noexcept = default; + Writer &operator=(Writer &&) noexcept = default; + + /* + * Append Array to output file. + * Throws if "array"'s DataType doesn't match writer's DataType. + */ + void push(const Array &array); + + /* + * Write footer and finalize the file. + * Throws on failure. Writer is closed afterwards and further uses throws. + */ + void finish(); + +private: + explicit Writer(vx_array_sink *sink) : handle_(sink) { + } + + struct Deleter { + void operator()(vx_array_sink *ptr) const noexcept; + }; + std::unique_ptr handle_; +}; +} // namespace vortex diff --git a/lang/cpp/src/array.cpp b/lang/cpp/src/array.cpp new file mode 100644 index 00000000000..11b012449e2 --- /dev/null +++ b/lang/cpp/src/array.cpp @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#include "vortex/array.hpp" +#include "vortex/common.hpp" +#include "vortex/dtype.hpp" +#include "vortex/error.hpp" + +#include + +#include +#include +#include +#include + +namespace vortex { + +using detail::Access; +using detail::throw_on_error; + +Validity::Validity(const Validity &other) + : type_(other.type_), array_(other.array_ != nullptr ? vx_array_clone(other.array_) : nullptr) { +} + +Validity::Validity(ValidityType type) : type_(type), array_(nullptr) { + if (type == ValidityType::Array) { + throw VortexException("Validity(ValidityType) called with ValidityType::Array", + ErrorCode::InvalidArgument); + } +} + +Validity Validity::from_array(const Array &bools) { + return {ValidityType::Array, vx_array_clone(Access::c_ptr(bools))}; +} + +Validity::Validity(Validity &&other) noexcept : type_(other.type_), array_(other.array_) { + other.array_ = nullptr; + other.type_ = ValidityType::NonNullable; +} + +Validity &Validity::operator=(const Validity &other) { + if (this != &other) { + *this = Validity(other); + } + return *this; +} + +Validity &Validity::operator=(Validity &&other) noexcept { + if (this != &other) { + vx_array_free(array_); + type_ = other.type_; + array_ = other.array_; + other.array_ = nullptr; + other.type_ = ValidityType::NonNullable; + } + return *this; +} + +Validity::~Validity() { + vx_array_free(array_); +} + +Array Validity::array() const { + if (type_ != ValidityType::Array || array_ == nullptr) { + throw VortexException("validity has no backing array", ErrorCode::InvalidArgument); + } + return Access::adopt(vx_array_clone(array_)); +} + +namespace detail { + +bool ValidityBits::is_null(size_t index) const { + if (all_invalid_) { + return true; + } + if (bits_ == nullptr) { + return false; + } + const size_t bit = bit_offset_ + index; + return (bits_[bit / 8] >> (bit % 8) & 1) == 0; +} + +ValidityBits::ValidityBits(const Session &session, const vx_array *canonical) { + vx_validity raw {}; + vx_error *error = nullptr; + vx_array_get_validity(canonical, &raw, &error); + throw_on_error(error); + + switch (static_cast(raw.type)) { + case ValidityType::NonNullable: + case ValidityType::AllValid: + return; + case ValidityType::AllInvalid: + all_invalid_ = true; + return; + case ValidityType::Array: + break; + } + + owner_ = vx_array_canonicalize(Access::c_ptr(session), raw.array, &error); + vx_array_free(raw.array); + throw_on_error(error); + + bits_ = static_cast(vx_array_data_ptr_bool(owner_, &bit_offset_, &error)); + if (error != nullptr) { + vx_array_free(owner_); + } + throw_on_error(error); +} + +ValidityBits::ValidityBits(ValidityBits &&other) noexcept + : owner_(other.owner_), bits_(other.bits_), bit_offset_(other.bit_offset_), + all_invalid_(other.all_invalid_) { + other.owner_ = nullptr; + other.bits_ = nullptr; +} + +ValidityBits &ValidityBits::operator=(ValidityBits &&other) noexcept { + if (this != &other) { + vx_array_free(owner_); + owner_ = other.owner_; + bits_ = other.bits_; + bit_offset_ = other.bit_offset_; + all_invalid_ = other.all_invalid_; + other.owner_ = nullptr; + other.bits_ = nullptr; + } + return *this; +} + +ValidityBits::~ValidityBits() { + vx_array_free(owner_); +} +} // namespace detail + +static const vx_struct_fields *struct_fields_or_throw(const vx_dtype *dtype) { + const vx_struct_fields *fields = vx_dtype_struct_dtype(dtype); + if (fields == nullptr) { + throw VortexException("dtype is not a struct", ErrorCode::MismatchedTypes); + } + return fields; +} + +void Array::Deleter::operator()(const vx_array *ptr) const noexcept { + vx_array_free(ptr); +} + +Array::Array(const Array &other) : handle_(vx_array_clone(other.handle_.get())) { +} + +Array &Array::operator=(const Array &other) { + if (this != &other) { + handle_.reset(vx_array_clone(other.handle_.get())); + } + return *this; +} + +Array Array::null(size_t len) { + return Access::adopt(vx_array_new_null(len)); +} + +Array Array::primitive_raw(vx_ptype ptype, const void *data, size_t len, const Validity &validity) { + std::optional keep_alive; + vx_validity raw {}; + raw.type = static_cast(validity.type()); + if (validity.type() == ValidityType::Array) { + keep_alive = validity.array(); + raw.array = Access::c_ptr(*keep_alive); + } + + vx_error *error = nullptr; + const vx_array *out = vx_array_new_primitive(ptype, data, len, &raw, &error); + throw_on_error(error); + return Access::adopt(out); +} + +Array Array::from_arrow(ArrowArray *array, ArrowSchema *schema, bool nullable) { + vx_error *error = nullptr; + const vx_array *out = vx_array_from_arrow(array, schema, nullable, &error); + throw_on_error(error); + return Access::adopt(out); +} + +size_t Array::size() const { + return vx_array_len(handle_.get()); +} + +bool Array::nullable() const { + return vx_array_is_nullable(handle_.get()); +} + +bool Array::has_dtype(DataTypeVariant v) const { + return vx_array_has_dtype(handle_.get(), static_cast(v)); +} + +bool Array::is_primitive(PType p) const { + return vx_array_is_primitive(handle_.get(), static_cast(p)); +} + +DataType Array::dtype() const { + return Access::adopt(vx_array_dtype(handle_.get())); +} + +Validity Array::validity() const { + vx_validity raw {}; + vx_error *error = nullptr; + vx_array_get_validity(handle_.get(), &raw, &error); + throw_on_error(error); + return Access::adopt(static_cast(raw.type), raw.array); +} + +size_t Array::null_count() const { + vx_error *error = nullptr; + const size_t count = vx_array_invalid_count(handle_.get(), &error); + throw_on_error(error); + return count; +} + +Array Array::field(size_t index) const { + vx_error *error = nullptr; + const vx_array *out = vx_array_get_field(handle_.get(), index, &error); + throw_on_error(error); + return Access::adopt(out); +} + +Array Array::field(std::string_view name) const { + const DataType dt = dtype(); + const std::unique_ptr fields( + struct_fields_or_throw(detail::Access::c_ptr(dt)), + &vx_struct_fields_free); + const uint64_t fields_size = vx_struct_fields_nfields(fields.get()); + for (uint64_t i = 0; i < fields_size; ++i) { + const vx_view field = vx_struct_fields_field_name(fields.get(), i); + if (std::string_view {field.ptr, field.len} == name) { + return this->field(i); + } + } + throw VortexException("no field named \"" + std::string(name) + "\"", ErrorCode::InvalidArgument); +} + +Array Array::slice(size_t begin, size_t end) const { + vx_error *error = nullptr; + const vx_array *out = vx_array_slice(handle_.get(), begin, end, &error); + throw_on_error(error); + return Access::adopt(out); +} + +Array Array::apply(const Expression &expr) const { + vx_error *error = nullptr; + const vx_array *out = vx_array_apply(handle_.get(), Access::c_ptr(expr), &error); + throw_on_error(error); + return Access::adopt(out); +} + +Array Array::canonicalize(const Session &session) const { + vx_error *error = nullptr; + const vx_array *out = vx_array_canonicalize(Access::c_ptr(session), handle_.get(), &error); + throw_on_error(error); + return Access::adopt(out); +} + +Array make_struct(std::span fields, const Validity &validity) { + vx_validity raw {}; + raw.type = static_cast(validity.type()); + + std::optional keep_alive; + if (validity.type() == ValidityType::Array) { + keep_alive = validity.array(); + raw.array = Access::c_ptr(*keep_alive); + } + + std::unique_ptr handle( + vx_struct_column_builder_new(&raw, fields.size()), + &vx_struct_column_builder_free); + + vx_error *error = nullptr; + for (const auto &[name, array] : fields) { + vx_struct_column_builder_add_field(handle.get(), detail::to_view(name), Access::c_ptr(array), &error); + throw_on_error(error); + } + + const vx_array *out = vx_struct_column_builder_finalize(handle.release(), &error); + throw_on_error(error); + return Access::adopt(out); +} + +Array make_struct(std::initializer_list fields, const Validity &validity) { + return make_struct({fields.begin(), fields.end()}, validity); +} + +PrimitiveView Array::bools(const Session &session) const { + Array canonical = canonicalize(session); + const vx_array *raw = Access::c_ptr(canonical); + if (!vx_array_has_dtype(raw, DTYPE_BOOL)) { + throw VortexException("bools(): array is not a Bool array", ErrorCode::MismatchedTypes); + } + detail::ValidityBits validity(session, raw); + const size_t len = vx_array_len(raw); + return PrimitiveView(std::move(canonical), std::move(validity), len); +} + +StringView Array::strings(const Session &session) const { + Array canonical = canonicalize(session); + const vx_array *raw = Access::c_ptr(canonical); + if (!vx_array_has_dtype(raw, DTYPE_UTF8)) { + throw VortexException("strings(): array is not a Utf8 array", ErrorCode::MismatchedTypes); + } + detail::ValidityBits validity(session, raw); + const size_t len = vx_array_len(raw); + return StringView(std::move(canonical), std::move(validity), len); +} + +BytesView Array::bytes(const Session &session) const { + Array canonical = canonicalize(session); + const vx_array *raw = Access::c_ptr(canonical); + if (!vx_array_has_dtype(raw, DTYPE_BINARY)) { + throw VortexException("bytes(): array is not a Binary array", ErrorCode::MismatchedTypes); + } + detail::ValidityBits validity(session, raw); + const size_t len = vx_array_len(raw); + return BytesView(std::move(canonical), std::move(validity), len); +} + +bool PrimitiveView::value(size_t i) const { + return vx_array_get_bool(Access::c_ptr(canonical_), i); +} + +std::string_view StringView::operator[](size_t i) const { + vx_error *error = nullptr; + const vx_view out = vx_array_utf8_at(Access::c_ptr(canonical_), i, &error); + throw_on_error(error); + return {out.ptr, out.len}; +} + +BinaryView BytesView::operator[](size_t i) const { + vx_error *error = nullptr; + const vx_view out = vx_array_binary_at(Access::c_ptr(canonical_), i, &error); + throw_on_error(error); + return {reinterpret_cast(out.ptr), out.len}; +} +} // namespace vortex diff --git a/lang/cpp/src/data_source.cpp b/lang/cpp/src/data_source.cpp new file mode 100644 index 00000000000..6aa8d3e3107 --- /dev/null +++ b/lang/cpp/src/data_source.cpp @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#include "vortex/common.hpp" +#include "vortex/data_source.hpp" +#include "vortex/dtype.hpp" +#include "vortex/error.hpp" + +#include + +#include +#include +#include +#include + +namespace vortex { + +using detail::Access; +using detail::throw_on_error; +using detail::to_view; + +void DataSource::Deleter::operator()(const vx_data_source *ptr) const noexcept { + vx_data_source_free(ptr); +} + +DataSource::DataSource(const DataSource &other) + : handle_(vx_data_source_clone(other.handle_.get())), session_(other.session_) { +} + +DataSource &DataSource::operator=(const DataSource &other) { + if (this != &other) { + handle_.reset(vx_data_source_clone(other.handle_.get())); + session_ = other.session_; + } + return *this; +} + +template +static DataSource open_paths(const Session &session, std::span paths) { + std::vector raw; + raw.reserve(paths.size()); + for (const auto &path : paths) { + raw.push_back(to_view(path)); + } + + vx_data_source_options options {}; + options.paths = raw.data(); + options.paths_len = raw.size(); + + vx_error *error = nullptr; + const vx_data_source *ds = vx_data_source_new(Access::c_ptr(session), &options, &error); + throw_on_error(error); + return Access::adopt(ds, session); +} + +DataSource DataSource::open(const Session &session, std::initializer_list paths) { + std::span span {paths.begin(), paths.end()}; + return open_paths(session, span); +} + +DataSource DataSource::open(const Session &session, std::span paths) { + return open_paths(session, paths); +} + +DataSource DataSource::open(const Session &session, std::span paths) { + return open_paths(session, paths); +} + +DataSource DataSource::from_buffer(const Session &session, std::span data) { + vx_error *error = nullptr; + const vx_data_source *ds = + vx_data_source_new_buffer(Access::c_ptr(session), data.data(), data.size(), &error); + throw_on_error(error); + return Access::adopt(ds, session); +} + +Estimate DataSource::row_count() const { + vx_estimate raw {}; + vx_data_source_get_row_count(handle_.get(), &raw); + return Estimate(raw); +} + +DataType DataSource::dtype() const { + return Access::adopt(vx_data_source_dtype(handle_.get())); +} + +Scan DataSource::scan(const ScanOptions &options) const { + vx_scan_options raw {}; + raw.projection = options.projection.has_value() ? Access::c_ptr(*options.projection) : nullptr; + raw.filter = options.filter.has_value() ? Access::c_ptr(*options.filter) : nullptr; + if (options.row_range.has_value()) { + raw.row_range_begin = options.row_range->begin; + raw.row_range_end = options.row_range->end; + } + if (options.selection.has_value()) { + raw.selection.idx = options.selection->indices.data(); + raw.selection.idx_len = options.selection->indices.size(); + raw.selection.include = static_cast(options.selection->kind); + } else { + raw.selection.include = VX_SELECTION_INCLUDE_ALL; + } + raw.limit = options.limit; + raw.ordered = options.ordered; + + vx_estimate estimate {}; + vx_error *error = nullptr; + vx_scan *scan = vx_data_source_scan(handle_.get(), &raw, &estimate, &error); + throw_on_error(error); + return Access::adopt(scan, Estimate(estimate), session_); +} + +} // namespace vortex diff --git a/lang/cpp/src/dtype.cpp b/lang/cpp/src/dtype.cpp new file mode 100644 index 00000000000..808ead6fd50 --- /dev/null +++ b/lang/cpp/src/dtype.cpp @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#include "vortex/common.hpp" +#include "vortex/dtype.hpp" +#include "vortex/error.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace vortex { + +using detail::Access; +using detail::throw_on_error; +using detail::to_view; +using namespace std::string_literals; + +void DataType::Deleter::operator()(const vx_dtype *ptr) const noexcept { + vx_dtype_free(ptr); +} + +DataType::DataType(const vx_dtype *owned) : handle_(owned) { +} + +DataType::DataType(const DataType &other) : handle_(vx_dtype_clone(other.handle_.get())) { +} + +DataType &DataType::operator=(const DataType &other) { + if (this != &other) { + handle_.reset(vx_dtype_clone(other.handle_.get())); + } + return *this; +} + +DataType DataType::from_arrow(ArrowSchema *schema) { + vx_error *error = nullptr; + const vx_dtype *dtype = vx_dtype_from_arrow_schema(schema, &error); + throw_on_error(error); + return DataType(dtype); +} + +ArrowSchema DataType::to_arrow() const { + ArrowSchema schema {}; + vx_error *error = nullptr; + vx_dtype_to_arrow_schema(handle_.get(), &schema, &error); + throw_on_error(error); + return schema; +} + +DataTypeVariant DataType::variant() const { + return static_cast(vx_dtype_get_variant(handle_.get())); +} + +bool DataType::nullable() const { + return vx_dtype_is_nullable(handle_.get()); +} + +PType DataType::primitive_type() const { + return static_cast(vx_dtype_primitive_ptype(handle_.get())); +} + +uint8_t DataType::decimal_precision() const { + return vx_dtype_decimal_precision(handle_.get()); +} + +int8_t DataType::decimal_scale() const { + return vx_dtype_decimal_scale(handle_.get()); +} + +namespace { +const vx_struct_fields *struct_fields_or_throw(const vx_dtype *dtype) { + const vx_struct_fields *fields = vx_dtype_struct_dtype(dtype); + if (fields == nullptr) { + throw VortexException("dtype is not a struct", ErrorCode::MismatchedTypes); + } + return fields; +} +} // namespace + +std::vector DataType::fields() const { + const std::unique_ptr fields( + struct_fields_or_throw(handle_.get()), + &vx_struct_fields_free); + const uint64_t fields_size = vx_struct_fields_nfields(fields.get()); + std::vector out; + out.reserve(fields_size); + for (uint64_t idx = 0; idx < fields_size; ++idx) { + const vx_view name = vx_struct_fields_field_name(fields.get(), idx); + if (name.ptr == nullptr) { + throw VortexException("error getting field name at index "s + std::to_string(idx), + ErrorCode::Other); + } + const vx_dtype *dtype = vx_struct_fields_field_dtype(fields.get(), idx); + if (dtype == nullptr) { + throw VortexException("error getting dtype at index "s + std::to_string(idx), ErrorCode::Other); + } + out.push_back(StructField {{name.ptr, name.len}, DataType(dtype)}); + } + return out; +} + +DataType DataType::list_element() const { + const vx_dtype *element = vx_dtype_list_element(handle_.get()); + if (element == nullptr) { + throw VortexException("dtype is not a list", ErrorCode::MismatchedTypes); + } + return DataType(element); +} + +DataType DataType::fixed_size_list_element() const { + const vx_dtype *element = vx_dtype_fixed_size_list_element(handle_.get()); + if (element == nullptr) { + throw VortexException("dtype is not a fixed-size list", ErrorCode::MismatchedTypes); + } + return DataType(element); +} + +uint32_t DataType::fixed_size_list_size() const { + return vx_dtype_fixed_size_list_size(handle_.get()); +} + +namespace dtype { + +DataType null() { + return Access::adopt(vx_dtype_new_null()); +} +DataType boolean(bool nullable) { + return Access::adopt(vx_dtype_new_bool(nullable)); +} +DataType primitive(PType ptype, bool nullable) { + return Access::adopt(vx_dtype_new_primitive(static_cast(ptype), nullable)); +} +DataType int8(bool nullable) { + return primitive(PType::I8, nullable); +} +DataType int16(bool nullable) { + return primitive(PType::I16, nullable); +} +DataType int32(bool nullable) { + return primitive(PType::I32, nullable); +} +DataType int64(bool nullable) { + return primitive(PType::I64, nullable); +} +DataType uint8(bool nullable) { + return primitive(PType::U8, nullable); +} +DataType uint16(bool nullable) { + return primitive(PType::U16, nullable); +} +DataType uint32(bool nullable) { + return primitive(PType::U32, nullable); +} +DataType uint64(bool nullable) { + return primitive(PType::U64, nullable); +} +DataType float16(bool nullable) { + return primitive(PType::F16, nullable); +} +DataType float32(bool nullable) { + return primitive(PType::F32, nullable); +} +DataType float64(bool nullable) { + return primitive(PType::F64, nullable); +} +DataType utf8(bool nullable) { + return Access::adopt(vx_dtype_new_utf8(nullable)); +} +DataType binary(bool nullable) { + return Access::adopt(vx_dtype_new_binary(nullable)); +} +DataType decimal(uint8_t precision, int8_t scale, bool nullable) { + return Access::adopt(vx_dtype_new_decimal(precision, scale, nullable)); +} +DataType list(DataType element, bool nullable) { + return Access::adopt(vx_dtype_new_list(Access::release(std::move(element)), nullable)); +} +DataType fixed_size_list(DataType element, uint32_t size, bool nullable) { + return Access::adopt( + vx_dtype_new_fixed_size_list(Access::release(std::move(element)), size, nullable)); +} + +DataType struct_(std::span fields, bool nullable) { + vx_error *error = nullptr; + std::unique_ptr handle( + vx_struct_fields_builder_new(), + vx_struct_fields_builder_free); + + for (const auto &[name, dtype] : fields) { + vx_struct_fields_builder_add_field(handle.get(), + to_view(name), + vx_dtype_clone(Access::c_ptr(dtype)), + &error); + throw_on_error(error); + } + vx_struct_fields *ffi_fields = vx_struct_fields_builder_finalize(handle.release()); + return Access::adopt(vx_dtype_new_struct(ffi_fields, nullable)); +} + +DataType struct_(std::initializer_list fields, bool nullable) { + return struct_({fields.begin(), fields.end()}, nullable); +} + +} // namespace dtype +} // namespace vortex diff --git a/lang/cpp/src/expression.cpp b/lang/cpp/src/expression.cpp new file mode 100644 index 00000000000..d314ae26293 --- /dev/null +++ b/lang/cpp/src/expression.cpp @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#include "vortex/common.hpp" +#include "vortex/error.hpp" +#include "vortex/expression.hpp" + +#include + +#include +#include +#include +#include + +namespace vortex { + +using detail::Access; +using detail::throw_on_error; +using detail::to_view; + +void Expression::Deleter::operator()(const vx_expression *ptr) const noexcept { + vx_expression_free(ptr); +} + +Expression::Expression(const Expression &other) : handle_(vx_expression_clone(other.handle_.get())) { +} + +Expression &Expression::operator=(const Expression &other) { + if (this != &other) { + handle_.reset(vx_expression_clone(other.handle_.get())); + } + return *this; +} + +Expression Expression::operator[](std::string_view field) const { + vx_expression *out = vx_expression_get_item(to_view(field), handle_.get()); + if (out == nullptr) { + throw VortexException("get_item: field name is not valid UTF-8", ErrorCode::InvalidArgument); + } + return Access::adopt(out); +} + +Expression Expression::is_null() const { + return expr::is_null(*this); +} + +template +static Expression select_impl(std::span names, const vx_expression *expr) { + std::vector raw; + raw.reserve(names.size()); + for (const auto &name : names) { + raw.push_back(to_view(name)); + } + return Access::adopt(vx_expression_select(raw.data(), raw.size(), expr)); +} + +Expression Expression::select(std::span names) const { + return select_impl(names, handle_.get()); +} + +Expression Expression::select(std::span names) const { + return select_impl(names, handle_.get()); +} + +Expression Expression::select(std::initializer_list names) const { + std::span span {names.begin(), names.end()}; + return select_impl(span, handle_.get()); +} + +namespace expr { + +Expression root() { + return Access::adopt(vx_expression_root()); +} + +Expression col(std::string_view name) { + return root()[name]; +} + +Expression lit(const Scalar &value) { + vx_error *error = nullptr; + vx_expression *out = vx_expression_literal(Access::c_ptr(value), &error); + throw_on_error(error); + return Access::adopt(out); +} + +Expression binary_op(BinaryOperator op, const Expression &l, const Expression &r) { + return Access::adopt( + vx_expression_binary(static_cast(op), Access::c_ptr(l), Access::c_ptr(r))); +} + +Expression eq(const Expression &l, const Expression &r) { + return binary_op(BinaryOperator::Eq, l, r); +} +Expression neq(const Expression &l, const Expression &r) { + return binary_op(BinaryOperator::NotEq, l, r); +} +Expression lt(const Expression &l, const Expression &r) { + return binary_op(BinaryOperator::Lt, l, r); +} +Expression lte(const Expression &l, const Expression &r) { + return binary_op(BinaryOperator::Lte, l, r); +} +Expression gt(const Expression &l, const Expression &r) { + return binary_op(BinaryOperator::Gt, l, r); +} +Expression gte(const Expression &l, const Expression &r) { + return binary_op(BinaryOperator::Gte, l, r); +} +Expression add(const Expression &l, const Expression &r) { + return binary_op(BinaryOperator::Add, l, r); +} +Expression sub(const Expression &l, const Expression &r) { + return binary_op(BinaryOperator::Sub, l, r); +} +Expression mul(const Expression &l, const Expression &r) { + return binary_op(BinaryOperator::Mul, l, r); +} +Expression div(const Expression &l, const Expression &r) { + return binary_op(BinaryOperator::Div, l, r); +} + +static Expression combine(vx_expression *(*combiner)(const vx_expression *const *, size_t), + std::span children) { + std::vector raw; + raw.reserve(children.size()); + for (const auto &child : children) { + raw.push_back(Access::c_ptr(child)); + } + vx_expression *out = combiner(raw.data(), raw.size()); + if (out == nullptr) { + throw VortexException("empty expression list", ErrorCode::InvalidArgument); + } + return Access::adopt(out); +} + +Expression and_all(std::span children) { + return combine(vx_expression_and, children); +} + +Expression or_all(std::span children) { + return combine(vx_expression_or, children); +} + +Expression logical_not(const Expression &child) { + return Access::adopt(vx_expression_not(Access::c_ptr(child))); +} + +Expression is_null(const Expression &child) { + return Access::adopt(vx_expression_is_null(Access::c_ptr(child))); +} + +Expression list_contains(const Expression &list, const Expression &value) { + return Access::adopt(vx_expression_list_contains(Access::c_ptr(list), Access::c_ptr(value))); +} + +} // namespace expr + +} // namespace vortex diff --git a/lang/cpp/src/f16.cpp b/lang/cpp/src/f16.cpp new file mode 100644 index 00000000000..de1058cc7d4 --- /dev/null +++ b/lang/cpp/src/f16.cpp @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#include "vortex/common.hpp" + +#include +#include + +namespace vortex { + +#if __STDCPP_FLOAT16_T__ != 1 +float16_t::operator float() const { + float result; + const uint32_t sign = (bits >> 15) & 1; + const uint32_t exponent = (bits >> 10) & 0x1F; + const uint32_t mantissa = bits & 0x3FF; + + uint32_t out; + if (exponent == 0x1F) { + out = (sign << 31) | 0x7F800000 | (mantissa << 13); + } else if (exponent == 0) { + if (mantissa == 0) { + out = sign << 31; + } else { + uint32_t m = mantissa; + int e = -1; + do { + m <<= 1; + ++e; + } while ((m & 0x400) == 0); + out = (sign << 31) | ((127 - 15 - e) << 23) | ((m & 0x3FF) << 13); + } + } else { + out = (sign << 31) | ((exponent - 15 + 127) << 23) | (mantissa << 13); + } + std::memcpy(&result, &out, sizeof(result)); + return result; +} +#endif +} // namespace vortex diff --git a/lang/cpp/src/scalar.cpp b/lang/cpp/src/scalar.cpp new file mode 100644 index 00000000000..2bef2699bba --- /dev/null +++ b/lang/cpp/src/scalar.cpp @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#include "vortex/common.hpp" +#include "vortex/error.hpp" +#include "vortex/scalar.hpp" + +#include + +#if __STDCPP_FLOAT16_T__ == 1 +#include +#endif +#include + +namespace vortex { + +using detail::Access; +using detail::throw_on_error; + +void Scalar::Deleter::operator()(vx_scalar *ptr) const noexcept { + vx_scalar_free(ptr); +} + +Scalar::Scalar(const Scalar &other) : handle_(vx_scalar_clone(other.handle_.get())) { +} + +Scalar &Scalar::operator=(const Scalar &other) { + if (this != &other) { + handle_.reset(vx_scalar_clone(other.handle_.get())); + } + return *this; +} + +bool Scalar::is_null() const { + return vx_scalar_is_null(handle_.get()); +} + +DataType Scalar::dtype() const { + return Access::adopt(vx_scalar_dtype(handle_.get())); +} + +namespace detail { + +Scalar adopt(vx_scalar *raw) { + return Access::adopt(raw); +} + +vx_scalar *make_bool(bool value, bool nullable) { + return vx_scalar_new_bool(value, nullable); +} + +vx_scalar *make_primitive(vx_ptype ptype, const void *value, bool nullable) { + switch (ptype) { + case PTYPE_U8: + return vx_scalar_new_u8(*static_cast(value), nullable); + case PTYPE_U16: + return vx_scalar_new_u16(*static_cast(value), nullable); + case PTYPE_U32: + return vx_scalar_new_u32(*static_cast(value), nullable); + case PTYPE_U64: + return vx_scalar_new_u64(*static_cast(value), nullable); + case PTYPE_I8: + return vx_scalar_new_i8(*static_cast(value), nullable); + case PTYPE_I16: + return vx_scalar_new_i16(*static_cast(value), nullable); + case PTYPE_I32: + return vx_scalar_new_i32(*static_cast(value), nullable); + case PTYPE_I64: + return vx_scalar_new_i64(*static_cast(value), nullable); +#if __STDCPP_FLOAT16_T__ != 1 + case PTYPE_F16: + return vx_scalar_new_f16_bits(static_cast(value)->bits, nullable); +#else + case PTYPE_F16: + return vx_scalar_new_f16_bits(std::bit_cast(*static_cast(value)), + nullable); +#endif + case PTYPE_F32: + return vx_scalar_new_f32(*static_cast(value), nullable); + case PTYPE_F64: + return vx_scalar_new_f64(*static_cast(value), nullable); + } + throw VortexException("unsupported ptype", ErrorCode::InvalidArgument); +} + +vx_scalar *make_utf8(std::string_view value, bool nullable) { + vx_error *error = nullptr; + vx_scalar *out = vx_scalar_new_utf8(to_view(value), nullable, &error); + throw_on_error(error); + return out; +} + +vx_scalar *make_binary(BinaryView value, bool nullable) { + vx_error *error = nullptr; + vx_scalar *out = + vx_scalar_new_binary(reinterpret_cast(value.data()), value.size(), nullable, &error); + throw_on_error(error); + return out; +} + +} // namespace detail + +namespace scalar { +Scalar null(const DataType &dtype) { + vx_error *error = nullptr; + vx_scalar *out = vx_scalar_new_null(Access::c_ptr(dtype), &error); + throw_on_error(error); + return Access::adopt(out); +} +} // namespace scalar +} // namespace vortex diff --git a/lang/cpp/src/scan.cpp b/lang/cpp/src/scan.cpp new file mode 100644 index 00000000000..8c7967b010d --- /dev/null +++ b/lang/cpp/src/scan.cpp @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#include "vortex/array.hpp" +#include "vortex/common.hpp" +#include "vortex/dtype.hpp" +#include "vortex/error.hpp" +#include "vortex/scan.hpp" + +#include + +#include +#include + +namespace vortex { + +using detail::Access; +using detail::throw_on_error; + +ArrowStream::ArrowStream(ArrowStream &&other) noexcept + : session_(std::move(other.session_)), stream_(other.stream_) { + other.stream_ = ArrowArrayStream {}; +} + +ArrowStream &ArrowStream::operator=(ArrowStream &&other) noexcept { + if (this != &other) { + if (stream_.release != nullptr) { + stream_.release(&stream_); + } + session_ = std::move(other.session_); + stream_ = other.stream_; + other.stream_ = ArrowArrayStream {}; + } + return *this; +} + +ArrowStream::~ArrowStream() { + if (stream_.release != nullptr) { + stream_.release(&stream_); + } +} + +void Partition::Deleter::operator()(vx_partition *ptr) const noexcept { + vx_partition_free(ptr); +} + +Estimate Partition::row_count() const { + vx_estimate raw {}; + vx_error *error = nullptr; + vx_partition_row_count(handle_.get(), &raw, &error); + throw_on_error(error); + return Estimate(raw); +} + +std::optional Partition::next() { + vx_error *error = nullptr; + const vx_array *array = vx_partition_next(handle_.get(), &error); + throw_on_error(error); + if (array == nullptr) { + return std::nullopt; + } + return Access::adopt(array); +} + +ArrowStream Partition::into_arrow_stream() && { + ArrowArrayStream stream {}; + vx_error *error = nullptr; + // Consumes handle even on error + vx_partition_scan_arrow(Access::c_ptr(session_), handle_.release(), &stream, &error); + throw_on_error(error); + return Access::adopt(std::move(session_), stream); +} + +void Scan::Deleter::operator()(vx_scan *ptr) const noexcept { + vx_scan_free(ptr); +} + +DataType Scan::dtype() const { + vx_error *error = nullptr; + const vx_dtype *out = vx_scan_dtype(handle_.get(), &error); + throw_on_error(error); + return Access::adopt(out); +} + +std::optional Scan::next_partition() { + vx_partition *partition = nullptr; + { + const std::lock_guard guard(*mutex_); + vx_error *error = nullptr; + partition = vx_scan_next_partition(handle_.get(), &error); + throw_on_error(error); + } + if (partition == nullptr) { + return std::nullopt; + } + return Access::adopt(partition, session_); +} +} // namespace vortex diff --git a/lang/cpp/src/session.cpp b/lang/cpp/src/session.cpp new file mode 100644 index 00000000000..fb2753476bd --- /dev/null +++ b/lang/cpp/src/session.cpp @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#include "vortex/session.hpp" + +#include + +namespace vortex { + +void Session::Deleter::operator()(vx_session *ptr) const noexcept { + vx_session_free(ptr); +} + +Session::Session() : handle_(vx_session_new()) { +} + +Session::Session(const Session &other) : handle_(vx_session_clone(other.handle_.get())) { +} + +Session &Session::operator=(const Session &other) { + if (this != &other) { + handle_.reset(vx_session_clone(other.handle_.get())); + } + return *this; +} + +} // namespace vortex diff --git a/lang/cpp/src/writer.cpp b/lang/cpp/src/writer.cpp new file mode 100644 index 00000000000..4adc5d77e50 --- /dev/null +++ b/lang/cpp/src/writer.cpp @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#include "vortex/array.hpp" +#include "vortex/common.hpp" +#include "vortex/dtype.hpp" +#include "vortex/error.hpp" +#include "vortex/writer.hpp" +#include "vortex/session.hpp" + +#include + +#include + +namespace vortex { + +using detail::Access; +using detail::throw_on_error; +using detail::to_view; + +void Writer::Deleter::operator()(vx_array_sink *ptr) const noexcept { + vx_array_sink_abort(ptr); +} + +Writer Writer::open(const Session &session, std::string_view path, const DataType &dtype) { + vx_error *error = nullptr; + vx_array_sink *sink = + vx_array_sink_open_file(Access::c_ptr(session), to_view(path), Access::c_ptr(dtype), &error); + throw_on_error(error); + return Writer(sink); +} + +void Writer::push(const Array &array) { + if (handle_ == nullptr) { + throw VortexException("null handle_", ErrorCode::InvalidArgument); + } + vx_error *error = nullptr; + vx_array_sink_push(handle_.get(), Access::c_ptr(array), &error); + throw_on_error(error); +} + +void Writer::finish() { + if (handle_ == nullptr) { + throw VortexException("finish() called twice", ErrorCode::InvalidArgument); + } + vx_error *error = nullptr; + vx_array_sink_close(handle_.release(), &error); + throw_on_error(error); +} +} // namespace vortex diff --git a/vortex-ffi/src/array.rs b/vortex-ffi/src/array.rs index 3eda7732190..577bd4d8be5 100644 --- a/vortex-ffi/src/array.rs +++ b/vortex-ffi/src/array.rs @@ -5,6 +5,7 @@ //! FFI interface for working with Vortex Arrays. use std::ffi::c_void; use std::ptr; +use std::ptr::NonNull; use std::sync::Arc; use arrow_array::array::make_array; @@ -305,7 +306,11 @@ unsafe fn primitive_from_raw( len: usize, validity: &vx_validity, ) -> *const vx_array { - let slice = unsafe { std::slice::from_raw_parts(ptr, len) }; + let slice = if ptr.is_null() { + unsafe { std::slice::from_raw_parts(NonNull::dangling().as_ptr(), len) } + } else { + unsafe { std::slice::from_raw_parts(ptr, len) } + }; let buffer = Buffer::copy_from(slice); let array = PrimitiveArray::new(buffer, validity.into()); vx_array::new(Arc::new(array.into_array())) diff --git a/vortex-ffi/src/sink.rs b/vortex-ffi/src/sink.rs index 7ca149d0731..f307cfc8bc0 100644 --- a/vortex-ffi/src/sink.rs +++ b/vortex-ffi/src/sink.rs @@ -65,7 +65,7 @@ pub unsafe extern "C-unwind" fn vx_array_sink_open_file( error_out: *mut *mut vx_error, ) -> *mut vx_array_sink { try_or_default(error_out, || { - let session = vx_session::as_ref(session); + let session = vx_session::as_ref(session).clone(); if path.ptr.is_null() { vortex_bail!("null path"); From 80d4c7e36ac9b0b16c7dbf0cf8b4fffcac502468 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:54:45 +0000 Subject: [PATCH 076/104] fix(onpair): pin OnPair dictionary-training seed for determinism (#8756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested by **Connor Tsui** · [Slack thread](https://spiraldb.slack.com/archives/C09QYV51VL6/p1784056443357189?thread_ts=1784055867.604669&cid=C09QYV51VL6)_ Pins OnPair's dictionary-training seed to `42` so compression is deterministic run-to-run. OnPair's default config left `seed: None`, which seeds the dictionary trainer's RNG from OS entropy — so the trained dictionary, and therefore compressed file sizes, varied between runs. That noise showed up in the file-size benchmark comments (e.g. #8753). This sets `DEFAULT_DICT12_CONFIG` (the only OnPair `Config` in the repo) to `seed: Some(42)`, making the production path, benches, and tests reproducible. Published `onpair` 0.0.4 already exposes the `seed` field, so no upstream release or version bump is needed. Co-authored-by: Claude --- encodings/experimental/onpair/src/compress.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/encodings/experimental/onpair/src/compress.rs b/encodings/experimental/onpair/src/compress.rs index adf9e73461c..3616ec7e717 100644 --- a/encodings/experimental/onpair/src/compress.rs +++ b/encodings/experimental/onpair/src/compress.rs @@ -27,7 +27,10 @@ use crate::OnPair; use crate::OnPairArray; /// Default OnPair training configuration: 12-bit codes ("dict-12"). -pub const DEFAULT_DICT12_CONFIG: Config = onpair::DEFAULT_CONFIG; +pub const DEFAULT_DICT12_CONFIG: Config = Config { + seed: Some(42), + ..onpair::DEFAULT_CONFIG +}; fn onpair_compress_varbinview( array: VarBinViewArray, From 369fd32e140fa869dd2844f6d8dcb1a2416e378a Mon Sep 17 00:00:00 2001 From: jackylee Date: Wed, 15 Jul 2026 17:05:24 +0800 Subject: [PATCH 077/104] Better error message for unsupported Arrow vector type in Spark reader (#8759) ## Rationale for this change `VortexArrowColumnVector.initAccessor` threw a bare `UnsupportedOperationException` with no message when it encountered an Arrow vector type it does not handle. When this fires, the user only gets a stack trace with no indication of which type was unsupported, making it needlessly hard to diagnose. ## What changes are included in this PR? Include the offending vector's class name in the exception message, matching the existing idiom already used in `ArrowUtils` (`"Unsupported Arrow type: " + ...`). No behavior change beyond the message text. ## What APIs are changed? Are there any user-facing changes? No API changes. Only the text of an already-thrown exception is improved. Signed-off-by: jackylee --- .../java/dev/vortex/spark/read/VortexArrowColumnVector.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/java/vortex-spark/src/main/java/dev/vortex/spark/read/VortexArrowColumnVector.java b/java/vortex-spark/src/main/java/dev/vortex/spark/read/VortexArrowColumnVector.java index 99e45feab72..698707cfdee 100644 --- a/java/vortex-spark/src/main/java/dev/vortex/spark/read/VortexArrowColumnVector.java +++ b/java/vortex-spark/src/main/java/dev/vortex/spark/read/VortexArrowColumnVector.java @@ -329,7 +329,8 @@ void initAccessor(ValueVector vector) { } else if (vector instanceof DurationVector) { accessor = new VortexArrowColumnVector.DurationAccessor((DurationVector) vector); } else { - throw new UnsupportedOperationException(); + throw new UnsupportedOperationException( + "Unsupported Arrow vector type: " + vector.getClass().getName()); } } From b084b3d20bef3cc2fd622cb1bb2d7b57cda72fed Mon Sep 17 00:00:00 2001 From: myrrc Date: Wed, 15 Jul 2026 11:41:48 +0100 Subject: [PATCH 078/104] duckdb 1.5.4 (#8761) Signed-off-by: Mikhail Kot --- .github/workflows/bench-pr.yml | 2 +- .github/workflows/bench.yml | 2 +- .github/workflows/sql-benchmarks.yml | 2 +- vortex-duckdb/build.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bench-pr.yml b/.github/workflows/bench-pr.yml index aad6caf2fc6..12137726122 100644 --- a/.github/workflows/bench-pr.yml +++ b/.github/workflows/bench-pr.yml @@ -50,7 +50,7 @@ jobs: - name: Install DuckDB run: | - wget -qO- https://github.com/duckdb/duckdb/releases/download/v1.5.3/duckdb_cli-linux-amd64.zip | funzip > duckdb + wget -qO- https://github.com/duckdb/duckdb/releases/download/v1.5.4/duckdb_cli-linux-amd64.zip | funzip > duckdb chmod +x duckdb echo "$PWD" >> $GITHUB_PATH diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index d78943fa059..4459726348b 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -64,7 +64,7 @@ jobs: - name: Install DuckDB run: | - wget -qO- https://github.com/duckdb/duckdb/releases/download/v1.5.3/duckdb_cli-linux-amd64.zip | funzip > duckdb + wget -qO- https://github.com/duckdb/duckdb/releases/download/v1.5.4/duckdb_cli-linux-amd64.zip | funzip > duckdb chmod +x duckdb echo "$PWD" >> $GITHUB_PATH diff --git a/.github/workflows/sql-benchmarks.yml b/.github/workflows/sql-benchmarks.yml index d9578a9bb15..243db83aff8 100644 --- a/.github/workflows/sql-benchmarks.yml +++ b/.github/workflows/sql-benchmarks.yml @@ -549,7 +549,7 @@ jobs: - name: Install DuckDB run: | - wget -qO- https://github.com/duckdb/duckdb/releases/download/v1.5.3/duckdb_cli-linux-amd64.zip | funzip > duckdb + wget -qO- https://github.com/duckdb/duckdb/releases/download/v1.5.4/duckdb_cli-linux-amd64.zip | funzip > duckdb chmod +x duckdb echo "$PWD" >> "$GITHUB_PATH" diff --git a/vortex-duckdb/build.rs b/vortex-duckdb/build.rs index c15009a92c3..caae2a8158a 100644 --- a/vortex-duckdb/build.rs +++ b/vortex-duckdb/build.rs @@ -23,7 +23,7 @@ const DUCKDB_RELEASES_URL: &str = "https://ci-builds.vortex.dev"; const DUCKDB_SOURCE_RELEASE_URL: &str = "https://github.com/duckdb/duckdb/archive/refs/tags"; const DUCKDB_SOURCE_COMMIT_URL: &str = "https://github.com/duckdb/duckdb/archive"; -const DEFAULT_DUCKDB_VERSION: &str = "1.5.3"; +const DEFAULT_DUCKDB_VERSION: &str = "1.5.4"; const BUILD_ARTIFACTS: [&str; 3] = ["libduckdb.dylib", "libduckdb.so", "libduckdb_static.a"]; From 204daccc494cb6b2909c5b9dba6a2de851f92d01 Mon Sep 17 00:00:00 2001 From: myrrc Date: Wed, 15 Jul 2026 11:51:36 +0100 Subject: [PATCH 079/104] forbid todo!/unimplemented! in vortex-duckdb (#8762) We should never crash the host if we don't have support for something Signed-off-by: Mikhail Kot --- vortex-duckdb/src/convert/dtype.rs | 31 +++++++++++++++-------------- vortex-duckdb/src/convert/expr.rs | 4 ++-- vortex-duckdb/src/convert/scalar.rs | 11 ++++++---- vortex-duckdb/src/convert/vector.rs | 2 +- vortex-duckdb/src/lib.rs | 2 ++ 5 files changed, 28 insertions(+), 22 deletions(-) diff --git a/vortex-duckdb/src/convert/dtype.rs b/vortex-duckdb/src/convert/dtype.rs index 863a41cd869..138a6f1bc69 100644 --- a/vortex-duckdb/src/convert/dtype.rs +++ b/vortex-duckdb/src/convert/dtype.rs @@ -179,17 +179,19 @@ impl FromLogicalType for DType { ) } DUCKDB_TYPE::DUCKDB_TYPE_VARIANT => DType::Variant(nullability), - DUCKDB_TYPE::DUCKDB_TYPE_TIME_TZ => todo!(), - DUCKDB_TYPE::DUCKDB_TYPE_INTERVAL => todo!(), - DUCKDB_TYPE::DUCKDB_TYPE_ENUM => todo!(), - DUCKDB_TYPE::DUCKDB_TYPE_MAP => todo!(), - DUCKDB_TYPE::DUCKDB_TYPE_UUID => todo!(), - DUCKDB_TYPE::DUCKDB_TYPE_UNION => todo!(), - DUCKDB_TYPE::DUCKDB_TYPE_BIT => todo!(), - DUCKDB_TYPE::DUCKDB_TYPE_ANY => todo!(), - DUCKDB_TYPE::DUCKDB_TYPE_BIGNUM => todo!(), - DUCKDB_TYPE::DUCKDB_TYPE_STRING_LITERAL => todo!(), - DUCKDB_TYPE::DUCKDB_TYPE_INTEGER_LITERAL => todo!(), + other @ (DUCKDB_TYPE::DUCKDB_TYPE_TIME_TZ + | DUCKDB_TYPE::DUCKDB_TYPE_INTERVAL + | DUCKDB_TYPE::DUCKDB_TYPE_ENUM + | DUCKDB_TYPE::DUCKDB_TYPE_MAP + | DUCKDB_TYPE::DUCKDB_TYPE_UUID + | DUCKDB_TYPE::DUCKDB_TYPE_UNION + | DUCKDB_TYPE::DUCKDB_TYPE_BIT + | DUCKDB_TYPE::DUCKDB_TYPE_ANY + | DUCKDB_TYPE::DUCKDB_TYPE_BIGNUM + | DUCKDB_TYPE::DUCKDB_TYPE_STRING_LITERAL + | DUCKDB_TYPE::DUCKDB_TYPE_INTEGER_LITERAL) => { + vortex_bail!("{other:?} -> DType conversion is not supported") + } }) } } @@ -241,10 +243,9 @@ impl TryFrom<&DType> for LogicalType { DType::Struct(struct_type, _) => { return LogicalType::try_from(struct_type); } - DType::Union(..) => todo!("TODO(connor)[Union]: unimplemented"), - DType::Variant(_) => { - vortex_bail!("Vortex Variant array aren't supported in DuckDB") - } + // TODO(connor): Union + DType::Union(..) => vortex_bail!("Vortex Union isn't supported"), + DType::Variant(_) => vortex_bail!("Vortex Variant array aren't supported"), DType::Extension(ext_dtype) => { // Handle first-party extension types that have DuckDB equivalents. if let Some(temporal) = ext_dtype.metadata_opt::() { diff --git a/vortex-duckdb/src/convert/expr.rs b/vortex-duckdb/src/convert/expr.rs index c8c40e20af6..aee065a2a20 100644 --- a/vortex-duckdb/src/convert/expr.rs +++ b/vortex-duckdb/src/convert/expr.rs @@ -732,14 +732,14 @@ impl TryFrom for Operator { fn try_from(value: DUCKDB_VX_EXPR_TYPE) -> VortexResult { Ok(match value { - DUCKDB_VX_EXPR_TYPE::DUCKDB_VX_EXPR_TYPE_INVALID => vortex_bail!("invalid expr"), + DUCKDB_VX_EXPR_TYPE::DUCKDB_VX_EXPR_TYPE_INVALID => vortex_bail!("invalid expression"), DUCKDB_VX_EXPR_TYPE::DUCKDB_VX_EXPR_TYPE_COMPARE_EQUAL => Operator::Eq, DUCKDB_VX_EXPR_TYPE::DUCKDB_VX_EXPR_TYPE_COMPARE_NOTEQUAL => Operator::NotEq, DUCKDB_VX_EXPR_TYPE::DUCKDB_VX_EXPR_TYPE_COMPARE_LESSTHAN => Operator::Lt, DUCKDB_VX_EXPR_TYPE::DUCKDB_VX_EXPR_TYPE_COMPARE_GREATERTHAN => Operator::Gt, DUCKDB_VX_EXPR_TYPE::DUCKDB_VX_EXPR_TYPE_COMPARE_LESSTHANOREQUALTO => Operator::Lte, DUCKDB_VX_EXPR_TYPE::DUCKDB_VX_EXPR_TYPE_COMPARE_GREATERTHANOREQUALTO => Operator::Gte, - _ => todo!("cannot convert {:?}", value), + _ => vortex_bail!("cannot convert {:?}", value), }) } } diff --git a/vortex-duckdb/src/convert/scalar.rs b/vortex-duckdb/src/convert/scalar.rs index 2591786d3d9..7ad12e89ca3 100644 --- a/vortex-duckdb/src/convert/scalar.rs +++ b/vortex-duckdb/src/convert/scalar.rs @@ -78,11 +78,14 @@ impl ToDuckDBScalar for Scalar { DType::Decimal(..) => self.as_decimal().try_to_duckdb_scalar(), DType::Utf8(_) => self.as_utf8().try_to_duckdb_scalar(), DType::Binary(_) => self.as_binary().try_to_duckdb_scalar(), - DType::List(..) | DType::FixedSizeList(..) | DType::Struct(..) => todo!(), - DType::Union(..) => todo!("TODO(connor)[Union]: unimplemented"), - DType::Variant(_) => { - vortex_bail!("Vortex Variant scalars aren't supported in DuckDB") + DType::List(..) => vortex_bail!("Vortex List scalars aren't supported"), + DType::FixedSizeList(..) => { + vortex_bail!("Vortex FixedSizeList scalars aren't supported") } + DType::Variant(_) => vortex_bail!("Vortex Variant scalars aren't supported"), + DType::Struct(..) => vortex_bail!("Vortex Struct scalars aren't supported"), + // TODO(connor): Union + DType::Union(..) => vortex_bail!("Vortex Union scalars aren't supported"), DType::Extension(..) => self.as_extension().try_to_duckdb_scalar(), } } diff --git a/vortex-duckdb/src/convert/vector.rs b/vortex-duckdb/src/convert/vector.rs index 1d12dab9dbe..a3fe098ffbb 100644 --- a/vortex-duckdb/src/convert/vector.rs +++ b/vortex-duckdb/src/convert/vector.rs @@ -361,7 +361,7 @@ pub fn flat_vector_to_vortex(vector: &VectorRef, len: usize) -> VortexResult unimplemented!("missing impl for {type_id:?}"), + type_id => vortex_bail!("{type_id:?} flat Vector to Vortex array not supported"), } } diff --git a/vortex-duckdb/src/lib.rs b/vortex-duckdb/src/lib.rs index 55039b88737..c68924868b3 100644 --- a/vortex-duckdb/src/lib.rs +++ b/vortex-duckdb/src/lib.rs @@ -2,6 +2,8 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors #![expect(clippy::missing_safety_doc)] +#![forbid(clippy::todo)] +#![forbid(clippy::unimplemented)] use std::ffi::c_char; use std::ffi::c_void; From 1bf87e6c115d07baac49ac30bc28008fdf1ee569 Mon Sep 17 00:00:00 2001 From: jackylee Date: Wed, 15 Jul 2026 21:45:25 +0800 Subject: [PATCH 080/104] fix(spark): warn that overwrite destructively deletes existing files (#8767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Rationale for this change The overwrite branch in `VortexBatchWrite.createBatchWriterFactory` already deletes the existing files before writing new data, but it logged: ``` overwrite currently does not do anything for vortex format ``` This message directly contradicts the `NativeFiles.delete(...)` call on the line right above it, and it hides a genuinely dangerous operation. The delete is a *pre-delete* (it runs before any new data is written), and `abort()` only removes the newly written files — it cannot restore what was deleted here. So if the subsequent write fails, the old data is already gone and unrecoverable, yet the log claims overwrite "does not do anything". The message dates back to when overwrite genuinely was a no-op; the actual deletion was added later (New Java Scan API, #7527) without updating the warning. ## What changes are included in this PR? Replace the misleading `warn` with one that accurately describes the destructive action: ```java // Deleting the existing files is destructive and happens before the new data is written: // if the subsequent write fails, abort() only removes the newly written files and cannot // restore what was deleted here. Log loudly so operators can see what was removed. log.warn( "Deleting {} existing file(s) under {} because of overwrite, before writing new data; " + "this cannot be undone if the subsequent write fails", uris.size(), outputPath); ``` This keeps the WARN level (deleting existing data warrants it), folds in the file count that was previously logged separately at INFO, and adds a short code comment documenting the pre-delete / non-atomic behavior for future maintainers. There is **no change to write or delete behavior** — this is purely a logging/clarity fix. ## What APIs are changed? Are there any user-facing changes? No API changes. The only user-facing change is the log output: operators will now see an accurate warning about the destructive deletion instead of a contradictory "does not do anything" message. Signed-off-by: jackylee --- .../java/dev/vortex/spark/write/VortexBatchWrite.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/java/vortex-spark/src/main/java/dev/vortex/spark/write/VortexBatchWrite.java b/java/vortex-spark/src/main/java/dev/vortex/spark/write/VortexBatchWrite.java index 7f96bac4404..03a70c2f676 100644 --- a/java/vortex-spark/src/main/java/dev/vortex/spark/write/VortexBatchWrite.java +++ b/java/vortex-spark/src/main/java/dev/vortex/spark/write/VortexBatchWrite.java @@ -90,9 +90,15 @@ public DataWriterFactory createBatchWriterFactory(PhysicalWriteInfo info) { if (overwrite) { var session = VortexSparkSession.get(options); var uris = NativeFiles.listFiles(session, outputPath, options); - log.info("truncating table with {} files", uris.size()); + // Deleting the existing files is destructive and happens before the new data is written: + // if the subsequent write fails, abort() only removes the newly written files and cannot + // restore what was deleted here. Log loudly so operators can see what was removed. + log.warn( + "Deleting {} existing file(s) under {} because of overwrite, before writing new data; " + + "this cannot be undone if the subsequent write fails", + uris.size(), + outputPath); NativeFiles.delete(session, uris.toArray(new String[0]), options); - log.warn("overwrite currently does not do anything for vortex format"); } return new VortexDataWriterFactory(outputPath, schema, options, resolvedTransforms); From 6b27c45238bbe09d5018392121089d96ccf881c5 Mon Sep 17 00:00:00 2001 From: jackylee Date: Wed, 15 Jul 2026 22:40:44 +0800 Subject: [PATCH 081/104] test(spark): characterize ArrowUtils Arrow-to-Spark type mapping (#8770) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Rationale for this change `ArrowUtils` (`java/vortex-spark/src/main/java/dev/vortex/spark/ArrowUtils.java`) maps Arrow types to Spark SQL `DataType`s on the read path, and currently has **no test coverage**. It has several branches that deliberately reject type configurations (unsigned ints, half floats, non-DAY dates, non-microsecond timestamps, unrecognized types), and the mapping table itself is exactly the kind of code that regresses quietly. ## What changes are included in this PR? A new `ArrowUtilsTest` that characterizes both directions of the mapping: - **Supported mappings**: `Bool`; signed ints 8/16/32/64 → `Byte/Short/Integer/Long`; `SINGLE/DOUBLE` float → `Float/Double`; `Decimal` (precision + scale preserved); `Utf8`/`LargeUtf8` → `StringType`; `Binary`/`LargeBinary` → `BinaryType`; `Date(DAY)` → `DateType`; `Timestamp(MICROSECOND)` → `TimestampType` with a timezone and `TimestampNTZType` without; `Null` → `NullType`; and the nested `fromArrowField` cases for `Struct` and `List` (carrying element nullability). - **Rejected configurations**: unsigned integers, half-precision float, non-DAY date units, and non-microsecond timestamp units all raise `UnsupportedOperationException`; an unrecognized Arrow type raises `IllegalArgumentException`. This is a **test-only** change; no production code is modified. 16 tests, all passing locally (`:vortex-spark_2.12:test --tests 'dev.vortex.spark.ArrowUtilsTest'`). ## What APIs are changed? Are there any user-facing changes? None. No production code or public API is touched. Signed-off-by: jackylee --- .../java/dev/vortex/spark/ArrowUtilsTest.java | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 java/vortex-spark/src/test/java/dev/vortex/spark/ArrowUtilsTest.java diff --git a/java/vortex-spark/src/test/java/dev/vortex/spark/ArrowUtilsTest.java b/java/vortex-spark/src/test/java/dev/vortex/spark/ArrowUtilsTest.java new file mode 100644 index 00000000000..881634e6630 --- /dev/null +++ b/java/vortex-spark/src/test/java/dev/vortex/spark/ArrowUtilsTest.java @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +package dev.vortex.spark; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.vortex.relocated.org.apache.arrow.vector.types.DateUnit; +import dev.vortex.relocated.org.apache.arrow.vector.types.FloatingPointPrecision; +import dev.vortex.relocated.org.apache.arrow.vector.types.TimeUnit; +import dev.vortex.relocated.org.apache.arrow.vector.types.pojo.ArrowType; +import dev.vortex.relocated.org.apache.arrow.vector.types.pojo.Field; +import dev.vortex.relocated.org.apache.arrow.vector.types.pojo.FieldType; +import java.util.List; +import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ArrowUtils}, which maps Arrow types to Spark SQL {@link DataType}s. + * + *

Characterizes both the supported mappings and the type configurations that are explicitly rejected, so that + * regressions in either direction are caught. + */ +final class ArrowUtilsTest { + @Test + @DisplayName("Bool maps to BooleanType") + void boolMapsToBoolean() { + assertEquals(DataTypes.BooleanType, ArrowUtils.fromArrowType(new ArrowType.Bool())); + } + + @Test + @DisplayName("Signed integers map to the matching Spark integral types by bit width") + void signedIntegersMapByWidth() { + assertEquals(DataTypes.ByteType, ArrowUtils.fromArrowType(new ArrowType.Int(8, true))); + assertEquals(DataTypes.ShortType, ArrowUtils.fromArrowType(new ArrowType.Int(16, true))); + assertEquals(DataTypes.IntegerType, ArrowUtils.fromArrowType(new ArrowType.Int(32, true))); + assertEquals(DataTypes.LongType, ArrowUtils.fromArrowType(new ArrowType.Int(64, true))); + } + + @Test + @DisplayName("Single/double floating point map to Float/Double") + void floatingPointMapsByPrecision() { + assertEquals( + DataTypes.FloatType, + ArrowUtils.fromArrowType(new ArrowType.FloatingPoint(FloatingPointPrecision.SINGLE))); + assertEquals( + DataTypes.DoubleType, + ArrowUtils.fromArrowType(new ArrowType.FloatingPoint(FloatingPointPrecision.DOUBLE))); + } + + @Test + @DisplayName("Decimal preserves precision and scale") + void decimalPreservesPrecisionAndScale() { + assertEquals(DataTypes.createDecimalType(20, 4), ArrowUtils.fromArrowType(new ArrowType.Decimal(20, 4, 128))); + } + + @Test + @DisplayName("Utf8 and LargeUtf8 map to StringType") + void utf8MapsToString() { + assertEquals(DataTypes.StringType, ArrowUtils.fromArrowType(new ArrowType.Utf8())); + assertEquals(DataTypes.StringType, ArrowUtils.fromArrowType(new ArrowType.LargeUtf8())); + } + + @Test + @DisplayName("Binary and LargeBinary map to BinaryType") + void binaryMapsToBinary() { + assertEquals(DataTypes.BinaryType, ArrowUtils.fromArrowType(new ArrowType.Binary())); + assertEquals(DataTypes.BinaryType, ArrowUtils.fromArrowType(new ArrowType.LargeBinary())); + } + + @Test + @DisplayName("Date(DAY) maps to DateType") + void dateDayMapsToDate() { + assertEquals(DataTypes.DateType, ArrowUtils.fromArrowType(new ArrowType.Date(DateUnit.DAY))); + } + + @Test + @DisplayName("Timestamp(MICROSECOND) maps to Timestamp with tz, TimestampNTZ without") + void timestampMapsByTimezonePresence() { + assertEquals( + DataTypes.TimestampType, + ArrowUtils.fromArrowType(new ArrowType.Timestamp(TimeUnit.MICROSECOND, "UTC"))); + assertEquals( + DataTypes.TimestampNTZType, + ArrowUtils.fromArrowType(new ArrowType.Timestamp(TimeUnit.MICROSECOND, null))); + } + + @Test + @DisplayName("Null maps to NullType") + void nullMapsToNull() { + assertEquals(DataTypes.NullType, ArrowUtils.fromArrowType(new ArrowType.Null())); + } + + @Test + @DisplayName("fromArrowField builds a StructType from nested children") + void structFieldBuildsStructType() { + Field struct = new Field( + "s", + FieldType.nullable(new ArrowType.Struct()), + List.of( + new Field("a", FieldType.nullable(new ArrowType.Int(32, true)), null), + new Field("b", FieldType.notNullable(new ArrowType.Utf8()), null))); + + DataType expected = DataTypes.createStructType(new org.apache.spark.sql.types.StructField[] { + DataTypes.createStructField("a", DataTypes.IntegerType, true), + DataTypes.createStructField("b", DataTypes.StringType, false) + }); + assertEquals(expected, ArrowUtils.fromArrowField(struct)); + } + + @Test + @DisplayName("fromArrowField builds an ArrayType carrying the element's nullability") + void listFieldBuildsArrayType() { + Field list = new Field( + "l", + FieldType.nullable(new ArrowType.List()), + List.of(new Field("element", FieldType.nullable(new ArrowType.Int(32, true)), null))); + + assertEquals(DataTypes.createArrayType(DataTypes.IntegerType, true), ArrowUtils.fromArrowField(list)); + } + + @Test + @DisplayName("Unsigned integers are unsupported") + void unsignedIntegerIsUnsupported() { + assertThrows(UnsupportedOperationException.class, () -> ArrowUtils.fromArrowType(new ArrowType.Int(32, false))); + } + + @Test + @DisplayName("Half-precision floating point is unsupported") + void halfPrecisionFloatIsUnsupported() { + assertThrows( + UnsupportedOperationException.class, + () -> ArrowUtils.fromArrowType(new ArrowType.FloatingPoint(FloatingPointPrecision.HALF))); + } + + @Test + @DisplayName("Non-DAY date units are unsupported") + void millisecondDateIsUnsupported() { + assertThrows( + UnsupportedOperationException.class, + () -> ArrowUtils.fromArrowType(new ArrowType.Date(DateUnit.MILLISECOND))); + } + + @Test + @DisplayName("Non-microsecond timestamp units are unsupported") + void secondTimestampIsUnsupported() { + assertThrows( + UnsupportedOperationException.class, + () -> ArrowUtils.fromArrowType(new ArrowType.Timestamp(TimeUnit.SECOND, "UTC"))); + } + + @Test + @DisplayName("Unrecognized Arrow types raise IllegalArgumentException") + void unrecognizedTypeIsIllegalArgument() { + assertThrows( + IllegalArgumentException.class, + () -> ArrowUtils.fromArrowType(new ArrowType.Duration(TimeUnit.SECOND))); + } +} From af1e6a63c4fdb85c7441073bbf89f2c1b67a0349 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Wed, 15 Jul 2026 19:08:28 +0100 Subject: [PATCH 082/104] Support reporting statistics and currently written byte size from writers in jni (#8726) Allow users to extract the whole file stats from the write instead of having to read the files back --- .../vortex/api/VortexColumnStatistics.java | 82 ++++++ .../dev/vortex/api/VortexWriteSummary.java | 40 +++ .../java/dev/vortex/api/VortexWriter.java | 39 ++- .../java/dev/vortex/jni/NativeWriter.java | 7 + .../java/dev/vortex/jni/JNIWriterTest.java | 19 ++ vortex-array/src/stats/stats_set.rs | 8 +- vortex-file/src/counting.rs | 6 +- vortex-file/src/footer/field_sizes.rs | 243 ++++++++++++++++ vortex-file/src/footer/mod.rs | 11 + vortex-file/src/lib.rs | 1 + vortex-file/src/writer.rs | 26 ++ vortex-jni/src/writer.rs | 265 ++++++++++++++++-- 12 files changed, 718 insertions(+), 29 deletions(-) create mode 100644 java/vortex-jni/src/main/java/dev/vortex/api/VortexColumnStatistics.java create mode 100644 java/vortex-jni/src/main/java/dev/vortex/api/VortexWriteSummary.java create mode 100644 vortex-file/src/footer/field_sizes.rs diff --git a/java/vortex-jni/src/main/java/dev/vortex/api/VortexColumnStatistics.java b/java/vortex-jni/src/main/java/dev/vortex/api/VortexColumnStatistics.java new file mode 100644 index 00000000000..308ca64a0df --- /dev/null +++ b/java/vortex-jni/src/main/java/dev/vortex/api/VortexColumnStatistics.java @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +package dev.vortex.api; + +import java.util.Optional; +import java.util.OptionalLong; + +/** Statistics and physical size reported for one top-level column after a Vortex write. */ +public final class VortexColumnStatistics { + private final int columnIndex; + private final long compressedSize; + private final long valueCount; + private final long nullValueCount; + private final long nanValueCount; + private final Object lowerBound; + private final Object upperBound; + + /** + * Construct native write statistics. + * + *

This constructor is public so the JNI implementation can instantiate the value without reflective access. + * Applications should obtain instances from {@link VortexWriteSummary#columnStatistics()}. + */ + public VortexColumnStatistics( + int columnIndex, + long compressedSize, + long valueCount, + long nullValueCount, + long nanValueCount, + Object lowerBound, + Object upperBound) { + this.columnIndex = columnIndex; + this.compressedSize = compressedSize; + this.valueCount = valueCount; + this.nullValueCount = nullValueCount; + this.nanValueCount = nanValueCount; + this.lowerBound = lowerBound; + this.upperBound = upperBound; + } + + /** Zero-based position of this column in the writer's Arrow schema. */ + public int columnIndex() { + return columnIndex; + } + + /** Compressed bytes referenced by this column's physical layout. */ + public long compressedSize() { + return compressedSize; + } + + /** Number of values in this top-level column. */ + public long valueCount() { + return valueCount; + } + + /** Exact null count, or empty when Vortex did not compute one for this data type. */ + public OptionalLong nullValueCount() { + return nullValueCount >= 0 ? OptionalLong.of(nullValueCount) : OptionalLong.empty(); + } + + /** Exact NaN count, or empty for non-floating-point columns. */ + public OptionalLong nanValueCount() { + return nanValueCount >= 0 ? OptionalLong.of(nanValueCount) : OptionalLong.empty(); + } + + /** + * Lower bound represented using the corresponding Arrow scalar's Java type. + * + *

Integers use {@link Integer}, {@link Long}, or {@link java.math.BigInteger}; floating-point values use + * {@link Float} or {@link Double}; decimal values use {@link java.math.BigDecimal}; strings use {@link String}; and + * binary values use {@code byte[]}. + */ + public Optional lowerBound() { + return Optional.ofNullable(lowerBound); + } + + /** Upper bound represented using the corresponding Arrow scalar's Java type. */ + public Optional upperBound() { + return Optional.ofNullable(upperBound); + } +} diff --git a/java/vortex-jni/src/main/java/dev/vortex/api/VortexWriteSummary.java b/java/vortex-jni/src/main/java/dev/vortex/api/VortexWriteSummary.java new file mode 100644 index 00000000000..69f042d2ff4 --- /dev/null +++ b/java/vortex-jni/src/main/java/dev/vortex/api/VortexWriteSummary.java @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +package dev.vortex.api; + +import java.util.List; + +/** Immutable metadata returned after a Vortex writer has finalized its file. */ +public final class VortexWriteSummary { + private final long fileSize; + private final long rowCount; + private final List columnStatistics; + + /** + * Construct a native write summary. + * + *

This constructor is public so the JNI implementation can instantiate the value without reflective access. + * Applications should obtain summaries from {@link VortexWriter#finish()}. + */ + public VortexWriteSummary(long fileSize, long rowCount, VortexColumnStatistics[] columnStatistics) { + this.fileSize = fileSize; + this.rowCount = rowCount; + this.columnStatistics = List.of(columnStatistics.clone()); + } + + /** Exact size of the completed file in bytes. */ + public long fileSize() { + return fileSize; + } + + /** Exact number of rows written to the file. */ + public long rowCount() { + return rowCount; + } + + /** Per-column statistics in Arrow schema order. */ + public List columnStatistics() { + return columnStatistics; + } +} diff --git a/java/vortex-jni/src/main/java/dev/vortex/api/VortexWriter.java b/java/vortex-jni/src/main/java/dev/vortex/api/VortexWriter.java index 7761be59cdc..e883e7911f7 100644 --- a/java/vortex-jni/src/main/java/dev/vortex/api/VortexWriter.java +++ b/java/vortex-jni/src/main/java/dev/vortex/api/VortexWriter.java @@ -29,6 +29,7 @@ public final class VortexWriter implements AutoCloseable { private final long pointer; private final AtomicBoolean closed = new AtomicBoolean(false); + private volatile VortexWriteSummary summary; private VortexWriter(long pointer) { Preconditions.checkArgument(pointer != 0, "invalid writer pointer"); @@ -79,15 +80,45 @@ public void writeBatch(long arrowArrayAddr, long arrowSchemaAddr) throws IOExcep } } - /** Flush any pending batches and finalize the file. Idempotent. */ - @Override - public void close() throws IOException { + /** + * Return the number of bytes successfully written to the underlying sink so far. + * + *

This count does not include queued batches or data still buffered by layout strategies, so it may lag the + * amount of input accepted by {@link #writeBatch(long, long)}. After {@link #finish()}, it is the exact completed + * file size and is equal to {@link VortexWriteSummary#fileSize()}. + */ + public synchronized long bytesWritten() { + if (summary != null) { + return summary.fileSize(); + } + Preconditions.checkState(!closed.get(), "writer closed without a write summary"); + long bytesWritten = NativeWriter.bytesWritten(pointer); + Preconditions.checkState(bytesWritten >= 0, "native writer returned an invalid byte count"); + return bytesWritten; + } + + /** + * Flush pending batches, finalize the file, and return its statistics and physical sizes. + * + *

This method is idempotent. Later calls return the same immutable summary. + */ + public synchronized VortexWriteSummary finish() throws IOException { if (closed.compareAndSet(false, true)) { try { - NativeWriter.close(pointer); + summary = NativeWriter.finish(pointer); } catch (RuntimeException e) { throw new IOException("failed to close writer", e); } } + if (summary == null) { + throw new IOException("writer was closed without retaining its write summary"); + } + return summary; + } + + /** Flush any pending batches and finalize the file. Idempotent. */ + @Override + public void close() throws IOException { + finish(); } } diff --git a/java/vortex-jni/src/main/java/dev/vortex/jni/NativeWriter.java b/java/vortex-jni/src/main/java/dev/vortex/jni/NativeWriter.java index d1f2e725f8c..7536712665a 100644 --- a/java/vortex-jni/src/main/java/dev/vortex/jni/NativeWriter.java +++ b/java/vortex-jni/src/main/java/dev/vortex/jni/NativeWriter.java @@ -3,6 +3,7 @@ package dev.vortex.jni; +import dev.vortex.api.VortexWriteSummary; import java.util.Map; /** JNI boundary for {@link dev.vortex.api.VortexWriter}. */ @@ -27,6 +28,12 @@ public static native long create( */ public static native boolean writeBatch(long writerPointer, long arrowArrayAddress, long arrowSchemaAddress); + /** Number of bytes successfully written to the underlying sink so far. */ + public static native long bytesWritten(long writerPointer); + /** Flush and close the writer. Must be called exactly once. */ public static native void close(long writerPointer); + + /** Flush and close the writer, returning its completed-file summary. Must be called exactly once. */ + public static native VortexWriteSummary finish(long writerPointer); } diff --git a/java/vortex-jni/src/test/java/dev/vortex/jni/JNIWriterTest.java b/java/vortex-jni/src/test/java/dev/vortex/jni/JNIWriterTest.java index 03869ae8598..ed84111bdf8 100644 --- a/java/vortex-jni/src/test/java/dev/vortex/jni/JNIWriterTest.java +++ b/java/vortex-jni/src/test/java/dev/vortex/jni/JNIWriterTest.java @@ -14,6 +14,7 @@ import dev.vortex.api.Scan; import dev.vortex.api.ScanOptions; import dev.vortex.api.Session; +import dev.vortex.api.VortexWriteSummary; import dev.vortex.api.VortexWriter; import dev.vortex.arrow.ArrowAllocation; import java.io.IOException; @@ -157,6 +158,8 @@ public void testWriteBatch() throws IOException { Schema schema = personSchema(); Session session = Session.create(); + VortexWriteSummary summary; + long bytesWhileOpen; try (VortexWriter writer = VortexWriter.create(session, writePath, schema, new HashMap<>(), allocator); VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { VarCharVector nameVec = (VarCharVector) root.getVector("name"); @@ -179,9 +182,25 @@ public void testWriteBatch() throws IOException { Data.exportVectorSchemaRoot(allocator, root, null, arrowArray, arrowSchemaFfi); writer.writeBatch(arrowArray.memoryAddress(), arrowSchemaFfi.memoryAddress()); } + bytesWhileOpen = writer.bytesWritten(); + summary = writer.finish(); + assertEquals(summary.fileSize(), writer.bytesWritten()); } assertTrue(Files.exists(outputPath), "output file should exist"); + assertTrue(bytesWhileOpen >= 0); + assertTrue(bytesWhileOpen <= summary.fileSize()); + assertEquals(Files.size(outputPath), summary.fileSize()); + assertEquals(3L, summary.rowCount()); + assertEquals(2, summary.columnStatistics().size()); + assertEquals(0, summary.columnStatistics().get(0).columnIndex()); + assertTrue(summary.columnStatistics().get(0).compressedSize() > 0); + assertEquals(3L, summary.columnStatistics().get(0).valueCount()); + assertEquals(0L, summary.columnStatistics().get(0).nullValueCount().orElseThrow()); + assertEquals("Alice", summary.columnStatistics().get(0).lowerBound().orElseThrow()); + assertEquals("Carol", summary.columnStatistics().get(0).upperBound().orElseThrow()); + assertEquals(25, summary.columnStatistics().get(1).lowerBound().orElseThrow()); + assertEquals(40, summary.columnStatistics().get(1).upperBound().orElseThrow()); DataSource ds = DataSource.open(session, writePath); assertEquals(new DataSource.RowCount.Exact(3L), ds.rowCount()); diff --git a/vortex-array/src/stats/stats_set.rs b/vortex-array/src/stats/stats_set.rs index 6025afed2c6..cb4af0b2c7f 100644 --- a/vortex-array/src/stats/stats_set.rs +++ b/vortex-array/src/stats/stats_set.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +use std::any::type_name; use std::fmt::Debug; use enum_iterator::all; @@ -133,12 +134,7 @@ impl StatsSet { .vortex_expect("failed to construct a scalar statistic"), ) .unwrap_or_else(|err| { - vortex_panic!( - err, - "Failed to get stat {} as {}", - stat, - std::any::type_name::() - ) + vortex_panic!(err, "Failed to get stat {} as {}", stat, type_name::()) }) }) } diff --git a/vortex-file/src/counting.rs b/vortex-file/src/counting.rs index 78f8a8741a9..003e3eb9465 100644 --- a/vortex-file/src/counting.rs +++ b/vortex-file/src/counting.rs @@ -9,13 +9,14 @@ use std::sync::atomic::Ordering; use vortex_io::IoBuf; use vortex_io::VortexWrite; -/// A wrapper around an `VortexWrite` that counts the number of bytes written. -pub(crate) struct CountingVortexWrite { +/// A wrapper around a [`VortexWrite`] that counts the number of bytes written. +pub struct CountingVortexWrite { inner: W, bytes_written: Arc, } impl CountingVortexWrite { + /// Wrap a writer with a new byte counter. pub fn new(inner: W) -> Self { Self { inner, @@ -23,6 +24,7 @@ impl CountingVortexWrite { } } + /// Returns the shared byte counter updated by this writer. pub fn counter(&self) -> Arc { Arc::clone(&self.bytes_written) } diff --git a/vortex-file/src/footer/field_sizes.rs b/vortex-file/src/footer/field_sizes.rs new file mode 100644 index 00000000000..5d7d2ce7c02 --- /dev/null +++ b/vortex-file/src/footer/field_sizes.rs @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use vortex_array::dtype::Field; +use vortex_array::dtype::FieldPath; +use vortex_error::VortexResult; +use vortex_error::vortex_err; +use vortex_layout::LayoutChildType; +use vortex_layout::LayoutRef; +use vortex_layout::segments::SegmentId; +use vortex_utils::aliases::hash_map::Entry; +use vortex_utils::aliases::hash_map::HashMap; + +use crate::footer::SegmentSpec; + +/// Compressed sizes of a file's fields, keyed by field path. +/// +/// Sizes are derived from the file's layout tree and segment map: walking the tree from the root, +/// each physical segment is attributed to the deepest [`FieldPath`] whose layout subtree +/// references it. A segment referenced by more than one field is attributed to their longest +/// common ancestor, so a segment is counted exactly once and the size of a field always equals +/// the sum of its child field sizes plus the bytes attributed directly to it (e.g. struct +/// validity, or auxiliary segments such as zone maps and dictionaries written above the field +/// split). +/// +/// Fields nested inside non-struct types (e.g. structs within lists) are not split into separate +/// layout subtrees by the writer, so their bytes are attributed to the enclosing field. +/// +/// File-level metadata, alignment padding, and the footer are not part of any segment and are +/// never attributed. +#[derive(Debug, Clone)] +pub struct CompressedFieldSizes { + sizes: HashMap, +} + +impl CompressedFieldSizes { + pub(crate) fn try_new(root: &LayoutRef, segments: &[SegmentSpec]) -> VortexResult { + // Every segment is attributed to the deepest field path referencing it, collapsing to the + // longest common ancestor if multiple fields reference the same segment. + let mut attribution = HashMap::::default(); + // Seed every field path present in the layout tree so empty fields report a zero size. + let mut sizes = HashMap::::default(); + sizes.insert(FieldPath::root(), 0); + + let mut stack = vec![(LayoutRef::clone(root), FieldPath::root())]; + while let Some((layout, path)) = stack.pop() { + for segment_id in layout.segment_ids() { + match attribution.entry(segment_id) { + Entry::Occupied(mut entry) => { + let ancestor = common_prefix(entry.get(), &path); + entry.insert(ancestor); + } + Entry::Vacant(entry) => { + entry.insert(path.clone()); + } + } + } + + for idx in 0..layout.nchildren() { + let child_path = match layout.child_type(idx) { + LayoutChildType::Field(name) => { + let child_path = path.clone().push(name); + sizes.entry(child_path.clone()).or_insert(0); + child_path + } + _ => path.clone(), + }; + stack.push((layout.child(idx)?, child_path)); + } + } + + for (segment_id, path) in attribution { + let segment = segments.get(*segment_id as usize).ok_or_else(|| { + vortex_err!( + "layout references missing segment {} (segment count: {})", + *segment_id, + segments.len() + ) + })?; + // A segment counts towards its attributed field and every enclosing field. + for len in 0..=path.parts().len() { + *sizes + .entry(FieldPath::from(path.parts()[..len].to_vec())) + .or_insert(0) += u64::from(segment.length); + } + } + + Ok(Self { sizes }) + } + + /// Returns the compressed size in bytes of the field at the given path, including all of its + /// descendants, or `None` if the layout tree contains no such field. + /// + /// [`FieldPath::root`] returns the same value as [`total`][Self::total]. + pub fn get(&self, path: &FieldPath) -> Option { + self.sizes.get(path).copied() + } + + /// Returns the total compressed size in bytes of all segments referenced by the layout tree. + pub fn total(&self) -> u64 { + self.get(&FieldPath::root()).unwrap_or_default() + } + + /// Iterates over all field paths in the layout tree and their compressed sizes. + pub fn iter(&self) -> impl Iterator { + self.sizes.iter().map(|(path, size)| (path, *size)) + } +} + +fn common_prefix(left: &FieldPath, right: &FieldPath) -> FieldPath { + left.parts() + .iter() + .zip(right.parts()) + .take_while(|(l, r)| l == r) + .map(|(l, _)| l.clone()) + .collect::>() + .into() +} + +#[cfg(test)] +mod tests { + use std::sync::LazyLock; + + use vortex_array::IntoArray; + use vortex_array::array_session; + use vortex_array::arrays::StructArray; + use vortex_array::dtype::FieldPath; + use vortex_array::field_path; + use vortex_buffer::ByteBufferMut; + use vortex_buffer::buffer; + use vortex_error::VortexResult; + use vortex_io::session::RuntimeSession; + use vortex_layout::session::LayoutSession; + use vortex_session::VortexSession; + + use crate::WriteOptionsSessionExt; + use crate::writer::WriteSummary; + + static SESSION: LazyLock = LazyLock::new(|| { + let session = array_session() + .with::() + .with::(); + crate::register_default_encodings(&session); + session + }); + + async fn write_nested() -> VortexResult { + let inner = StructArray::from_fields(&[ + ("c", buffer![10i64, 20, 30, 40].into_array()), + ( + "d", + StructArray::from_fields(&[("e", buffer![1.0f64, 2.0, 3.0, 4.0].into_array())])? + .into_array(), + ), + ])?; + let root = StructArray::from_fields(&[ + ("a", buffer![1u32, 2, 3, 4].into_array()), + ("b", inner.into_array()), + ])?; + + let mut buf = ByteBufferMut::empty(); + SESSION + .write_options() + .write(&mut buf, root.into_array().to_array_stream()) + .await + } + + #[tokio::test] + async fn nested_field_sizes() -> VortexResult<()> { + let summary = write_nested().await?; + let sizes = summary.footer().compressed_field_sizes()?; + + for path in [ + field_path!(a), + field_path!(b), + field_path!(b.c), + field_path!(b.d), + field_path!(b.d.e), + ] { + assert!( + sizes.get(&path).is_some_and(|size| size > 0), + "expected non-zero size for {path}, got {:?}", + sizes.get(&path) + ); + } + assert_eq!(sizes.get(&field_path!(missing)), None); + assert_eq!(sizes.get(&field_path!(b.d.e.missing)), None); + + // Attribution partitions segments: a field's size is the sum of its children plus its own + // directly-attributed segments (none here, as the structs are non-nullable). + let size = |path: FieldPath| sizes.get(&path).unwrap_or_default(); + assert_eq!( + size(field_path!(b)), + size(field_path!(b.c)) + size(field_path!(b.d)) + ); + assert_eq!(size(field_path!(b.d)), size(field_path!(b.d.e))); + assert_eq!(sizes.total(), size(field_path!(a)) + size(field_path!(b))); + + // Every segment in the file is attributed exactly once. + let segment_total: u64 = summary + .footer() + .segment_map() + .iter() + .map(|segment| u64::from(segment.length)) + .sum(); + assert_eq!(sizes.total(), segment_total); + + Ok(()) + } + + #[tokio::test] + async fn column_sizes_in_schema_order() -> VortexResult<()> { + let summary = write_nested().await?; + let sizes = summary.footer().compressed_field_sizes()?; + + assert_eq!( + summary.compressed_column_sizes()?, + vec![ + sizes.get(&field_path!(a)).unwrap_or_default(), + sizes.get(&field_path!(b)).unwrap_or_default(), + ] + ); + Ok(()) + } + + #[tokio::test] + async fn non_struct_file_reports_single_column() -> VortexResult<()> { + let mut buf = ByteBufferMut::empty(); + let summary = SESSION + .write_options() + .write( + &mut buf, + buffer![1i32, 2, 3, 4].into_array().to_array_stream(), + ) + .await?; + + let sizes = summary.footer().compressed_field_sizes()?; + assert!(sizes.total() > 0); + assert_eq!(sizes.get(&FieldPath::root()), Some(sizes.total())); + assert_eq!(summary.compressed_column_sizes()?, vec![sizes.total()]); + Ok(()) + } +} diff --git a/vortex-file/src/footer/mod.rs b/vortex-file/src/footer/mod.rs index f4bf7e19760..fda02182e1b 100644 --- a/vortex-file/src/footer/mod.rs +++ b/vortex-file/src/footer/mod.rs @@ -8,6 +8,7 @@ //! //! The byte-level footer and postscript layout is part of the file-format spec; this module exposes //! the structured Rust representation and serializer/deserializer state machine. +mod field_sizes; mod file_layout; mod file_statistics; mod postscript; @@ -19,6 +20,7 @@ mod serializer; pub use serializer::*; mod deserializer; pub use deserializer::*; +pub use field_sizes::CompressedFieldSizes; pub use file_statistics::FileStatistics; use flatbuffers::root; use itertools::Itertools; @@ -146,6 +148,15 @@ impl Footer { self.statistics.as_ref() } + /// Computes the compressed size in bytes of every field in the file, keyed by field path. + /// + /// Sizes are derived by attributing each segment in the [segment map][Self::segment_map] to a + /// field in the [layout tree][Self::layout]; see [`CompressedFieldSizes`] for the exact + /// attribution semantics. No IO is performed. + pub fn compressed_field_sizes(&self) -> VortexResult { + CompressedFieldSizes::try_new(&self.root_layout, &self.segments) + } + /// Returns the [`DType`] of the file. pub fn dtype(&self) -> &DType { self.root_layout.dtype() diff --git a/vortex-file/src/lib.rs b/vortex-file/src/lib.rs index e9c7741af93..261ba7191d6 100644 --- a/vortex-file/src/lib.rs +++ b/vortex-file/src/lib.rs @@ -113,6 +113,7 @@ mod tests; pub mod v2; mod writer; +pub use counting::CountingVortexWrite; pub use file::*; pub use footer::*; pub use forever_constant::*; diff --git a/vortex-file/src/writer.rs b/vortex-file/src/writer.rs index 2afd61f7b23..80da9f4fb89 100644 --- a/vortex-file/src/writer.rs +++ b/vortex-file/src/writer.rs @@ -19,6 +19,7 @@ use itertools::Itertools; use vortex_array::ArrayContext; use vortex_array::ArrayRef; use vortex_array::dtype::DType; +use vortex_array::dtype::FieldPath; use vortex_array::expr::stats::Stat; use vortex_array::iter::ArrayIterator; use vortex_array::iter::ArrayIteratorExt; @@ -505,4 +506,29 @@ impl WriteSummary { pub fn row_count(&self) -> u64 { self.footer.row_count() } + + /// Returns the compressed size in bytes of each top-level column in schema order. + /// + /// A column's size includes every physical segment attributed to its layout subtree, + /// including auxiliary segments such as zone maps and dictionaries; see + /// [`Footer::compressed_field_sizes`] for the exact attribution semantics and for sizes of + /// nested fields. Bytes not attributable to a specific column (e.g. top-level struct + /// validity) are not included in any column's size. + /// + /// For a non-struct file, the returned vector contains a single entry for the root column. + pub fn compressed_column_sizes(&self) -> VortexResult> { + let sizes = self.footer.compressed_field_sizes()?; + let Some(fields) = self.footer.dtype().as_struct_fields_opt() else { + return Ok(vec![sizes.total()]); + }; + Ok(fields + .names() + .iter() + .map(|name| { + sizes + .get(&FieldPath::from_name(name.clone())) + .unwrap_or_default() + }) + .collect()) + } } diff --git a/vortex-jni/src/writer.rs b/vortex-jni/src/writer.rs index 5b098b4c320..309248fd73c 100644 --- a/vortex-jni/src/writer.rs +++ b/vortex-jni/src/writer.rs @@ -8,6 +8,8 @@ use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; use arrow_array::RecordBatch; use arrow_array::StructArray; @@ -17,24 +19,35 @@ use arrow_schema::SchemaRef; use async_fs::File; use futures::SinkExt; use futures::channel::mpsc; +use jni::Env; use jni::EnvUnowned; use jni::objects::JClass; use jni::objects::JObject; use jni::objects::JString; +use jni::objects::JValue; use jni::sys::JNI_FALSE; use jni::sys::JNI_TRUE; use jni::sys::jboolean; use jni::sys::jlong; +use jni::sys::jobject; use object_store::ObjectStore; use object_store::path::Path as ObjectStorePath; use vortex::array::ArrayRef; use vortex::array::VTable; +use vortex::array::scalar::PValue; +use vortex::array::scalar::Scalar; +use vortex::array::scalar::ScalarValue; +use vortex::array::stats::StatsSet; use vortex::array::stream::ArrayStreamAdapter; use vortex::dtype::DType; use vortex::dtype::Field as DTypeField; use vortex::dtype::FieldPath; +use vortex::error::VortexError; use vortex::error::VortexResult; use vortex::error::vortex_err; +use vortex::expr::stats::Stat; +use vortex::expr::stats::StatsProvider; +use vortex::file::CountingVortexWrite; use vortex::file::WriteOptionsSessionExt; use vortex::file::WriteStrategyBuilder; use vortex::file::WriteSummary; @@ -130,6 +143,7 @@ pub struct NativeWriter { session: VortexSession, arrow_schema: SchemaRef, write_schema: DType, + bytes_written: Arc, sender: mpsc::Sender>, } @@ -138,6 +152,7 @@ impl NativeWriter { session: VortexSession, arrow_schema: SchemaRef, write_schema: DType, + bytes_written: Arc, handle: Task>, sender: mpsc::Sender>, ) -> Self { @@ -146,6 +161,7 @@ impl NativeWriter { session, arrow_schema, write_schema, + bytes_written, sender, } } @@ -183,17 +199,189 @@ impl NativeWriter { .map_err(|e| vortex_err!("failed to send batch: {e}")) } - fn close(mut self) -> VortexResult<()> { + fn bytes_written(&self) -> u64 { + self.bytes_written.load(Ordering::Relaxed) + } + + fn close(mut self) -> VortexResult { self.sender.disconnect(); let handle = self .handle .take() .ok_or_else(|| vortex_err!("writer already closed"))?; - RUNTIME.block_on(async { - handle.await?; - VortexResult::Ok(()) - }) + RUNTIME.block_on(handle) + } +} + +fn checked_jlong(value: u64, name: &str) -> VortexResult { + jlong::try_from(value).map_err(|_| vortex_err!("{name} exceeds Java long range: {value}")) +} + +fn exact_count_jlong( + stats: Option<&StatsSet>, + dtype: Option<&DType>, + stat: Stat, +) -> VortexResult { + stats + .zip(dtype.and_then(|dt| stat.dtype(dt))) + .and_then(|(stats, dt)| stats.get_as::(stat, &dt).as_exact()) + .map(|value| checked_jlong(value, stat.name())) + .transpose() + .map(|value| value.unwrap_or(-1)) +} + +fn big_integer<'local>( + env: &mut Env<'local>, + value: impl ToString, +) -> Result, JNIError> { + let string = env.new_string(value.to_string())?; + Ok(env.new_object( + jni::jni_str!("java/math/BigInteger"), + jni::jni_sig!("(Ljava/lang/String;)V"), + &[JValue::Object(string.as_ref())], + )?) +} + +fn scalar_to_java<'local>( + env: &mut Env<'local>, + scalar: Scalar, +) -> Result, JNIError> { + if scalar.is_null() { + return Ok(JObject::null()); + } + if scalar.dtype().is_extension() { + return scalar_to_java(env, scalar.as_extension().to_storage_scalar()); } + + let Some(value) = scalar.value() else { + return Ok(JObject::null()); + }; + match value { + ScalarValue::Bool(value) => Ok(env.new_object( + jni::jni_str!("java/lang/Boolean"), + jni::jni_sig!("(Z)V"), + &[JValue::Bool(if *value { JNI_TRUE } else { JNI_FALSE })], + )?), + ScalarValue::Primitive(value) => match value { + PValue::U8(_) | PValue::U16(_) | PValue::I8(_) | PValue::I16(_) | PValue::I32(_) => { + Ok(env.new_object( + jni::jni_str!("java/lang/Integer"), + jni::jni_sig!("(I)V"), + &[JValue::Int(value.cast::()?)], + )?) + } + PValue::U32(_) | PValue::I64(_) => Ok(env.new_object( + jni::jni_str!("java/lang/Long"), + jni::jni_sig!("(J)V"), + &[JValue::Long(value.cast::()?)], + )?), + PValue::U64(value) => big_integer(env, value), + PValue::F16(_) | PValue::F32(_) => Ok(env.new_object( + jni::jni_str!("java/lang/Float"), + jni::jni_sig!("(F)V"), + &[JValue::Float(value.cast::()?)], + )?), + PValue::F64(value) => Ok(env.new_object( + jni::jni_str!("java/lang/Double"), + jni::jni_sig!("(D)V"), + &[JValue::Double(*value)], + )?), + }, + ScalarValue::Decimal(value) => { + let DType::Decimal(decimal_dtype, _) = scalar.dtype() else { + return Err(JNIError::Vortex(vortex_err!( + "decimal statistic has non-decimal dtype {}", + scalar.dtype() + ))); + }; + let unscaled = big_integer(env, value.as_i256())?; + Ok(env.new_object( + jni::jni_str!("java/math/BigDecimal"), + jni::jni_sig!("(Ljava/math/BigInteger;I)V"), + &[ + JValue::Object(&unscaled), + JValue::Int(i32::from(decimal_dtype.scale())), + ], + )?) + } + ScalarValue::Utf8(value) => Ok(env.new_string(value.as_str())?.into()), + ScalarValue::Binary(value) => Ok(env.byte_array_from_slice(value.as_slice())?.into()), + ScalarValue::Tuple(_) | ScalarValue::Variant(_) => Err(JNIError::Vortex(vortex_err!( + "cannot return nested scalar write statistic with dtype {} to Java", + scalar.dtype() + ))), + } +} + +fn write_summary_to_java<'local>( + env: &mut Env<'local>, + summary: &WriteSummary, +) -> Result, JNIError> { + let column_sizes = summary.compressed_column_sizes()?; + let file_stats = summary.footer().statistics(); + let columns = env.new_object_array( + i32::try_from(column_sizes.len()) + .map_err(|_| vortex_err!("column count exceeds Java array range"))?, + jni::jni_str!("dev/vortex/api/VortexColumnStatistics"), + JObject::null(), + )?; + + for (column_index, compressed_size) in column_sizes.into_iter().enumerate() { + let (stats, dtype) = file_stats + .and_then(|all_stats| { + all_stats + .stats_sets() + .get(column_index) + .zip(all_stats.dtypes().get(column_index)) + }) + .map_or((None, None), |(stats, dtype)| (Some(stats), Some(dtype))); + let null_count = exact_count_jlong(stats, dtype, Stat::NullCount)?; + let nan_count = exact_count_jlong(stats, dtype, Stat::NaNCount)?; + let lower_bound = stats + .zip(dtype) + .and_then(|(stats, dtype)| stats.as_typed_ref(dtype).get(Stat::Min).into_inner()); + let upper_bound = stats + .zip(dtype) + .and_then(|(stats, dtype)| stats.as_typed_ref(dtype).get(Stat::Max).into_inner()); + let column = env.with_local_frame_returning_local::<_, JObject, JNIError>(16, |env| { + let lower_bound = match lower_bound { + Some(value) => scalar_to_java(env, value)?, + None => JObject::null(), + }; + let upper_bound = match upper_bound { + Some(value) => scalar_to_java(env, value)?, + None => JObject::null(), + }; + Ok(env.new_object( + jni::jni_str!("dev/vortex/api/VortexColumnStatistics"), + jni::jni_sig!("(IJJJJLjava/lang/Object;Ljava/lang/Object;)V"), + &[ + JValue::Int( + i32::try_from(column_index) + .map_err(|_| vortex_err!("column index exceeds Java int range"))?, + ), + JValue::Long(checked_jlong(compressed_size, "compressed column size")?), + JValue::Long(checked_jlong(summary.row_count(), "row count")?), + JValue::Long(null_count), + JValue::Long(nan_count), + JValue::Object(&lower_bound), + JValue::Object(&upper_bound), + ], + )?) + })?; + columns.set_element(env, column_index, &column)?; + env.delete_local_ref(column); + } + + Ok(env.new_object( + jni::jni_str!("dev/vortex/api/VortexWriteSummary"), + jni::jni_sig!("(JJ[Ldev/vortex/api/VortexColumnStatistics;)V"), + &[ + JValue::Long(checked_jlong(summary.size(), "file size")?), + JValue::Long(checked_jlong(summary.row_count(), "row count")?), + JValue::Object(columns.as_ref()), + ], + )?) } #[unsafe(no_mangle)] @@ -224,31 +412,42 @@ pub extern "system" fn Java_dev_vortex_jni_NativeWriter_create( let stream = ArrayStreamAdapter::new(write_schema.clone(), rx); let write_options = write_options_for_schema(session, &write_schema); - let handle = session.handle().spawn(async move { - match resolved { - ResolvedStore::Path(path) => { + let (bytes_written, handle) = match resolved { + ResolvedStore::Path(path) => { + let file = RUNTIME.block_on(async { if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) { async_fs::create_dir_all(parent).await?; } - let mut file = File::create(path).await?; - let summary = write_options.write(&mut file, stream).await?; - file.shutdown().await?; + Ok::<_, VortexError>(File::create(path).await?) + })?; + let mut write = CountingVortexWrite::new(file); + let bytes_written = write.counter(); + let handle = session.handle().spawn(async move { + let summary = write_options.write(&mut write, stream).await?; + write.shutdown().await?; Ok(summary) - } - ResolvedStore::ObjectStore(store, path) => { - let mut write = - ObjectStoreWrite::new(Arc::new(Compat::new(store)), &path).await?; + }); + (bytes_written, handle) + } + ResolvedStore::ObjectStore(store, path) => { + let object_write = + RUNTIME.block_on(ObjectStoreWrite::new(Arc::new(Compat::new(store)), &path))?; + let mut write = CountingVortexWrite::new(object_write); + let bytes_written = write.counter(); + let handle = session.handle().spawn(async move { let summary = write_options.write(&mut write, stream).await?; write.shutdown().await?; Ok(summary) - } + }); + (bytes_written, handle) } - }); + }; Ok(Box::new(NativeWriter::new( session.clone(), arrow_schema, write_schema, + bytes_written, handle, tx, )) @@ -285,6 +484,38 @@ pub extern "system" fn Java_dev_vortex_jni_NativeWriter_writeBatch( }) } +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_vortex_jni_NativeWriter_bytesWritten( + mut env: EnvUnowned, + _class: JClass, + writer_ptr: jlong, +) -> jlong { + if writer_ptr <= 0 { + return -1; + } + + try_or_throw(&mut env, |_env| { + let writer = unsafe { NativeWriter::from_ptr(writer_ptr) }; + Ok(checked_jlong(writer.bytes_written(), "bytes written")?) + }) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_vortex_jni_NativeWriter_finish( + mut env: EnvUnowned, + _class: JClass, + writer_ptr: jlong, +) -> jobject { + if writer_ptr <= 0 { + return JObject::null().into_raw(); + } + let writer = unsafe { NativeWriter::from_raw(writer_ptr) }; + try_or_throw(&mut env, |env| { + let summary = writer.close()?; + Ok(write_summary_to_java(env, &summary)?.into_raw()) + }) +} + #[unsafe(no_mangle)] pub extern "system" fn Java_dev_vortex_jni_NativeWriter_close( mut env: EnvUnowned, From 8a1f91c0a94c7b8e1d0b75eee153760c77e651b7 Mon Sep 17 00:00:00 2001 From: Nemo Yu <83347615+HarukiMoriarty@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:15:37 -0400 Subject: [PATCH 083/104] feat(vortex-geo): geometry `Bbox` zone-map statistic + distance-filter pruning (#8646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds spatial chunk-pruning to Vortex. A new `GeometryBounds` aggregate stores a per-chunk minimum bounding box (MBR) as a zone-map statistic, and a stats-rewrite rule uses it to skip chunks that cannot satisfy a `ST_Distance(geom, const) <= r` filter. ## Limitation - Only the `<=` / `<` are pruned. `>` / `>=` are soundly prunable via the symmetric farthest-corner bound but are intentionally omitted (rarely?) - Pruning is sound, but the performance is highly related with the geo column write order, selectivity depends on a spatially clustered layout (e.g. a Hilbert/Z-order sort) so chunk MBRs are tight and non-overlapping. ## Testing 8 new vortex-geo tests. Point bbox across batches; Polygon bbox over all ring vertices, empty group → null, and registry self-declaration. only <=/< prune while >/>=/==/!= don't (parameterized), distance symmetry, non-distance comparisons ignored, and an end-to-end falsify. ## Performance SF=1 ``` ┏━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Query ┃ duckdb:parquet (base) ┃ duckdb:vortex ┃ duckdb:vortex-geo-native ┃ ┡━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ 1 │ 39.8ms │ 15.5ms (0.39x) │ 3.1ms (0.08x) │ │ 2 │ 147.8ms │ 39.9ms (0.27x) │ 47.4ms (0.32x) │ │ 3 │ 66.1ms │ 24.0ms (0.36x) │ 5.1ms (0.08x) │ │ 4 │ 563.1ms │ 60.2ms (0.11x) │ 109.7ms (0.19x) │ │ 5 │ 356.8ms │ 284.1ms (0.80x) │ 287.9ms (0.81x) │ │ 6 │ 677.3ms │ 93.9ms (0.14x) │ 149.5ms (0.22x) │ │ 7 │ 174.0ms │ 70.3ms (0.40x) │ 96.7ms (0.56x) │ │ 8 │ 142.4ms │ 48.4ms (0.34x) │ 66.1ms (0.46x) │ │ 9 │ 18.7ms │ 17.0ms (0.91x) │ 19.3ms (1.03x) │ └───────┴───────────────────────┴─────────────────┴──────────────────────────┘ ``` SF=3 ``` ┏━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Query ┃ duckdb:parquet (base) ┃ duckdb:vortex ┃ duckdb:vortex-geo-native ┃ ┡━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ 1 │ 47.8ms │ 36.4ms (0.76x) │ 4.0ms (0.08x) │ │ 2 │ 143.8ms │ 61.0ms (0.42x) │ 113.1ms (0.79x) │ │ 3 │ 83.1ms │ 68.2ms (0.82x) │ 7.2ms (0.09x) │ │ 4 │ 557.2ms │ 67.4ms (0.12x) │ 146.2ms (0.26x) │ │ 5 │ 949.6ms │ 882.9ms (0.93x) │ 897.8ms (0.95x) │ │ 6 │ 674.5ms │ 124.5ms (0.18x) │ 231.7ms (0.34x) │ │ 7 │ 332.5ms │ 330.9ms (1.00x) │ 277.2ms (0.83x) │ │ 8 │ 183.7ms │ 162.0ms (0.88x) │ 209.2ms (1.14x) │ │ 9 │ 28.5ms │ 25.9ms (0.91x) │ 28.7ms (1.01x) │ └───────┴───────────────────────┴─────────────────┴──────────────────────────┘ ``` SF=10 ``` ┏━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Query ┃ duckdb:parquet (base) ┃ duckdb:vortex ┃ duckdb:vortex-geo-native ┃ ┡━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ 1 │ 161.6ms │ 109.2ms (0.68x) │ 7.8ms (0.05x) │ │ 2 │ 358.4ms │ 187.9ms (0.52x) │ 329.1ms (0.92x) │ │ 3 │ 270.7ms │ 237.7ms (0.88x) │ 14.3ms (0.05x) │ │ 4 │ 912.7ms │ 131.0ms (0.14x) │ 157.8ms (0.17x) │ │ 5 │ 3.37s │ 3.10s (0.92x) │ 3.14s (0.93x) │ │ 6 │ 1.25s │ 301.7ms (0.24x) │ 501.0ms (0.40x) │ │ 7 │ 1.27s │ 914.1ms (0.72x) │ 969.6ms (0.76x) │ │ 8 │ 693.5ms │ 918.7ms (1.32x) │ 683.1ms (0.99x) │ │ 9 │ 36.3ms │ 35.7ms (0.98x) │ 44.5ms (1.22x) │ └───────┴───────────────────────┴─────────────────┴──────────────────────────┘ ``` Takeaway: when the data is pre-sorted, the bounding box pruning can significantly reduce the intermediate results DuckDB read in, make Q1 and Q3 significantly faster --------- Signed-off-by: Nemo Yu --- Cargo.lock | 3 + vortex-array/src/aggregate_fn/plugin.rs | 18 + vortex-array/src/aggregate_fn/session.rs | 18 + vortex-array/src/aggregate_fn/vtable.rs | 6 + vortex-bench/Cargo.toml | 2 + vortex-bench/src/spatialbench/benchmark.rs | 10 + vortex-bench/src/spatialbench/datagen/mod.rs | 2 + .../src/spatialbench/datagen/spatial_sort.rs | 255 +++++++++ vortex-geo/Cargo.toml | 1 + vortex-geo/src/aggregate_fn/aabb.rs | 464 ++++++++++++++++ vortex-geo/src/aggregate_fn/mod.rs | 8 + vortex-geo/src/extension/mod.rs | 32 +- vortex-geo/src/extension/rect.rs | 2 +- vortex-geo/src/lib.rs | 13 + vortex-geo/src/prune.rs | 524 ++++++++++++++++++ vortex-geo/src/test_harness.rs | 143 ++++- vortex-geo/src/tests/mod.rs | 6 +- vortex-layout/src/layouts/zoned/writer.rs | 18 +- 18 files changed, 1504 insertions(+), 21 deletions(-) create mode 100644 vortex-bench/src/spatialbench/datagen/spatial_sort.rs create mode 100644 vortex-geo/src/aggregate_fn/aabb.rs create mode 100644 vortex-geo/src/aggregate_fn/mod.rs create mode 100644 vortex-geo/src/prune.rs diff --git a/Cargo.lock b/Cargo.lock index 74236675f9f..ca59b40e9dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9762,6 +9762,8 @@ dependencies = [ "bzip2", "clap", "futures", + "geo", + "geo-traits", "geoarrow", "geoarrow-cast", "get_dir", @@ -10263,6 +10265,7 @@ dependencies = [ "vortex-array", "vortex-arrow", "vortex-error", + "vortex-layout", "vortex-session", "wkb", ] diff --git a/vortex-array/src/aggregate_fn/plugin.rs b/vortex-array/src/aggregate_fn/plugin.rs index b7ff8b893ac..baf6f303466 100644 --- a/vortex-array/src/aggregate_fn/plugin.rs +++ b/vortex-array/src/aggregate_fn/plugin.rs @@ -10,6 +10,7 @@ use crate::aggregate_fn::AggregateFn; use crate::aggregate_fn::AggregateFnId; use crate::aggregate_fn::AggregateFnRef; use crate::aggregate_fn::AggregateFnVTable; +use crate::dtype::DType; /// Reference-counted pointer to an aggregate function plugin. pub type AggregateFnPluginRef = Arc; @@ -28,6 +29,19 @@ pub trait AggregateFnPlugin: 'static + Send + Sync { /// Deserialize an aggregate function from serialized metadata. fn deserialize(&self, metadata: &[u8], session: &VortexSession) -> VortexResult; + + /// The default zone statistic (per-chunk) for a column of `input_dtype`, or `None` if the + /// dtype is not supported (or this aggregate is not a zone statistic at all). + /// + /// This is how a registered aggregate volunteers itself as a per-chunk statistic: when a + /// zoned layout writer opens a column, it collects every plugin's default via + /// [`AggregateFnSession::zone_stat_defaults`], so new statistics can be added without the + /// writer knowing about them. + /// + /// [`AggregateFnSession::zone_stat_defaults`]: crate::aggregate_fn::session::AggregateFnSession::zone_stat_defaults + fn zone_stat_default(&self, _input_dtype: &DType) -> Option { + None + } } impl std::fmt::Debug for dyn AggregateFnPlugin { @@ -51,4 +65,8 @@ impl AggregateFnPlugin for V { let options = AggregateFnVTable::deserialize(self, metadata, session)?; Ok(AggregateFn::new(self.clone(), options).erased()) } + + fn zone_stat_default(&self, input_dtype: &DType) -> Option { + AggregateFnVTable::zone_stat_default(self, input_dtype) + } } diff --git a/vortex-array/src/aggregate_fn/session.rs b/vortex-array/src/aggregate_fn/session.rs index c496f7a9bf8..72ef78a400c 100644 --- a/vortex-array/src/aggregate_fn/session.rs +++ b/vortex-array/src/aggregate_fn/session.rs @@ -10,6 +10,7 @@ use vortex_session::SessionVar; use crate::aggregate_fn::AggregateFnId; use crate::aggregate_fn::AggregateFnPluginRef; +use crate::aggregate_fn::AggregateFnRef; use crate::aggregate_fn::AggregateFnVTable; use crate::aggregate_fn::fns::all_nan::AllNan; use crate::aggregate_fn::fns::all_non_distinct::AllNonDistinct; @@ -44,6 +45,7 @@ use crate::arrays::chunked::compute::aggregate::ChunkedArrayAggregate; use crate::arrays::dict::compute::is_constant::DictIsConstantKernel; use crate::arrays::dict::compute::is_sorted::DictIsSortedKernel; use crate::arrays::dict::compute::min_max::DictMinMaxKernel; +use crate::dtype::DType; /// Session state for aggregate functions and encoding-specific aggregate kernels. /// @@ -134,6 +136,22 @@ impl AggregateFnSession { self.registry.insert(id, pluginref); } + /// The default per-chunk zone statistics for a column of `input_dtype`, collected from every + /// registered aggregate's `zone_stat_default`. + /// + /// Each call scans the whole plugin registry, so this is intended to be called once per + /// column when a zoned writer is opened, not per chunk or per row. + pub fn zone_stat_defaults(&self, input_dtype: &DType) -> Vec { + self.registry.read(|registry| { + let mut fns: Vec = registry + .values() + .filter_map(|plugin| plugin.zone_stat_default(input_dtype)) + .collect(); + fns.sort_by_key(|f| f.id()); + fns + }) + } + /// Returns the aggregate kernel registered for `array_id` and `agg_fn_id`, if any. /// /// Lookup first checks for a kernel registered for the exact aggregate function, then falls diff --git a/vortex-array/src/aggregate_fn/vtable.rs b/vortex-array/src/aggregate_fn/vtable.rs index 49b28dd26d7..3f0a4cf567c 100644 --- a/vortex-array/src/aggregate_fn/vtable.rs +++ b/vortex-array/src/aggregate_fn/vtable.rs @@ -93,6 +93,12 @@ pub trait AggregateFnVTable: 'static + Sized + Clone + Send + Sync { /// Returns `None` if the aggregate function cannot be applied to the input dtype. fn return_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option; + /// If this aggregate should be computed as a default zone statistic for `input_dtype`, return + /// the bound aggregate to store. Default: not a zone-map default. + fn zone_stat_default(&self, _input_dtype: &DType) -> Option { + None + } + /// DType of the intermediate partial accumulator state. /// /// Use a struct dtype when multiple fields are needed diff --git a/vortex-bench/Cargo.toml b/vortex-bench/Cargo.toml index ff604fdb48a..f228e1571d3 100644 --- a/vortex-bench/Cargo.toml +++ b/vortex-bench/Cargo.toml @@ -35,6 +35,8 @@ async-trait = { workspace = true } bzip2 = { workspace = true } clap = { workspace = true, features = ["derive"] } futures = { workspace = true } +geo = { workspace = true } +geo-traits = { workspace = true } geoarrow = { workspace = true } geoarrow-cast = { workspace = true } get_dir = { workspace = true } diff --git a/vortex-bench/src/spatialbench/benchmark.rs b/vortex-bench/src/spatialbench/benchmark.rs index f2a6016a5ac..48fa84f8ec1 100644 --- a/vortex-bench/src/spatialbench/benchmark.rs +++ b/vortex-bench/src/spatialbench/benchmark.rs @@ -96,6 +96,16 @@ impl Benchmark for SpatialBenchBenchmark { crate::conversions::add_geoparquet_metadata(&zone_file, &geo).await?; } } + + // Cluster the source parquet along a spatial curve so all lanes read the same layout and + // the geometry zone-map prune can skip chunks. + let derived_dirs = [ + base_data_dir.join(Format::OnDiskVortex.name()), + base_data_dir.join(Format::VortexCompact.name()), + base_data_dir.join(NATIVE_DIR), + ]; + datagen::spatially_sort_tables(&base_data_dir.join(Format::Parquet.name()), &derived_dirs) + .await?; Ok(()) } diff --git a/vortex-bench/src/spatialbench/datagen/mod.rs b/vortex-bench/src/spatialbench/datagen/mod.rs index 7808e06cbc3..4c0d4bd2f81 100644 --- a/vortex-bench/src/spatialbench/datagen/mod.rs +++ b/vortex-bench/src/spatialbench/datagen/mod.rs @@ -6,9 +6,11 @@ //! source of truth for the base tables both stages share. pub mod native; +pub mod spatial_sort; pub mod table; pub mod wkb; pub use native::write_native_vortex; +pub use spatial_sort::spatially_sort_tables; pub use table::Table; pub use wkb::generate_tables; diff --git a/vortex-bench/src/spatialbench/datagen/spatial_sort.rs b/vortex-bench/src/spatialbench/datagen/spatial_sort.rs new file mode 100644 index 00000000000..d77aea15846 --- /dev/null +++ b/vortex-bench/src/spatialbench/datagen/spatial_sort.rs @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Spatial clustering of the source parquet, in place, so every downstream lane (parquet, +//! vortex-WKB, `vortex-geo-native`) reads the same layout. +//! +//! The geometry zone-map prune skips a chunk only when its bounding box is disjoint from the query +//! region. In generation order every chunk's box spans the whole map, so nothing prunes; ordering +//! rows on a Z-order (Morton) curve of each geometry's bounding-box center gives each chunk a +//! compact box instead. The center works uniformly for every geometry type. +//! +//! Every table with a geometry column is sorted by its first one, each part independently — global +//! order is unnecessary for per-chunk pruning. A parquet marker makes it idempotent; derived vortex +//! files from pre-sort parquet are deleted so the existence-keyed conversions regenerate. + +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::Context; +use arrow_array::Array; +use arrow_array::ArrayRef; +use arrow_array::RecordBatch; +use arrow_array::UInt64Array; +use arrow_schema::DataType; +use arrow_select::concat::concat_batches; +use arrow_select::take::take; +use futures::TryStreamExt; +use geo::BoundingRect; +use geo::Geometry; +use geo_traits::to_geo::ToGeoGeometry; +use geoarrow::array::GenericWkbArray; +use geoarrow::array::GeoArrowArrayAccessor; +use geoarrow::array::WkbViewArray; +use geoarrow::datatypes::Crs; +use geoarrow::datatypes::Metadata; +use geoarrow::datatypes::WkbType; +use parquet::arrow::AsyncArrowWriter; +use parquet::arrow::ParquetRecordBatchStreamBuilder; +use parquet::basic::Compression; +use parquet::file::metadata::KeyValue; +use parquet::file::properties::WriterProperties; +use tokio::fs::File as TokioFile; +use tracing::info; + +use super::table::Table; +use super::wkb::geo_parquet_metadata; + +/// Parquet metadata marker: this file is already spatially sorted (makes the step idempotent). +const SORTED_KEY: &str = "vortex_spatial_sorted"; + +fn geo_metadata() -> Arc { + Arc::new(Metadata::new(Crs::default(), None)) +} + +/// Spatially sort every table with a geometry column, in place. +pub async fn spatially_sort_tables( + parquet_dir: &Path, + derived_dirs: &[PathBuf], +) -> anyhow::Result<()> { + for table in Table::ALL.into_iter().filter(|table| table.is_generated()) { + sort_table_parquet(table, parquet_dir, derived_dirs).await?; + } + Ok(()) +} + +/// Sort every unsorted `{table}_*.parquet` part by its first geometry column's bounding-box center. +async fn sort_table_parquet( + table: Table, + parquet_dir: &Path, + derived_dirs: &[PathBuf], +) -> anyhow::Result<()> { + let Some(geom) = table.geometry_columns().first() else { + return Ok(()); + }; + + let pattern = parquet_dir.join(format!("{}_*.parquet", table.name())); + let mut files: Vec = + glob::glob(&pattern.to_string_lossy())?.collect::>()?; + files.sort(); + let mut pending = Vec::new(); + for file in files { + if !is_sorted(&file).await? { + pending.push(file); + } + } + if pending.is_empty() { + return Ok(()); + } + + // Delete before rewriting: an interrupted run leaves unsorted parts unmarked and re-deletes. + for dir in derived_dirs { + let stale = dir.join(format!("{}_*.vortex", table.name())); + for file in glob::glob(&stale.to_string_lossy())?.flatten() { + tokio::fs::remove_file(&file).await?; + info!(path = %file.display(), "removed stale derived file from pre-sort parquet"); + } + } + + for file in pending { + sort_part(&file, table, geom.name).await?; + } + Ok(()) +} + +/// Whether `path` already carries the [`SORTED_KEY`] marker. +async fn is_sorted(path: &Path) -> anyhow::Result { + let builder = ParquetRecordBatchStreamBuilder::new(TokioFile::open(path).await?).await?; + Ok(builder + .metadata() + .file_metadata() + .key_value_metadata() + .is_some_and(|kvs| kvs.iter().any(|kv| kv.key == SORTED_KEY))) +} + +/// Rewrite one parquet part with its rows in Z-order of `geom_col`. +async fn sort_part(path: &Path, table: Table, geom_col: &str) -> anyhow::Result<()> { + let builder = ParquetRecordBatchStreamBuilder::new(TokioFile::open(path).await?).await?; + let schema = Arc::clone(builder.schema()); + let mut reader = builder.build()?; + let mut batches = Vec::new(); + while let Some(batch) = reader.try_next().await? { + batches.push(batch); + } + if batches.iter().all(|batch| batch.num_rows() == 0) { + return Ok(()); + } + let batch = concat_batches(&schema, &batches)?; + drop(batches); + + let geom_idx = schema.index_of(geom_col)?; + let (xs, ys) = wkb_centers(batch.column(geom_idx).as_ref()) + .with_context(|| format!("decoding geometry column {geom_col} of {}", path.display()))?; + let indices = morton_sort_indices(&xs, &ys); + + let columns: Vec = batch + .columns() + .iter() + .map(|column| take(column.as_ref(), &indices, None)) + .collect::>()?; + let sorted = RecordBatch::try_new(Arc::clone(&schema), columns)?; + let num_rows = sorted.num_rows(); + + let tmp_path = path.with_extension("parquet.sorttmp"); + let props = WriterProperties::builder() + .set_compression(Compression::SNAPPY) + .build(); + let mut writer = + AsyncArrowWriter::try_new(TokioFile::create(&tmp_path).await?, schema, Some(props))?; + writer.write(&sorted).await?; + // A fresh write drops metadata: re-tag geo so DuckDB reads `GEOMETRY`, and add the sorted marker. + if let Some(geo) = geo_parquet_metadata(table) { + writer.append_key_value_metadata(KeyValue::new("geo".to_string(), Some(geo))); + } + writer.append_key_value_metadata(KeyValue::new( + SORTED_KEY.to_string(), + Some(format!("morton:{geom_col}")), + )); + writer.close().await?; + tokio::fs::rename(&tmp_path, path).await?; + + info!( + path = %path.display(), + rows = num_rows, + column = geom_col, + "spatially sorted parquet (morton z-order)" + ); + Ok(()) +} + +/// The bounding-box center `(x, y)` of every geometry in a WKB column, whatever its geometry type. +fn wkb_centers(column: &dyn Array) -> anyhow::Result<(Vec, Vec)> { + let wkb_type = WkbType::new(geo_metadata()); + // Expanded per concrete WKB array type. + macro_rules! centers { + ($array:expr) => {{ + let mut xy = Vec::new(); + for item in $array.iter() { + xy.push(match item { + Some(geometry) => bbox_center(&geometry?.to_geometry()), + None => (f64::NAN, f64::NAN), + }); + } + Ok(xy.into_iter().unzip()) + }}; + } + match column.data_type() { + DataType::Binary => centers!(GenericWkbArray::::try_from((column, wkb_type))?), + DataType::LargeBinary => centers!(GenericWkbArray::::try_from((column, wkb_type))?), + DataType::BinaryView => centers!(WkbViewArray::try_from((column, wkb_type))?), + other => anyhow::bail!("unsupported WKB column type {other}"), + } +} + +/// The center of a geometry's bounding box, or `NaN` for an empty geometry. +fn bbox_center(geometry: &Geometry) -> (f64, f64) { + geometry + .bounding_rect() + .map(|r| ((r.min().x + r.max().x) / 2.0, (r.min().y + r.max().y) / 2.0)) + .unwrap_or((f64::NAN, f64::NAN)) +} + +/// Row indices ordering `(x, y)` along a 32-bit-per-axis Z-order curve, with each axis +/// quantized over the data's own extent. +fn morton_sort_indices(xs: &[f64], ys: &[f64]) -> UInt64Array { + let (mut xmin, mut xmax) = (f64::INFINITY, f64::NEG_INFINITY); + let (mut ymin, mut ymax) = (f64::INFINITY, f64::NEG_INFINITY); + for (&x, &y) in xs.iter().zip(ys) { + if !x.is_nan() && !y.is_nan() { + xmin = xmin.min(x); + xmax = xmax.max(x); + ymin = ymin.min(y); + ymax = ymax.max(y); + } + } + + let keys: Vec = xs + .iter() + .zip(ys) + .map(|(&x, &y)| { + if x.is_nan() || y.is_nan() { + 0 + } else { + interleave(quantize(x, xmin, xmax)) | (interleave(quantize(y, ymin, ymax)) << 1) + } + }) + .collect(); + + let mut order: Vec = (0..xs.len()).collect(); + order.sort_unstable_by_key(|&i| keys[i]); + UInt64Array::from_iter_values(order.into_iter().map(|i| i as u64)) +} + +/// Map `value` within `[min, max]` to the full `u32` range. +// `t` is clamped to [0, 1], so the product never exceeds `u32::MAX`. +#[expect(clippy::cast_possible_truncation)] +fn quantize(value: f64, min: f64, max: f64) -> u32 { + let range = max - min; + if range <= 0.0 { + return 0; + } + let t = ((value - min) / range).clamp(0.0, 1.0); + (t * f64::from(u32::MAX)) as u32 +} + +/// Spread a 32-bit value's bits into the even positions of a `u64`. +fn interleave(value: u32) -> u64 { + let mut n = u64::from(value); + n = (n | (n << 16)) & 0x0000_FFFF_0000_FFFF; + n = (n | (n << 8)) & 0x00FF_00FF_00FF_00FF; + n = (n | (n << 4)) & 0x0F0F_0F0F_0F0F_0F0F; + n = (n | (n << 2)) & 0x3333_3333_3333_3333; + n = (n | (n << 1)) & 0x5555_5555_5555_5555; + n +} diff --git a/vortex-geo/Cargo.toml b/vortex-geo/Cargo.toml index 36a26bfd835..9a7980bc631 100644 --- a/vortex-geo/Cargo.toml +++ b/vortex-geo/Cargo.toml @@ -31,6 +31,7 @@ wkb = { workspace = true } [dev-dependencies] rstest = { workspace = true } vortex-array = { workspace = true, features = ["_test-harness"] } +vortex-layout = { workspace = true } [lints] workspace = true diff --git a/vortex-geo/src/aggregate_fn/aabb.rs b/vortex-geo/src/aggregate_fn/aabb.rs new file mode 100644 index 00000000000..f217494fc1a --- /dev/null +++ b/vortex-geo/src/aggregate_fn/aabb.rs @@ -0,0 +1,464 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! The 2D axis-aligned bounding-box (AABB) aggregate for native geometry columns. + +use geo::Rect as GeoRect; +use vortex_array::ArrayRef; +use vortex_array::Columnar; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::aggregate_fn::AggregateFnId; +use vortex_array::aggregate_fn::AggregateFnRef; +use vortex_array::aggregate_fn::AggregateFnVTable; +use vortex_array::aggregate_fn::AggregateFnVTableExt; +use vortex_array::aggregate_fn::EmptyOptions; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::arrays::struct_::StructArrayExt; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::extension::ExtDType; +use vortex_array::scalar::Scalar; +use vortex_error::VortexExpect; +use vortex_error::VortexResult; +use vortex_error::vortex_err; +use vortex_session::VortexSession; +use vortex_session::registry::CachedId; + +use crate::extension::GeoMetadata; +use crate::extension::Rect; +use crate::extension::box_storage_dtype; +use crate::extension::coordinate::Dimension; +use crate::extension::flatten_coordinates; +use crate::extension::is_native_geometry; + +/// Aggregates a native geometry column's 2D axis-aligned bounding box (AABB) as a native +/// `geoarrow.box`, the spatial analogue of min/max. Also the default zone statistic for such +/// columns (via `zone_stat_default`). +#[derive(Clone, Debug)] +pub struct GeometryAabb; + +/// Running union of geometry AABBs, or `None` until the first row. A transient +/// `geo::Rect` value - the persisted stat is the native box (see `to_scalar`). +pub struct AabbPartial { + rect: Option>, +} + +impl AabbPartial { + /// Grow the accumulated box to also cover `other`. + fn merge(&mut self, other: GeoRect) { + self.rect = Some(self.rect.map_or(other, |cur| { + GeoRect::new( + ( + cur.min().x.min(other.min().x), + cur.min().y.min(other.min().y), + ), + ( + cur.max().x.max(other.max().x), + cur.max().y.max(other.max().y), + ), + ) + })); + } +} + +/// The stat's type: the native `geoarrow.box` (2D), nullable so an empty group is a null box. +fn aabb_dtype() -> DType { + DType::Extension( + ExtDType::::try_new(GeoMetadata::default(), aabb_storage_dtype()) + .vortex_expect("2D box storage is a valid Rect") + .erased(), + ) +} + +/// The `Rect` storage `Struct` backing the zone statistic. +fn aabb_storage_dtype() -> DType { + box_storage_dtype(Dimension::Xy, Nullability::Nullable) +} + +/// The AABB of the raw `x`/`y` slices, or `None` when empty. +fn aabb_of(xs: &[f64], ys: &[f64]) -> Option> { + if xs.is_empty() { + return None; + } + let min_max = |vals: &[f64]| { + vals.iter() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| { + (lo.min(v), hi.max(v)) + }) + }; + let (xmin, xmax) = min_max(xs); + let (ymin, ymax) = min_max(ys); + Some(GeoRect::new((xmin, ymin), (xmax, ymax))) +} + +/// Read an AABB stat scalar (a nullable native `geoarrow.box`) into a [`GeoRect`], or `None` when +/// the scalar is null (an empty group). +fn rect_from_storage(scalar: &Scalar) -> VortexResult>> { + if scalar.is_null() { + return Ok(None); + } + let storage = scalar.as_extension().to_storage_scalar(); + let fields = storage.as_struct(); + let read = |name: &str| -> VortexResult { + f64::try_from( + &fields + .field(name) + .ok_or_else(|| vortex_err!("AABB missing {name}"))?, + ) + }; + Ok(Some(GeoRect::new( + (read("xmin")?, read("ymin")?), + (read("xmax")?, read("ymax")?), + ))) +} + +/// Serialize a [`GeoRect`] as a native `geoarrow.box` stat scalar (inverse of [`rect_from_storage`]). +fn rect_to_storage(rect: GeoRect) -> Scalar { + let storage = Scalar::struct_( + aabb_storage_dtype(), + vec![ + Scalar::primitive(rect.min().x, Nullability::NonNullable), + Scalar::primitive(rect.min().y, Nullability::NonNullable), + Scalar::primitive(rect.max().x, Nullability::NonNullable), + Scalar::primitive(rect.max().y, Nullability::NonNullable), + ], + ); + Scalar::extension::(GeoMetadata::default(), storage) +} + +impl AggregateFnVTable for GeometryAabb { + type Options = EmptyOptions; + type Partial = AabbPartial; + + fn id(&self) -> AggregateFnId { + static ID: CachedId = CachedId::new("vortex.geo.aabb"); + *ID + } + + // Serializable so the zoned writer can persist this as a per-chunk stat. No options to encode. + fn serialize(&self, _options: &Self::Options) -> VortexResult>> { + Ok(Some(vec![])) + } + + fn deserialize( + &self, + _metadata: &[u8], + _session: &VortexSession, + ) -> VortexResult { + Ok(EmptyOptions) + } + + fn return_dtype(&self, _options: &Self::Options, input_dtype: &DType) -> Option { + is_native_geometry(input_dtype).then(aabb_dtype) + } + + fn zone_stat_default(&self, input_dtype: &DType) -> Option { + is_native_geometry(input_dtype).then(|| self.bind(EmptyOptions)) + } + + fn partial_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option { + self.return_dtype(options, input_dtype) + } + + fn empty_partial( + &self, + _options: &Self::Options, + _input_dtype: &DType, + ) -> VortexResult { + Ok(AabbPartial { rect: None }) + } + + fn combine_partials(&self, partial: &mut Self::Partial, other: Scalar) -> VortexResult<()> { + if let Some(rect) = rect_from_storage(&other)? { + partial.merge(rect); + } + Ok(()) + } + + fn to_scalar(&self, partial: &Self::Partial) -> VortexResult { + Ok(match partial.rect { + Some(rect) => rect_to_storage(rect), + None => Scalar::null(aabb_dtype()), + }) + } + + fn reset(&self, partial: &mut Self::Partial) { + partial.rect = None; + } + + fn is_saturated(&self, _partial: &Self::Partial) -> bool { + // An AABB can always grow, so it is never saturated. + false + } + + fn accumulate( + &self, + partial: &mut Self::Partial, + batch: &Columnar, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + let array = match batch { + Columnar::Canonical(canonical) => canonical.clone().into_array(), + Columnar::Constant(constant) => constant.clone().into_array(), + }; + // Min/max the raw x/y buffers directly - cheap, and avoids `to_geometry`'s panic on empty + // points (which decoding each geometry would hit). + let coords = flatten_coordinates(&array, ctx)?; + let xs = coords + .unmasked_field_by_name("x")? + .clone() + .execute::(ctx)?; + let ys = coords + .unmasked_field_by_name("y")? + .clone() + .execute::(ctx)?; + if let Some(rect) = aabb_of(xs.as_slice::(), ys.as_slice::()) { + partial.merge(rect); + } + Ok(()) + } + + fn finalize(&self, partials: ArrayRef) -> VortexResult { + // The stored partial is already the AABB struct, so finalizing is the identity. + Ok(partials) + } + + fn finalize_scalar(&self, partial: &Self::Partial) -> VortexResult { + self.to_scalar(partial) + } +} + +#[cfg(test)] +mod tests { + use geo::Rect as GeoRect; + use vortex_array::ArrayRef; + use vortex_array::VortexSessionExecute; + use vortex_array::aggregate_fn::Accumulator; + use vortex_array::aggregate_fn::AggregateFnVTable; + use vortex_array::aggregate_fn::DynAccumulator; + use vortex_array::aggregate_fn::EmptyOptions; + use vortex_array::aggregate_fn::session::AggregateFnSessionExt; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::scalar::Scalar; + use vortex_error::VortexResult; + + use super::AabbPartial; + use super::GeometryAabb; + use super::aabb_dtype; + use super::rect_from_storage; + use crate::test_harness::geo_session; + use crate::test_harness::linestring_column; + use crate::test_harness::multilinestring_column; + use crate::test_harness::multipoint_column; + use crate::test_harness::multipolygon_column; + use crate::test_harness::point_column; + use crate::test_harness::polygon_column; + + /// One column of every native geometry type over the same `(x, y)` vertex set. + fn every_native_column(vertices: &[(f64, f64)]) -> VortexResult> { + let (xs, ys): (Vec, Vec) = vertices.iter().copied().unzip(); + let flat = vertices.to_vec(); + Ok(vec![ + point_column(xs, ys)?, + linestring_column(vec![flat.clone()])?, + multipoint_column(vec![flat.clone()])?, + polygon_column(vec![vec![flat.clone()]])?, + multilinestring_column(vec![vec![flat.clone()]])?, + multipolygon_column(vec![vec![vec![flat]]])?, + ]) + } + + /// The aggregate must be serializable so the zoned writer can persist its zone-stat descriptor. + #[test] + fn serializes_for_zone_storage() -> VortexResult<()> { + let session = vortex_array::array_session(); + let metadata = GeometryAabb + .serialize(&EmptyOptions)? + .expect("GeometryAabb must be serializable to be stored as a zone statistic"); + GeometryAabb.deserialize(&metadata, &session)?; + Ok(()) + } + + /// The AABB result's corners as `(xmin, ymin, xmax, ymax)`. + fn aabb(result: &Scalar) -> VortexResult<(f64, f64, f64, f64)> { + let rect = rect_from_storage(result)?.expect("non-null AABB"); + Ok((rect.min().x, rect.min().y, rect.max().x, rect.max().y)) + } + + /// The AABB of a Point column is the min/max of its coordinates, accumulated across batches. + #[test] + fn point_aabb_across_batches() -> VortexResult<()> { + let session = vortex_array::array_session(); + let mut ctx = session.create_execution_ctx(); + + let dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, dtype)?; + + acc.accumulate(&point_column(vec![1.0, 3.0], vec![2.0, 4.0])?, &mut ctx)?; + acc.accumulate(&point_column(vec![-1.0], vec![5.0])?, &mut ctx)?; + + assert_eq!(aabb(&acc.finish()?)?, (-1.0, 2.0, 3.0, 5.0)); + Ok(()) + } + + /// The AABB of a Polygon column unions every ring vertex - exercising the `List>` + /// unwrap, not just the bare Point struct. + #[test] + fn polygon_aabb_union_all_vertices() -> VortexResult<()> { + let session = vortex_array::array_session(); + let mut ctx = session.create_execution_ctx(); + + // Two rectangles: (0,0)-(2,3) and (5,5)-(7,8). The chunk AABB is their union: (0,0)-(7,8). + let polygons = polygon_column(vec![ + vec![vec![(0.0, 0.0), (2.0, 0.0), (2.0, 3.0), (0.0, 3.0)]], + vec![vec![(5.0, 5.0), (7.0, 5.0), (7.0, 8.0), (5.0, 8.0)]], + ])?; + let dtype = polygons.dtype().clone(); + let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, dtype)?; + acc.accumulate(&polygons, &mut ctx)?; + + assert_eq!(aabb(&acc.finish()?)?, (0.0, 0.0, 7.0, 8.0)); + Ok(()) + } + + /// Every native geometry type over the same vertex set yields the same AABB - the zone stat + /// covers the whole type family. + #[test] + fn aabb_covers_every_native_geometry_type() -> VortexResult<()> { + let session = vortex_array::array_session(); + let mut ctx = session.create_execution_ctx(); + + for column in every_native_column(&[(1.0, 2.0), (-1.0, 5.0), (3.0, 4.0)])? { + let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, column.dtype().clone())?; + acc.accumulate(&column, &mut ctx)?; + assert_eq!( + aabb(&acc.finish()?)?, + (-1.0, 2.0, 3.0, 5.0), + "AABB mismatch for {}", + column.dtype() + ); + } + Ok(()) + } + + /// The AABB of a MultiPolygon column unions every vertex of every polygon's rings - exercising + /// the triple-`List` unwrap. + #[test] + fn multipolygon_aabb_union_all_vertices() -> VortexResult<()> { + let session = vortex_array::array_session(); + let mut ctx = session.create_execution_ctx(); + + // Multipolygon 0: squares (0,0)-(1,1) and (4,4)-(5,5); multipolygon 1: square (-3,7)-(-2,9). + let multipolygons = multipolygon_column(vec![ + vec![ + vec![vec![(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]], + vec![vec![(4.0, 4.0), (5.0, 4.0), (5.0, 5.0), (4.0, 5.0)]], + ], + vec![vec![vec![ + (-3.0, 7.0), + (-2.0, 7.0), + (-2.0, 9.0), + (-3.0, 9.0), + ]]], + ])?; + let dtype = multipolygons.dtype().clone(); + let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, dtype)?; + acc.accumulate(&multipolygons, &mut ctx)?; + + assert_eq!(aabb(&acc.finish()?)?, (-3.0, 0.0, 5.0, 9.0)); + Ok(()) + } + + /// `combine_partials` unions partial boxes - the path the zoned writer takes when a zone's + /// array is chunked. + #[test] + fn combine_partials_unions_boxes() -> VortexResult<()> { + let bbox = |xmin, ymin, xmax, ymax| AabbPartial { + rect: Some(GeoRect::new((xmin, ymin), (xmax, ymax))), + }; + let mut partial = AabbPartial { rect: None }; + GeometryAabb.combine_partials( + &mut partial, + GeometryAabb.to_scalar(&bbox(0.0, 0.0, 1.0, 1.0))?, + )?; + GeometryAabb.combine_partials( + &mut partial, + GeometryAabb.to_scalar(&bbox(5.0, -2.0, 7.0, 3.0))?, + )?; + assert_eq!( + aabb(&GeometryAabb.to_scalar(&partial)?)?, + (0.0, -2.0, 7.0, 3.0) + ); + Ok(()) + } + + /// A null partial (an empty group's AABB) is a no-op in `combine_partials`. + #[test] + fn combine_partials_ignores_null() -> VortexResult<()> { + let mut partial = AabbPartial { + rect: Some(GeoRect::new((0.0, 0.0), (1.0, 1.0))), + }; + GeometryAabb.combine_partials(&mut partial, Scalar::null(aabb_dtype()))?; + assert_eq!( + aabb(&GeometryAabb.to_scalar(&partial)?)?, + (0.0, 0.0, 1.0, 1.0) + ); + Ok(()) + } + + /// All-NaN coordinates: `f64::min`/`max` skip the NaNs and `geo::Rect` normalizes the result to + /// a valid (whole-plane) box, so such a chunk is always kept. Sound - NaN-coordinate rows can + /// never satisfy `distance <= r` anyway. + #[test] + fn all_nan_coordinates_kept() -> VortexResult<()> { + let session = vortex_array::array_session(); + let mut ctx = session.create_execution_ctx(); + + let column = point_column(vec![f64::NAN, f64::NAN], vec![f64::NAN, f64::NAN])?; + let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, column.dtype().clone())?; + acc.accumulate(&column, &mut ctx)?; + + let (xmin, ymin, xmax, ymax) = aabb(&acc.finish()?)?; + assert!(xmin <= xmax && ymin <= ymax); + Ok(()) + } + + /// An empty group yields a null AABB. + #[test] + fn empty_group_is_null() -> VortexResult<()> { + let dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + let mut acc = Accumulator::try_new(GeometryAabb, EmptyOptions, dtype)?; + assert!(acc.finish()?.is_null()); + Ok(()) + } + + /// After `initialize`, the registry yields a default zone statistic for every native geometry + /// type (so the zoned writer stores it) but none for ordinary numeric columns. + #[test] + fn registered_as_geometry_zone_default() -> VortexResult<()> { + let session = geo_session(); + + for column in every_native_column(&[(0.0, 0.0), (1.0, 1.0)])? { + assert!( + !session + .aggregate_fns() + .zone_stat_defaults(column.dtype()) + .is_empty(), + "a geometry zone-stat default should be discovered for {}", + column.dtype() + ); + } + let i32_dtype = DType::Primitive(PType::I32, Nullability::NonNullable); + assert!( + session + .aggregate_fns() + .zone_stat_defaults(&i32_dtype) + .is_empty(), + "no geometry zone-stat default should apply to numeric columns" + ); + Ok(()) + } +} diff --git a/vortex-geo/src/aggregate_fn/mod.rs b/vortex-geo/src/aggregate_fn/mod.rs new file mode 100644 index 00000000000..70831f02905 --- /dev/null +++ b/vortex-geo/src/aggregate_fn/mod.rs @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Aggregate functions over geometry columns. + +mod aabb; + +pub use aabb::*; diff --git a/vortex-geo/src/extension/mod.rs b/vortex-geo/src/extension/mod.rs index e5f551a04e8..4bd8ae3800f 100644 --- a/vortex-geo/src/extension/mod.rs +++ b/vortex-geo/src/extension/mod.rs @@ -40,11 +40,14 @@ pub use point::*; pub use polygon::*; pub use rect::*; use vortex_array::ArrayRef; +use vortex_array::Canonical; use vortex_array::ExecutionCtx; use vortex_array::IntoArray; use vortex_array::arrays::ConstantArray; use vortex_array::arrays::ExtensionArray; +use vortex_array::arrays::StructArray; use vortex_array::arrays::extension::ExtensionArrayExt; +use vortex_array::arrays::listview::ListViewArrayExt; use vortex_array::dtype::DType; use vortex_array::dtype::extension::ExtDType; use vortex_array::dtype::extension::ExtVTable; @@ -57,7 +60,7 @@ use vortex_error::vortex_err; pub use wkb::*; /// Whether `dtype` is one of the native geometry extension types the geo kernels operate on. -fn is_native_geometry(dtype: &DType) -> bool { +pub(crate) fn is_native_geometry(dtype: &DType) -> bool { dtype.as_extension_opt().is_some_and(|ext| { ext.is::() || ext.is::() @@ -85,6 +88,33 @@ pub(crate) fn validate_geometry_operands(dtypes: &[DType]) -> VortexResult<()> { Ok(()) } +/// Flatten a native geometry column into a single coordinate `Struct` containing +/// every vertex of every geometry. +pub(crate) fn flatten_coordinates( + array: &ArrayRef, + ctx: &mut ExecutionCtx, +) -> VortexResult { + if !is_native_geometry(array.dtype()) { + vortex_bail!( + "geo: operand is not a native geometry extension type, was {}", + array.dtype() + ); + } + let mut node = array + .clone() + .execute::(ctx)? + .storage_array() + .clone(); + while matches!(node.dtype(), DType::List(..)) { + node = node + .execute::(ctx)? + .into_listview() + .elements() + .clone(); + } + node.execute::(ctx) +} + /// Decode a native geometry column to `geo_types`. A non-geometry operand is an error. pub(crate) fn geometries( array: &ArrayRef, diff --git a/vortex-geo/src/extension/rect.rs b/vortex-geo/src/extension/rect.rs index d2dddf51e79..3576966368c 100644 --- a/vortex-geo/src/extension/rect.rs +++ b/vortex-geo/src/extension/rect.rs @@ -56,7 +56,7 @@ use super::coordinate::Dimension; use super::geo_metadata_from_arrow; use super::geoarrow_metadata; -/// A bounding box (`geoarrow.box`), stored as `Struct`. +/// An axis-aligned bounding box (`geoarrow.box`), stored as `Struct`. // Named `Rect`, not `Box`: matches `geo::Rect` / geoarrow-rs `RectArray`, and `Box` is a std name. #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] pub struct Rect; diff --git a/vortex-geo/src/lib.rs b/vortex-geo/src/lib.rs index 3e695dbba65..652311be3b2 100644 --- a/vortex-geo/src/lib.rs +++ b/vortex-geo/src/lib.rs @@ -3,11 +3,14 @@ use std::sync::Arc; +use vortex_array::aggregate_fn::session::AggregateFnSessionExt; use vortex_array::dtype::session::DTypeSessionExt; use vortex_array::scalar_fn::session::ScalarFnSessionExt; +use vortex_array::stats::session::StatsSessionExt; use vortex_arrow::ArrowSessionExt; use vortex_session::VortexSession; +use crate::aggregate_fn::GeometryAabb; use crate::extension::LineString; use crate::extension::MultiLineString; use crate::extension::MultiPoint; @@ -16,11 +19,14 @@ use crate::extension::Point; use crate::extension::Polygon; use crate::extension::Rect; use crate::extension::WellKnownBinary; +use crate::prune::GeoDistancePrune; use crate::scalar_fn::contains::GeoContains; use crate::scalar_fn::distance::GeoDistance; use crate::scalar_fn::intersects::GeoIntersects; +pub mod aggregate_fn; pub mod extension; +pub mod prune; pub mod scalar_fn; #[cfg(test)] mod test_harness; @@ -59,4 +65,11 @@ pub fn initialize(session: &VortexSession) { session.scalar_fns().register(GeoContains); session.scalar_fns().register(GeoDistance); session.scalar_fns().register(GeoIntersects); + + // The axis-aligned bounding-box (AABB) aggregate; self-declares as a per-chunk zone stat for + // geometry columns. + session.aggregate_fns().register(GeometryAabb); + + // Register the spatial pruning rule that uses that AABB. + session.stats().register_rewrite(GeoDistancePrune); } diff --git a/vortex-geo/src/prune.rs b/vortex-geo/src/prune.rs new file mode 100644 index 00000000000..6b86c57c3e2 --- /dev/null +++ b/vortex-geo/src/prune.rs @@ -0,0 +1,524 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Chunk pruning for spatial filters, using the per-chunk [`GeometryAabb`] axis-aligned +//! bounding box (AABB). + +use geo::BoundingRect; +use geo::Rect as GeoRect; +use vortex_array::VortexSessionExecute; +use vortex_array::aggregate_fn::AggregateFnVTableExt; +use vortex_array::aggregate_fn::EmptyOptions; +use vortex_array::expr::Expression; +use vortex_array::expr::case_when; +use vortex_array::expr::checked_add; +use vortex_array::expr::ext_storage; +use vortex_array::expr::get_item; +use vortex_array::expr::gt; +use vortex_array::expr::gt_eq; +use vortex_array::expr::is_root; +use vortex_array::expr::lit; +use vortex_array::expr::lt; +use vortex_array::expr::lt_eq; +use vortex_array::scalar::Scalar; +use vortex_array::scalar_fn::ScalarFnId; +use vortex_array::scalar_fn::ScalarFnVTable; +use vortex_array::scalar_fn::ScalarFnVTableExt; +use vortex_array::scalar_fn::fns::binary::Binary; +use vortex_array::scalar_fn::fns::literal::Literal; +use vortex_array::scalar_fn::fns::operators::Operator; +use vortex_array::stats::rewrite::StatsRewriteCtx; +use vortex_array::stats::rewrite::StatsRewriteRule; +use vortex_array::stats::stat; +use vortex_error::VortexExpect; +use vortex_error::VortexResult; + +use crate::aggregate_fn::GeometryAabb; +use crate::extension::is_native_geometry; +use crate::extension::single_geometry; +use crate::scalar_fn::distance::GeoDistance; + +/// Prunes chunks for `ST_Distance(geom, const) r` filters using the chunk's [`GeometryAabb`] +/// bounding box. Register it with `crate::initialize`; a chunk written without the `GeometryAabb` +/// statistic is scanned rather than skipped. +/// +/// All four comparisons prune: `<= r` / `< r` skip a chunk whose box is wholly beyond `r` (box +/// min-distance); `>= r` / `> r` skip one wholly within `r` (box max-distance). `==` / `!=` don't +/// prune. To add another spatial predicate, write a sibling [`StatsRewriteRule`] from the +/// `geometry_and_constant` + `distance_prune_proof` helpers; no new statistic or file-format change +/// is needed. +#[derive(Debug)] +pub struct GeoDistancePrune; + +impl StatsRewriteRule for GeoDistancePrune { + fn scalar_fn_id(&self) -> ScalarFnId { + // The predicate root is the comparison, not `GeoDistance`, so key on `Binary`. + Binary.id() + } + + fn falsify( + &self, + expr: &Expression, + ctx: &StatsRewriteCtx<'_>, + ) -> VortexResult> { + // Only the ordered comparisons prune today. `== r` could prune in the future (a chunk is + // provably empty when `r` lies outside its box's [min, max] distance interval), it's just + // not implemented. `!= r` cannot: pruning would need every row's distance to equal `r`, + // which an AABB can't prove. + let op = *expr.as_::(); + if !matches!( + op, + Operator::Lte | Operator::Lt | Operator::Gte | Operator::Gt + ) { + return Ok(None); + } + + // The left operand must be `GeoDistance(geom, const)`; the right, the radius literal. + let distance = expr.child(0); + if distance.as_opt::().is_none() { + return Ok(None); + } + let Some((geom, constant)) = geometry_and_constant(distance, ctx)? else { + return Ok(None); + }; + let Some(radius) = expr.child(1).as_opt::() else { + return Ok(None); + }; + // Casts any primitive radius (integer literals included); it fails only for a null or + // non-primitive literal, where falling through means "scan the chunk", which is always + // sound. + let Ok(radius) = f64::try_from(radius) else { + return Ok(None); + }; + // A NaN radius has no sound proof: `distance NaN` is not a total order, so the chunk + // must be scanned, not pruned. + if radius.is_nan() { + return Ok(None); + } + + // Reduce `const` (any geometry type) to its AABB. Every row sits in the chunk AABB + // and `const` in this box, so the box-to-box distance bounds the true distance soundly for + // any geometry types. + let mut exec = ctx.session().create_execution_ctx(); + let Some(query) = single_geometry(constant, &mut exec)?.bounding_rect() else { + return Ok(None); + }; + Ok(distance_prune_proof(geom, query, op, radius)) + } +} + +/// Shared AABB-pruning helper: split a symmetric geo predicate's operands into the scope-rooted +/// geometry column and the constant's scalar, or `None` when the expression doesn't have that +/// shape or the geometry's dtype has no [`GeometryAabb`] support. Symmetric only - an asymmetric +/// predicate that needs to know *which* operand is the column must recover the role separately. +fn geometry_and_constant<'a>( + expr: &'a Expression, + ctx: &StatsRewriteCtx<'_>, +) -> VortexResult> { + // The predicate is symmetric, so the geometry column (scope root) and the constant may be on + // either side. + let (lhs, rhs) = (expr.child(0), expr.child(1)); + let (geom, constant) = if is_root(lhs) { + (lhs, rhs) + } else if is_root(rhs) { + (rhs, lhs) + } else { + return Ok(None); + }; + + // A `GeometryAabb` stat reference only binds for dtypes it supports; anything else (e.g. a + // WKB column) must fall through to the scan. + if !is_native_geometry(&ctx.return_dtype(geom)?) { + return Ok(None); + } + + Ok(constant.as_opt::().map(|scalar| (geom, scalar))) +} + +/// Build the prune proof for `ST_Distance(geom, const) radius` from the chunk's bounding-box +/// stat, or `None` when this operator/radius cannot prune. `<=` / `<` prune a chunk whose box is +/// wholly beyond `radius` (min box-distance); `>=` / `>` prune one wholly within it (max +/// box-distance). Every row sits in the box, so those bounds prove the whole chunk one-sided. +/// +/// A distance is always `>= 0`, which decides the degenerate radii up front. +fn distance_prune_proof( + geom: &Expression, + query: GeoRect, + op: Operator, + radius: f64, +) -> Option { + // A distance is always non-negative, so degenerate radii resolve without touching the box. + match op { + // `<= r` / `< r` with a negative radius (or zero, for `<`) match nothing: prune every chunk. + Operator::Lte if radius < 0.0 => return Some(lit(true)), + Operator::Lt if radius <= 0.0 => return Some(lit(true)), + // `>= r` / `> r` with a negative radius (or zero, for `>=`) match all rows: never prune. + Operator::Gte if radius <= 0.0 => return None, + Operator::Gt if radius < 0.0 => return None, + _ => {} + } + // The stat is read through `ext_storage`/`get_item`, which propagate a missing stat (null) to + // "keep the chunk". Compared squared to avoid a `sqrt`; all operands are `>= 0`. + let aabb = ext_storage(stat(geom.clone(), GeometryAabb.bind(EmptyOptions))); + let r2 = lit(radius * radius); + Some(match op { + // Beyond the threshold: even the nearest the box can be exceeds `r`. + Operator::Lte => gt(min_dist_sq(&aabb, query), r2), + Operator::Lt => gt_eq(min_dist_sq(&aabb, query), r2), + // Within the threshold: even the farthest the box can be is below `r`. + Operator::Gte => lt(max_dist_sq(&aabb, query), r2), + Operator::Gt => lt_eq(max_dist_sq(&aabb, query), r2), + _ => return None, + }) +} + +/// Squared minimum distance between the chunk box `aabb` and the query box - a lower bound on every +/// row's distance. `dx^2 + dy^2`, each axis gap clamped at zero (zero when the intervals overlap). +fn min_dist_sq(aabb: &Expression, query: GeoRect) -> Expression { + let field = |name: &str| get_item(name, aabb.clone()); + // max(0, q_lo - aabb_hi, aabb_lo - q_hi): positive only when the intervals are separated. + let gap = |q_lo: f64, q_hi: f64, lo: Expression, hi: Expression| { + maximum( + lit(0.0), + maximum( + binop(Operator::Sub, lit(q_lo), hi), + binop(Operator::Sub, lo, lit(q_hi)), + ), + ) + }; + let dx = gap(query.min().x, query.max().x, field("xmin"), field("xmax")); + let dy = gap(query.min().y, query.max().y, field("ymin"), field("ymax")); + checked_add(square(dx), square(dy)) +} + +/// Squared maximum distance between the chunk box `aabb` and the query box - an upper bound on every +/// row's distance. `Dx^2 + Dy^2`, each axis span the full extent of the two intervals' union. +fn max_dist_sq(aabb: &Expression, query: GeoRect) -> Expression { + let field = |name: &str| get_item(name, aabb.clone()); + // max(q_hi, aabb_hi) - min(q_lo, aabb_lo): the farthest two points of the boxes can be on an axis. + // The (nullable) AABB field is passed as the second arg so `case_when`'s else branch carries the + // nullability - a missing stat then propagates null through to "keep the chunk". + let span = |q_lo: f64, q_hi: f64, lo: Expression, hi: Expression| { + binop( + Operator::Sub, + maximum(lit(q_hi), hi), + minimum(lit(q_lo), lo), + ) + }; + let dx = span(query.min().x, query.max().x, field("xmin"), field("xmax")); + let dy = span(query.min().y, query.max().y, field("ymin"), field("ymax")); + checked_add(square(dx), square(dy)) +} + +/// `a b` as a binary-operator expression. +fn binop(op: Operator, a: Expression, b: Expression) -> Expression { + Binary + .try_new_expr(op, [a, b]) + .vortex_expect("binary expression") +} + +/// `e * e` as an expression. +fn square(e: Expression) -> Expression { + binop(Operator::Mul, e.clone(), e) +} + +/// `max(a, b)` as an expression. +fn maximum(a: Expression, b: Expression) -> Expression { + case_when(gt(a.clone(), b.clone()), a, b) +} + +/// `min(a, b)` as an expression. +fn minimum(a: Expression, b: Expression) -> Expression { + case_when(lt(a.clone(), b.clone()), a, b) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use rstest::rstest; + use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::aggregate_fn::AggregateFnVTableExt; + use vortex_array::aggregate_fn::EmptyOptions as AggregateEmptyOptions; + use vortex_array::arrays::ExtensionArray; + use vortex_array::arrays::PrimitiveArray; + use vortex_array::arrays::StructArray; + use vortex_array::dtype::DType; + use vortex_array::dtype::FieldNames; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::dtype::extension::ExtDType; + use vortex_array::expr::Expression; + use vortex_array::expr::gt_eq; + use vortex_array::expr::lit; + use vortex_array::expr::lt_eq; + use vortex_array::expr::root; + use vortex_array::scalar_fn::EmptyOptions; + use vortex_array::scalar_fn::ScalarFnVTableExt; + use vortex_array::scalar_fn::fns::binary::Binary; + use vortex_array::scalar_fn::fns::operators::Operator; + use vortex_array::stats::rewrite::StatsRewriteCtx; + use vortex_array::stats::rewrite::StatsRewriteRule; + use vortex_array::validity::Validity; + use vortex_error::VortexResult; + use vortex_layout::layouts::zoned::zone_map::ZoneMap; + + use super::GeoDistancePrune; + use crate::aggregate_fn::GeometryAabb; + use crate::extension::GeoMetadata; + use crate::extension::Rect; + use crate::scalar_fn::distance::GeoDistance; + use crate::test_harness::geo_session; + use crate::test_harness::point_column; + + /// Run the rule against `GeoDistance(root, origin) radius`, operands swapped when + /// `geom_first` is false. + fn falsify_distance( + operator: Operator, + geom_first: bool, + radius: f64, + ) -> VortexResult> { + let session = geo_session(); + let mut ctx = session.create_execution_ctx(); + + let scope = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?; + let operands = if geom_first { + [root(), lit(origin)] + } else { + [lit(origin), root()] + }; + let distance = GeoDistance.new_expr(EmptyOptions, operands); + let predicate = Binary.new_expr(operator, [distance, lit(radius)]); + + GeoDistancePrune.falsify(&predicate, &StatsRewriteCtx::new(&session, &scope)) + } + + /// All four distance comparisons prune (`<=`/`<` via min-distance, `>=`/`>` via max-distance); + /// `==`/`!=` are left to the scan. + #[rstest] + #[case(Operator::Lte, true)] + #[case(Operator::Lt, true)] + #[case(Operator::Gt, true)] + #[case(Operator::Gte, true)] + #[case(Operator::Eq, false)] + #[case(Operator::NotEq, false)] + fn prunes_distance_comparisons( + #[case] operator: Operator, + #[case] prunes: bool, + ) -> VortexResult<()> { + assert_eq!(falsify_distance(operator, true, 0.5)?.is_some(), prunes); + Ok(()) + } + + /// Distance is symmetric: `GeoDistance(const, geom) <= r` falsifies just like the geom-first form. + #[test] + fn falsifies_with_constant_as_left_operand() -> VortexResult<()> { + assert!(falsify_distance(Operator::Lte, false, 0.5)?.is_some()); + Ok(()) + } + + /// A NaN radius must not prune - the scan's total-order compare treats `dist <= NaN` as true. + #[test] + fn nan_radius_never_prunes() -> VortexResult<()> { + assert!(falsify_distance(Operator::Lte, true, f64::NAN)?.is_none()); + Ok(()) + } + + /// A negative radius prunes every zone - vacuously sound: `distance <= r < 0` matches no row. + #[test] + fn negative_radius_prunes_vacuously() -> VortexResult<()> { + assert!(falsify_distance(Operator::Lte, true, -0.5)?.is_some()); + Ok(()) + } + + /// A scope dtype without `GeometryAabb` support gets no proof - the stat reference would + /// fail to bind at prune time. + #[test] + fn unsupported_scope_is_not_pruned() -> VortexResult<()> { + let session = geo_session(); + let mut ctx = session.create_execution_ctx(); + + let scope = DType::Primitive(PType::F64, Nullability::NonNullable); + let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?; + let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]); + let predicate = lt_eq(distance, lit(0.5f64)); + + let ctx = StatsRewriteCtx::new(&session, &scope); + assert!(GeoDistancePrune.falsify(&predicate, &ctx)?.is_none()); + Ok(()) + } + + /// A comparison that does not wrap `GeoDistance` is left untouched. + #[test] + fn ignores_non_distance_comparison() -> VortexResult<()> { + let session = geo_session(); + let scope = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + + let predicate = lt_eq(lit(1.0f64), lit(2.0f64)); + let ctx = StatsRewriteCtx::new(&session, &scope); + assert!(GeoDistancePrune.falsify(&predicate, &ctx)?.is_none()); + Ok(()) + } + + /// End-to-end over a hand-built zone map: the far chunk is skipped, the near one kept. + #[test] + fn prunes_far_chunk_keeps_near() -> VortexResult<()> { + let session = geo_session(); + let mut ctx = session.create_execution_ctx(); + + let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + let aabb_fn = GeometryAabb.bind(AggregateEmptyOptions); + + // Two chunks' AABBs, stored as the native `geoarrow.box` stat column with default + // (unreferenced) metadata to match the aggregate's return dtype: chunk 0 near the origin + // (0,0..1,1), chunk 1 far away (100,100..101,101). + let ord = |a: f64, b: f64| PrimitiveArray::from_iter([a, b]).into_array(); + let boxes = StructArray::try_new( + ["xmin", "ymin", "xmax", "ymax"].into(), + vec![ + ord(0.0, 100.0), + ord(0.0, 100.0), + ord(1.0, 101.0), + ord(1.0, 101.0), + ], + 2, + Validity::AllValid, + )? + .into_array(); + let box_dtype = + ExtDType::::try_new(GeoMetadata::default(), boxes.dtype().clone())?.erased(); + let aabbs = ExtensionArray::try_new(box_dtype, boxes)?.into_array(); + let zone_array = StructArray::from_fields(&[(aabb_fn.to_string().as_str(), aabbs)])?; + let zone_map = + ZoneMap::try_new(point_dtype.clone(), zone_array, Arc::new([aabb_fn]), 1, 2)?; + + let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?; + let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]); + let predicate = lt_eq(distance, lit(0.5f64)); + let proof = predicate + .falsify(&point_dtype, &session)? + .expect("distance filter should be falsifiable"); + + // `true` means the zone is pruned: chunk 0 (near origin) is kept, chunk 1 (far) is skipped. + let mask = zone_map.prune(&proof, &session)?; + assert_eq!(mask.iter().collect::>(), vec![false, true]); + Ok(()) + } + + /// The true-distance prune skips a chunk that is *diagonally* farther than `r`, even though + /// neither axis alone exceeds `r` - the case a per-axis box-overlap test would wrongly keep. + #[test] + fn prunes_diagonally_distant_chunk() -> VortexResult<()> { + let session = geo_session(); + let mut ctx = session.create_execution_ctx(); + + let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + let aabb_fn = GeometryAabb.bind(AggregateEmptyOptions); + + // One chunk, AABB (0.8,0.8)..(0.9,0.9): each axis is only 0.8 from the origin (<= r = 1), but + // the near corner is sqrt(0.8^2 + 0.8^2) ~= 1.13 away (> 1), so no point in the box is within 1. + let ord = |a: f64| PrimitiveArray::from_iter([a]).into_array(); + let boxes = StructArray::try_new( + ["xmin", "ymin", "xmax", "ymax"].into(), + vec![ord(0.8), ord(0.8), ord(0.9), ord(0.9)], + 1, + Validity::AllValid, + )? + .into_array(); + let box_dtype = + ExtDType::::try_new(GeoMetadata::default(), boxes.dtype().clone())?.erased(); + let aabbs = ExtensionArray::try_new(box_dtype, boxes)?.into_array(); + let zone_array = StructArray::from_fields(&[(aabb_fn.to_string().as_str(), aabbs)])?; + let zone_map = + ZoneMap::try_new(point_dtype.clone(), zone_array, Arc::new([aabb_fn]), 1, 1)?; + + let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?; + let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]); + let predicate = lt_eq(distance, lit(1.0f64)); + let proof = predicate + .falsify(&point_dtype, &session)? + .expect("distance filter should be falsifiable"); + + assert_eq!( + zone_map + .prune(&proof, &session)? + .iter() + .collect::>(), + vec![true], + ); + Ok(()) + } + + /// Backward compat: a zone map written without the `GeometryAabb` stat (an older file) keeps + /// every zone - the missing stat binds to null and `null_as_false` retains the zone. + #[test] + fn missing_aabb_stat_keeps_all_zones() -> VortexResult<()> { + let session = geo_session(); + let mut ctx = session.create_execution_ctx(); + + let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + let zone_map = ZoneMap::try_new( + point_dtype.clone(), + StructArray::try_new(FieldNames::empty(), vec![], 2, Validity::NonNullable)?, + Arc::new([]), + 1, + 2, + )?; + + let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?; + let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]); + let proof = lt_eq(distance, lit(0.5f64)) + .falsify(&point_dtype, &session)? + .expect("distance filter should be falsifiable"); + + let mask = zone_map.prune(&proof, &session)?; + assert_eq!(mask.iter().collect::>(), vec![false, false]); + Ok(()) + } + + /// A `>= r` filter prunes a chunk lying wholly *within* `r` (every row nearer than `r`, so none + /// satisfy `>= r`) via the box max-distance, while a chunk beyond `r` is kept. + #[test] + fn prunes_within_chunk_for_far_filter() -> VortexResult<()> { + let session = geo_session(); + let mut ctx = session.create_execution_ctx(); + + let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + let aabb_fn = GeometryAabb.bind(AggregateEmptyOptions); + + // Chunk 0 (AABB 0,0..0.5,0.5, farthest corner ~= 0.707) is entirely within 2 of the origin; + // chunk 1 (100,100..101,101) is entirely beyond it. + let ord = |a: f64, b: f64| PrimitiveArray::from_iter([a, b]).into_array(); + let boxes = StructArray::try_new( + ["xmin", "ymin", "xmax", "ymax"].into(), + vec![ + ord(0.0, 100.0), + ord(0.0, 100.0), + ord(0.5, 101.0), + ord(0.5, 101.0), + ], + 2, + Validity::AllValid, + )? + .into_array(); + let box_dtype = + ExtDType::::try_new(GeoMetadata::default(), boxes.dtype().clone())?.erased(); + let aabbs = ExtensionArray::try_new(box_dtype, boxes)?.into_array(); + let zone_array = StructArray::from_fields(&[(aabb_fn.to_string().as_str(), aabbs)])?; + let zone_map = + ZoneMap::try_new(point_dtype.clone(), zone_array, Arc::new([aabb_fn]), 1, 2)?; + + let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?; + let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]); + let proof = gt_eq(distance, lit(2.0f64)) + .falsify(&point_dtype, &session)? + .expect("distance filter should be falsifiable"); + + // Chunk 0 (within 2) is pruned for `>= 2`; chunk 1 (beyond 2) is kept. + let mask = zone_map.prune(&proof, &session)?; + assert_eq!(mask.iter().collect::>(), vec![true, false]); + Ok(()) + } +} diff --git a/vortex-geo/src/test_harness.rs b/vortex-geo/src/test_harness.rs index 4711e2e64db..79dc2d3361d 100644 --- a/vortex-geo/src/test_harness.rs +++ b/vortex-geo/src/test_harness.rs @@ -6,17 +6,43 @@ use vortex_array::ArrayRef; use vortex_array::IntoArray; use vortex_array::arrays::ExtensionArray; +use vortex_array::arrays::ListArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::StructArray; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; use vortex_array::dtype::extension::ExtDType; +use vortex_array::dtype::extension::ExtVTable; use vortex_array::scalar::Scalar; +use vortex_array::validity::Validity; use vortex_error::VortexResult; +use vortex_error::vortex_err; +use vortex_session::VortexSession; use crate::extension::GeoMetadata; +use crate::extension::LineString; +use crate::extension::MultiLineString; +use crate::extension::MultiPoint; +use crate::extension::MultiPolygon; use crate::extension::Point; +use crate::extension::Polygon; use crate::extension::Rect; +use crate::extension::box_storage_dtype; use crate::extension::coordinate::Coordinate; +use crate::extension::coordinate::Dimension; use crate::extension::coordinate::coordinate_from_struct; +use crate::extension::linestring_storage_dtype; +use crate::extension::multilinestring_storage_dtype; +use crate::extension::multipoint_storage_dtype; +use crate::extension::multipolygon_storage_dtype; +use crate::extension::polygon_storage_dtype; + +/// A fresh session with the geospatial types, functions, and pruning rules registered. +pub(crate) fn geo_session() -> VortexSession { + let session = vortex_array::array_session(); + crate::initialize(&session); + session +} /// The WGS 84 (`EPSG:4326`) metadata tagged onto test geometry columns. fn wgs84() -> GeoMetadata { @@ -25,18 +51,115 @@ fn wgs84() -> GeoMetadata { } } -/// A `Point` column (CRS `EPSG:4326`) over the given x/y coordinates. -pub(crate) fn point_column(xs: Vec, ys: Vec) -> VortexResult { - let storage = StructArray::from_fields(&[ +/// A coordinate `Struct` over the parallel x/y buffers. +fn xy_struct(xs: Vec, ys: Vec) -> VortexResult { + Ok(StructArray::from_fields(&[ ("x", PrimitiveArray::from_iter(xs).into_array()), ("y", PrimitiveArray::from_iter(ys).into_array()), ])? - .into_array(); - let dtype = ExtDType::::try_new(wgs84(), storage.dtype().clone())?; - Ok(ExtensionArray::new(dtype.erased(), storage).into_array()) + .into_array()) +} + +/// A list offset as `i32`. +fn offset(n: usize) -> VortexResult { + i32::try_from(n).map_err(|_| vortex_err!("geometry offset overflow")) +} + +/// Wrap `elements` — built from `rows` flattened one level — in a non-nullable `List` with one +/// entry per row. +fn nest(rows: &[Vec], elements: ArrayRef) -> VortexResult { + let mut offsets = vec![0i32]; + let mut len = 0usize; + for row in rows { + len += row.len(); + offsets.push(offset(len)?); + } + Ok(ListArray::try_new( + elements, + PrimitiveArray::from_iter(offsets).into_array(), + Validity::NonNullable, + )? + .into_array()) +} + +/// `List>` storage: one list of `(x, y)` vertices per row. +fn vertex_lists(rows: &[Vec<(f64, f64)>]) -> VortexResult { + let (xs, ys) = rows.iter().flatten().copied().unzip(); + nest(rows, xy_struct(xs, ys)?) +} + +/// `List>>` storage: one list of vertex lists per row. +fn vertex_list_lists(rows: &[Vec>]) -> VortexResult { + let inner: Vec> = rows.iter().flatten().cloned().collect(); + nest(rows, vertex_lists(&inner)?) +} + +/// Wrap `storage` in the geometry extension `vtable` (CRS `EPSG:4326`) with the canonical +/// `storage_dtype` of that type. +fn geo_column + Default>( + storage: ArrayRef, + storage_dtype: DType, +) -> VortexResult { + let dtype = ExtDType::::try_new(wgs84(), storage_dtype)?; + Ok(ExtensionArray::try_new(dtype.erased(), storage)?.into_array()) +} + +/// A `Point` column over the given x/y coordinates, stored as `Struct`. +pub(crate) fn point_column(xs: Vec, ys: Vec) -> VortexResult { + let storage = xy_struct(xs, ys)?; + let storage_dtype = storage.dtype().clone(); + geo_column::(storage, storage_dtype) +} + +/// A `LineString` column: each line a list of `(x, y)` vertices, stored as `List>`. +pub(crate) fn linestring_column(lines: Vec>) -> VortexResult { + geo_column::( + vertex_lists(&lines)?, + linestring_storage_dtype(Dimension::Xy, Nullability::NonNullable), + ) +} + +/// A `MultiPoint` column: each row a list of `(x, y)` points, stored as `List>`. +pub(crate) fn multipoint_column(points: Vec>) -> VortexResult { + geo_column::( + vertex_lists(&points)?, + multipoint_storage_dtype(Dimension::Xy, Nullability::NonNullable), + ) +} + +/// A `Polygon` column: each polygon a list of rings, each ring a list of `(x, y)` vertices, +/// stored as `List>>`. +pub(crate) fn polygon_column(polygons: Vec>>) -> VortexResult { + geo_column::( + vertex_list_lists(&polygons)?, + polygon_storage_dtype(Dimension::Xy, Nullability::NonNullable), + ) +} + +/// A `MultiLineString` column: each row a list of lines, stored as `List>>`. +pub(crate) fn multilinestring_column( + multilines: Vec>>, +) -> VortexResult { + geo_column::( + vertex_list_lists(&multilines)?, + multilinestring_storage_dtype(Dimension::Xy, Nullability::NonNullable), + ) +} + +/// One multipolygon: polygons → rings → `(x, y)` vertices. +pub(crate) type MultiPolygonRings = Vec>>; + +/// A `MultiPolygon` column, stored as `List>>>`. +pub(crate) fn multipolygon_column(multipolygons: Vec) -> VortexResult { + let polygons: Vec>> = multipolygons.iter().flatten().cloned().collect(); + geo_column::( + nest(&multipolygons, vertex_list_lists(&polygons)?)?, + multipolygon_storage_dtype(Dimension::Xy, Nullability::NonNullable), + ) } -/// A 2D `Rect` (`geoarrow.box`) column (CRS `EPSG:4326`) over `(xmin, ymin, xmax, ymax)` boxes. +/// A 2D `Rect` (`geoarrow.box`) column over `(xmin, ymin, xmax, ymax)` boxes, stored as +/// `Struct`. pub(crate) fn rect_column(boxes: Vec<(f64, f64, f64, f64)>) -> VortexResult { let field = |select: fn(&(f64, f64, f64, f64)) -> f64| { PrimitiveArray::from_iter(boxes.iter().map(select)).into_array() @@ -48,8 +171,10 @@ pub(crate) fn rect_column(boxes: Vec<(f64, f64, f64, f64)>) -> VortexResult::try_new(wgs84(), storage.dtype().clone())?; - Ok(ExtensionArray::new(dtype.erased(), storage).into_array()) + geo_column::( + storage, + box_storage_dtype(Dimension::Xy, Nullability::NonNullable), + ) } /// Decode a [`Coordinate`] from an extension-typed point scalar (unwrapped to its coordinate diff --git a/vortex-geo/src/tests/mod.rs b/vortex-geo/src/tests/mod.rs index 3708d5ad1fb..3ee1714ce47 100644 --- a/vortex-geo/src/tests/mod.rs +++ b/vortex-geo/src/tests/mod.rs @@ -17,8 +17,4 @@ use std::sync::LazyLock; use vortex_session::VortexSession; /// A session with the geospatial types and functions registered. -static SESSION: LazyLock = LazyLock::new(|| { - let session = vortex_array::array_session(); - crate::initialize(&session); - session -}); +static SESSION: LazyLock = LazyLock::new(crate::test_harness::geo_session); diff --git a/vortex-layout/src/layouts/zoned/writer.rs b/vortex-layout/src/layouts/zoned/writer.rs index c1c12d7ddea..048905dade6 100644 --- a/vortex-layout/src/layouts/zoned/writer.rs +++ b/vortex-layout/src/layouts/zoned/writer.rs @@ -26,6 +26,7 @@ use vortex_array::aggregate_fn::fns::min::Min; use vortex_array::aggregate_fn::fns::nan_count::NanCount; use vortex_array::aggregate_fn::fns::null_count::NullCount; use vortex_array::aggregate_fn::fns::sum::Sum; +use vortex_array::aggregate_fn::session::AggregateFnSessionExt; use vortex_array::dtype::DType; use vortex_error::VortexError; use vortex_error::VortexResult; @@ -109,7 +110,7 @@ impl LayoutStrategy for ZonedStrategy { .options .aggregate_fns .clone() - .unwrap_or_else(|| default_zoned_aggregate_fns(stream.dtype())); + .unwrap_or_else(|| default_zoned_aggregate_fns(stream.dtype(), session)); let compute_session = session.clone(); let stats_accumulator = Arc::new(Mutex::new(AggregateStatsAccumulator::new( @@ -195,7 +196,7 @@ impl LayoutStrategy for ZonedStrategy { } } -fn default_zoned_aggregate_fns(dtype: &DType) -> Arc<[AggregateFnRef]> { +fn default_zoned_aggregate_fns(dtype: &DType, session: &VortexSession) -> Arc<[AggregateFnRef]> { let (max, min) = match dtype { DType::Utf8(_) | DType::Binary(_) => ( BoundedMax.bind(BoundedMaxOptions { @@ -221,6 +222,9 @@ fn default_zoned_aggregate_fns(dtype: &DType) -> Arc<[AggregateFnRef]> { aggregate_fns.push(NanCount.bind(EmptyOptions)); aggregate_fns.push(NullCount.bind(EmptyOptions)); + // Stats from geo extension types are discovered from the registry at runtime instead. + aggregate_fns.extend(session.aggregate_fns().zone_stat_defaults(dtype)); + aggregate_fns.into() } @@ -240,7 +244,10 @@ mod tests { #[test] fn default_aggregates_bound_variable_length_min_max() { - let aggregate_fns = default_zoned_aggregate_fns(&DType::Utf8(Nullability::NonNullable)); + let aggregate_fns = default_zoned_aggregate_fns( + &DType::Utf8(Nullability::NonNullable), + &vortex_array::array_session(), + ); assert_eq!( aggregate_fns[0].as_::().max_bytes, @@ -254,7 +261,8 @@ mod tests { #[test] fn default_aggregates_keep_fixed_width_min_max_exact() { - let aggregate_fns = default_zoned_aggregate_fns(&PType::I32.into()); + let aggregate_fns = + default_zoned_aggregate_fns(&PType::I32.into(), &vortex_array::array_session()); assert!(aggregate_fns[0].is::()); assert!(aggregate_fns[1].is::()); @@ -266,7 +274,7 @@ mod tests { let dtype = DType::Extension( Timestamp::new(TimeUnit::Microseconds, Nullability::Nullable).erased(), ); - let aggregate_fns = default_zoned_aggregate_fns(&dtype); + let aggregate_fns = default_zoned_aggregate_fns(&dtype, &vortex_array::array_session()); assert!( aggregate_fns From 7f8d92f0e315e7a92f0d84d2379b9f584f57aad0 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Wed, 15 Jul 2026 20:55:15 +0100 Subject: [PATCH 084/104] VortexArrowColumnVector supports conversion from all arrow types that Spark can support (#8771) Since we don't use upstream ArrowColumnVector but our own VortexArrowColumnVector because of arrow version compatibilites we can support all arrow types that spark and vortex care about without restrictions. This also removes the strip_views function that forced us to translate string/binary/list types --- .../test/java/dev/vortex/api/TestMinimal.java | 9 +- .../java/dev/vortex/jni/JNIWriterTest.java | 3 +- .../java/dev/vortex/spark/ArrowUtils.java | 111 ++-- .../spark/read/VortexArrowColumnVector.java | 302 +++++++--- .../java/dev/vortex/spark/ArrowUtilsTest.java | 129 ++++- .../read/VortexArrowColumnVectorTest.java | 523 ++++++++++++++++++ vortex-jni/src/dtype.rs | 55 +- vortex-jni/src/scan.rs | 13 +- 8 files changed, 947 insertions(+), 198 deletions(-) create mode 100644 java/vortex-spark/src/test/java/dev/vortex/spark/read/VortexArrowColumnVectorTest.java diff --git a/java/vortex-jni/src/test/java/dev/vortex/api/TestMinimal.java b/java/vortex-jni/src/test/java/dev/vortex/api/TestMinimal.java index aeee8febcf4..3a6f9ca0883 100644 --- a/java/vortex-jni/src/test/java/dev/vortex/api/TestMinimal.java +++ b/java/vortex-jni/src/test/java/dev/vortex/api/TestMinimal.java @@ -23,7 +23,6 @@ import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.vector.DecimalVector; import org.apache.arrow.vector.FieldVector; -import org.apache.arrow.vector.VarCharVector; import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.arrow.vector.ViewVarCharVector; import org.apache.arrow.vector.ipc.ArrowReader; @@ -155,8 +154,8 @@ public void testProjectedScan() throws Exception { List people = readAll(ds, options, allocator, batch -> { List results = new ArrayList<>(); - VarCharVector names = (VarCharVector) batch.getVector("Name"); - VarCharVector states = (VarCharVector) batch.getVector("State"); + ViewVarCharVector names = (ViewVarCharVector) batch.getVector("Name"); + ViewVarCharVector states = (ViewVarCharVector) batch.getVector("State"); for (int i = 0; i < batch.getRowCount(); i++) { String name = names.isNull(i) ? null : new String(names.get(i), UTF_8); String state = states.isNull(i) ? null : new String(states.get(i), UTF_8); @@ -272,9 +271,9 @@ private static List readAll( private static List readFullBatch(VectorSchemaRoot root) { List result = new ArrayList<>(); - VarCharVector names = (VarCharVector) root.getVector("Name"); + ViewVarCharVector names = (ViewVarCharVector) root.getVector("Name"); FieldVector salaries = root.getVector("Salary"); - VarCharVector states = (VarCharVector) root.getVector("State"); + ViewVarCharVector states = (ViewVarCharVector) root.getVector("State"); for (int i = 0; i < root.getRowCount(); i++) { String name = names.isNull(i) ? null : new String(names.get(i), UTF_8); diff --git a/java/vortex-jni/src/test/java/dev/vortex/jni/JNIWriterTest.java b/java/vortex-jni/src/test/java/dev/vortex/jni/JNIWriterTest.java index ed84111bdf8..1bb51f8799f 100644 --- a/java/vortex-jni/src/test/java/dev/vortex/jni/JNIWriterTest.java +++ b/java/vortex-jni/src/test/java/dev/vortex/jni/JNIWriterTest.java @@ -31,6 +31,7 @@ import org.apache.arrow.vector.VarBinaryVector; import org.apache.arrow.vector.VarCharVector; import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.ViewVarCharVector; import org.apache.arrow.vector.complex.StructVector; import org.apache.arrow.vector.ipc.ArrowReader; import org.apache.arrow.vector.types.pojo.ArrowType; @@ -211,7 +212,7 @@ public void testWriteBatch() throws IOException { try (ArrowReader reader = p.scanArrow(allocator)) { reader.loadNextBatch(); VectorSchemaRoot resultRoot = reader.getVectorSchemaRoot(); - VarCharVector nameOut = (VarCharVector) resultRoot.getVector("name"); + ViewVarCharVector nameOut = (ViewVarCharVector) resultRoot.getVector("name"); IntVector ageOut = (IntVector) resultRoot.getVector("age"); assertEquals("Alice", nameOut.getObject(0).toString()); assertEquals("Bob", nameOut.getObject(1).toString()); diff --git a/java/vortex-spark/src/main/java/dev/vortex/spark/ArrowUtils.java b/java/vortex-spark/src/main/java/dev/vortex/spark/ArrowUtils.java index 6b3c130c467..6e0166a2873 100644 --- a/java/vortex-spark/src/main/java/dev/vortex/spark/ArrowUtils.java +++ b/java/vortex-spark/src/main/java/dev/vortex/spark/ArrowUtils.java @@ -4,7 +4,6 @@ package dev.vortex.spark; import dev.vortex.relocated.org.apache.arrow.vector.types.DateUnit; -import dev.vortex.relocated.org.apache.arrow.vector.types.FloatingPointPrecision; import dev.vortex.relocated.org.apache.arrow.vector.types.TimeUnit; import dev.vortex.relocated.org.apache.arrow.vector.types.pojo.ArrowType; import dev.vortex.relocated.org.apache.arrow.vector.types.pojo.Field; @@ -18,8 +17,18 @@ * Utility class for converting Arrow types to Spark SQL data types. * *

This class provides static methods to convert Arrow field definitions and type definitions into their - * corresponding Spark SQL DataType representations. It handles the mapping between Arrow's type system and Spark's type - * system, including complex types like structs and arrays. + * corresponding Spark SQL DataType representations. The mapping matches Spark 4.1's own {@code ArrowUtils}, extended + * where Vortex produces types Spark's mapping does not cover: + * + *

    + *
  • the Arrow view types map to their logical Spark types: {@code Utf8View} to {@code StringType}, + * {@code BinaryView} to {@code BinaryType}, and {@code ListView} to {@code ArrayType} + *
  • timestamps of every unit map to {@code TimestampType}/{@code TimestampNTZType}; + * {@link dev.vortex.spark.read.VortexArrowColumnVector} normalizes non-microsecond values on read + *
+ * + *

The one intentional divergence from Spark 4.1 is Arrow's Time type: Spark's {@code TimeType} only exists in Spark + * 4.1+, and this module compiles a single source set against both Spark 3.5 and 4.1, so Time is rejected. */ public final class ArrowUtils { private ArrowUtils() {} @@ -27,8 +36,8 @@ private ArrowUtils() {} /** * Converts an Arrow Field to a Spark SQL DataType. * - *

This method handles complex types like structs and arrays by recursively converting their child fields. For - * primitive types, it delegates to {@link #fromArrowType(ArrowType)}. + *

This method handles nested types like structs, lists and maps by recursively converting their child fields. + * For non-nested types, it delegates to {@link #fromArrowType(ArrowType)}. * * @param field the Arrow field to convert * @return the corresponding Spark SQL DataType @@ -43,11 +52,19 @@ public static DataType fromArrowField(Field field) { return new StructField(child.getName(), dt, child.isNullable(), Metadata.empty()); }) .collect(Collectors.toList())); - case List: { + case List: + case ListView: { Field elementField = field.getChildren().get(0); DataType elementType = fromArrowField(elementField); return DataTypes.createArrayType(elementType, elementField.isNullable()); } + case Map: { + Field entries = field.getChildren().get(0); + Field keyField = entries.getChildren().get(0); + Field valueField = entries.getChildren().get(1); + return DataTypes.createMapType( + fromArrowField(keyField), fromArrowField(valueField), valueField.isNullable()); + } default: return fromArrowType(field.getType()); } @@ -56,13 +73,12 @@ public static DataType fromArrowField(Field field) { /** * Converts an Arrow type to a Spark SQL DataType. * - *

This method maps primitive Arrow types to their corresponding Spark SQL types. It supports most common Arrow - * types including integers, floating point numbers, strings, binary data, dates, timestamps, decimals, and nulls. + *

This method maps non-nested Arrow types to their corresponding Spark SQL types, following Spark 4.1's own + * Arrow type mapping plus the view types (see the class documentation). * * @param dt the Arrow type to convert * @return the corresponding Spark SQL DataType - * @throws UnsupportedOperationException if the Arrow type configuration is not supported - * @throws RuntimeException if the Arrow type is not recognized + * @throws UnsupportedOperationException if the Arrow type has no Spark representation */ public static DataType fromArrowType(ArrowType dt) { switch (dt.getTypeID()) { @@ -70,26 +86,31 @@ public static DataType fromArrowType(ArrowType dt) { return DataTypes.BooleanType; case Int: { ArrowType.Int intType = (ArrowType.Int) dt; - if (intType.getIsSigned() && intType.getBitWidth() == 8) { - return DataTypes.ByteType; - } else if (intType.getIsSigned() && intType.getBitWidth() == 16) { - return DataTypes.ShortType; - } else if (intType.getIsSigned() && intType.getBitWidth() == 32) { - return DataTypes.IntegerType; - } else if (intType.getIsSigned() && intType.getBitWidth() == 64) { - return DataTypes.LongType; - } else { - throw new UnsupportedOperationException("Unsupported Arrow type: " + dt); + if (!intType.getIsSigned()) { + throw new UnsupportedOperationException("Unsupported Arrow unsigned integer type: " + dt); + } + switch (intType.getBitWidth()) { + case 8: + return DataTypes.ByteType; + case 16: + return DataTypes.ShortType; + case 32: + return DataTypes.IntegerType; + case 64: + return DataTypes.LongType; + default: + throw new UnsupportedOperationException("Unsupported Arrow integer bit width: " + dt); } } case FloatingPoint: { ArrowType.FloatingPoint floatType = (ArrowType.FloatingPoint) dt; - if (floatType.getPrecision() == FloatingPointPrecision.SINGLE) { - return DataTypes.FloatType; - } else if (floatType.getPrecision() == FloatingPointPrecision.DOUBLE) { - return DataTypes.DoubleType; - } else { - throw new UnsupportedOperationException("Unsupported Arrow type: " + dt); + switch (floatType.getPrecision()) { + case SINGLE: + return DataTypes.FloatType; + case DOUBLE: + return DataTypes.DoubleType; + default: + throw new UnsupportedOperationException("Unsupported Arrow float precision: " + dt); } } case Decimal: { @@ -98,30 +119,52 @@ public static DataType fromArrowType(ArrowType dt) { } case Utf8: case LargeUtf8: + case Utf8View: return DataTypes.StringType; case Binary: case LargeBinary: + case BinaryView: return DataTypes.BinaryType; case Date: { ArrowType.Date dateType = (ArrowType.Date) dt; if (dateType.getUnit() == DateUnit.DAY) { return DataTypes.DateType; - } else { - throw new UnsupportedOperationException("Unsupported Arrow type: " + dt); } + throw new UnsupportedOperationException("Unsupported Arrow date unit: " + dt); } case Timestamp: { + // Spark timestamps are physically microseconds; VortexArrowColumnVector's accessor + // normalizes second/millisecond/nanosecond values on read. ArrowType.Timestamp ts = (ArrowType.Timestamp) dt; - if (ts.getUnit() == TimeUnit.MICROSECOND) { - return ts.getTimezone() != null ? DataTypes.TimestampType : DataTypes.TimestampNTZType; - } else { - throw new UnsupportedOperationException("Unsupported Arrow type: " + dt); - } + return ts.getTimezone() != null ? DataTypes.TimestampType : DataTypes.TimestampNTZType; } case Null: return DataTypes.NullType; + case Interval: { + ArrowType.Interval interval = (ArrowType.Interval) dt; + switch (interval.getUnit()) { + case YEAR_MONTH: + return DataTypes.createYearMonthIntervalType(); + case MONTH_DAY_NANO: + return DataTypes.CalendarIntervalType; + default: + throw new UnsupportedOperationException("Unsupported Arrow interval unit: " + dt); + } + } + case Duration: { + ArrowType.Duration duration = (ArrowType.Duration) dt; + if (duration.getUnit() != TimeUnit.MICROSECOND) { + throw new UnsupportedOperationException("Unsupported Arrow duration unit: " + dt); + } + return DataTypes.createDayTimeIntervalType(); + } + case Time: + // Spark 3.5 has no TIME type (TimeType only exists in Spark 4.1+), and this + // module compiles a single source set against both Spark versions. + throw new UnsupportedOperationException("Arrow Time type has no Spark 3.5 representation: " + dt); default: - throw new IllegalArgumentException("Unsupported Arrow type: " + dt); + throw new UnsupportedOperationException( + "Unsupported Arrow type: " + dt + " (type id: " + dt.getTypeID() + ")"); } } } diff --git a/java/vortex-spark/src/main/java/dev/vortex/spark/read/VortexArrowColumnVector.java b/java/vortex-spark/src/main/java/dev/vortex/spark/read/VortexArrowColumnVector.java index 698707cfdee..be191a0ed43 100644 --- a/java/vortex-spark/src/main/java/dev/vortex/spark/read/VortexArrowColumnVector.java +++ b/java/vortex-spark/src/main/java/dev/vortex/spark/read/VortexArrowColumnVector.java @@ -7,12 +7,16 @@ import com.jakewharton.nopen.annotation.Open; import dev.vortex.relocated.org.apache.arrow.vector.*; import dev.vortex.relocated.org.apache.arrow.vector.complex.ListVector; +import dev.vortex.relocated.org.apache.arrow.vector.complex.ListViewVector; import dev.vortex.relocated.org.apache.arrow.vector.complex.MapVector; import dev.vortex.relocated.org.apache.arrow.vector.complex.StructVector; import dev.vortex.relocated.org.apache.arrow.vector.holders.NullableLargeVarCharHolder; import dev.vortex.relocated.org.apache.arrow.vector.holders.NullableVarCharHolder; +import dev.vortex.relocated.org.apache.arrow.vector.types.TimeUnit; +import dev.vortex.relocated.org.apache.arrow.vector.types.pojo.ArrowType; import dev.vortex.spark.ArrowUtils; import org.apache.spark.sql.types.DataType; +import org.apache.spark.sql.types.DataTypes; import org.apache.spark.sql.types.Decimal; import org.apache.spark.sql.vectorized.ColumnVector; import org.apache.spark.sql.vectorized.ColumnarArray; @@ -23,12 +27,17 @@ * Spark ColumnVector implementation that wraps Apache Arrow vectors from Vortex data. *

* This class provides a bridge between Vortex's Arrow-based data representation and Spark's - * ColumnVector interface. It supports all major Arrow data types including primitives, strings, - * binary data, decimals, dates, timestamps, arrays, maps, and structs. + * ColumnVector interface. It supports the same Arrow vector types as Spark 4.1's own + * {@code ArrowColumnVector} — booleans, signed integers, single/double floats, 128-bit decimals, + * strings and binary (regular and large), day dates, timestamps of every unit with and without + * timezone (normalized to microseconds), microsecond durations, year-month and month-day-nano + * intervals, nulls, lists, maps, and structs — plus the Arrow view types (Utf8View, BinaryView, + * ListView) that Vortex produces natively. *

- * The implementation uses type-specific accessors to efficiently retrieve values from the - * underlying Arrow vectors while maintaining Spark's expected API contract. - * + * Arrow types outside that set (unsigned integers, half floats, 256-bit decimals, + * non-microsecond durations, Time, fixed-size binary and lists, large lists, unions, run-end and + * dictionary encodings) are rejected with a descriptive {@link UnsupportedOperationException}. + * * @see ColumnVector * @see ValueVector */ @@ -39,7 +48,7 @@ public class VortexArrowColumnVector extends ColumnVector { /** * Returns the underlying Apache Arrow ValueVector wrapped by this column vector. - * + * * @return the Arrow ValueVector containing the actual data */ public ValueVector getValueVector() { @@ -48,7 +57,7 @@ public ValueVector getValueVector() { /** * Returns whether this column contains any null values. - * + * * @return true if the column contains at least one null value, false otherwise */ @Override @@ -58,7 +67,7 @@ public boolean hasNull() { /** * Returns the total number of null values in this column. - * + * * @return the count of null values */ @Override @@ -76,7 +85,7 @@ public void close() {} /** * Returns whether the value at the specified row is null. - * + * * @param rowId the row index to check * @return true if the value at rowId is null, false otherwise */ @@ -87,7 +96,7 @@ public boolean isNullAt(int rowId) { /** * Returns the boolean value at the specified row. - * + * * @param rowId the row index * @return the boolean value at rowId * @throws UnsupportedOperationException if this column is not of boolean type @@ -99,7 +108,7 @@ public boolean getBoolean(int rowId) { /** * Returns the byte value at the specified row. - * + * * @param rowId the row index * @return the byte value at rowId * @throws UnsupportedOperationException if this column is not of byte type @@ -111,7 +120,7 @@ public byte getByte(int rowId) { /** * Returns the short value at the specified row. - * + * * @param rowId the row index * @return the short value at rowId * @throws UnsupportedOperationException if this column is not of short type @@ -123,7 +132,7 @@ public short getShort(int rowId) { /** * Returns the int value at the specified row. - * + * * @param rowId the row index * @return the int value at rowId * @throws UnsupportedOperationException if this column is not of int type @@ -135,7 +144,7 @@ public int getInt(int rowId) { /** * Returns the long value at the specified row. - * + * * @param rowId the row index * @return the long value at rowId * @throws UnsupportedOperationException if this column is not of long type @@ -147,7 +156,7 @@ public long getLong(int rowId) { /** * Returns the float value at the specified row. - * + * * @param rowId the row index * @return the float value at rowId * @throws UnsupportedOperationException if this column is not of float type @@ -159,7 +168,7 @@ public float getFloat(int rowId) { /** * Returns the double value at the specified row. - * + * * @param rowId the row index * @return the double value at rowId * @throws UnsupportedOperationException if this column is not of double type @@ -171,7 +180,7 @@ public double getDouble(int rowId) { /** * Returns the decimal value at the specified row with the given precision and scale. - * + * * @param rowId the row index * @param precision the precision of the decimal * @param scale the scale of the decimal @@ -186,7 +195,7 @@ public Decimal getDecimal(int rowId, int precision, int scale) { /** * Returns the UTF8String value at the specified row. - * + * * @param rowId the row index * @return the UTF8String value at rowId, or null if the value is null * @throws UnsupportedOperationException if this column is not of string type @@ -199,7 +208,7 @@ public UTF8String getUTF8String(int rowId) { /** * Returns the binary data (byte array) at the specified row. - * + * * @param rowId the row index * @return the byte array at rowId, or null if the value is null * @throws UnsupportedOperationException if this column is not of binary type @@ -212,7 +221,7 @@ public byte[] getBinary(int rowId) { /** * Returns the array value at the specified row. - * + * * @param rowId the row index * @return the ColumnarArray at rowId, or null if the value is null * @throws UnsupportedOperationException if this column is not of array type @@ -225,7 +234,7 @@ public ColumnarArray getArray(int rowId) { /** * Returns the map value at the specified row. - * + * * @param rowId the row index * @return the ColumnarMap at rowId, or null if the value is null * @throws UnsupportedOperationException if this column is not of map type @@ -240,8 +249,9 @@ public ColumnarMap getMap(int rowId) { * Returns the child column at the specified ordinal. *

* This is used for complex types like structs where each field is represented - * as a child column. - * + * as a child column. For month-day-nano interval columns, the children follow the + * months/days/microseconds protocol documented on {@link ColumnVector#getInterval(int)}. + * * @param ordinal the index of the child column * @return the child VortexArrowColumnVector at the specified ordinal * @throws ArrayIndexOutOfBoundsException if ordinal is out of bounds @@ -256,7 +266,7 @@ public VortexArrowColumnVector getChild(int ordinal) { *

* This constructor is used internally for creating column vectors before * the underlying Arrow vector is available. - * + * * @param type the Spark DataType for this column */ VortexArrowColumnVector(DataType type) { @@ -268,7 +278,7 @@ public VortexArrowColumnVector getChild(int ordinal) { *

* This constructor automatically determines the appropriate Spark DataType from * the Arrow field and initializes the type-specific accessor. - * + * * @param vector the Arrow ValueVector to wrap * @throws UnsupportedOperationException if the vector type is not supported */ @@ -277,60 +287,78 @@ public VortexArrowColumnVector(ValueVector vector) { initAccessor(vector); } + private static VortexArrowColumnVector withAccessor(DataType type, ArrowVectorAccessor accessor) { + VortexArrowColumnVector column = new VortexArrowColumnVector(type); + column.accessor = accessor; + return column; + } + void initAccessor(ValueVector vector) { - if (vector instanceof BitVector) { - accessor = new VortexArrowColumnVector.BooleanAccessor((BitVector) vector); - } else if (vector instanceof TinyIntVector) { - accessor = new VortexArrowColumnVector.ByteAccessor((TinyIntVector) vector); - } else if (vector instanceof SmallIntVector) { - accessor = new VortexArrowColumnVector.ShortAccessor((SmallIntVector) vector); - } else if (vector instanceof IntVector) { - accessor = new VortexArrowColumnVector.IntAccessor((IntVector) vector); - } else if (vector instanceof BigIntVector) { - accessor = new VortexArrowColumnVector.LongAccessor((BigIntVector) vector); - } else if (vector instanceof Float4Vector) { - accessor = new VortexArrowColumnVector.FloatAccessor((Float4Vector) vector); - } else if (vector instanceof Float8Vector) { - accessor = new VortexArrowColumnVector.DoubleAccessor((Float8Vector) vector); - } else if (vector instanceof DecimalVector) { - accessor = new VortexArrowColumnVector.DecimalAccessor((DecimalVector) vector); - } else if (vector instanceof VarCharVector) { - accessor = new VortexArrowColumnVector.StringAccessor((VarCharVector) vector); - } else if (vector instanceof LargeVarCharVector) { - accessor = new VortexArrowColumnVector.LargeStringAccessor((LargeVarCharVector) vector); - } else if (vector instanceof VarBinaryVector) { - accessor = new VortexArrowColumnVector.BinaryAccessor((VarBinaryVector) vector); - } else if (vector instanceof LargeVarBinaryVector) { - accessor = new VortexArrowColumnVector.LargeBinaryAccessor((LargeVarBinaryVector) vector); - } else if (vector instanceof DateDayVector) { - accessor = new VortexArrowColumnVector.DateAccessor((DateDayVector) vector); - } else if (vector instanceof TimeStampMicroTZVector) { - accessor = new VortexArrowColumnVector.TimestampAccessor((TimeStampMicroTZVector) vector); - } else if (vector instanceof TimeStampMicroVector) { - accessor = new VortexArrowColumnVector.TimestampNTZAccessor((TimeStampMicroVector) vector); - } else if (vector instanceof MapVector) { - MapVector mapVector = (MapVector) vector; + if (vector instanceof BitVector bitVector) { + accessor = new VortexArrowColumnVector.BooleanAccessor(bitVector); + } else if (vector instanceof TinyIntVector tinyIntVector) { + accessor = new VortexArrowColumnVector.ByteAccessor(tinyIntVector); + } else if (vector instanceof SmallIntVector smallIntVector) { + accessor = new VortexArrowColumnVector.ShortAccessor(smallIntVector); + } else if (vector instanceof IntVector intVector) { + accessor = new VortexArrowColumnVector.IntAccessor(intVector); + } else if (vector instanceof BigIntVector bigIntVector) { + accessor = new VortexArrowColumnVector.LongAccessor(bigIntVector); + } else if (vector instanceof Float4Vector float4Vector) { + accessor = new VortexArrowColumnVector.FloatAccessor(float4Vector); + } else if (vector instanceof Float8Vector float8Vector) { + accessor = new VortexArrowColumnVector.DoubleAccessor(float8Vector); + } else if (vector instanceof DecimalVector decimalVector) { + accessor = new VortexArrowColumnVector.DecimalAccessor(decimalVector); + } else if (vector instanceof VarCharVector varCharVector) { + accessor = new VortexArrowColumnVector.StringAccessor(varCharVector); + } else if (vector instanceof LargeVarCharVector largeVarCharVector) { + accessor = new VortexArrowColumnVector.LargeStringAccessor(largeVarCharVector); + } else if (vector instanceof ViewVarCharVector viewVarCharVector) { + accessor = new VortexArrowColumnVector.StringViewAccessor(viewVarCharVector); + } else if (vector instanceof VarBinaryVector varBinaryVector) { + accessor = new VortexArrowColumnVector.BinaryAccessor(varBinaryVector); + } else if (vector instanceof LargeVarBinaryVector largeVarBinaryVector) { + accessor = new VortexArrowColumnVector.LargeBinaryAccessor(largeVarBinaryVector); + } else if (vector instanceof ViewVarBinaryVector viewVarBinaryVector) { + accessor = new VortexArrowColumnVector.BinaryViewAccessor(viewVarBinaryVector); + } else if (vector instanceof DateDayVector dateDayVector) { + accessor = new VortexArrowColumnVector.DateAccessor(dateDayVector); + } else if (vector instanceof TimeStampVector timeStampVector) { + // Covers all eight unit/timezone variants; values are normalized to microseconds. + accessor = new VortexArrowColumnVector.TimestampAccessor(timeStampVector); + } else if (vector instanceof MapVector mapVector) { + // MapVector extends ListVector, so this check must come first. accessor = new VortexArrowColumnVector.MapAccessor(mapVector); - } else if (vector instanceof ListVector) { - ListVector listVector = (ListVector) vector; + } else if (vector instanceof ListVector listVector) { accessor = new VortexArrowColumnVector.ArrayAccessor(listVector); - } else if (vector instanceof StructVector) { - StructVector structVector = (StructVector) vector; + } else if (vector instanceof ListViewVector listViewVector) { + accessor = new VortexArrowColumnVector.ListViewAccessor(listViewVector); + } else if (vector instanceof StructVector structVector) { accessor = new VortexArrowColumnVector.StructAccessor(structVector); childColumns = new VortexArrowColumnVector[structVector.size()]; for (int i = 0; i < childColumns.length; ++i) { childColumns[i] = new VortexArrowColumnVector(structVector.getVectorById(i)); } - } else if (vector instanceof NullVector) { - accessor = new VortexArrowColumnVector.NullAccessor((NullVector) vector); - } else if (vector instanceof IntervalYearVector) { - accessor = new VortexArrowColumnVector.IntervalYearAccessor((IntervalYearVector) vector); - } else if (vector instanceof DurationVector) { - accessor = new VortexArrowColumnVector.DurationAccessor((DurationVector) vector); + } else if (vector instanceof NullVector nullVector) { + accessor = new VortexArrowColumnVector.NullAccessor(nullVector); + } else if (vector instanceof IntervalYearVector intervalYearVector) { + accessor = new VortexArrowColumnVector.IntervalYearAccessor(intervalYearVector); + } else if (vector instanceof IntervalMonthDayNanoVector intervalMonthDayNanoVector) { + accessor = new VortexArrowColumnVector.IntervalMonthDayNanoAccessor(intervalMonthDayNanoVector); + // CalendarInterval values are read through ColumnVector.getInterval, which uses the + // months/days/microseconds child column protocol. + childColumns = new VortexArrowColumnVector[] { + withAccessor(DataTypes.IntegerType, new IntervalMonthsAccessor(intervalMonthDayNanoVector)), + withAccessor(DataTypes.IntegerType, new IntervalDaysAccessor(intervalMonthDayNanoVector)), + withAccessor(DataTypes.LongType, new IntervalMicrosAccessor(intervalMonthDayNanoVector)), + }; + } else if (vector instanceof DurationVector durationVector) { + accessor = new VortexArrowColumnVector.DurationAccessor(durationVector); } else { - throw new UnsupportedOperationException( - "Unsupported Arrow vector type: " + vector.getClass().getName()); + throw new UnsupportedOperationException("Unsupported Arrow vector type: " + + vector.getClass().getSimpleName() + " for field " + vector.getField()); } } @@ -579,6 +607,24 @@ final UTF8String getUTF8String(int rowId) { } } + @Open + static class StringViewAccessor extends VortexArrowColumnVector.ArrowVectorAccessor { + + private final ViewVarCharVector accessor; + + StringViewAccessor(ViewVarCharVector vector) { + super(vector); + this.accessor = vector; + } + + @Override + final UTF8String getUTF8String(int rowId) { + // View values may be inlined in the view buffer rather than contiguous in a data + // buffer, so copy out rather than aliasing vector memory. + return UTF8String.fromBytes(accessor.get(rowId)); + } + } + @Open static class BinaryAccessor extends VortexArrowColumnVector.ArrowVectorAccessor { @@ -611,6 +657,22 @@ final byte[] getBinary(int rowId) { } } + @Open + static class BinaryViewAccessor extends VortexArrowColumnVector.ArrowVectorAccessor { + + private final ViewVarBinaryVector accessor; + + BinaryViewAccessor(ViewVarBinaryVector vector) { + super(vector); + this.accessor = vector; + } + + @Override + final byte[] getBinary(int rowId) { + return accessor.getObject(rowId); + } + } + @Open static class DateAccessor extends VortexArrowColumnVector.ArrowVectorAccessor { @@ -627,45 +689,63 @@ final int getInt(int rowId) { } } + /** + * Reads any of the eight timestamp vector variants (four units, with or without timezone), + * normalizing values to the microseconds Spark expects for TimestampType and TimestampNTZType. + * Seconds and milliseconds fail on overflow rather than silently wrapping; nanoseconds floor + * towards negative infinity, matching Spark's nanosecond-to-microsecond conversions. + */ @Open static class TimestampAccessor extends VortexArrowColumnVector.ArrowVectorAccessor { - private final TimeStampMicroTZVector accessor; + private final TimeStampVector accessor; + private final TimeUnit unit; - TimestampAccessor(TimeStampMicroTZVector vector) { + TimestampAccessor(TimeStampVector vector) { super(vector); this.accessor = vector; + this.unit = ((ArrowType.Timestamp) vector.getField().getType()).getUnit(); } @Override final long getLong(int rowId) { - return accessor.get(rowId); + long value = accessor.get(rowId); + return switch (unit) { + case SECOND -> Math.multiplyExact(value, 1_000_000L); + case MILLISECOND -> Math.multiplyExact(value, 1_000L); + case MICROSECOND -> value; + case NANOSECOND -> Math.floorDiv(value, 1_000L); + }; } } @Open - static class TimestampNTZAccessor extends VortexArrowColumnVector.ArrowVectorAccessor { + static class ArrayAccessor extends VortexArrowColumnVector.ArrowVectorAccessor { - private final TimeStampMicroVector accessor; + private final ListVector accessor; + private final VortexArrowColumnVector arrayData; - TimestampNTZAccessor(TimeStampMicroVector vector) { + ArrayAccessor(ListVector vector) { super(vector); this.accessor = vector; + this.arrayData = new VortexArrowColumnVector(vector.getDataVector()); } @Override - final long getLong(int rowId) { - return accessor.get(rowId); + final ColumnarArray getArray(int rowId) { + int start = accessor.getElementStartIndex(rowId); + int end = accessor.getElementEndIndex(rowId); + return new ColumnarArray(arrayData, start, end - start); } } @Open - static class ArrayAccessor extends VortexArrowColumnVector.ArrowVectorAccessor { + static class ListViewAccessor extends VortexArrowColumnVector.ArrowVectorAccessor { - private final ListVector accessor; + private final ListViewVector accessor; private final VortexArrowColumnVector arrayData; - ArrayAccessor(ListVector vector) { + ListViewAccessor(ListViewVector vector) { super(vector); this.accessor = vector; this.arrayData = new VortexArrowColumnVector(vector.getDataVector()); @@ -741,6 +821,68 @@ int getInt(int rowId) { } } + /** + * Accessor for the {@code MONTH_DAY_NANO} interval vector itself. Values are read through + * {@link ColumnVector#getInterval(int)}, which uses the months/days/microseconds child + * columns; this accessor only provides null tracking. + */ + @Open + static class IntervalMonthDayNanoAccessor extends VortexArrowColumnVector.ArrowVectorAccessor { + + IntervalMonthDayNanoAccessor(IntervalMonthDayNanoVector vector) { + super(vector); + } + } + + @Open + static class IntervalMonthsAccessor extends VortexArrowColumnVector.ArrowVectorAccessor { + + private final IntervalMonthDayNanoVector accessor; + + IntervalMonthsAccessor(IntervalMonthDayNanoVector vector) { + super(vector); + this.accessor = vector; + } + + @Override + final int getInt(int rowId) { + return IntervalMonthDayNanoVector.getMonths(accessor.getDataBuffer(), rowId); + } + } + + @Open + static class IntervalDaysAccessor extends VortexArrowColumnVector.ArrowVectorAccessor { + + private final IntervalMonthDayNanoVector accessor; + + IntervalDaysAccessor(IntervalMonthDayNanoVector vector) { + super(vector); + this.accessor = vector; + } + + @Override + final int getInt(int rowId) { + return IntervalMonthDayNanoVector.getDays(accessor.getDataBuffer(), rowId); + } + } + + @Open + static class IntervalMicrosAccessor extends VortexArrowColumnVector.ArrowVectorAccessor { + + private final IntervalMonthDayNanoVector accessor; + + IntervalMicrosAccessor(IntervalMonthDayNanoVector vector) { + super(vector); + this.accessor = vector; + } + + @Override + final long getLong(int rowId) { + // Truncating division matches Spark's ArrowColumnVector month-day-nano handling. + return IntervalMonthDayNanoVector.getNanoseconds(accessor.getDataBuffer(), rowId) / 1_000L; + } + } + @Open static class DurationAccessor extends VortexArrowColumnVector.ArrowVectorAccessor { diff --git a/java/vortex-spark/src/test/java/dev/vortex/spark/ArrowUtilsTest.java b/java/vortex-spark/src/test/java/dev/vortex/spark/ArrowUtilsTest.java index 881634e6630..50c74eb41f8 100644 --- a/java/vortex-spark/src/test/java/dev/vortex/spark/ArrowUtilsTest.java +++ b/java/vortex-spark/src/test/java/dev/vortex/spark/ArrowUtilsTest.java @@ -8,7 +8,9 @@ import dev.vortex.relocated.org.apache.arrow.vector.types.DateUnit; import dev.vortex.relocated.org.apache.arrow.vector.types.FloatingPointPrecision; +import dev.vortex.relocated.org.apache.arrow.vector.types.IntervalUnit; import dev.vortex.relocated.org.apache.arrow.vector.types.TimeUnit; +import dev.vortex.relocated.org.apache.arrow.vector.types.UnionMode; import dev.vortex.relocated.org.apache.arrow.vector.types.pojo.ArrowType; import dev.vortex.relocated.org.apache.arrow.vector.types.pojo.Field; import dev.vortex.relocated.org.apache.arrow.vector.types.pojo.FieldType; @@ -52,23 +54,26 @@ void floatingPointMapsByPrecision() { } @Test - @DisplayName("Decimal preserves precision and scale") + @DisplayName("Decimal preserves precision and scale regardless of bit width") void decimalPreservesPrecisionAndScale() { assertEquals(DataTypes.createDecimalType(20, 4), ArrowUtils.fromArrowType(new ArrowType.Decimal(20, 4, 128))); + assertEquals(DataTypes.createDecimalType(20, 4), ArrowUtils.fromArrowType(new ArrowType.Decimal(20, 4, 256))); } @Test - @DisplayName("Utf8 and LargeUtf8 map to StringType") + @DisplayName("Utf8, LargeUtf8 and Utf8View map to StringType") void utf8MapsToString() { assertEquals(DataTypes.StringType, ArrowUtils.fromArrowType(new ArrowType.Utf8())); assertEquals(DataTypes.StringType, ArrowUtils.fromArrowType(new ArrowType.LargeUtf8())); + assertEquals(DataTypes.StringType, ArrowUtils.fromArrowType(new ArrowType.Utf8View())); } @Test - @DisplayName("Binary and LargeBinary map to BinaryType") + @DisplayName("Binary, LargeBinary and BinaryView map to BinaryType") void binaryMapsToBinary() { assertEquals(DataTypes.BinaryType, ArrowUtils.fromArrowType(new ArrowType.Binary())); assertEquals(DataTypes.BinaryType, ArrowUtils.fromArrowType(new ArrowType.LargeBinary())); + assertEquals(DataTypes.BinaryType, ArrowUtils.fromArrowType(new ArrowType.BinaryView())); } @Test @@ -78,14 +83,13 @@ void dateDayMapsToDate() { } @Test - @DisplayName("Timestamp(MICROSECOND) maps to Timestamp with tz, TimestampNTZ without") + @DisplayName("Timestamps of every unit map to Timestamp with tz, TimestampNTZ without") void timestampMapsByTimezonePresence() { - assertEquals( - DataTypes.TimestampType, - ArrowUtils.fromArrowType(new ArrowType.Timestamp(TimeUnit.MICROSECOND, "UTC"))); - assertEquals( - DataTypes.TimestampNTZType, - ArrowUtils.fromArrowType(new ArrowType.Timestamp(TimeUnit.MICROSECOND, null))); + for (TimeUnit unit : + new TimeUnit[] {TimeUnit.SECOND, TimeUnit.MILLISECOND, TimeUnit.MICROSECOND, TimeUnit.NANOSECOND}) { + assertEquals(DataTypes.TimestampType, ArrowUtils.fromArrowType(new ArrowType.Timestamp(unit, "UTC"))); + assertEquals(DataTypes.TimestampNTZType, ArrowUtils.fromArrowType(new ArrowType.Timestamp(unit, null))); + } } @Test @@ -94,6 +98,30 @@ void nullMapsToNull() { assertEquals(DataTypes.NullType, ArrowUtils.fromArrowType(new ArrowType.Null())); } + @Test + @DisplayName("Interval(YEAR_MONTH) maps to YearMonthIntervalType") + void yearMonthIntervalMapsToYearMonthIntervalType() { + assertEquals( + DataTypes.createYearMonthIntervalType(), + ArrowUtils.fromArrowType(new ArrowType.Interval(IntervalUnit.YEAR_MONTH))); + } + + @Test + @DisplayName("Interval(MONTH_DAY_NANO) maps to CalendarIntervalType") + void monthDayNanoIntervalMapsToCalendarIntervalType() { + assertEquals( + DataTypes.CalendarIntervalType, + ArrowUtils.fromArrowType(new ArrowType.Interval(IntervalUnit.MONTH_DAY_NANO))); + } + + @Test + @DisplayName("Duration(MICROSECOND) maps to DayTimeIntervalType") + void microsecondDurationMapsToDayTimeIntervalType() { + assertEquals( + DataTypes.createDayTimeIntervalType(), + ArrowUtils.fromArrowType(new ArrowType.Duration(TimeUnit.MICROSECOND))); + } + @Test @DisplayName("fromArrowField builds a StructType from nested children") void structFieldBuildsStructType() { @@ -122,6 +150,35 @@ void listFieldBuildsArrayType() { assertEquals(DataTypes.createArrayType(DataTypes.IntegerType, true), ArrowUtils.fromArrowField(list)); } + @Test + @DisplayName("fromArrowField builds an ArrayType from a ListView field") + void listViewFieldBuildsArrayType() { + Field listView = new Field( + "lv", + FieldType.nullable(new ArrowType.ListView()), + List.of(new Field("element", FieldType.notNullable(new ArrowType.Utf8View()), null))); + + assertEquals(DataTypes.createArrayType(DataTypes.StringType, false), ArrowUtils.fromArrowField(listView)); + } + + @Test + @DisplayName("fromArrowField builds a MapType carrying the value's nullability") + void mapFieldBuildsMapType() { + assertEquals( + DataTypes.createMapType(DataTypes.StringType, DataTypes.LongType, true), + ArrowUtils.fromArrowField(mapField(FieldType.nullable(new ArrowType.Int(64, true))))); + assertEquals( + DataTypes.createMapType(DataTypes.StringType, DataTypes.LongType, false), + ArrowUtils.fromArrowField(mapField(FieldType.notNullable(new ArrowType.Int(64, true))))); + } + + private static Field mapField(FieldType valueType) { + Field key = new Field("key", FieldType.notNullable(new ArrowType.Utf8()), null); + Field value = new Field("value", valueType, null); + Field entries = new Field("entries", FieldType.notNullable(new ArrowType.Struct()), List.of(key, value)); + return new Field("m", FieldType.nullable(new ArrowType.Map(false)), List.of(entries)); + } + @Test @DisplayName("Unsigned integers are unsupported") void unsignedIntegerIsUnsupported() { @@ -145,18 +202,56 @@ void millisecondDateIsUnsupported() { } @Test - @DisplayName("Non-microsecond timestamp units are unsupported") - void secondTimestampIsUnsupported() { + @DisplayName("Non-microsecond duration units are unsupported") + void nonMicrosecondDurationsAreUnsupported() { + for (TimeUnit unit : new TimeUnit[] {TimeUnit.SECOND, TimeUnit.MILLISECOND, TimeUnit.NANOSECOND}) { + assertThrows( + UnsupportedOperationException.class, () -> ArrowUtils.fromArrowType(new ArrowType.Duration(unit))); + } + } + + @Test + @DisplayName("Time is unsupported: Spark's TimeType only exists in Spark 4.1+") + void timeIsUnsupported() { + assertThrows( + UnsupportedOperationException.class, + () -> ArrowUtils.fromArrowType(new ArrowType.Time(TimeUnit.NANOSECOND, 64))); + assertThrows( + UnsupportedOperationException.class, + () -> ArrowUtils.fromArrowType(new ArrowType.Time(TimeUnit.MILLISECOND, 32))); + } + + @Test + @DisplayName("Interval(DAY_TIME) is unsupported") + void dayTimeIntervalIsUnsupported() { assertThrows( UnsupportedOperationException.class, - () -> ArrowUtils.fromArrowType(new ArrowType.Timestamp(TimeUnit.SECOND, "UTC"))); + () -> ArrowUtils.fromArrowType(new ArrowType.Interval(IntervalUnit.DAY_TIME))); + } + + @Test + @DisplayName("FixedSizeBinary is unsupported") + void fixedSizeBinaryIsUnsupported() { + assertThrows( + UnsupportedOperationException.class, () -> ArrowUtils.fromArrowType(new ArrowType.FixedSizeBinary(16))); } @Test - @DisplayName("Unrecognized Arrow types raise IllegalArgumentException") - void unrecognizedTypeIsIllegalArgument() { + @DisplayName("Union is unsupported") + void unionIsUnsupported() { assertThrows( - IllegalArgumentException.class, - () -> ArrowUtils.fromArrowType(new ArrowType.Duration(TimeUnit.SECOND))); + UnsupportedOperationException.class, + () -> ArrowUtils.fromArrowType(new ArrowType.Union(UnionMode.Sparse, new int[0]))); + } + + @Test + @DisplayName("LargeList and FixedSizeList fields are unsupported") + void largeAndFixedSizeListFieldsAreUnsupported() { + Field element = new Field("element", FieldType.nullable(new ArrowType.Int(32, true)), null); + Field largeList = new Field("ll", FieldType.nullable(new ArrowType.LargeList()), List.of(element)); + Field fixedSizeList = new Field("fsl", FieldType.nullable(new ArrowType.FixedSizeList(2)), List.of(element)); + + assertThrows(UnsupportedOperationException.class, () -> ArrowUtils.fromArrowField(largeList)); + assertThrows(UnsupportedOperationException.class, () -> ArrowUtils.fromArrowField(fixedSizeList)); } } diff --git a/java/vortex-spark/src/test/java/dev/vortex/spark/read/VortexArrowColumnVectorTest.java b/java/vortex-spark/src/test/java/dev/vortex/spark/read/VortexArrowColumnVectorTest.java new file mode 100644 index 00000000000..9ae0ba86fd8 --- /dev/null +++ b/java/vortex-spark/src/test/java/dev/vortex/spark/read/VortexArrowColumnVectorTest.java @@ -0,0 +1,523 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +package dev.vortex.spark.read; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.vortex.relocated.org.apache.arrow.memory.BufferAllocator; +import dev.vortex.relocated.org.apache.arrow.memory.RootAllocator; +import dev.vortex.relocated.org.apache.arrow.vector.BigIntVector; +import dev.vortex.relocated.org.apache.arrow.vector.BitVector; +import dev.vortex.relocated.org.apache.arrow.vector.DateDayVector; +import dev.vortex.relocated.org.apache.arrow.vector.DateMilliVector; +import dev.vortex.relocated.org.apache.arrow.vector.Decimal256Vector; +import dev.vortex.relocated.org.apache.arrow.vector.DecimalVector; +import dev.vortex.relocated.org.apache.arrow.vector.DurationVector; +import dev.vortex.relocated.org.apache.arrow.vector.Float2Vector; +import dev.vortex.relocated.org.apache.arrow.vector.Float4Vector; +import dev.vortex.relocated.org.apache.arrow.vector.Float8Vector; +import dev.vortex.relocated.org.apache.arrow.vector.IntVector; +import dev.vortex.relocated.org.apache.arrow.vector.IntervalMonthDayNanoVector; +import dev.vortex.relocated.org.apache.arrow.vector.IntervalYearVector; +import dev.vortex.relocated.org.apache.arrow.vector.LargeVarBinaryVector; +import dev.vortex.relocated.org.apache.arrow.vector.LargeVarCharVector; +import dev.vortex.relocated.org.apache.arrow.vector.NullVector; +import dev.vortex.relocated.org.apache.arrow.vector.SmallIntVector; +import dev.vortex.relocated.org.apache.arrow.vector.TimeMicroVector; +import dev.vortex.relocated.org.apache.arrow.vector.TimeStampMicroTZVector; +import dev.vortex.relocated.org.apache.arrow.vector.TimeStampMicroVector; +import dev.vortex.relocated.org.apache.arrow.vector.TimeStampMilliTZVector; +import dev.vortex.relocated.org.apache.arrow.vector.TimeStampMilliVector; +import dev.vortex.relocated.org.apache.arrow.vector.TimeStampNanoTZVector; +import dev.vortex.relocated.org.apache.arrow.vector.TimeStampNanoVector; +import dev.vortex.relocated.org.apache.arrow.vector.TimeStampSecTZVector; +import dev.vortex.relocated.org.apache.arrow.vector.TimeStampSecVector; +import dev.vortex.relocated.org.apache.arrow.vector.TimeStampVector; +import dev.vortex.relocated.org.apache.arrow.vector.TinyIntVector; +import dev.vortex.relocated.org.apache.arrow.vector.UInt4Vector; +import dev.vortex.relocated.org.apache.arrow.vector.VarBinaryVector; +import dev.vortex.relocated.org.apache.arrow.vector.VarCharVector; +import dev.vortex.relocated.org.apache.arrow.vector.ViewVarBinaryVector; +import dev.vortex.relocated.org.apache.arrow.vector.ViewVarCharVector; +import dev.vortex.relocated.org.apache.arrow.vector.complex.ListVector; +import dev.vortex.relocated.org.apache.arrow.vector.complex.ListViewVector; +import dev.vortex.relocated.org.apache.arrow.vector.complex.MapVector; +import dev.vortex.relocated.org.apache.arrow.vector.complex.StructVector; +import dev.vortex.relocated.org.apache.arrow.vector.complex.impl.NullableStructWriter; +import dev.vortex.relocated.org.apache.arrow.vector.complex.impl.UnionListViewWriter; +import dev.vortex.relocated.org.apache.arrow.vector.complex.impl.UnionListWriter; +import dev.vortex.relocated.org.apache.arrow.vector.complex.impl.UnionMapWriter; +import dev.vortex.relocated.org.apache.arrow.vector.types.TimeUnit; +import dev.vortex.relocated.org.apache.arrow.vector.types.pojo.ArrowType; +import dev.vortex.relocated.org.apache.arrow.vector.types.pojo.FieldType; +import dev.vortex.relocated.org.apache.arrow.vector.util.Text; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import org.apache.spark.sql.types.ArrayType; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.MapType; +import org.apache.spark.sql.types.StructType; +import org.apache.spark.sql.vectorized.ColumnarArray; +import org.apache.spark.sql.vectorized.ColumnarMap; +import org.apache.spark.unsafe.types.CalendarInterval; +import org.apache.spark.unsafe.types.UTF8String; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link VortexArrowColumnVector} covering every Arrow vector type it supports (Spark 4.1's own + * ArrowColumnVector set plus the Arrow view types): the Spark type mapping, the value conversion, and null handling. + */ +final class VortexArrowColumnVectorTest { + + private static final BufferAllocator ALLOCATOR = new RootAllocator(); + + @AfterAll + static void closeAllocator() { + ALLOCATOR.close(); + } + + @Test + @DisplayName("BitVector maps to BooleanType") + void booleanVector() { + try (BitVector vector = new BitVector("bool", ALLOCATOR)) { + vector.allocateNew(3); + vector.set(0, 1); + vector.set(2, 0); + vector.setValueCount(3); + + VortexArrowColumnVector column = new VortexArrowColumnVector(vector); + assertEquals(DataTypes.BooleanType, column.dataType()); + assertTrue(column.getBoolean(0)); + assertTrue(column.isNullAt(1)); + assertFalse(column.getBoolean(2)); + assertTrue(column.hasNull()); + assertEquals(1, column.numNulls()); + } + } + + @Test + @DisplayName("Signed integer vectors map to Byte/Short/Integer/LongType") + void signedIntegers() { + try (TinyIntVector i8 = new TinyIntVector("i8", ALLOCATOR); + SmallIntVector i16 = new SmallIntVector("i16", ALLOCATOR); + IntVector i32 = new IntVector("i32", ALLOCATOR); + BigIntVector i64 = new BigIntVector("i64", ALLOCATOR)) { + i8.allocateNew(2); + i8.set(0, Byte.MIN_VALUE); + i8.setValueCount(2); + i16.allocateNew(2); + i16.set(0, Short.MIN_VALUE); + i16.setValueCount(2); + i32.allocateNew(2); + i32.set(0, Integer.MIN_VALUE); + i32.setValueCount(2); + i64.allocateNew(2); + i64.set(0, Long.MIN_VALUE); + i64.setValueCount(2); + + VortexArrowColumnVector byteColumn = new VortexArrowColumnVector(i8); + assertEquals(DataTypes.ByteType, byteColumn.dataType()); + assertEquals(Byte.MIN_VALUE, byteColumn.getByte(0)); + assertTrue(byteColumn.isNullAt(1)); + + VortexArrowColumnVector shortColumn = new VortexArrowColumnVector(i16); + assertEquals(DataTypes.ShortType, shortColumn.dataType()); + assertEquals(Short.MIN_VALUE, shortColumn.getShort(0)); + + VortexArrowColumnVector intColumn = new VortexArrowColumnVector(i32); + assertEquals(DataTypes.IntegerType, intColumn.dataType()); + assertEquals(Integer.MIN_VALUE, intColumn.getInt(0)); + + VortexArrowColumnVector longColumn = new VortexArrowColumnVector(i64); + assertEquals(DataTypes.LongType, longColumn.dataType()); + assertEquals(Long.MIN_VALUE, longColumn.getLong(0)); + } + } + + @Test + @DisplayName("Float vectors map to Float/DoubleType") + void floats() { + try (Float4Vector f32 = new Float4Vector("f32", ALLOCATOR); + Float8Vector f64 = new Float8Vector("f64", ALLOCATOR)) { + f32.allocateNew(2); + f32.set(0, 2.5f); + f32.setValueCount(2); + f64.allocateNew(2); + f64.set(0, 3.5d); + f64.setValueCount(2); + + VortexArrowColumnVector floatColumn = new VortexArrowColumnVector(f32); + assertEquals(DataTypes.FloatType, floatColumn.dataType()); + assertEquals(2.5f, floatColumn.getFloat(0)); + assertTrue(floatColumn.isNullAt(1)); + + VortexArrowColumnVector doubleColumn = new VortexArrowColumnVector(f64); + assertEquals(DataTypes.DoubleType, doubleColumn.dataType()); + assertEquals(3.5d, doubleColumn.getDouble(0)); + } + } + + @Test + @DisplayName("Decimal128 vectors map to DecimalType") + void decimal() { + try (DecimalVector d128 = new DecimalVector("d128", ALLOCATOR, 10, 2)) { + d128.allocateNew(2); + d128.set(0, new BigDecimal("12345678.90")); + d128.setValueCount(2); + + VortexArrowColumnVector column = new VortexArrowColumnVector(d128); + assertEquals(DataTypes.createDecimalType(10, 2), column.dataType()); + assertEquals( + new BigDecimal("12345678.90"), column.getDecimal(0, 10, 2).toJavaBigDecimal()); + assertTrue(column.isNullAt(1)); + } + } + + @Test + @DisplayName("String vectors (regular, large, view) map to StringType") + void strings() { + try (VarCharVector utf8 = new VarCharVector("utf8", ALLOCATOR); + LargeVarCharVector largeUtf8 = new LargeVarCharVector("large_utf8", ALLOCATOR); + ViewVarCharVector utf8View = new ViewVarCharVector("utf8_view", ALLOCATOR)) { + utf8.allocateNew(2); + utf8.setSafe(0, "hello".getBytes(StandardCharsets.UTF_8)); + utf8.setValueCount(2); + largeUtf8.allocateNew(2); + largeUtf8.setSafe(0, "world".getBytes(StandardCharsets.UTF_8)); + largeUtf8.setValueCount(2); + utf8View.allocateNew(2); + utf8View.setSafe(0, new Text("a string long enough to not be inlined")); + utf8View.setValueCount(2); + + VortexArrowColumnVector utf8Column = new VortexArrowColumnVector(utf8); + assertEquals(DataTypes.StringType, utf8Column.dataType()); + assertEquals(UTF8String.fromString("hello"), utf8Column.getUTF8String(0)); + assertTrue(utf8Column.isNullAt(1)); + + VortexArrowColumnVector largeUtf8Column = new VortexArrowColumnVector(largeUtf8); + assertEquals(DataTypes.StringType, largeUtf8Column.dataType()); + assertEquals(UTF8String.fromString("world"), largeUtf8Column.getUTF8String(0)); + + VortexArrowColumnVector utf8ViewColumn = new VortexArrowColumnVector(utf8View); + assertEquals(DataTypes.StringType, utf8ViewColumn.dataType()); + assertEquals( + UTF8String.fromString("a string long enough to not be inlined"), utf8ViewColumn.getUTF8String(0)); + assertTrue(utf8ViewColumn.isNullAt(1)); + } + } + + @Test + @DisplayName("Binary vectors (regular, large, view) map to BinaryType") + void binary() { + byte[] payload = new byte[] {1, 2, 3, 4}; + try (VarBinaryVector bin = new VarBinaryVector("bin", ALLOCATOR); + LargeVarBinaryVector largeBin = new LargeVarBinaryVector("large_bin", ALLOCATOR); + ViewVarBinaryVector binView = new ViewVarBinaryVector("bin_view", ALLOCATOR)) { + bin.allocateNew(2); + bin.setSafe(0, payload); + bin.setValueCount(2); + largeBin.allocateNew(2); + largeBin.setSafe(0, payload); + largeBin.setValueCount(2); + binView.allocateNew(2); + binView.setSafe(0, payload); + binView.setValueCount(2); + + for (VortexArrowColumnVector column : new VortexArrowColumnVector[] { + new VortexArrowColumnVector(bin), + new VortexArrowColumnVector(largeBin), + new VortexArrowColumnVector(binView), + }) { + assertEquals(DataTypes.BinaryType, column.dataType()); + assertArrayEquals(payload, column.getBinary(0)); + assertTrue(column.isNullAt(1)); + } + } + } + + @Test + @DisplayName("Day-unit date vectors map to DateType") + void date() { + try (DateDayVector vector = new DateDayVector("date_day", ALLOCATOR)) { + vector.allocateNew(2); + vector.set(0, 19000); + vector.setValueCount(2); + + VortexArrowColumnVector column = new VortexArrowColumnVector(vector); + assertEquals(DataTypes.DateType, column.dataType()); + assertEquals(19000, column.getInt(0)); + assertTrue(column.isNullAt(1)); + } + } + + @Test + @DisplayName("Timestamp vectors of every unit normalize to microseconds") + void timestampsWithoutTimezone() { + try (TimeStampSecVector sec = new TimeStampSecVector("ts_s", ALLOCATOR); + TimeStampMilliVector milli = new TimeStampMilliVector("ts_ms", ALLOCATOR); + TimeStampMicroVector micro = new TimeStampMicroVector("ts_us", ALLOCATOR); + TimeStampNanoVector nano = new TimeStampNanoVector("ts_ns", ALLOCATOR)) { + sec.allocateNew(3); + sec.set(0, 1_700_000_000L); + sec.setValueCount(3); + milli.allocateNew(3); + milli.set(0, 1_700_000_000_123L); + milli.setValueCount(3); + micro.allocateNew(3); + micro.set(0, 1_700_000_000_123_456L); + micro.setValueCount(3); + nano.allocateNew(3); + nano.set(0, 1_700_000_000_123_456_789L); + // Negative nanos floor towards negative infinity when reduced to micros. + nano.set(2, -1_500L); + nano.setValueCount(3); + + assertTimestamp(sec, DataTypes.TimestampNTZType, 1_700_000_000_000_000L); + assertTimestamp(milli, DataTypes.TimestampNTZType, 1_700_000_000_123_000L); + assertTimestamp(micro, DataTypes.TimestampNTZType, 1_700_000_000_123_456L); + VortexArrowColumnVector nanoColumn = + assertTimestamp(nano, DataTypes.TimestampNTZType, 1_700_000_000_123_456L); + assertEquals(-2L, nanoColumn.getLong(2)); + } + } + + @Test + @DisplayName("Timezone-aware timestamp vectors of every unit map to TimestampType") + void timestampsWithTimezone() { + try (TimeStampSecTZVector sec = new TimeStampSecTZVector("ts_s", ALLOCATOR, "UTC"); + TimeStampMilliTZVector milli = new TimeStampMilliTZVector("ts_ms", ALLOCATOR, "UTC"); + TimeStampMicroTZVector micro = new TimeStampMicroTZVector("ts_us", ALLOCATOR, "UTC"); + TimeStampNanoTZVector nano = new TimeStampNanoTZVector("ts_ns", ALLOCATOR, "UTC")) { + sec.allocateNew(1); + sec.set(0, 1_700_000_000L); + sec.setValueCount(1); + milli.allocateNew(1); + milli.set(0, 1_700_000_000_123L); + milli.setValueCount(1); + micro.allocateNew(1); + micro.set(0, 1_700_000_000_123_456L); + micro.setValueCount(1); + nano.allocateNew(1); + nano.set(0, 1_700_000_000_123_456_789L); + nano.setValueCount(1); + + assertTimestamp(sec, DataTypes.TimestampType, 1_700_000_000_000_000L); + assertTimestamp(milli, DataTypes.TimestampType, 1_700_000_000_123_000L); + assertTimestamp(micro, DataTypes.TimestampType, 1_700_000_000_123_456L); + assertTimestamp(nano, DataTypes.TimestampType, 1_700_000_000_123_456L); + } + } + + private static VortexArrowColumnVector assertTimestamp( + TimeStampVector vector, org.apache.spark.sql.types.DataType expectedType, long expectedMicros) { + VortexArrowColumnVector column = new VortexArrowColumnVector(vector); + assertEquals(expectedType, column.dataType()); + assertEquals(expectedMicros, column.getLong(0)); + return column; + } + + @Test + @DisplayName("Microsecond duration vectors map to DayTimeIntervalType") + void duration() { + try (DurationVector vector = new DurationVector( + "dur_us", FieldType.nullable(new ArrowType.Duration(TimeUnit.MICROSECOND)), ALLOCATOR)) { + vector.allocateNew(2); + vector.set(0, 1_500L); + vector.setValueCount(2); + + VortexArrowColumnVector column = new VortexArrowColumnVector(vector); + assertEquals(DataTypes.createDayTimeIntervalType(), column.dataType()); + assertEquals(1_500L, column.getLong(0)); + assertTrue(column.isNullAt(1)); + } + } + + @Test + @DisplayName("Year-month interval vectors map to YearMonthIntervalType as months") + void intervalYear() { + try (IntervalYearVector vector = new IntervalYearVector("interval_ym", ALLOCATOR)) { + vector.allocateNew(2); + vector.set(0, 14); + vector.setValueCount(2); + + VortexArrowColumnVector column = new VortexArrowColumnVector(vector); + assertEquals(DataTypes.createYearMonthIntervalType(), column.dataType()); + assertEquals(14, column.getInt(0)); + assertTrue(column.isNullAt(1)); + } + } + + @Test + @DisplayName("Month-day-nano interval vectors map to CalendarIntervalType") + void intervalMonthDayNano() { + try (IntervalMonthDayNanoVector vector = new IntervalMonthDayNanoVector("interval_mdn", ALLOCATOR)) { + vector.allocateNew(2); + vector.set(0, 1, 2, 3_500L); + vector.setValueCount(2); + + VortexArrowColumnVector column = new VortexArrowColumnVector(vector); + assertEquals(DataTypes.CalendarIntervalType, column.dataType()); + // Nanoseconds truncate to microseconds, matching Spark's ArrowColumnVector. + assertEquals(new CalendarInterval(1, 2, 3L), column.getInterval(0)); + assertTrue(column.isNullAt(1)); + } + } + + @Test + @DisplayName("Null vectors map to NullType") + void nullVector() { + try (NullVector vector = new NullVector("null_col")) { + vector.setValueCount(2); + + VortexArrowColumnVector column = new VortexArrowColumnVector(vector); + assertEquals(DataTypes.NullType, column.dataType()); + assertTrue(column.isNullAt(0)); + assertTrue(column.isNullAt(1)); + } + } + + @Test + @DisplayName("List vectors map to ArrayType") + void list() { + try (ListVector vector = ListVector.empty("list", ALLOCATOR)) { + UnionListWriter writer = vector.getWriter(); + writer.allocate(); + writer.setPosition(0); + writer.startList(); + writer.writeInt(1); + writer.writeInt(2); + writer.writeInt(3); + writer.endList(); + writer.setPosition(2); + writer.startList(); + writer.endList(); + vector.setValueCount(3); + + VortexArrowColumnVector column = new VortexArrowColumnVector(vector); + assertEquals(DataTypes.IntegerType, ((ArrayType) column.dataType()).elementType()); + ColumnarArray array = column.getArray(0); + assertEquals(3, array.numElements()); + assertEquals(1, array.getInt(0)); + assertEquals(3, array.getInt(2)); + assertTrue(column.isNullAt(1)); + assertEquals(0, column.getArray(2).numElements()); + } + } + + @Test + @DisplayName("List view vectors map to ArrayType") + void listView() { + try (ListViewVector vector = ListViewVector.empty("list_view", ALLOCATOR)) { + UnionListViewWriter writer = vector.getWriter(); + writer.allocate(); + writer.setPosition(0); + writer.startListView(); + writer.writeInt(7); + writer.writeInt(8); + writer.endListView(); + vector.setValueCount(2); + + VortexArrowColumnVector column = new VortexArrowColumnVector(vector); + assertEquals(DataTypes.IntegerType, ((ArrayType) column.dataType()).elementType()); + ColumnarArray array = column.getArray(0); + assertEquals(2, array.numElements()); + assertEquals(7, array.getInt(0)); + assertEquals(8, array.getInt(1)); + assertTrue(column.isNullAt(1)); + } + } + + @Test + @DisplayName("Map vectors map to MapType") + void map() { + try (MapVector vector = MapVector.empty("map", ALLOCATOR, false)) { + UnionMapWriter writer = vector.getWriter(); + writer.allocate(); + writer.setPosition(0); + writer.startMap(); + writer.startEntry(); + writer.key().integer().writeInt(1); + writer.value().bigInt().writeBigInt(100L); + writer.endEntry(); + writer.startEntry(); + writer.key().integer().writeInt(2); + writer.value().bigInt().writeBigInt(200L); + writer.endEntry(); + writer.endMap(); + vector.setValueCount(2); + + VortexArrowColumnVector column = new VortexArrowColumnVector(vector); + MapType mapType = (MapType) column.dataType(); + assertEquals(DataTypes.IntegerType, mapType.keyType()); + assertEquals(DataTypes.LongType, mapType.valueType()); + + ColumnarMap columnarMap = column.getMap(0); + assertEquals(2, columnarMap.numElements()); + assertEquals(1, columnarMap.keyArray().getInt(0)); + assertEquals(100L, columnarMap.valueArray().getLong(0)); + assertEquals(2, columnarMap.keyArray().getInt(1)); + assertEquals(200L, columnarMap.valueArray().getLong(1)); + assertTrue(column.isNullAt(1)); + } + } + + @Test + @DisplayName("Struct vectors map to StructType with child columns") + void struct() { + try (StructVector vector = StructVector.empty("struct", ALLOCATOR)) { + NullableStructWriter writer = vector.getWriter(); + writer.allocate(); + writer.setPosition(0); + writer.start(); + writer.integer("a").writeInt(5); + writer.bigInt("b").writeBigInt(7L); + writer.end(); + vector.setValueCount(2); + + VortexArrowColumnVector column = new VortexArrowColumnVector(vector); + StructType structType = (StructType) column.dataType(); + assertEquals(2, structType.fields().length); + assertEquals(DataTypes.IntegerType, structType.fields()[0].dataType()); + assertEquals(DataTypes.LongType, structType.fields()[1].dataType()); + assertEquals(5, column.getChild(0).getInt(0)); + assertEquals(7L, column.getChild(1).getLong(0)); + assertTrue(column.isNullAt(1)); + } + } + + @Test + @DisplayName("Arrow types outside the supported set are rejected with descriptive errors") + void unsupportedTypes() { + try (UInt4Vector unsignedInt = new UInt4Vector("u32", ALLOCATOR); + Float2Vector halfFloat = new Float2Vector("f16", ALLOCATOR); + Decimal256Vector decimal256 = new Decimal256Vector("d256", ALLOCATOR, 38, 2); + DateMilliVector dateMilli = new DateMilliVector("date_ms", ALLOCATOR); + TimeMicroVector time = new TimeMicroVector("time_us", ALLOCATOR); + DurationVector durationSec = new DurationVector( + "dur_s", FieldType.nullable(new ArrowType.Duration(TimeUnit.SECOND)), ALLOCATOR)) { + assertUnsupported(unsignedInt, "unsigned"); + assertUnsupported(halfFloat, "float precision"); + // Decimal256 maps to DecimalType but has no accessor, matching Spark's ArrowColumnVector. + assertUnsupported(decimal256, "Decimal256Vector"); + assertUnsupported(dateMilli, "date unit"); + assertUnsupported(time, "Time"); + assertUnsupported(durationSec, "duration unit"); + } + } + + private static void assertUnsupported( + dev.vortex.relocated.org.apache.arrow.vector.ValueVector vector, String expectedMessagePart) { + UnsupportedOperationException e = + assertThrows(UnsupportedOperationException.class, () -> new VortexArrowColumnVector(vector)); + assertTrue( + e.getMessage().contains(expectedMessagePart), + "expected error message to contain \"" + expectedMessagePart + "\" but was: " + e.getMessage()); + } +} diff --git a/vortex-jni/src/dtype.rs b/vortex-jni/src/dtype.rs index 8969d7b573a..1014ae07613 100644 --- a/vortex-jni/src/dtype.rs +++ b/vortex-jni/src/dtype.rs @@ -7,70 +7,23 @@ use std::ptr; use arrow_array::ffi::FFI_ArrowSchema; -use arrow_schema::DataType; -use arrow_schema::FieldRef; -use arrow_schema::Fields; use arrow_schema::Schema; use vortex::dtype::DType; use vortex::error::VortexResult; use vortex_arrow::ToArrowType; -/// Export a Vortex [`DType`] to the Arrow C Data Interface struct at `schema_addr`. Views -/// (Utf8View/BinaryView) are downgraded to regular Utf8/Binary so Spark and other consumers -/// without view support can read them. +/// Export a Vortex [`DType`] to the Arrow C Data Interface struct at `schema_addr`. String and +/// binary columns are exported as their native view types (Utf8View/BinaryView); consumers are +/// expected to handle them. pub(crate) fn export_dtype_to_arrow(dtype: &DType, schema_addr: i64) -> VortexResult<()> { let arrow_schema = dtype.to_arrow_schema()?; - let viewless = strip_views(DataType::Struct(arrow_schema.fields().clone())); - let fields = match viewless { - DataType::Struct(fields) => fields, - _ => unreachable!("Vortex DType always exports as a struct"), - }; - let schema = Schema::new(fields); - let ffi_schema = FFI_ArrowSchema::try_from(&schema)?; + let ffi_schema = FFI_ArrowSchema::try_from(&arrow_schema)?; unsafe { ptr::write(schema_addr as *mut FFI_ArrowSchema, ffi_schema); } Ok(()) } -/// Replace view-based Arrow types with their non-view counterparts throughout the tree. -pub(crate) fn strip_views(data_type: DataType) -> DataType { - match data_type { - DataType::BinaryView => DataType::Binary, - DataType::Utf8View => DataType::Utf8, - DataType::List(inner) | DataType::ListView(inner) => { - let new_inner = (*inner) - .clone() - .with_data_type(strip_views(inner.data_type().clone())); - DataType::List(FieldRef::new(new_inner)) - } - DataType::LargeList(inner) | DataType::LargeListView(inner) => { - let new_inner = (*inner) - .clone() - .with_data_type(strip_views(inner.data_type().clone())); - DataType::LargeList(FieldRef::new(new_inner)) - } - DataType::Struct(fields) => { - let viewless_fields: Vec = fields - .iter() - .map(|field_ref| { - let field = (**field_ref).clone(); - let data_type = field.data_type().clone(); - FieldRef::new(field.with_data_type(strip_views(data_type))) - }) - .collect(); - DataType::Struct(Fields::from(viewless_fields)) - } - DataType::FixedSizeList(inner, size) => { - let new_inner = (*inner) - .clone() - .with_data_type(strip_views(inner.data_type().clone())); - DataType::FixedSizeList(FieldRef::new(new_inner), size) - } - dt => dt, - } -} - /// Decode an [`FFI_ArrowSchema`] pointed to by `schema_addr` into an Arrow [`Schema`]. pub(crate) fn import_arrow_schema(schema_addr: i64) -> VortexResult { let ffi_schema = unsafe { &*(schema_addr as *const FFI_ArrowSchema) }; diff --git a/vortex-jni/src/scan.rs b/vortex-jni/src/scan.rs index 87819681686..4b067706eba 100644 --- a/vortex-jni/src/scan.rs +++ b/vortex-jni/src/scan.rs @@ -19,7 +19,6 @@ use arrow_array::RecordBatch; use arrow_array::cast::AsArray; use arrow_array::ffi_stream::FFI_ArrowArrayStream; use arrow_schema::ArrowError; -use arrow_schema::DataType; use arrow_schema::Field; use futures::StreamExt; use jni::EnvUnowned; @@ -49,7 +48,7 @@ use vortex_arrow::ToArrowType; use crate::POOL; use crate::RUNTIME; use crate::data_source::NativeDataSource; -use crate::dtype::strip_views; +use crate::dtype::export_dtype_to_arrow; use crate::errors::try_or_throw; use crate::session::session_ref; @@ -216,7 +215,7 @@ pub extern "system" fn Java_dev_vortex_jni_NativeScan_arrowSchema( let NativeScan::Pending(scan) = scan else { throw_runtime!("schema unavailable: scan already started"); }; - crate::dtype::export_dtype_to_arrow(scan.dtype(), schema_addr)?; + export_dtype_to_arrow(scan.dtype(), schema_addr)?; Ok(()) }); } @@ -344,13 +343,7 @@ pub extern "system" fn Java_dev_vortex_jni_NativePartition_scanArrow( let array_stream = partition.execute()?; let dtype = array_stream.dtype().clone(); - let raw_schema = dtype.to_arrow_schema()?; - let viewless = strip_views(DataType::Struct(raw_schema.fields().clone())); - let fields = match viewless { - DataType::Struct(fields) => fields, - _ => unreachable!("Vortex DType always exports as a struct"), - }; - let schema = Arc::new(arrow_schema::Schema::new(fields)); + let schema = Arc::new(dtype.to_arrow_schema()?); let target = Arc::new(Field::new_struct("", schema.fields().clone(), false)); let session = unsafe { session_ref(session_ptr) }; From cdaa5a5e80f822eb9edcb2183cffc624aed8ed57 Mon Sep 17 00:00:00 2001 From: Nemo Yu <83347615+HarukiMoriarty@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:20:26 -0400 Subject: [PATCH 085/104] feat(vortex-geo): prune ST_Intersects filters via the AABB zone statistics (#8775) ## Rationale for this change Follow-up to #8646, which added the `GeometryAabb` zone-map statistic and used it to prune `ST_Distance(geom, const) r` filters. This PR adds the second spatial predicate on the same statistic: `ST_Intersects(geom, const)`. A chunk whose AABB is strictly separated from the constant's bounding box cannot contain an intersecting row, so the zone is skipped without any IO. This PR also folds in two review follow-ups from #8646 that were pushed minutes after it merged: tests pinning the radius-literal tolerance set, and the `aabb_stat` constructor requested in review. ## What changes are included in this PR? - **`GeoIntersectsPrune`** (`prune/intersects.rs`): The proof is `min_dist_sq(chunk_aabb, query_aabb) > 0`, strictly: boxes that merely touch must scan, since touching geometries intersect under OGC semantics. - **`prune/` restructure**. - **#8646 review follow-ups**: the radius conversion's contract is documented and pinned by tests. Signed-off-by: Nemo Yu --- vortex-geo/src/lib.rs | 4 +- .../src/{prune.rs => prune/distance.rs} | 319 +++++------------- vortex-geo/src/prune/intersects.rs | 166 +++++++++ vortex-geo/src/prune/mod.rs | 164 +++++++++ vortex-geo/src/prune/test_harness.rs | 59 ++++ 5 files changed, 478 insertions(+), 234 deletions(-) rename vortex-geo/src/{prune.rs => prune/distance.rs} (54%) create mode 100644 vortex-geo/src/prune/intersects.rs create mode 100644 vortex-geo/src/prune/mod.rs create mode 100644 vortex-geo/src/prune/test_harness.rs diff --git a/vortex-geo/src/lib.rs b/vortex-geo/src/lib.rs index 652311be3b2..3a0c52f1eb9 100644 --- a/vortex-geo/src/lib.rs +++ b/vortex-geo/src/lib.rs @@ -20,6 +20,7 @@ use crate::extension::Polygon; use crate::extension::Rect; use crate::extension::WellKnownBinary; use crate::prune::GeoDistancePrune; +use crate::prune::GeoIntersectsPrune; use crate::scalar_fn::contains::GeoContains; use crate::scalar_fn::distance::GeoDistance; use crate::scalar_fn::intersects::GeoIntersects; @@ -70,6 +71,7 @@ pub fn initialize(session: &VortexSession) { // geometry columns. session.aggregate_fns().register(GeometryAabb); - // Register the spatial pruning rule that uses that AABB. + // Register the spatial pruning rules that use that AABB. session.stats().register_rewrite(GeoDistancePrune); + session.stats().register_rewrite(GeoIntersectsPrune); } diff --git a/vortex-geo/src/prune.rs b/vortex-geo/src/prune/distance.rs similarity index 54% rename from vortex-geo/src/prune.rs rename to vortex-geo/src/prune/distance.rs index 6b86c57c3e2..380571c11f9 100644 --- a/vortex-geo/src/prune.rs +++ b/vortex-geo/src/prune/distance.rs @@ -1,52 +1,36 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -//! Chunk pruning for spatial filters, using the per-chunk [`GeometryAabb`] axis-aligned -//! bounding box (AABB). +//! `ST_Distance(geom, const) radius` pruning. -use geo::BoundingRect; use geo::Rect as GeoRect; -use vortex_array::VortexSessionExecute; -use vortex_array::aggregate_fn::AggregateFnVTableExt; -use vortex_array::aggregate_fn::EmptyOptions; use vortex_array::expr::Expression; -use vortex_array::expr::case_when; -use vortex_array::expr::checked_add; -use vortex_array::expr::ext_storage; -use vortex_array::expr::get_item; use vortex_array::expr::gt; use vortex_array::expr::gt_eq; -use vortex_array::expr::is_root; use vortex_array::expr::lit; use vortex_array::expr::lt; use vortex_array::expr::lt_eq; -use vortex_array::scalar::Scalar; use vortex_array::scalar_fn::ScalarFnId; use vortex_array::scalar_fn::ScalarFnVTable; -use vortex_array::scalar_fn::ScalarFnVTableExt; use vortex_array::scalar_fn::fns::binary::Binary; use vortex_array::scalar_fn::fns::literal::Literal; use vortex_array::scalar_fn::fns::operators::Operator; use vortex_array::stats::rewrite::StatsRewriteCtx; use vortex_array::stats::rewrite::StatsRewriteRule; -use vortex_array::stats::stat; -use vortex_error::VortexExpect; use vortex_error::VortexResult; -use crate::aggregate_fn::GeometryAabb; -use crate::extension::is_native_geometry; -use crate::extension::single_geometry; +use super::aabb_stat; +use super::geometry_and_constant; +use super::max_dist_sq; +use super::min_dist_sq; +use super::query_aabb; use crate::scalar_fn::distance::GeoDistance; -/// Prunes chunks for `ST_Distance(geom, const) r` filters using the chunk's [`GeometryAabb`] -/// bounding box. Register it with `crate::initialize`; a chunk written without the `GeometryAabb` -/// statistic is scanned rather than skipped. +/// Prunes chunks for `ST_Distance(geom, const) r` filters. /// /// All four comparisons prune: `<= r` / `< r` skip a chunk whose box is wholly beyond `r` (box /// min-distance); `>= r` / `> r` skip one wholly within `r` (box max-distance). `==` / `!=` don't -/// prune. To add another spatial predicate, write a sibling [`StatsRewriteRule`] from the -/// `geometry_and_constant` + `distance_prune_proof` helpers; no new statistic or file-format change -/// is needed. +/// prune. #[derive(Debug)] pub struct GeoDistancePrune; @@ -78,15 +62,12 @@ impl StatsRewriteRule for GeoDistancePrune { if distance.as_opt::().is_none() { return Ok(None); } - let Some((geom, constant)) = geometry_and_constant(distance, ctx)? else { - return Ok(None); - }; let Some(radius) = expr.child(1).as_opt::() else { return Ok(None); }; - // Casts any primitive radius (integer literals included); it fails only for a null or - // non-primitive literal, where falling through means "scan the chunk", which is always - // sound. + // Casts any primitive radius (filters arrive uncoerced, so integer literals are + // legitimate); a null or non-primitive (e.g. extension-typed) literal has no value to + // reason about, decline and scan, never error. let Ok(radius) = f64::try_from(radius) else { return Ok(None); }; @@ -96,49 +77,20 @@ impl StatsRewriteRule for GeoDistancePrune { return Ok(None); } - // Reduce `const` (any geometry type) to its AABB. Every row sits in the chunk AABB - // and `const` in this box, so the box-to-box distance bounds the true distance soundly for - // any geometry types. - let mut exec = ctx.session().create_execution_ctx(); - let Some(query) = single_geometry(constant, &mut exec)?.bounding_rect() else { + let Some((geom, constant)) = geometry_and_constant(distance, ctx)? else { + return Ok(None); + }; + let Some(query) = query_aabb(constant, ctx)? else { return Ok(None); }; Ok(distance_prune_proof(geom, query, op, radius)) } } -/// Shared AABB-pruning helper: split a symmetric geo predicate's operands into the scope-rooted -/// geometry column and the constant's scalar, or `None` when the expression doesn't have that -/// shape or the geometry's dtype has no [`GeometryAabb`] support. Symmetric only - an asymmetric -/// predicate that needs to know *which* operand is the column must recover the role separately. -fn geometry_and_constant<'a>( - expr: &'a Expression, - ctx: &StatsRewriteCtx<'_>, -) -> VortexResult> { - // The predicate is symmetric, so the geometry column (scope root) and the constant may be on - // either side. - let (lhs, rhs) = (expr.child(0), expr.child(1)); - let (geom, constant) = if is_root(lhs) { - (lhs, rhs) - } else if is_root(rhs) { - (rhs, lhs) - } else { - return Ok(None); - }; - - // A `GeometryAabb` stat reference only binds for dtypes it supports; anything else (e.g. a - // WKB column) must fall through to the scan. - if !is_native_geometry(&ctx.return_dtype(geom)?) { - return Ok(None); - } - - Ok(constant.as_opt::().map(|scalar| (geom, scalar))) -} - -/// Build the prune proof for `ST_Distance(geom, const) radius` from the chunk's bounding-box -/// stat, or `None` when this operator/radius cannot prune. `<=` / `<` prune a chunk whose box is -/// wholly beyond `radius` (min box-distance); `>=` / `>` prune one wholly within it (max -/// box-distance). Every row sits in the box, so those bounds prove the whole chunk one-sided. +/// Build the prune proof for `ST_Distance(geom, const) radius`, or `None` when this +/// operator/radius cannot prune. The box-to-box distance bounds every row's true distance for any +/// geometry types: `<=` / `<` prune a chunk whose box is wholly beyond `radius` (min +/// box-distance); `>=` / `>` prune one wholly within it (max box-distance). /// /// A distance is always `>= 0`, which decides the degenerate radii up front. fn distance_prune_proof( @@ -157,9 +109,8 @@ fn distance_prune_proof( Operator::Gt if radius < 0.0 => return None, _ => {} } - // The stat is read through `ext_storage`/`get_item`, which propagate a missing stat (null) to - // "keep the chunk". Compared squared to avoid a `sqrt`; all operands are `>= 0`. - let aabb = ext_storage(stat(geom.clone(), GeometryAabb.bind(EmptyOptions))); + // Compared squared to avoid a `sqrt`; all operands are `>= 0`. + let aabb = aabb_stat(geom); let r2 = lit(radius * radius); Some(match op { // Beyond the threshold: even the nearest the box can be exceeds `r`. @@ -172,112 +123,41 @@ fn distance_prune_proof( }) } -/// Squared minimum distance between the chunk box `aabb` and the query box - a lower bound on every -/// row's distance. `dx^2 + dy^2`, each axis gap clamped at zero (zero when the intervals overlap). -fn min_dist_sq(aabb: &Expression, query: GeoRect) -> Expression { - let field = |name: &str| get_item(name, aabb.clone()); - // max(0, q_lo - aabb_hi, aabb_lo - q_hi): positive only when the intervals are separated. - let gap = |q_lo: f64, q_hi: f64, lo: Expression, hi: Expression| { - maximum( - lit(0.0), - maximum( - binop(Operator::Sub, lit(q_lo), hi), - binop(Operator::Sub, lo, lit(q_hi)), - ), - ) - }; - let dx = gap(query.min().x, query.max().x, field("xmin"), field("xmax")); - let dy = gap(query.min().y, query.max().y, field("ymin"), field("ymax")); - checked_add(square(dx), square(dy)) -} - -/// Squared maximum distance between the chunk box `aabb` and the query box - an upper bound on every -/// row's distance. `Dx^2 + Dy^2`, each axis span the full extent of the two intervals' union. -fn max_dist_sq(aabb: &Expression, query: GeoRect) -> Expression { - let field = |name: &str| get_item(name, aabb.clone()); - // max(q_hi, aabb_hi) - min(q_lo, aabb_lo): the farthest two points of the boxes can be on an axis. - // The (nullable) AABB field is passed as the second arg so `case_when`'s else branch carries the - // nullability - a missing stat then propagates null through to "keep the chunk". - let span = |q_lo: f64, q_hi: f64, lo: Expression, hi: Expression| { - binop( - Operator::Sub, - maximum(lit(q_hi), hi), - minimum(lit(q_lo), lo), - ) - }; - let dx = span(query.min().x, query.max().x, field("xmin"), field("xmax")); - let dy = span(query.min().y, query.max().y, field("ymin"), field("ymax")); - checked_add(square(dx), square(dy)) -} - -/// `a b` as a binary-operator expression. -fn binop(op: Operator, a: Expression, b: Expression) -> Expression { - Binary - .try_new_expr(op, [a, b]) - .vortex_expect("binary expression") -} - -/// `e * e` as an expression. -fn square(e: Expression) -> Expression { - binop(Operator::Mul, e.clone(), e) -} - -/// `max(a, b)` as an expression. -fn maximum(a: Expression, b: Expression) -> Expression { - case_when(gt(a.clone(), b.clone()), a, b) -} - -/// `min(a, b)` as an expression. -fn minimum(a: Expression, b: Expression) -> Expression { - case_when(lt(a.clone(), b.clone()), a, b) -} - #[cfg(test)] mod tests { - use std::sync::Arc; - use rstest::rstest; - use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; - use vortex_array::aggregate_fn::AggregateFnVTableExt; - use vortex_array::aggregate_fn::EmptyOptions as AggregateEmptyOptions; - use vortex_array::arrays::ExtensionArray; - use vortex_array::arrays::PrimitiveArray; - use vortex_array::arrays::StructArray; use vortex_array::dtype::DType; - use vortex_array::dtype::FieldNames; use vortex_array::dtype::Nullability; use vortex_array::dtype::PType; - use vortex_array::dtype::extension::ExtDType; use vortex_array::expr::Expression; use vortex_array::expr::gt_eq; use vortex_array::expr::lit; use vortex_array::expr::lt_eq; use vortex_array::expr::root; + use vortex_array::scalar::Scalar; use vortex_array::scalar_fn::EmptyOptions; use vortex_array::scalar_fn::ScalarFnVTableExt; use vortex_array::scalar_fn::fns::binary::Binary; use vortex_array::scalar_fn::fns::operators::Operator; use vortex_array::stats::rewrite::StatsRewriteCtx; use vortex_array::stats::rewrite::StatsRewriteRule; - use vortex_array::validity::Validity; use vortex_error::VortexResult; - use vortex_layout::layouts::zoned::zone_map::ZoneMap; use super::GeoDistancePrune; - use crate::aggregate_fn::GeometryAabb; - use crate::extension::GeoMetadata; - use crate::extension::Rect; + use crate::prune::test_harness::aabb_zone_map; + use crate::prune::test_harness::empty_zone_map; use crate::scalar_fn::distance::GeoDistance; use crate::test_harness::geo_session; use crate::test_harness::point_column; /// Run the rule against `GeoDistance(root, origin) radius`, operands swapped when - /// `geom_first` is false. + /// `geom_first` is false. The radius is any literal scalar, matching the uncoerced filter + /// expressions the rule sees in production. fn falsify_distance( operator: Operator, geom_first: bool, - radius: f64, + radius: impl Into, ) -> VortexResult> { let session = geo_session(); let mut ctx = session.create_execution_ctx(); @@ -290,7 +170,7 @@ mod tests { [lit(origin), root()] }; let distance = GeoDistance.new_expr(EmptyOptions, operands); - let predicate = Binary.new_expr(operator, [distance, lit(radius)]); + let predicate = Binary.new_expr(operator, [distance, lit(radius.into())]); GeoDistancePrune.falsify(&predicate, &StatsRewriteCtx::new(&session, &scope)) } @@ -312,10 +192,12 @@ mod tests { Ok(()) } - /// Distance is symmetric: `GeoDistance(const, geom) <= r` falsifies just like the geom-first form. - #[test] - fn falsifies_with_constant_as_left_operand() -> VortexResult<()> { - assert!(falsify_distance(Operator::Lte, false, 0.5)?.is_some()); + /// Distance is symmetric: both operand orders produce a proof. + #[rstest] + #[case(true)] + #[case(false)] + fn falsifies_either_operand_order(#[case] geom_first: bool) -> VortexResult<()> { + assert!(falsify_distance(Operator::Lte, geom_first, 0.5)?.is_some()); Ok(()) } @@ -333,6 +215,34 @@ mod tests { Ok(()) } + /// Filter expressions arrive uncoerced, so `distance <= 10` may carry an integer literal - + /// it casts and prunes like an f64 radius. + #[test] + fn integer_radius_prunes() -> VortexResult<()> { + assert!(falsify_distance(Operator::Lte, true, 10i64)?.is_some()); + Ok(()) + } + + /// A null radius has no value to reason about; the rule declines and the chunk is scanned. + #[test] + fn null_radius_never_prunes() -> VortexResult<()> { + let radius = Scalar::null(DType::Primitive(PType::F64, Nullability::Nullable)); + assert!(falsify_distance(Operator::Lte, true, radius)?.is_none()); + Ok(()) + } + + /// An extension-typed radius passes `Binary`'s typecheck (extension operands are exempt) but + /// has no numeric value - the rule declines rather than erroring, and the chunk is scanned. + #[test] + fn extension_radius_never_prunes() -> VortexResult<()> { + let session = geo_session(); + let mut ctx = session.create_execution_ctx(); + + let geometry = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?; + assert!(falsify_distance(Operator::Lte, true, geometry)?.is_none()); + Ok(()) + } + /// A scope dtype without `GeometryAabb` support gets no proof - the stat reference would /// fail to bind at prune time. #[test] @@ -369,30 +279,11 @@ mod tests { let mut ctx = session.create_execution_ctx(); let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone(); - let aabb_fn = GeometryAabb.bind(AggregateEmptyOptions); - - // Two chunks' AABBs, stored as the native `geoarrow.box` stat column with default - // (unreferenced) metadata to match the aggregate's return dtype: chunk 0 near the origin - // (0,0..1,1), chunk 1 far away (100,100..101,101). - let ord = |a: f64, b: f64| PrimitiveArray::from_iter([a, b]).into_array(); - let boxes = StructArray::try_new( - ["xmin", "ymin", "xmax", "ymax"].into(), - vec![ - ord(0.0, 100.0), - ord(0.0, 100.0), - ord(1.0, 101.0), - ord(1.0, 101.0), - ], - 2, - Validity::AllValid, - )? - .into_array(); - let box_dtype = - ExtDType::::try_new(GeoMetadata::default(), boxes.dtype().clone())?.erased(); - let aabbs = ExtensionArray::try_new(box_dtype, boxes)?.into_array(); - let zone_array = StructArray::from_fields(&[(aabb_fn.to_string().as_str(), aabbs)])?; - let zone_map = - ZoneMap::try_new(point_dtype.clone(), zone_array, Arc::new([aabb_fn]), 1, 2)?; + // Chunk 0 near the origin (0,0..1,1), chunk 1 far away (100,100..101,101). + let zone_map = aabb_zone_map( + &point_dtype, + &[[0.0, 0.0, 1.0, 1.0], [100.0, 100.0, 101.0, 101.0]], + )?; let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?; let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]); @@ -408,31 +299,16 @@ mod tests { } /// The true-distance prune skips a chunk that is *diagonally* farther than `r`, even though - /// neither axis alone exceeds `r` - the case a per-axis box-overlap test would wrongly keep. + /// neither axis alone exceeds `r`, the case a per-axis box-overlap test would wrongly keep. #[test] fn prunes_diagonally_distant_chunk() -> VortexResult<()> { let session = geo_session(); let mut ctx = session.create_execution_ctx(); let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone(); - let aabb_fn = GeometryAabb.bind(AggregateEmptyOptions); - // One chunk, AABB (0.8,0.8)..(0.9,0.9): each axis is only 0.8 from the origin (<= r = 1), but // the near corner is sqrt(0.8^2 + 0.8^2) ~= 1.13 away (> 1), so no point in the box is within 1. - let ord = |a: f64| PrimitiveArray::from_iter([a]).into_array(); - let boxes = StructArray::try_new( - ["xmin", "ymin", "xmax", "ymax"].into(), - vec![ord(0.8), ord(0.8), ord(0.9), ord(0.9)], - 1, - Validity::AllValid, - )? - .into_array(); - let box_dtype = - ExtDType::::try_new(GeoMetadata::default(), boxes.dtype().clone())?.erased(); - let aabbs = ExtensionArray::try_new(box_dtype, boxes)?.into_array(); - let zone_array = StructArray::from_fields(&[(aabb_fn.to_string().as_str(), aabbs)])?; - let zone_map = - ZoneMap::try_new(point_dtype.clone(), zone_array, Arc::new([aabb_fn]), 1, 1)?; + let zone_map = aabb_zone_map(&point_dtype, &[[0.8, 0.8, 0.9, 0.9]])?; let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?; let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]); @@ -451,74 +327,51 @@ mod tests { Ok(()) } - /// Backward compat: a zone map written without the `GeometryAabb` stat (an older file) keeps - /// every zone - the missing stat binds to null and `null_as_false` retains the zone. + /// A `>= r` filter prunes a chunk lying wholly *within* `r` (every row nearer than `r`, so none + /// satisfy `>= r`) via the box max-distance, while a chunk beyond `r` is kept. #[test] - fn missing_aabb_stat_keeps_all_zones() -> VortexResult<()> { + fn prunes_within_chunk_for_far_filter() -> VortexResult<()> { let session = geo_session(); let mut ctx = session.create_execution_ctx(); let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone(); - let zone_map = ZoneMap::try_new( - point_dtype.clone(), - StructArray::try_new(FieldNames::empty(), vec![], 2, Validity::NonNullable)?, - Arc::new([]), - 1, - 2, + // Chunk 0 (AABB 0,0..0.5,0.5, farthest corner ~= 0.707) is entirely within 2 of the origin; + // chunk 1 (100,100..101,101) is entirely beyond it. + let zone_map = aabb_zone_map( + &point_dtype, + &[[0.0, 0.0, 0.5, 0.5], [100.0, 100.0, 101.0, 101.0]], )?; let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?; let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]); - let proof = lt_eq(distance, lit(0.5f64)) + let proof = gt_eq(distance, lit(2.0f64)) .falsify(&point_dtype, &session)? .expect("distance filter should be falsifiable"); + // Chunk 0 (within 2) is pruned for `>= 2`; chunk 1 (beyond 2) is kept. let mask = zone_map.prune(&proof, &session)?; - assert_eq!(mask.iter().collect::>(), vec![false, false]); + assert_eq!(mask.iter().collect::>(), vec![true, false]); Ok(()) } - /// A `>= r` filter prunes a chunk lying wholly *within* `r` (every row nearer than `r`, so none - /// satisfy `>= r`) via the box max-distance, while a chunk beyond `r` is kept. + /// Backward compat: a zone map written without the `GeometryAabb` stat (an older file) keeps + /// every zone, the missing stat binds to null and `null_as_false` retains the zone. #[test] - fn prunes_within_chunk_for_far_filter() -> VortexResult<()> { + fn missing_aabb_stat_keeps_all_zones() -> VortexResult<()> { let session = geo_session(); let mut ctx = session.create_execution_ctx(); let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone(); - let aabb_fn = GeometryAabb.bind(AggregateEmptyOptions); - - // Chunk 0 (AABB 0,0..0.5,0.5, farthest corner ~= 0.707) is entirely within 2 of the origin; - // chunk 1 (100,100..101,101) is entirely beyond it. - let ord = |a: f64, b: f64| PrimitiveArray::from_iter([a, b]).into_array(); - let boxes = StructArray::try_new( - ["xmin", "ymin", "xmax", "ymax"].into(), - vec![ - ord(0.0, 100.0), - ord(0.0, 100.0), - ord(0.5, 101.0), - ord(0.5, 101.0), - ], - 2, - Validity::AllValid, - )? - .into_array(); - let box_dtype = - ExtDType::::try_new(GeoMetadata::default(), boxes.dtype().clone())?.erased(); - let aabbs = ExtensionArray::try_new(box_dtype, boxes)?.into_array(); - let zone_array = StructArray::from_fields(&[(aabb_fn.to_string().as_str(), aabbs)])?; - let zone_map = - ZoneMap::try_new(point_dtype.clone(), zone_array, Arc::new([aabb_fn]), 1, 2)?; + let zone_map = empty_zone_map(&point_dtype)?; let origin = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?; let distance = GeoDistance.new_expr(EmptyOptions, [root(), lit(origin)]); - let proof = gt_eq(distance, lit(2.0f64)) + let proof = lt_eq(distance, lit(0.5f64)) .falsify(&point_dtype, &session)? .expect("distance filter should be falsifiable"); - // Chunk 0 (within 2) is pruned for `>= 2`; chunk 1 (beyond 2) is kept. let mask = zone_map.prune(&proof, &session)?; - assert_eq!(mask.iter().collect::>(), vec![true, false]); + assert_eq!(mask.iter().collect::>(), vec![false, false]); Ok(()) } } diff --git a/vortex-geo/src/prune/intersects.rs b/vortex-geo/src/prune/intersects.rs new file mode 100644 index 00000000000..9af6c5b4d9e --- /dev/null +++ b/vortex-geo/src/prune/intersects.rs @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! `ST_Intersects(geom, const)` pruning. + +use vortex_array::expr::Expression; +use vortex_array::expr::gt; +use vortex_array::expr::lit; +use vortex_array::scalar_fn::ScalarFnId; +use vortex_array::scalar_fn::ScalarFnVTable; +use vortex_array::stats::rewrite::StatsRewriteCtx; +use vortex_array::stats::rewrite::StatsRewriteRule; +use vortex_error::VortexResult; + +use super::aabb_stat; +use super::geometry_and_constant; +use super::min_dist_sq; +use super::query_aabb; +use crate::scalar_fn::intersects::GeoIntersects; + +/// Prunes chunks for `ST_Intersects(geom, const)` filters: a chunk whose box is strictly +/// separated from the constant's bounding box cannot contain an intersecting row. +/// +/// Only the positive form prunes. `NOT ST_Intersects` cannot: it would need every row to provably +/// intersect the constant, and box overlap never proves geometry intersection. +#[derive(Debug)] +pub struct GeoIntersectsPrune; + +impl StatsRewriteRule for GeoIntersectsPrune { + fn scalar_fn_id(&self) -> ScalarFnId { + // Unlike the distance rule, the boolean predicate is itself the expression root. + GeoIntersects.id() + } + + fn falsify( + &self, + expr: &Expression, + ctx: &StatsRewriteCtx<'_>, + ) -> VortexResult> { + let Some((geom, constant)) = geometry_and_constant(expr, ctx)? else { + return Ok(None); + }; + let Some(query) = query_aabb(constant, ctx)? else { + return Ok(None); + }; + // Disjoint iff the minimum box-to-box distance is positive. Strictly (`gt`, not `gt_eq`): + // boxes that merely touch must scan, since touching geometries do intersect. + Ok(Some(gt(min_dist_sq(&aabb_stat(geom), query), lit(0.0)))) + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use vortex_array::VortexSessionExecute; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::expr::Expression; + use vortex_array::expr::lit; + use vortex_array::expr::root; + use vortex_array::scalar_fn::EmptyOptions; + use vortex_array::scalar_fn::ScalarFnVTableExt; + use vortex_array::stats::rewrite::StatsRewriteCtx; + use vortex_array::stats::rewrite::StatsRewriteRule; + use vortex_error::VortexResult; + + use super::GeoIntersectsPrune; + use crate::prune::test_harness::aabb_zone_map; + use crate::prune::test_harness::empty_zone_map; + use crate::scalar_fn::intersects::GeoIntersects; + use crate::test_harness::geo_session; + use crate::test_harness::point_column; + + /// Run the intersects rule against `GeoIntersects(root, point(1.0, 0.5))`, operands swapped + /// when `geom_first` is false. + fn falsify_intersects(geom_first: bool) -> VortexResult> { + let session = geo_session(); + let mut ctx = session.create_execution_ctx(); + + let scope = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + let query = point_column(vec![1.0], vec![0.5])?.execute_scalar(0, &mut ctx)?; + let operands = if geom_first { + [root(), lit(query)] + } else { + [lit(query), root()] + }; + let predicate = GeoIntersects.new_expr(EmptyOptions, operands); + GeoIntersectsPrune.falsify(&predicate, &StatsRewriteCtx::new(&session, &scope)) + } + + /// Intersects is symmetric: both operand orders produce a proof. + #[rstest] + #[case(true)] + #[case(false)] + fn falsifies_either_operand_order(#[case] geom_first: bool) -> VortexResult<()> { + assert!(falsify_intersects(geom_first)?.is_some()); + Ok(()) + } + + /// A scope dtype without `GeometryAabb` support gets no proof, the stat reference would + /// fail to bind at prune time. + #[test] + fn unsupported_scope_is_not_pruned() -> VortexResult<()> { + let session = geo_session(); + let mut ctx = session.create_execution_ctx(); + + let scope = DType::Primitive(PType::F64, Nullability::NonNullable); + let query = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?; + let predicate = GeoIntersects.new_expr(EmptyOptions, [root(), lit(query)]); + + let ctx = StatsRewriteCtx::new(&session, &scope); + assert!(GeoIntersectsPrune.falsify(&predicate, &ctx)?.is_none()); + Ok(()) + } + + /// End-to-end: a zone strictly separated from the query is skipped; zones containing or merely + /// touching the query must scan, touching geometries intersect under OGC semantics. + #[test] + fn prunes_disjoint_keeps_touching_and_containing() -> VortexResult<()> { + let session = geo_session(); + let mut ctx = session.create_execution_ctx(); + + let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + // Query point (1.0, 0.5): zone 0 contains it, zone 1 only touches it at `x == 1`, zone 2 + // is strictly separated. + let zone_map = aabb_zone_map( + &point_dtype, + &[ + [0.5, 0.0, 1.5, 1.0], + [-1.0, 0.0, 1.0, 1.0], + [3.0, 0.0, 4.0, 1.0], + ], + )?; + + let query = point_column(vec![1.0], vec![0.5])?.execute_scalar(0, &mut ctx)?; + let predicate = GeoIntersects.new_expr(EmptyOptions, [root(), lit(query)]); + let proof = predicate + .falsify(&point_dtype, &session)? + .expect("intersects filter should be falsifiable"); + + let mask = zone_map.prune(&proof, &session)?; + assert_eq!(mask.iter().collect::>(), vec![false, false, true]); + Ok(()) + } + + /// Backward compat: a zone map written without the `GeometryAabb` stat keeps every zone. + #[test] + fn missing_aabb_stat_keeps_all_zones() -> VortexResult<()> { + let session = geo_session(); + let mut ctx = session.create_execution_ctx(); + + let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone(); + let zone_map = empty_zone_map(&point_dtype)?; + + let query = point_column(vec![0.0], vec![0.0])?.execute_scalar(0, &mut ctx)?; + let proof = GeoIntersects + .new_expr(EmptyOptions, [root(), lit(query)]) + .falsify(&point_dtype, &session)? + .expect("intersects filter should be falsifiable"); + + let mask = zone_map.prune(&proof, &session)?; + assert_eq!(mask.iter().collect::>(), vec![false, false]); + Ok(()) + } +} diff --git a/vortex-geo/src/prune/mod.rs b/vortex-geo/src/prune/mod.rs new file mode 100644 index 00000000000..00200f1500c --- /dev/null +++ b/vortex-geo/src/prune/mod.rs @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Chunk pruning for spatial filters, using the per-chunk [`GeometryAabb`] axis-aligned +//! bounding box (AABB). +//! +//! One module per predicate. To prune a new one: recover the geometry column and the constant +//! (symmetric predicates can use `geometry_and_constant`), reduce the constant to its box with +//! `query_aabb`, build the proof over `aabb_stat` from the box-relation builders (`min_dist_sq`, +//! `max_dist_sq`), and register the rule in [`crate::initialize`]. No new statistic or +//! file-format change is needed. + +mod distance; +mod intersects; +#[cfg(test)] +mod test_harness; + +pub use distance::GeoDistancePrune; +use geo::BoundingRect; +use geo::Rect as GeoRect; +pub use intersects::GeoIntersectsPrune; +use vortex_array::VortexSessionExecute; +use vortex_array::aggregate_fn::AggregateFnVTableExt; +use vortex_array::aggregate_fn::EmptyOptions; +use vortex_array::expr::Expression; +use vortex_array::expr::case_when; +use vortex_array::expr::checked_add; +use vortex_array::expr::ext_storage; +use vortex_array::expr::get_item; +use vortex_array::expr::gt; +use vortex_array::expr::is_root; +use vortex_array::expr::lit; +use vortex_array::expr::lt; +use vortex_array::scalar::Scalar; +use vortex_array::scalar_fn::ScalarFnVTableExt; +use vortex_array::scalar_fn::fns::binary::Binary; +use vortex_array::scalar_fn::fns::literal::Literal; +use vortex_array::scalar_fn::fns::operators::Operator; +use vortex_array::stats::rewrite::StatsRewriteCtx; +use vortex_array::stats::stat; +use vortex_error::VortexExpect; +use vortex_error::VortexResult; + +use crate::aggregate_fn::GeometryAabb; +use crate::extension::is_native_geometry; +use crate::extension::single_geometry; + +/// Splits a symmetric two-operand geo predicate into the scope-rooted geometry column and the +/// constant operand's scalar. +/// +/// `None` means the rule must decline: the expression doesn't have the `f(column, constant)` +/// shape (in either operand order), or the column's dtype carries no [`GeometryAabb`] statistic. +/// An asymmetric predicate (e.g. a future contains) must recover which operand is the column +/// itself instead of calling this. +fn geometry_and_constant<'a>( + expr: &'a Expression, + ctx: &StatsRewriteCtx<'_>, +) -> VortexResult> { + // The predicate is symmetric, so the column (scope root) and the constant may be on either + // side. + let (lhs, rhs) = (expr.child(0), expr.child(1)); + let (geom, constant) = if is_root(lhs) { + (lhs, rhs) + } else if is_root(rhs) { + (rhs, lhs) + } else { + return Ok(None); + }; + + // A `GeometryAabb` stat reference only binds for dtypes it supports; anything else (e.g. a + // WKB column) must fall through to the scan. + if !is_native_geometry(&ctx.return_dtype(geom)?) { + return Ok(None); + } + + Ok(constant.as_opt::().map(|scalar| (geom, scalar))) +} + +/// The 2D bounding box of a constant geometry of any type, or `None` for one without an extent +/// (e.g. an empty geometry). +/// +/// Prove claims against this box rather than the constant itself: the constant lies inside it, +/// so whatever holds for the box holds for the geometry. +fn query_aabb(constant: &Scalar, ctx: &StatsRewriteCtx<'_>) -> VortexResult>> { + // Decoding the constant into a concrete geometry runs through the compute stack, which needs + // an execution context. + let mut exec = ctx.session().create_execution_ctx(); + Ok(single_geometry(constant, &mut exec)?.bounding_rect()) +} + +/// The chunk's AABB statistic, as the storage struct with `xmin`/`ymin`/`xmax`/`ymax` fields. +/// +/// A chunk written without the statistic reads as null here; every proof built on top must let +/// that null propagate to its root, where the zone map keeps the chunk. +fn aabb_stat(geom: &Expression) -> Expression { + // `ext_storage` unwraps the native `geoarrow.box` stat value to its backing struct, so + // proofs can `get_item` the coordinate fields. + ext_storage(stat(geom.clone(), GeometryAabb.bind(EmptyOptions))) +} + +/// Lower bound on every row's squared distance to the query AABB: zero when the boxes overlap or +/// touch, positive iff they are strictly separated. +/// +/// Prunes "near" predicates: `min_dist_sq > r^2` proves every row is farther than `r`. +fn min_dist_sq(aabb: &Expression, query: GeoRect) -> Expression { + let field = |name: &str| get_item(name, aabb.clone()); + // Per axis: gap = max(0, q_lo - aabb_hi, aabb_lo - q_hi), positive only when the intervals + // are separated. The nearest two points of the boxes are one axis-gap apart per axis, so the + // squared distance is gap_x^2 + gap_y^2 (squared throughout to avoid a sqrt). + let gap = |q_lo: f64, q_hi: f64, lo: Expression, hi: Expression| { + maximum( + lit(0.0), + maximum( + binop(Operator::Sub, lit(q_lo), hi), + binop(Operator::Sub, lo, lit(q_hi)), + ), + ) + }; + let dx = gap(query.min().x, query.max().x, field("xmin"), field("xmax")); + let dy = gap(query.min().y, query.max().y, field("ymin"), field("ymax")); + checked_add(square(dx), square(dy)) +} + +/// Upper bound on every row's squared distance to the query AABB. +/// +/// Prunes "far" predicates: `max_dist_sq < r^2` proves every row is within `r`. +fn max_dist_sq(aabb: &Expression, query: GeoRect) -> Expression { + let field = |name: &str| get_item(name, aabb.clone()); + // Per axis: span = max(q_hi, aabb_hi) - min(q_lo, aabb_lo), the farthest two points of the + // boxes can be apart. The nullable AABB field is the second `maximum`/`minimum` argument so + // that `case_when`'s else branch carries the nullability - a missing stat propagates null. + let span = |q_lo: f64, q_hi: f64, lo: Expression, hi: Expression| { + binop( + Operator::Sub, + maximum(lit(q_hi), hi), + minimum(lit(q_lo), lo), + ) + }; + let dx = span(query.min().x, query.max().x, field("xmin"), field("xmax")); + let dy = span(query.min().y, query.max().y, field("ymin"), field("ymax")); + checked_add(square(dx), square(dy)) +} + +/// `a b`. +fn binop(op: Operator, a: Expression, b: Expression) -> Expression { + Binary + .try_new_expr(op, [a, b]) + .vortex_expect("binary expression") +} + +/// `e * e`. +fn square(e: Expression) -> Expression { + binop(Operator::Mul, e.clone(), e) +} + +/// `max(a, b)`. +fn maximum(a: Expression, b: Expression) -> Expression { + case_when(gt(a.clone(), b.clone()), a, b) +} + +/// `min(a, b)`. +fn minimum(a: Expression, b: Expression) -> Expression { + case_when(lt(a.clone(), b.clone()), a, b) +} diff --git a/vortex-geo/src/prune/test_harness.rs b/vortex-geo/src/prune/test_harness.rs new file mode 100644 index 00000000000..fb3e12ef9d5 --- /dev/null +++ b/vortex-geo/src/prune/test_harness.rs @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Shared zone-map fixtures for the pruning-rule tests; each rule module owns its own cases. + +use std::sync::Arc; + +use vortex_array::IntoArray; +use vortex_array::aggregate_fn::AggregateFnVTableExt; +use vortex_array::aggregate_fn::EmptyOptions; +use vortex_array::arrays::ExtensionArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::arrays::StructArray; +use vortex_array::dtype::DType; +use vortex_array::dtype::FieldNames; +use vortex_array::dtype::extension::ExtDType; +use vortex_array::validity::Validity; +use vortex_error::VortexResult; +use vortex_layout::layouts::zoned::zone_map::ZoneMap; + +use crate::aggregate_fn::GeometryAabb; +use crate::extension::GeoMetadata; +use crate::extension::Rect; + +/// A single-column zone map holding one native-box AABB stat row (`[xmin, ymin, xmax, ymax]`) +/// per zone, with default (unreferenced) metadata to match the aggregate's return dtype. +pub(super) fn aabb_zone_map(point_dtype: &DType, boxes: &[[f64; 4]]) -> VortexResult { + let aabb_fn = GeometryAabb.bind(EmptyOptions); + let col = |i: usize| PrimitiveArray::from_iter(boxes.iter().map(move |b| b[i])).into_array(); + let storage = StructArray::try_new( + ["xmin", "ymin", "xmax", "ymax"].into(), + vec![col(0), col(1), col(2), col(3)], + boxes.len(), + Validity::AllValid, + )? + .into_array(); + let box_dtype = + ExtDType::::try_new(GeoMetadata::default(), storage.dtype().clone())?.erased(); + let aabbs = ExtensionArray::try_new(box_dtype, storage)?.into_array(); + let zone_array = StructArray::from_fields(&[(aabb_fn.to_string().as_str(), aabbs)])?; + ZoneMap::try_new( + point_dtype.clone(), + zone_array, + Arc::new([aabb_fn]), + 1, + boxes.len() as u64, + ) +} + +/// A two-zone zone map with no stat columns at all, as written before the AABB stat existed. +pub(super) fn empty_zone_map(point_dtype: &DType) -> VortexResult { + ZoneMap::try_new( + point_dtype.clone(), + StructArray::try_new(FieldNames::empty(), vec![], 2, Validity::NonNullable)?, + Arc::new([]), + 1, + 2, + ) +} From 2cade61a61eb68597fae8faca0c0b83e5d41b165 Mon Sep 17 00:00:00 2001 From: Alexander Droste Date: Wed, 15 Jul 2026 23:18:59 +0100 Subject: [PATCH 086/104] fix(cuda): preserve dictionary value nulls (#8774) Export logical dictionary validity through Arrow Device arrays. Add a packed-boolean dictionary gather so device-resident validity remains on the GPU. Signed-off-by: Alexander Droste --- vortex-cuda/kernels/src/dict.cu | 61 +++++++++++++- vortex-cuda/src/arrow/canonical.rs | 49 +++++++++-- vortex-cuda/src/kernel/arrays/dict.rs | 117 ++++++++++++++++++++++++++ 3 files changed, 219 insertions(+), 8 deletions(-) diff --git a/vortex-cuda/kernels/src/dict.cu b/vortex-cuda/kernels/src/dict.cu index 3422980c65e..a456b476821 100644 --- a/vortex-cuda/kernels/src/dict.cu +++ b/vortex-cuda/kernels/src/dict.cu @@ -19,12 +19,51 @@ __device__ void dict_kernel(const IndexT *const __restrict codes, (block_start + elements_per_block < codes_len) ? (block_start + elements_per_block) : codes_len; for (uint64_t idx = block_start + threadIdx.x; idx < block_end; idx += blockDim.x) { - IndexT code = codes[idx]; + const IndexT code = codes[idx]; output[idx] = values[code]; } } -// Macro to generate dict kernels for all value/index type combinations +// Nullable dictionary values require gathering validity through the row codes: +// +// row_validity[i] = values_validity[codes[i]] +// +// Vortex represents this gather lazily as `Dict`; null codes are carried by the resulting +// boolean array's own validity. Consequently, this kernel may materialize validity for a +// dictionary whose logical values are strings or another non-boolean type. +// +// Vortex booleans are bit-packed, so fixed-width `output[i] = values[codes[i]]` semantics do not +// apply. Assigning each complete output byte to one thread avoids races between individual bit +// writes. +template +__device__ void dict_bool_kernel(const IndexT *const __restrict codes, + uint64_t codes_len, + const uint8_t *const __restrict values, + uint64_t values_bit_offset, + uint8_t *const __restrict output) { + const uint64_t output_len = (codes_len + 7) / 8; + const uint32_t elements_per_block = blockDim.x * ELEMENTS_PER_THREAD; + const uint64_t block_start = static_cast(blockIdx.x) * elements_per_block; + const uint64_t block_end = + (block_start + elements_per_block < output_len) ? (block_start + elements_per_block) : output_len; + + for (uint64_t output_idx = block_start + threadIdx.x; output_idx < block_end; output_idx += blockDim.x) { + const uint64_t row_start = output_idx * 8; + uint8_t packed = 0; +#pragma unroll + for (uint32_t bit = 0; bit < 8; ++bit) { + const uint64_t row = row_start + bit; + if (row < codes_len) { + const uint64_t value_idx = values_bit_offset + static_cast(codes[row]); + const uint8_t value = (values[value_idx / 8] >> (value_idx % 8)) & 1; + packed |= static_cast(value << bit); + } + } + output[output_idx] = packed; + } +} + +// Macro to generate dict kernels for all fixed-width value/index type combinations. #define GENERATE_DICT_KERNEL(value_suffix, ValueType, index_suffix, IndexType) \ extern "C" __global__ void dict_##value_suffix##_##index_suffix( \ const IndexType *const __restrict codes, \ @@ -41,5 +80,21 @@ __device__ void dict_kernel(const IndexT *const __restrict codes, GENERATE_DICT_KERNEL(value_suffix, ValueType, u32, uint32_t) \ GENERATE_DICT_KERNEL(value_suffix, ValueType, u64, uint64_t) -// Generate for all native ptypes & decimal values +#define GENERATE_DICT_BOOL_KERNEL(index_suffix, IndexType) \ + extern "C" __global__ void dict_bool_##index_suffix(const IndexType *const __restrict codes, \ + uint64_t codes_len, \ + const uint8_t *const __restrict values, \ + uint64_t values_bit_offset, \ + uint8_t *const __restrict output) { \ + dict_bool_kernel(codes, codes_len, values, values_bit_offset, output); \ + } + +// Generate fixed-width kernels for all native ptypes and decimal values. FOR_EACH_NUMERIC(GENERATE_DICT_FOR_ALL_INDICES) + +// Boolean values use a different physical layout and launch unit, but still dispatch over the +// same unsigned dictionary-code types. +GENERATE_DICT_BOOL_KERNEL(u8, uint8_t) +GENERATE_DICT_BOOL_KERNEL(u16, uint16_t) +GENERATE_DICT_BOOL_KERNEL(u32, uint32_t) +GENERATE_DICT_BOOL_KERNEL(u64, uint64_t) diff --git a/vortex-cuda/src/arrow/canonical.rs b/vortex-cuda/src/arrow/canonical.rs index d5fa1c26682..af141bfe548 100644 --- a/vortex-cuda/src/arrow/canonical.rs +++ b/vortex-cuda/src/arrow/canonical.rs @@ -371,12 +371,14 @@ async fn export_dict( ctx: &mut CudaExecutionCtx, ) -> VortexResult<(ArrowArray, SyncEvent)> { let len = array.len(); + let validity = array.validity()?; let parts = array.into_parts(); - let PrimitiveDataParts { - buffer, validity, .. - } = export_dictionary_codes(parts.codes, ctx).await?; + let PrimitiveDataParts { buffer, .. } = export_dictionary_codes(parts.codes, ctx).await?; let (validity_buffer, null_count) = export_arrow_validity_buffer(validity, len, 0, ctx).await?; let codes_buffer = ctx.ensure_on_device(buffer).await?; + // Arrow permits null dictionary values, so preserve the child's validity bitmap. The outer + // bitmap independently marks each row whose code selects a null dictionary value, ensuring + // consumers that require non-null dictionary keys do not lose the logical nulls. let (dictionary, _) = export_array(parts.values, ctx).await?; let mut private_data = PrivateData::new_with_dictionary( @@ -1837,6 +1839,35 @@ mod tests { Ok(()) } + #[crate::test] + async fn test_export_dictionary_propagates_value_nulls_to_codes() -> VortexResult<()> { + let mut ctx = CudaSession::create_execution_ctx(&crate::cuda_session()) + .vortex_expect("failed to create execution context"); + + let array = DictArray::try_new( + PrimitiveArray::from_iter([0u8, 1, 2, 1]).into_array(), + VarBinViewArray::from_iter_nullable_str([ + Some("alpha"), + None, + Some("a dictionary value stored out-of-line"), + ]) + .into_array(), + )? + .into_array(); + let mut exported = array.export_device_array_with_schema(&mut ctx).await?; + + assert_eq!(exported.array.array.null_count, 2); + assert_eq!( + private_data_buffer_bytes(&exported.array.array, 0)?.as_ref(), + &[0b0000_0101] + ); + let dictionary = unsafe { &*exported.array.array.dictionary }; + assert_eq!(dictionary.null_count, 1); + + unsafe { release_exported_array(&raw mut exported.array.array) }; + Ok(()) + } + #[crate::test] async fn test_export_struct_preserves_dictionary_child() -> VortexResult<()> { let mut ctx = CudaSession::create_execution_ctx(&crate::cuda_session()) @@ -1900,7 +1931,11 @@ mod tests { true, ) ); - assert_eq!(exported.array.array.null_count, 0); + assert_eq!(exported.array.array.null_count, 1); + assert_eq!( + private_data_buffer_bytes(&exported.array.array, 0)?.as_ref(), + &[0b0000_0101] + ); assert_eq!( private_data_buffer_i16_values(&exported.array.array, 1)?, [0, 1, 0] @@ -2691,7 +2726,11 @@ mod tests { let children = unsafe { std::slice::from_raw_parts(exported.array.array.children, 1) }; let elements = unsafe { &*children[0] }; - assert_eq!(elements.null_count, 0); + assert_eq!(elements.null_count, 2); + assert_eq!( + private_data_buffer_bytes(elements, 0)?.as_ref(), + &[0b0001_0101] + ); assert_eq!( private_data_buffer_i16_values(elements, 1)?, [0, 1, 0, 1, 2] diff --git a/vortex-cuda/src/kernel/arrays/dict.rs b/vortex-cuda/src/kernel/arrays/dict.rs index 394de699f49..840a3cdbbac 100644 --- a/vortex-cuda/src/kernel/arrays/dict.rs +++ b/vortex-cuda/src/kernel/arrays/dict.rs @@ -10,11 +10,13 @@ use tracing::instrument; use vortex::array::ArrayRef; use vortex::array::Canonical; use vortex::array::IntoArray; +use vortex::array::arrays::BoolArray; use vortex::array::arrays::DecimalArray; use vortex::array::arrays::Dict; use vortex::array::arrays::DictArray; use vortex::array::arrays::PrimitiveArray; use vortex::array::arrays::VarBinViewArray; +use vortex::array::arrays::bool::BoolDataParts; use vortex::array::arrays::decimal::DecimalDataParts; use vortex::array::arrays::dict::DictArraySlotsExt; use vortex::array::arrays::primitive::PrimitiveDataParts; @@ -29,6 +31,7 @@ use vortex::dtype::NativePType; use vortex::error::VortexExpect; use vortex::error::VortexResult; use vortex::error::vortex_bail; +use vortex::error::vortex_ensure; use crate::CudaBufferExt; use crate::CudaDeviceBuffer; @@ -55,6 +58,8 @@ impl CudaExecute for DictExecutor { let values_dtype = dict_array.values().dtype().clone(); match &values_dtype { + // Nullable dictionary values expose their logical validity as a lazy `Dict`. + DType::Bool(..) => execute_dict_bool(dict_array, ctx).await, DType::Decimal(..) => execute_dict_decimal(dict_array, ctx).await, DType::Primitive(..) => execute_dict_prim(dict_array, ctx).await, DType::Utf8(..) | DType::Binary(..) => execute_dict_varbinview(dict_array, ctx).await, @@ -86,6 +91,80 @@ async fn execute_dict_prim(dict: DictArray, ctx: &mut CudaExecutionCtx) -> Vorte }) } +/// Gather bit-packed boolean values through dictionary codes. +/// +/// This path is especially important for dictionary validity. When dictionary values are +/// nullable, `Dict::validity()` represents `take(values_validity, codes)` lazily as a `Dict`. +/// Materializing that validity on the GPU therefore requires a boolean dictionary gather even +/// when the user-visible array contains strings or another non-boolean type. +async fn execute_dict_bool(dict: DictArray, ctx: &mut CudaExecutionCtx) -> VortexResult { + let values = dict.values().clone().execute_cuda(ctx).await?.into_bool(); + let codes = dict + .codes() + .clone() + .execute_cuda(ctx) + .await? + .into_primitive(); + let codes_ptype = codes.ptype(); + + match_each_integer_ptype!(codes_ptype, |I| { + execute_dict_bool_typed::(values, codes, ctx).await + }) +} + +async fn execute_dict_bool_typed( + values: BoolArray, + codes: PrimitiveArray, + ctx: &mut CudaExecutionCtx, +) -> VortexResult { + vortex_ensure!(!codes.is_empty(), "cannot CUDA-decode an empty dictionary"); + let codes_len = codes.len(); + + let values_len = values.len(); + let values_validity = values.validity()?; + let BoolDataParts { + bits: values_buffer, + meta: values_meta, + } = values.into_data().into_parts(values_len); + let output_validity = values_validity.take(&codes.clone().into_array())?; + let PrimitiveDataParts { + buffer: codes_buffer, + .. + } = codes.into_data_parts(); + + let values_device = ctx.ensure_on_device(values_buffer).await?; + let codes_device = ctx.ensure_on_device(codes_buffer).await?; + + // Each CUDA thread owns complete output bytes, avoiding races between threads writing + // different bits in the same byte. The kernel handles the final partial byte explicitly. + let output_bytes = codes_len.div_ceil(8); + let output_slice = ctx.device_alloc::(output_bytes)?; + let output_device = CudaDeviceBuffer::new(output_slice); + + let values_view = values_device.cuda_view::()?; + let codes_view = codes_device.cuda_view::()?; + let output_view = output_device.as_view::(); + let codes_len_u64 = codes_len as u64; + let values_offset_u64 = values_meta.offset() as u64; + + let codes_ptype = I::PTYPE.to_string(); + let kernel_function = ctx.load_function_with_suffixes("dict", &["bool", &codes_ptype])?; + ctx.launch_kernel(&kernel_function, output_bytes, |args| { + args.arg(&codes_view) + .arg(&codes_len_u64) + .arg(&values_view) + .arg(&values_offset_u64) + .arg(&output_view); + })?; + + Ok(Canonical::Bool(BoolArray::new_handle( + BufferHandle::new_device(Arc::new(output_device)), + 0, + codes_len, + output_validity, + ))) +} + async fn execute_dict_prim_typed( values: PrimitiveArray, codes: PrimitiveArray, @@ -298,6 +377,7 @@ async fn execute_dict_varbinview( #[cfg(test)] mod tests { use vortex::array::IntoArray; + use vortex::array::arrays::BoolArray; use vortex::array::arrays::DecimalArray; use vortex::array::arrays::DictArray; use vortex::array::arrays::PrimitiveArray; @@ -323,6 +403,43 @@ mod tests { )) } + #[crate::test] + async fn test_cuda_dict_bool_gathers_packed_validity_bits() -> VortexResult<()> { + let mut ctx = vortex_array::array_session().create_execution_ctx(); + let mut cuda_ctx = CudaSession::create_execution_ctx(&crate::cuda_session()) + .vortex_expect("failed to create execution context"); + + // Slicing leaves the dictionary values at a non-zero bit offset. Thirteen codes also + // exercise a final partial output byte. + let values = BoolArray::from_iter([ + false, true, false, true, false, true, true, false, true, false, + ]) + .into_array() + .slice(3..8)?; + let codes = PrimitiveArray::new( + Buffer::from(vec![0u8, 1, 2, 3, 4, 3, 2, 1, 0, 4, 1, 3, 0]), + NonNullable, + ); + let expected = DictArray::try_new(codes.clone().into_array(), values.clone())?.into_array(); + + let codes_handle = cuda_ctx + .ensure_on_device(codes.buffer_handle().clone()) + .await?; + let device_codes = + PrimitiveArray::from_buffer_handle(codes_handle, codes.ptype(), codes.validity()?); + let dict = DictArray::try_new(device_codes.into_array(), values)?.into_array(); + + let actual = DictExecutor + .execute(dict, &mut cuda_ctx) + .await? + .into_host() + .await? + .into_bool(); + + assert_arrays_eq!(actual.into_array(), expected, &mut ctx); + Ok(()) + } + #[crate::test] async fn test_cuda_dict_u32_values_u8_codes() -> VortexResult<()> { let mut ctx = vortex_array::array_session().create_execution_ctx(); From f47bf544dbf88b123dd5763351c2351f33013113 Mon Sep 17 00:00:00 2001 From: Andrew Duffy Date: Wed, 15 Jul 2026 19:11:24 -0400 Subject: [PATCH 087/104] ci: cut wasted minutes from measurement_id and WASM jobs (#8776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Three targeted fixes from profiling recent CI runs (job/step timings + logs from PR runs of `Linters and Tests`): - **`measurement_id golden vectors`: 6.2 min → ~20 s.** The `setup-uv` action defaults to `uv sync --all-extras --dev`, which builds the vortex-data PyO3 extension via maturin on an uncached `ubuntu-latest` runner — ~6 min of the job (verified in job logs: `Run uv sync --all-extras --dev` → `Building vortex-data`). The test itself takes ~1 s and uses `uv run --no-project`, so it needs no workspace packages. Pass `sync: false`, matching the existing `python-cuda-test` job. - **WASM integration smoke test: ~5.2 min → expected ~1–2 min.** It was the only Rust-compiling job left on plain `ubuntu-latest` with no compiler cache; ~4.5 min is a cold `cargo build`. Moved to the prebuilt runs-on image with S3 sccache, following the same pattern as the other build jobs (fork fallback to `ubuntu-latest` preserved; `rustup target add wasm32-wasip1` mirrors what the wasm32 build job does). - **Windows sccache observability.** `Rust tests (windows-x64)` is the ci.yml PR critical path (consistently 6.8–7.7 min across the last 8 PR runs, ~5 min of it compile). The sccache server demonstrably starts, but logs give no way to see hit rates. Added an `sccache --show-stats` step (`if: always()`, main repo only) so cache effectiveness on Windows is visible per run. ## Not included (infra-side observations from the same investigation) - Codspeed PR runs take 9–14 min but shards compute for only 0.8–4 min — they queue 5.5–9.8 min for `amd64-medium` runners while concurrent ci.yml jobs get runners in <1 min. One PR push requests ~26 runs-on VMs at once; this looks like a concurrent-instance/pool cap in `.github-private` (this repo's `runs-on.yml` only defines images). Raising capacity there, or consolidating the 8 Codspeed shards to 4, would cut the largest remaining chunk of PR wall clock. - The Windows pool uses `m8i-flex.2xlarge` (8 vCPU); a size bump would directly shrink the PR critical path since compile dominates and linking 53 test binaries is uncacheable. ## Checks - `yamllint --strict -c .yamllint.yaml .github/workflows/ci.yml` — passes. - Workflow-only change; no Rust/Python code touched, so no cargo/pytest checks were run per repo guidance. The WASM job change is best validated by this PR's own CI run. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Andrew Duffy Co-authored-by: Claude Fable 5 --- .github/workflows/ci.yml | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7aae5dc647..fad3f1a807f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,7 +146,11 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + # sync: false — the test runs via `uv run --no-project` and needs no workspace + # packages; the default `uv sync` builds the vortex-data Rust extension (~6 min). - uses: spiraldb/actions/.github/actions/setup-uv@a746510eafaa926484c354541cfc49b2ec06cc63 # 0.18.6 + with: + sync: false - name: Pytest - measurement_id golden vectors run: | uv run --no-project --with pytest --with xxhash \ @@ -415,6 +419,10 @@ jobs: --exclude compress-bench --exclude xtask --exclude vortex-datafusion ` --exclude gpu-scan-cli --exclude vortex-sqllogictest + - name: sccache stats + if: always() && github.repository == 'vortex-data/vortex' + run: sccache --show-stats + - name: Alert incident.io if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/develop' uses: ./.github/actions/alert-incident-io @@ -584,14 +592,23 @@ jobs: wasm-integration: name: "WASM integration smoke test" - runs-on: ubuntu-latest timeout-minutes: 30 + runs-on: >- + ${{ github.repository == 'vortex-data/vortex' + && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=wasm-integration', github.run_id) + || 'ubuntu-latest' }} steps: + - uses: runs-on/action@v2 + if: github.repository == 'vortex-data/vortex' + with: + sccache: s3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: ./.github/actions/setup-rust + - uses: ./.github/actions/setup-prebuild with: - repo-token: ${{ secrets.GITHUB_TOKEN }} + enable-sccache: "true" targets: "wasm32-wasip1" + - name: Install wasm32-wasip1 target + run: rustup target add wasm32-wasip1 - name: Setup Wasmer shell: bash run: | From 159207ec9930c453885efe04bb6531928aed9326 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Thu, 16 Jul 2026 02:25:26 +0100 Subject: [PATCH 088/104] ListArray::reset_offsets doesn't need to revalidate array after reset (#8781) Reset offsets operations if they start from a valid array they should stay valid --- vortex-array/src/arrays/list/array.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/vortex-array/src/arrays/list/array.rs b/vortex-array/src/arrays/list/array.rs index dbe2943fc51..8350b1a3558 100644 --- a/vortex-array/src/arrays/list/array.rs +++ b/vortex-array/src/arrays/list/array.rs @@ -28,6 +28,7 @@ use crate::array::child_to_validity; use crate::array::validity_to_child; use crate::arrays::ConstantArray; use crate::arrays::List; +use crate::arrays::ListArray; use crate::arrays::Primitive; use crate::builtins::ArrayBuiltins; use crate::dtype::DType; @@ -338,7 +339,6 @@ pub trait ListArrayExt: TypedArrayRef { let mut elements = self.sliced_elements()?; if recurse && elements.is_canonical() { let compacted = elements - .clone() .execute::(ctx)? .compact(ctx)? .into_array(); @@ -357,7 +357,8 @@ pub trait ListArrayExt: TypedArrayRef { Operator::Sub, )?; - Array::::try_new(elements, adjusted_offsets, self.list_validity()) + // SAFETY: By resetting the offsets we simply "shift" everything left and discard trailing garbage, so all invariants remain the same. + Ok(unsafe { ListArray::new_unchecked(elements, adjusted_offsets, self.list_validity()) }) } } impl> ListArrayExt for T {} From 198cbd5cddc840d13a491d6157d8a0b95fd14921 Mon Sep 17 00:00:00 2001 From: myrrc Date: Thu, 16 Jul 2026 11:02:54 +0100 Subject: [PATCH 089/104] New C++ API tests (#8751) - Add tests for new C++ API. - Upload test results to codecov. - Make vx_array_new_primitive return an error on validity dtype/length mismatch Signed-off-by: Mikhail Kot --- .github/workflows/ci.yml | 7 +- .github/workflows/rust-instrumented.yml | 38 ++- docs/api/cpp/index.rst | 9 +- lang/cpp/.gitignore | 1 + lang/cpp/CMakeLists.txt | 23 ++ lang/cpp/README.md | 8 + lang/cpp/gcov-report.sh | 18 ++ lang/cpp/include/vortex/array.hpp | 4 +- lang/cpp/include/vortex/common.hpp | 42 ++- lang/cpp/include/vortex/scan.hpp | 7 +- lang/cpp/include/vortex/writer.hpp | 4 + lang/cpp/src/array.cpp | 17 +- lang/cpp/src/f16.cpp | 40 --- lang/cpp/src/writer.cpp | 17 +- lang/cpp/tests/CMakeLists.txt | 24 ++ lang/cpp/tests/array.cpp | 184 +++++++++++++ lang/cpp/tests/arrow.cpp | 115 ++++++++ lang/cpp/tests/common.hpp | 110 ++++++++ lang/cpp/tests/dtype.cpp | 81 ++++++ lang/cpp/tests/enum_sizes.cpp | 15 + lang/cpp/tests/expression.cpp | 102 +++++++ lang/cpp/tests/float16_t.cpp | 35 +++ lang/cpp/tests/float16_t_compat.cpp | 38 +++ lang/cpp/tests/scalar.cpp | 100 +++++++ lang/cpp/tests/scan.cpp | 348 ++++++++++++++++++++++++ lang/cpp/tests/session.cpp | 17 ++ lang/cpp/tests/string_binary.cpp | 133 +++++++++ lang/cpp/tests/writer.cpp | 53 ++++ vortex-ffi/src/array.rs | 59 ++-- 29 files changed, 1564 insertions(+), 85 deletions(-) create mode 100644 lang/cpp/.gitignore create mode 100755 lang/cpp/gcov-report.sh delete mode 100644 lang/cpp/src/f16.cpp create mode 100644 lang/cpp/tests/CMakeLists.txt create mode 100644 lang/cpp/tests/array.cpp create mode 100644 lang/cpp/tests/arrow.cpp create mode 100644 lang/cpp/tests/common.hpp create mode 100644 lang/cpp/tests/dtype.cpp create mode 100644 lang/cpp/tests/enum_sizes.cpp create mode 100644 lang/cpp/tests/expression.cpp create mode 100644 lang/cpp/tests/float16_t.cpp create mode 100644 lang/cpp/tests/float16_t_compat.cpp create mode 100644 lang/cpp/tests/scalar.cpp create mode 100644 lang/cpp/tests/scan.cpp create mode 100644 lang/cpp/tests/session.cpp create mode 100644 lang/cpp/tests/string_binary.cpp create mode 100644 lang/cpp/tests/writer.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fad3f1a807f..daba1d91a87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,7 +215,7 @@ jobs: # including all private docs. cargo doc --profile ci --no-deps --document-private-items --workspace --exclude vortex-python --exclude vortex-python-cuda # nextest doesn't support doc tests, so we run it here - cargo test --profile ci --doc --workspace --all-features --exclude vortex-cxx --exclude vortex-jni --exclude vortex-ffi --exclude xtask --exclude vortex-python-cuda --no-fail-fast + cargo test --profile ci --doc --workspace --all-features --exclude vortex-jni --exclude vortex-ffi --exclude xtask --exclude vortex-python-cuda --no-fail-fast build-rust: name: "Rust build (${{matrix.config.name}})" @@ -561,8 +561,11 @@ jobs: - name: Build C++ library (asan) run: | mkdir -p build - cmake -S lang/cpp -B build -DSANITIZER=asan -DTARGET_TRIPLE="x86_64-unknown-linux-gnu" + cmake -S lang/cpp -Bbuild -DSANITIZER=asan -DBUILD_TESTS=1 -DTARGET_TRIPLE="x86_64-unknown-linux-gnu" cmake --build build --parallel $(nproc) + - name: Run C++ tests + run: | + build/tests/vortex_cxx_test sqllogic-test: name: "SQL logic tests" diff --git a/.github/workflows/rust-instrumented.yml b/.github/workflows/rust-instrumented.yml index 5430f1512fc..f4ff732be74 100644 --- a/.github/workflows/rust-instrumented.yml +++ b/.github/workflows/rust-instrumented.yml @@ -101,7 +101,7 @@ jobs: --ignore 'home/*' --ignore 'xtask/*' --ignore 'target/*' --ignore 'vortex-error/*' \ --ignore 'vortex-python/*' --ignore 'vortex-jni/*' --ignore 'vortex-flatbuffers/*' \ --ignore 'vortex-proto/*' --ignore 'vortex-tui/*' --ignore 'vortex-datafusion/examples/*' \ - --ignore 'vortex-ffi/examples/*' --ignore '*/arbitrary/*' --ignore '*/arbitrary.rs' --ignore 'vortex-cxx/*' \ + --ignore 'vortex-ffi/examples/*' --ignore '*/arbitrary/*' --ignore '*/arbitrary.rs' \ --ignore benchmarks/* --ignore 'vortex-test/*' \ -o ${{ env.GRCOV_OUTPUT_FILE }} test -s ${{ env.GRCOV_OUTPUT_FILE }} @@ -114,6 +114,42 @@ jobs: flags: ${{ matrix.suite }} use_oidc: true + cxx-coverage: + name: "C++ API (coverage)" + timeout-minutes: 15 + permissions: + id-token: write + runs-on: >- + ${{ github.repository == 'vortex-data/vortex' + && format('runs-on={0}/runner=amd64-medium/image=ubuntu24-full-x64-pre-v2/extras=s3-cache/tag=cxx-coverage', github.run_id) + || 'ubuntu-latest' }} + steps: + - uses: runs-on/action@v2 + if: github.repository == 'vortex-data/vortex' + with: + sccache: s3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: ./.github/actions/setup-prebuild + with: + enable-sccache: "true" + - name: Install lcov + run: | + sudo apt-get update + sudo apt-get install -y lcov libjson-xs-perl + - name: Build FFI library + run: cargo build -p vortex-ffi + - name: Build and test C++ API with coverage + working-directory: lang/cpp + run: ./gcov-report.sh + - name: Codecov + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7 + with: + name: cxx-api + files: lang/cpp/coverage.info + disable_search: true + flags: cxx-api + use_oidc: true + rust-test-sanitizer: strategy: fail-fast: false diff --git a/docs/api/cpp/index.rst b/docs/api/cpp/index.rst index 6d1f33b177d..642b3d18ce9 100644 --- a/docs/api/cpp/index.rst +++ b/docs/api/cpp/index.rst @@ -164,8 +164,8 @@ Source code for this example is `writer.cpp .. code-block:: cpp - const Session session; - const DataType dtype = dtype::struct_({ + Session session; + DataType dtype = dtype::struct_({ {"age", dtype::uint8()}, {"height", dtype::uint16(Nullable)}, }); @@ -187,15 +187,14 @@ Source code for this example is `writer.cpp Expression age_gt_10 = expr::gt(expr::col("age"), expr::lit(10)); Array validity_array = array.apply(age_gt_10); - const Validity validity = Validity::from_array(validity_array); + Validity validity = Validity::from_array(validity_array); Array array2 = make_struct({ {"age", age}, {"height", Array::primitive(height_buffer, validity)}, }); Writer writer = Writer::open(session, argv[1], dtype); - writer.push(array); - writer.push(array2); + writer.push({array, array2}); writer.finish(); DataType diff --git a/lang/cpp/.gitignore b/lang/cpp/.gitignore new file mode 100644 index 00000000000..3ea99d6c913 --- /dev/null +++ b/lang/cpp/.gitignore @@ -0,0 +1 @@ +coverage* diff --git a/lang/cpp/CMakeLists.txt b/lang/cpp/CMakeLists.txt index 28099a44baa..8949c1977cc 100644 --- a/lang/cpp/CMakeLists.txt +++ b/lang/cpp/CMakeLists.txt @@ -21,6 +21,8 @@ else () message(STATUS "Sccache not found") endif () +option(BUILD_TESTS "Build tests" OFF) + set(SANITIZER "" CACHE STRING "Build with sanitizers") set(TARGET_TRIPLE "" CACHE STRING "Rust target triple for FFI library") set(RUST_BUILD_PROFILE "" CACHE STRING "Cargo profile name for Rust FFI library") @@ -122,3 +124,24 @@ if(TARGET vortex_ffi_shared) target_link_libraries(vortex_cxx_shared PRIVATE ${APPLE_LINK_FLAGS}) endif() endif() + +if (BUILD_TESTS) + FetchContent_Declare( + Nanoarrow + GIT_REPOSITORY https://github.com/apache/arrow-nanoarrow + GIT_TAG apache-arrow-nanoarrow-0.8.0 + ) + FetchContent_MakeAvailable(Nanoarrow) + + FetchContent_Declare( + Catch + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.8.1 + ) + FetchContent_MakeAvailable(Catch) + include(Catch) + target_compile_definitions(Catch2 PRIVATE CATCH_CONFIG_NO_POSIX_SIGNALS) + + include(CTest) + add_subdirectory(tests) +endif() diff --git a/lang/cpp/README.md b/lang/cpp/README.md index a0b92f21835..fe9d2527e63 100644 --- a/lang/cpp/README.md +++ b/lang/cpp/README.md @@ -35,3 +35,11 @@ cmake -Bbuild -DBUILD_EXAMPLES=ON cmake --build build -j ./build/examples/hello-vortex ``` + +## Check coverage + +This will generate an LCOV directory `coverage`: + +```sh +./gcov-report.sh generate +``` diff --git a/lang/cpp/gcov-report.sh b/lang/cpp/gcov-report.sh new file mode 100755 index 00000000000..6c1365df57a --- /dev/null +++ b/lang/cpp/gcov-report.sh @@ -0,0 +1,18 @@ +#!/bin/sh + +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + +set -eu +cmake -Bbuild -DBUILD_TESTS=1 -DCMAKE_CXX_FLAGS='-fprofile-arcs -ftest-coverage' +cmake --build build -j +ctest --test-dir build --output-on-failure + +geninfo build/CMakeFiles/vortex_cxx_shared.dir/ \ + build/tests/CMakeFiles/vortex_cxx_test.dir/ \ + --rc geninfo_unexecuted_blocks=1 \ + --exclude /usr --exclude build/_deps --exclude tests \ + -j -b src -o coverage.info +if [ $# -gt 0 ]; then + genhtml coverage.info -o coverage +fi diff --git a/lang/cpp/include/vortex/array.hpp b/lang/cpp/include/vortex/array.hpp index 82c1b5d747d..43b986f4b42 100644 --- a/lang/cpp/include/vortex/array.hpp +++ b/lang/cpp/include/vortex/array.hpp @@ -40,8 +40,8 @@ enum class ValidityType { AllValid = VX_VALIDITY_ALL_VALID, // All items are invalid AllInvalid = VX_VALIDITY_ALL_INVALID, - // Item validity is set in a boolean array: true = valid, false = invalid - Array = VX_VALIDITY_ARRAY, + // Item validity is set from a boolean array: true = valid, false = invalid + FromArray = VX_VALIDITY_ARRAY, }; /** diff --git a/lang/cpp/include/vortex/common.hpp b/lang/cpp/include/vortex/common.hpp index 1248f6d53bd..cc8559e96c6 100644 --- a/lang/cpp/include/vortex/common.hpp +++ b/lang/cpp/include/vortex/common.hpp @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors #pragma once +#include #include #include #include @@ -13,9 +14,34 @@ namespace vortex { struct float16_t { uint16_t bits; - friend bool operator==(float16_t, float16_t) = default; + constexpr friend bool operator==(float16_t, float16_t) = default; // NOLINTNEXTLINE - operator float() const; + constexpr operator float() const { + float result; + const uint32_t sign = (bits >> 15) & 1; + const uint32_t exponent = (bits >> 10) & 0x1F; + const uint32_t mantissa = bits & 0x3FF; + + uint32_t out; + if (exponent == 0x1F) { + out = (sign << 31) | 0x7F800000 | (mantissa << 13); + } else if (exponent == 0) { + if (mantissa == 0) { + out = sign << 31; + } else { + uint32_t m = mantissa; + int e = -1; + do { + m <<= 1; + ++e; + } while ((m & 0x400) == 0); + out = (sign << 31) | ((127 - 15 - e) << 23) | ((m & 0x3FF) << 13); + } + } else { + out = (sign << 31) | ((exponent - 15 + 127) << 23) | (mantissa << 13); + } + return std::bit_cast(out); + } }; static_assert(sizeof(float16_t) == 2 && std::is_trivially_copyable_v); } // namespace vortex @@ -64,6 +90,10 @@ constexpr vx_ptype to_ptype() { return PTYPE_I32; } else if constexpr (std::is_same_v) { return PTYPE_I64; +#if __STDCPP_FLOAT16_T__ == 1 + } else if constexpr (std::is_same_v) { + return PTYPE_F16; +#endif } else if constexpr (std::is_same_v) { return PTYPE_F16; } else if constexpr (std::is_same_v) { @@ -79,7 +109,13 @@ inline constexpr bool is_numeric_element = std::is_same_v || std::is_same_v || std::is_same_v || std::is_same_v || std::is_same_v || std::is_same_v || std::is_same_v || std::is_same_v || std::is_same_v || - std::is_same_v || std::is_same_v; + std::is_same_v +#if __STDCPP_FLOAT16_T__ == 1 + || std::is_same_v +#else + || std::is_same_v +#endif + ; } // namespace vortex::detail namespace vortex { diff --git a/lang/cpp/include/vortex/scan.hpp b/lang/cpp/include/vortex/scan.hpp index 7175f494bef..1b8cc2ef7a4 100644 --- a/lang/cpp/include/vortex/scan.hpp +++ b/lang/cpp/include/vortex/scan.hpp @@ -20,8 +20,9 @@ namespace vortex { namespace detail { + // range-for support for Scan and Partition -template +template (Source::*Next)()> class PullRange { public: class iterator { @@ -37,7 +38,7 @@ class PullRange { return *cur_; } iterator &operator++() { - cur_ = Next(src_); + cur_ = (src_->*Next)(); return *this; } void operator++(int) { @@ -55,7 +56,7 @@ class PullRange { explicit PullRange(Source &src) : src_(&src) { } iterator begin() { - return iterator(src_, Next(src_)); + return iterator(src_, (src_->*Next)()); } std::default_sentinel_t end() { return std::default_sentinel; diff --git a/lang/cpp/include/vortex/writer.hpp b/lang/cpp/include/vortex/writer.hpp index da761c334f9..8826423e995 100644 --- a/lang/cpp/include/vortex/writer.hpp +++ b/lang/cpp/include/vortex/writer.hpp @@ -18,6 +18,8 @@ namespace vortex { * * finish() writes the footer and finalizes the file. * Not calling finish() leaves file corrupted. + * + * Writer methods are thread-unsafe. */ class Writer { public: @@ -32,7 +34,9 @@ class Writer { * Append Array to output file. * Throws if "array"'s DataType doesn't match writer's DataType. */ + void push(std::span arrays); void push(const Array &array); + void push(std::initializer_list arrays); /* * Write footer and finalize the file. diff --git a/lang/cpp/src/array.cpp b/lang/cpp/src/array.cpp index 11b012449e2..b4adf69fe5c 100644 --- a/lang/cpp/src/array.cpp +++ b/lang/cpp/src/array.cpp @@ -23,14 +23,17 @@ Validity::Validity(const Validity &other) } Validity::Validity(ValidityType type) : type_(type), array_(nullptr) { - if (type == ValidityType::Array) { - throw VortexException("Validity(ValidityType) called with ValidityType::Array", + if (type == ValidityType::FromArray) { + throw VortexException("Validity(ValidityType) called with ValidityType::FromArray", ErrorCode::InvalidArgument); } } Validity Validity::from_array(const Array &bools) { - return {ValidityType::Array, vx_array_clone(Access::c_ptr(bools))}; + if (!bools.has_dtype(DataTypeVariant::Bool)) { + throw VortexException("Validity array isn't a Bool array", ErrorCode::InvalidArgument); + } + return {ValidityType::FromArray, vx_array_clone(Access::c_ptr(bools))}; } Validity::Validity(Validity &&other) noexcept : type_(other.type_), array_(other.array_) { @@ -61,7 +64,7 @@ Validity::~Validity() { } Array Validity::array() const { - if (type_ != ValidityType::Array || array_ == nullptr) { + if (type_ != ValidityType::FromArray || array_ == nullptr) { throw VortexException("validity has no backing array", ErrorCode::InvalidArgument); } return Access::adopt(vx_array_clone(array_)); @@ -93,7 +96,7 @@ ValidityBits::ValidityBits(const Session &session, const vx_array *canonical) { case ValidityType::AllInvalid: all_invalid_ = true; return; - case ValidityType::Array: + case ValidityType::FromArray: break; } @@ -163,7 +166,7 @@ Array Array::primitive_raw(vx_ptype ptype, const void *data, size_t len, const V std::optional keep_alive; vx_validity raw {}; raw.type = static_cast(validity.type()); - if (validity.type() == ValidityType::Array) { + if (validity.type() == ValidityType::FromArray) { keep_alive = validity.array(); raw.array = Access::c_ptr(*keep_alive); } @@ -264,7 +267,7 @@ Array make_struct(std::span fields, const Validity &validity) raw.type = static_cast(validity.type()); std::optional keep_alive; - if (validity.type() == ValidityType::Array) { + if (validity.type() == ValidityType::FromArray) { keep_alive = validity.array(); raw.array = Access::c_ptr(*keep_alive); } diff --git a/lang/cpp/src/f16.cpp b/lang/cpp/src/f16.cpp deleted file mode 100644 index de1058cc7d4..00000000000 --- a/lang/cpp/src/f16.cpp +++ /dev/null @@ -1,40 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -#include "vortex/common.hpp" - -#include -#include - -namespace vortex { - -#if __STDCPP_FLOAT16_T__ != 1 -float16_t::operator float() const { - float result; - const uint32_t sign = (bits >> 15) & 1; - const uint32_t exponent = (bits >> 10) & 0x1F; - const uint32_t mantissa = bits & 0x3FF; - - uint32_t out; - if (exponent == 0x1F) { - out = (sign << 31) | 0x7F800000 | (mantissa << 13); - } else if (exponent == 0) { - if (mantissa == 0) { - out = sign << 31; - } else { - uint32_t m = mantissa; - int e = -1; - do { - m <<= 1; - ++e; - } while ((m & 0x400) == 0); - out = (sign << 31) | ((127 - 15 - e) << 23) | ((m & 0x3FF) << 13); - } - } else { - out = (sign << 31) | ((exponent - 15 + 127) << 23) | (mantissa << 13); - } - std::memcpy(&result, &out, sizeof(result)); - return result; -} -#endif -} // namespace vortex diff --git a/lang/cpp/src/writer.cpp b/lang/cpp/src/writer.cpp index 4adc5d77e50..57035171a14 100644 --- a/lang/cpp/src/writer.cpp +++ b/lang/cpp/src/writer.cpp @@ -8,6 +8,7 @@ #include "vortex/writer.hpp" #include "vortex/session.hpp" +#include #include #include @@ -30,13 +31,23 @@ Writer Writer::open(const Session &session, std::string_view path, const DataTyp return Writer(sink); } -void Writer::push(const Array &array) { +void Writer::push(std::span arrays) { if (handle_ == nullptr) { throw VortexException("null handle_", ErrorCode::InvalidArgument); } vx_error *error = nullptr; - vx_array_sink_push(handle_.get(), Access::c_ptr(array), &error); - throw_on_error(error); + for (const Array &array : arrays) { + vx_array_sink_push(handle_.get(), Access::c_ptr(array), &error); + throw_on_error(error); + } +} + +void Writer::push(const Array &array) { + push(std::span {&array, 1}); +} + +void Writer::push(std::initializer_list arrays) { + push({arrays.begin(), arrays.end()}); } void Writer::finish() { diff --git a/lang/cpp/tests/CMakeLists.txt b/lang/cpp/tests/CMakeLists.txt new file mode 100644 index 00000000000..16a6e0290b9 --- /dev/null +++ b/lang/cpp/tests/CMakeLists.txt @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright the Vortex contributors + +# need this for std::float16_t +set(CMAKE_CXX_STANDARD 23) + +FetchContent_Declare( + magic_enum + GIT_REPOSITORY https://github.com/Neargye/magic_enum.git + GIT_TAG v0.9.7 +) +FetchContent_MakeAvailable(magic_enum) + +file(GLOB TEST_FILES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp") + +add_executable(vortex_cxx_test ${TEST_FILES}) +target_include_directories(vortex_cxx_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(vortex_cxx_test PRIVATE + vortex_cxx_shared + Catch2::Catch2WithMain + magic_enum::magic_enum + nanoarrow_shared) + +catch_discover_tests(vortex_cxx_test) diff --git a/lang/cpp/tests/array.cpp b/lang/cpp/tests/array.cpp new file mode 100644 index 00000000000..998cb945833 --- /dev/null +++ b/lang/cpp/tests/array.cpp @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#include +#include +#include +#include +#include + +using namespace vortex; +using namespace vortex::expr::ops; + +namespace { +using enum vortex::PType; +using enum ValidityType; + +TEST_CASE("Null array", "[array]") { + Array a = Array::null(1999); + REQUIRE(a.size() == 1999); + REQUIRE(a.nullable()); + REQUIRE(a.has_dtype(DataTypeVariant::Null)); +} + +TEST_CASE("Empty array", "[array]") { + Session session; + + auto empty = Array::primitive({}); + REQUIRE(empty.size() == 0); + REQUIRE(empty.is_primitive(I32)); + + REQUIRE(empty.null_count() == 0); + + auto view = empty.values(session); + REQUIRE(view.size() == 0); + auto values = view.values(); + REQUIRE(values.empty()); +} + +void test_primitive_array(Array array, const int32_t *begin) { + Session session; + REQUIRE(array.size() == 3); + REQUIRE(array.is_primitive(I32)); + REQUIRE_FALSE(array.nullable()); + REQUIRE(array.null_count() == 0); + + auto view = array.values(session); + REQUIRE(view.size() == 3); + REQUIRE(std::equal(view.values().begin(), view.values().end(), begin)); + REQUIRE_FALSE(view.is_null(1)); +} + +TEST_CASE("Primitive array", "[array]") { + int32_t c_array[3] = {10, 20, 30}; + test_primitive_array(Array::primitive(c_array), c_array); + + const int32_t const_c_array[3] = {10, 20, 30}; + test_primitive_array(Array::primitive(const_c_array), const_c_array); + + const std::array cpp_array = {10, 20, 30}; + test_primitive_array(Array::primitive(cpp_array), cpp_array.begin()); + + std::vector cpp_vector = {10, 20, 30}; + test_primitive_array(Array::primitive(cpp_vector), cpp_vector.data()); +} + +TEST_CASE("values with wrong type", "[array]") { + Session session; + std::vector data = {1}; + Array a = Array::primitive(data); + REQUIRE_THROWS_AS(a.values(session), VortexException); +} + +TEST_CASE("Validity from a boolean mask", "[array]") { + Session session; + std::vector data = {10, 20, 30}; + std::vector mask_bytes = {1, 0, 1}; + + Array mask_u8 = Array::primitive(std::span(mask_bytes)); + Array mask = mask_u8.apply(expr::root() == expr::lit(1)); + + Array a = Array::primitive(std::span(data), Validity::from_array(mask)); + REQUIRE(a.nullable()); + REQUIRE(a.null_count() == 1); + + auto view = a.values(session); + REQUIRE_FALSE(view.is_null(0)); + REQUIRE(view.is_null(1)); + REQUIRE_FALSE(view.is_null(2)); + REQUIRE(view.values()[0] == 10); + REQUIRE(view.values()[2] == 30); + + Validity validity = a.validity(); + REQUIRE(validity.type() == FromArray); + REQUIRE(validity.array().size() == 3); +} + +TEST_CASE("Invalid validity", "[array]") { + std::vector invalid_mask = {1, 2, 3}; + Array mask = Array::primitive(invalid_mask); + REQUIRE_THROWS_AS(Validity::from_array(mask), VortexException); +} + +TEST_CASE("AllInvalid", "[array]") { + Session session; + std::vector data = {1, 2}; + Array a = Array::primitive(std::span(data), AllInvalid); + REQUIRE(a.null_count() == 2); + auto view = a.values(session); + REQUIRE(view.is_null(0)); + REQUIRE(view.is_null(1)); +} + +TEST_CASE("make_struct and fields", "[array]") { + Array empty = make_struct({}); + REQUIRE(empty.size() == 0); + REQUIRE(empty.has_dtype(DataTypeVariant::Struct)); + REQUIRE(empty.dtype().fields().size() == 0); + + std::vector ages = {10, 20, 30}; + std::vector heights = {150, 160, 170}; + + Array s = make_struct({ + {"age", Array::primitive(ages)}, + {"height", Array::primitive(heights, AllValid)}, + }); + + REQUIRE(s.size() == 3); + REQUIRE(s.has_dtype(DataTypeVariant::Struct)); + REQUIRE(s.dtype().fields().size() == 2); + + Array by_index = s.field(0); + REQUIRE(by_index.is_primitive(U8)); + + Session session; + Array by_name = s.field("height"); + REQUIRE(by_name.is_primitive(U16)); + auto view = by_name.values(session); + REQUIRE(view.values()[2] == 170); + + REQUIRE_THROWS_AS(s.field(2), VortexException); + REQUIRE_THROWS_AS(s.field("nope"), VortexException); + + std::vector fields_vec; + fields_vec.emplace_back("age", Array::primitive(ages)); + Array other = make_struct(fields_vec); + REQUIRE(other.size() == 3); + REQUIRE(other.has_dtype(DataTypeVariant::Struct)); +} + +TEST_CASE("Mismatched field length", "[array]") { + std::vector a = {1, 2}; + std::vector b = {1, 2, 3}; + REQUIRE_THROWS_AS(make_struct({ + {"a", Array::primitive(a)}, + {"b", Array::primitive(b)}, + }), + VortexException); +} + +TEST_CASE("Slice", "[array]") { + Session session; + std::vector data = {0, 1, 2, 3, 4, 5}; + Array a = Array::primitive(data); + Array sliced = a.slice(2, 5); + REQUIRE(sliced.size() == 3); + auto view = sliced.values(session); + REQUIRE(view.values()[0] == 2); + REQUIRE(view.values()[2] == 4); + + REQUIRE_THROWS_AS(a.slice(2, 100), VortexException); +} + +TEST_CASE("Error with a code", "[array]") { + std::vector data = {0}; + Array a = Array::primitive(data); + try { + (void)a.slice(2, 100); + FAIL("expected exception"); + } catch (const VortexException &e) { + REQUIRE_FALSE(std::string(e.what()).empty()); + (void)e.code(); + } +} +} // namespace diff --git a/lang/cpp/tests/arrow.cpp b/lang/cpp/tests/arrow.cpp new file mode 100644 index 00000000000..8675d816067 --- /dev/null +++ b/lang/cpp/tests/arrow.cpp @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#include + +#include +#include + +typedef struct ArrowSchema FFI_ArrowSchema; +typedef struct ArrowArray FFI_ArrowArray; +typedef struct ArrowArrayStream FFI_ArrowArrayStream; +#define USE_OWN_ARROW 1 + +#include + +#include "common.hpp" + +using namespace vortex; +using vortex_test::sample_dtype; +using vortex_test::TempPath; +using vortex_test::write_sample; + +namespace { +using enum vortex::PType; + +TEST_CASE("dtype to ArrowSchema", "[arrow]") { + DataType d = sample_dtype(); + ArrowSchema schema = d.to_arrow(); + + nanoarrow::UniqueSchema unique_schema; + ArrowSchemaMove(&schema, unique_schema.get()); + REQUIRE(unique_schema->format != nullptr); + REQUIRE(unique_schema->n_children == 2); +} + +TEST_CASE("dtype from ArrowSchema", "[arrow]") { + nanoarrow::UniqueSchema schema; + REQUIRE(ArrowSchemaInitFromType(schema.get(), NANOARROW_TYPE_STRUCT) == NANOARROW_OK); + REQUIRE(ArrowSchemaAllocateChildren(schema.get(), 1) == NANOARROW_OK); + REQUIRE(ArrowSchemaInitFromType(schema->children[0], NANOARROW_TYPE_INT64) == NANOARROW_OK); + REQUIRE(ArrowSchemaSetName(schema->children[0], "n") == NANOARROW_OK); + + ArrowSchema raw = {}; + ArrowSchemaMove(schema.get(), &raw); + DataType d = DataType::from_arrow(&raw); + REQUIRE(d.variant() == DataTypeVariant::Struct); + const std::vector fields = d.fields(); + REQUIRE(fields.size() == 1); + REQUIRE(fields[0].name == "n"); + REQUIRE(fields[0].dtype.primitive_type() == I64); +} + +TEST_CASE("Import Arrow array as Vortex array", "[arrow]") { + Session session; + nanoarrow::UniqueSchema schema; + REQUIRE(ArrowSchemaInitFromType(schema.get(), NANOARROW_TYPE_STRUCT) == NANOARROW_OK); + REQUIRE(ArrowSchemaAllocateChildren(schema.get(), 1) == NANOARROW_OK); + REQUIRE(ArrowSchemaInitFromType(schema->children[0], NANOARROW_TYPE_INT32) == NANOARROW_OK); + REQUIRE(ArrowSchemaSetName(schema->children[0], "a") == NANOARROW_OK); + + nanoarrow::UniqueArray arr; + REQUIRE(ArrowArrayInitFromSchema(arr.get(), schema.get(), nullptr) == NANOARROW_OK); + REQUIRE(ArrowArrayStartAppending(arr.get()) == NANOARROW_OK); + for (int i : {10, 20, 30}) { + REQUIRE(ArrowArrayAppendInt(arr->children[0], i) == NANOARROW_OK); + REQUIRE(ArrowArrayFinishElement(arr.get()) == NANOARROW_OK); + } + REQUIRE(ArrowArrayFinishBuildingDefault(arr.get(), nullptr) == NANOARROW_OK); + + ArrowArray raw_arr = {}; + ArrowSchema raw_schema = {}; + ArrowArrayMove(arr.get(), &raw_arr); + ArrowSchemaMove(schema.get(), &raw_schema); + + Array vx = Array::from_arrow(&raw_arr, &raw_schema, false); + REQUIRE(vx.size() == 3); + REQUIRE(vx.has_dtype(DataTypeVariant::Struct)); + + Array a = vx.field(0); + REQUIRE(a.is_primitive(I32)); + auto view = a.values(session); + REQUIRE(view.values()[0] == 10); + REQUIRE(view.values()[2] == 30); +} + +TEST_CASE("Scan partition to ArrowArrayStream", "[arrow]") { + Session session; + TempPath path = write_sample(session); + DataSource ds = DataSource::open(session, {path.string()}); + + Scan scan = ds.scan(); + auto partition = scan.next_partition(); + REQUIRE(partition.has_value()); + + ArrowStream vx_stream = std::move(partition.value()).into_arrow_stream(); + nanoarrow::UniqueArrayStream owned; + ArrowArrayStreamMove(vx_stream.raw(), owned.get()); + + nanoarrow::UniqueSchema schema; + ArrowError err {}; + REQUIRE(ArrowArrayStreamGetSchema(owned.get(), schema.get(), &err) == NANOARROW_OK); + REQUIRE(schema->n_children == 2); + + size_t rows = 0; + while (true) { + nanoarrow::UniqueArray chunk; + int rc = owned->get_next(owned.get(), chunk.get()); + REQUIRE(rc == NANOARROW_OK); + if (chunk->release == nullptr) { + break; + } + rows += chunk->length; + } + REQUIRE(rows == vortex_test::SAMPLE_ROWS); +} +} // namespace diff --git a/lang/cpp/tests/common.hpp b/lang/cpp/tests/common.hpp new file mode 100644 index 00000000000..b1b3d982ffa --- /dev/null +++ b/lang/cpp/tests/common.hpp @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#pragma once + +#include "vortex/array.hpp" +#include +#include +#include +#include + +#include +#include + +namespace vortex_test { + +namespace fs = std::filesystem; +using namespace vortex; + +class TempPath { +public: + TempPath() = default; + explicit TempPath(fs::path p) : path_(std::move(p)) { + } + + TempPath(const TempPath &) = delete; + TempPath &operator=(const TempPath &) = delete; + + TempPath(TempPath &&other) noexcept : path_(std::move(other.path_)) { + other.path_.clear(); + } + TempPath &operator=(TempPath &&other) noexcept { + if (this != &other) { + reset(); + path_ = std::move(other.path_); + other.path_.clear(); + } + return *this; + } + + ~TempPath() { + reset(); + } + + const fs::path &path() const noexcept { + return path_; + } + std::string string() const { + return path_.string(); + } + + static TempPath unique() { + auto dir = fs::temp_directory_path() / "vortex_cxx_test"; + fs::create_directories(dir); + std::string name = std::to_string(std::random_device {}()) + ".vortex"; + return TempPath {dir / name}; + } + +private: + void reset() noexcept { + if (!path_.empty()) { + std::error_code ec; + fs::remove(path_, ec); + } + } + + fs::path path_; +}; + +inline DataType sample_dtype() { + return dtype::struct_({ + {"age", dtype::uint8()}, + {"height", dtype::uint16(dtype::Nullable)}, + }); +} + +constexpr size_t SAMPLE_ROWS = 100; + +inline std::vector sample_ages() { + std::vector buf(SAMPLE_ROWS); + for (size_t i = 0; i < SAMPLE_ROWS; ++i) { + buf[i] = static_cast(i); + } + return buf; +} + +inline std::vector sample_heights() { + std::vector buf(SAMPLE_ROWS); + for (size_t i = 0; i < SAMPLE_ROWS; ++i) { + buf[i] = static_cast((i + 1) % 200); + } + return buf; +} + +inline Array sample_array() { + auto ages = sample_ages(); + auto heights = sample_heights(); + return make_struct({ + {"age", Array::primitive(ages)}, + {"height", Array::primitive(heights, ValidityType::AllValid)}, + }); +} + +inline TempPath write_sample(const Session &session) { + TempPath path = TempPath::unique(); + Writer writer = Writer::open(session, path.string(), sample_dtype()); + writer.push(sample_array()); + writer.finish(); + return path; +} +} // namespace vortex_test diff --git a/lang/cpp/tests/dtype.cpp b/lang/cpp/tests/dtype.cpp new file mode 100644 index 00000000000..1b5e3573092 --- /dev/null +++ b/lang/cpp/tests/dtype.cpp @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#include "vortex/dtype.hpp" +#include +#include + +using namespace vortex; + +namespace { +using enum vortex::PType; + +TEST_CASE("Null dtype", "[dtype]") { + auto d = dtype::null(); + REQUIRE(d.variant() == DataTypeVariant::Null); + REQUIRE(d.nullable()); +} + +TEST_CASE("Decimal dtype", "[dtype]") { + auto d = dtype::decimal(5, 2, false); + REQUIRE(d.variant() == DataTypeVariant::Decimal); + REQUIRE(d.decimal_precision() == 5); + REQUIRE(d.decimal_scale() == 2); + REQUIRE_FALSE(d.nullable()); + + REQUIRE_THROWS_AS(d.fields(), VortexException); + REQUIRE_THROWS_AS(d.list_element(), VortexException); +} + +TEST_CASE("copy dtype", "[dtype]") { + auto d = dtype::int32(true); + DataType d2 = d; + REQUIRE(d2.variant() == DataTypeVariant::Primitive); + REQUIRE(d2.primitive_type() == I32); + REQUIRE(d2.nullable()); + REQUIRE(d.variant() == DataTypeVariant::Primitive); +} + +TEST_CASE("list dtype", "[dtype]") { + auto d = dtype::list(dtype::float64(), dtype::Nullable); + REQUIRE(d.variant() == DataTypeVariant::List); + REQUIRE(d.nullable()); + REQUIRE(d.list_element().primitive_type() == F64); + + auto fsl = dtype::fixed_size_list(dtype::int16(), 4); + REQUIRE(fsl.variant() == DataTypeVariant::FixedSizeList); + REQUIRE(fsl.fixed_size_list_size() == 4); + REQUIRE(fsl.fixed_size_list_element().primitive_type() == I16); +} + +TEST_CASE("Struct DataType", "[dtype]") { + DataType d = dtype::struct_({ + {"col1", dtype::uint8()}, + {"col2", dtype::binary(dtype::Nullable)}, + }); + + REQUIRE(d.variant() == DataTypeVariant::Struct); + REQUIRE_FALSE(d.nullable()); + const std::vector fields = d.fields(); + REQUIRE(fields.size() == 2); + REQUIRE(fields[0].name == "col1"); + REQUIRE(fields[1].name == "col2"); + REQUIRE(fields[0].dtype.primitive_type() == U8); + REQUIRE(fields[1].dtype.variant() == DataTypeVariant::Binary); + REQUIRE(fields[1].dtype.nullable()); + + std::vector fields_vec; + fields_vec.emplace_back("col1", dtype::uint8()); + fields_vec.emplace_back("col2", dtype::utf8()); + d = dtype::struct_(fields_vec, dtype::Nullable); + + REQUIRE(d.variant() == DataTypeVariant::Struct); + REQUIRE(d.nullable()); + const std::vector built = d.fields(); + REQUIRE(built.size() == 2); + REQUIRE(built[1].name == "col2"); + + fields_vec = {}; + fields_vec.emplace_back("\xFF\xFE", dtype::uint8()); + REQUIRE_THROWS_AS(dtype::struct_(fields_vec), VortexException); +} +} // namespace diff --git a/lang/cpp/tests/enum_sizes.cpp b/lang/cpp/tests/enum_sizes.cpp new file mode 100644 index 00000000000..a100fa3b583 --- /dev/null +++ b/lang/cpp/tests/enum_sizes.cpp @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#include + +#include +#include + +using magic_enum::enum_count; + +static_assert(enum_count() == enum_count()); +static_assert(enum_count() == enum_count()); +static_assert(enum_count() == enum_count()); +static_assert(enum_count() == enum_count()); +static_assert(enum_count() == enum_count()); +static_assert(enum_count() == enum_count()); diff --git a/lang/cpp/tests/expression.cpp b/lang/cpp/tests/expression.cpp new file mode 100644 index 00000000000..1a55364df02 --- /dev/null +++ b/lang/cpp/tests/expression.cpp @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#include +#include + +#include +#include +#include + +using namespace vortex; + +namespace { +using enum vortex::PType; + +TEST_CASE("Expression copy", "[expr]") { + Expression clone = expr::root(); + { + Expression orig = expr::col("age"); + clone = orig; + } + Expression another = clone; + (void)another; + SUCCEED(); +} + +TEST_CASE("Apply root()", "[expr]") { + Session session; + std::vector data = {10, 20, 30, 40, 50}; + Array array = Array::primitive(data); + + Array applied = array.apply(expr::root()); + + REQUIRE(applied.size() == data.size()); + REQUIRE(applied.is_primitive(I32)); + auto values = applied.values(session); + for (size_t i = 0; i < data.size(); ++i) { + REQUIRE(values.values()[i] == data[i]); + } +} + +TEST_CASE("Apply projection", "[expr]") { + Session session; + std::vector ages = {10, 20, 30}; + std::vector heights = {150, 160, 170}; + + Array struct_arr = make_struct({ + {"age", Array::primitive(ages)}, + {"height", Array::primitive(heights)}, + }); + + Array projected = struct_arr.apply(expr::col("age")); + REQUIRE(projected.size() == ages.size()); + REQUIRE(projected.is_primitive(U8)); + auto values = projected.values(session); + for (size_t i = 0; i < ages.size(); ++i) { + REQUIRE(values.values()[i] == ages[i]); + } +} + +TEST_CASE("Apply arithmetic", "[expr]") { + Session session; + std::vector data = {1, 2, 3, 4, 5}; + Array array = Array::primitive(data); + + Expression e = expr::add(expr::root(), expr::lit(10)); + Array applied = array.apply(e); + + REQUIRE(applied.is_primitive(I32)); + auto values = applied.values(session); + for (size_t i = 0; i < data.size(); ++i) { + REQUIRE(values.values()[i] == data[i] + 10); + } +} + +TEST_CASE("Operator overloading", "[expr]") { + using namespace vortex::expr::ops; + Session session; + std::vector data = {1, 2, 2, 7}; + Array array = Array::primitive(data); + + Array applied = array.apply(expr::root() == expr::lit(2)); + auto bits = applied.bools(session); + REQUIRE(bits.size() == data.size()); + REQUIRE_FALSE(bits.value(0)); + REQUIRE(bits.value(1)); + REQUIRE(bits.value(2)); + REQUIRE_FALSE(bits.value(3)); +} + +TEST_CASE("Apply error", "[expr]") { + std::vector data = {1, 2, 3}; + Array array = Array::primitive(data); + + Expression bad = expr::add(expr::root(), expr::lit(1)); + REQUIRE_THROWS_AS(array.apply(bad), VortexException); +} + +TEST_CASE("Empty conjunction", "[expr]") { + REQUIRE_THROWS_AS(expr::and_all({}), VortexException); + REQUIRE_THROWS_AS(expr::or_all({}), VortexException); +} +} // namespace diff --git a/lang/cpp/tests/float16_t.cpp b/lang/cpp/tests/float16_t.cpp new file mode 100644 index 00000000000..7a08a92a05b --- /dev/null +++ b/lang/cpp/tests/float16_t.cpp @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#include "vortex/dtype.hpp" +#include +#include + +using namespace vortex; + +namespace { + +static_assert(float(1.0f16) == 1.0f); + +TEST_CASE("float16_t to_float", "[float]") { + REQUIRE(float(1.0f16) == 1.0F); + REQUIRE(float(-2.0f16) == -2.0F); + REQUIRE(float(0.0f) == 0.0F); + REQUIRE(float(-0.0f) == -0.0F); +} + +#if __STDCPP_FLOAT16_T__ == 1 +TEST_CASE("F16 scalar", "[scalar]") { + std::float16_t float16t = 1.0f16; + + Scalar scalar = scalar::of(float16t); + REQUIRE(scalar.dtype().variant() == DataTypeVariant::Primitive); + REQUIRE(scalar.dtype().primitive_type() == vortex::PType::F16); + + _Float16 float16t_alias = 1.0f16; + scalar = scalar::of(float16t_alias); + REQUIRE(scalar.dtype().variant() == DataTypeVariant::Primitive); + REQUIRE(scalar.dtype().primitive_type() == vortex::PType::F16); +} +#endif +} // namespace diff --git a/lang/cpp/tests/float16_t_compat.cpp b/lang/cpp/tests/float16_t_compat.cpp new file mode 100644 index 00000000000..94acb0df071 --- /dev/null +++ b/lang/cpp/tests/float16_t_compat.cpp @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +#include +#include +// This is UB but no other way to test compatibility macro, fine +// as we're running tests +#undef __STDCPP_FLOAT16_T__ +#include +#include + +using namespace vortex; + +namespace { + +constexpr float16_t one {0x3C00}; +static_assert(static_cast(one) == 1.0f); + +TEST_CASE("float16_t to_float (compatibility)", "[float]") { + REQUIRE(float(float16_t {0x3C00}) == 1.0F); + REQUIRE(float(float16_t {0xC000}) == -2.0F); + REQUIRE(float(float16_t {0}) == 0.0F); + REQUIRE(float(float16_t {0x8000}) == -0.0F); + REQUIRE(float(float16_t {0x7C00}) == std::numeric_limits::infinity()); + REQUIRE(float(float16_t {0xFC00}) == -std::numeric_limits::infinity()); + REQUIRE(std::fpclassify(float(float16_t {0x7E01})) == FP_NAN); + // Denormalized float16_t always gets to normal floats on conversion to float + REQUIRE(std::fpclassify(float(float16_t {0x0001})) == FP_NORMAL); + REQUIRE(std::fpclassify(float(float16_t {0x83FF})) == FP_NORMAL); +} + +TEST_CASE("F16 scalar (compatibility)", "[scalar]") { + const float16_t float16t {0x3C00}; + Scalar scalar = scalar::of(float16t); + REQUIRE(scalar.dtype().variant() == DataTypeVariant::Primitive); + REQUIRE(scalar.dtype().primitive_type() == vortex::PType::F16); +} +} // namespace diff --git a/lang/cpp/tests/scalar.cpp b/lang/cpp/tests/scalar.cpp new file mode 100644 index 00000000000..27ff73f656a --- /dev/null +++ b/lang/cpp/tests/scalar.cpp @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#include "vortex/error.hpp" +#include +#include +#include + +using namespace vortex; +using namespace std::string_view_literals; + +namespace { +using enum vortex::PType; + +TEST_CASE("Boolean scalar", "[scalar]") { + Scalar s = scalar::of(true); + REQUIRE_FALSE(s.is_null()); + REQUIRE(s.dtype().variant() == DataTypeVariant::Bool); +} + +TEST_CASE("Integer scalars", "[scalar]") { + REQUIRE(scalar::of(42).dtype().primitive_type() == U8); + REQUIRE(scalar::of(42).dtype().primitive_type() == U16); + REQUIRE(scalar::of(42).dtype().primitive_type() == U32); + REQUIRE(scalar::of(42).dtype().primitive_type() == U64); + REQUIRE(scalar::of(-1).dtype().primitive_type() == I8); + REQUIRE(scalar::of(-1).dtype().primitive_type() == I16); + REQUIRE(scalar::of(-1).dtype().primitive_type() == I32); + REQUIRE(scalar::of(-1).dtype().primitive_type() == I64); +} + +TEST_CASE("Float scalars", "[scalar]") { + REQUIRE(scalar::of(1.5F).dtype().primitive_type() == F32); + REQUIRE(scalar::of(1.5).dtype().primitive_type() == F64); + REQUIRE(scalar::of(float16_t {0x3C00}).dtype().primitive_type() == F16); +} + +TEST_CASE("Nullable scalars", "[scalar]") { + Scalar s = scalar::of(0, true); + REQUIRE(s.dtype().nullable()); + REQUIRE_FALSE(s.is_null()); +} + +TEST_CASE("Null scalar", "[scalar]") { + Scalar s = scalar::null(dtype::int32(true)); + REQUIRE(s.is_null()); + REQUIRE(s.dtype().variant() == DataTypeVariant::Primitive); +} + +TEST_CASE("UTF-8 scalar", "[scalar]") { + Scalar s = scalar::of("hello"sv); + REQUIRE_FALSE(s.is_null()); + REQUIRE(s.dtype().variant() == DataTypeVariant::Utf8); + + REQUIRE_FALSE(scalar::of(""sv).is_null()); + s = scalar::of("Широкая строка"sv); + REQUIRE(s.dtype().variant() == DataTypeVariant::Utf8); + + REQUIRE_THROWS_AS(scalar::of("\xFF\xFE"sv), VortexException); +} + +TEST_CASE("Binary scalar", "[scalar]") { + const std::byte bytes[] = {std::byte {1}, std::byte {2}, std::byte {0}, std::byte {4}}; + Scalar s = scalar::of(std::span {bytes}); + REQUIRE_FALSE(s.is_null()); + REQUIRE(s.dtype().variant() == DataTypeVariant::Binary); +} + +TEST_CASE("Decimal scalars", "[scalar]") { + Scalar d8 = scalar::decimal(56, 5, 2); + REQUIRE(d8.dtype().variant() == DataTypeVariant::Decimal); + REQUIRE(d8.dtype().decimal_precision() == 5); + REQUIRE(d8.dtype().decimal_scale() == 2); + + Scalar d16 = scalar::decimal(int16_t(1234), 5, 2); + REQUIRE(d16.dtype().variant() == DataTypeVariant::Decimal); + REQUIRE(d16.dtype().decimal_precision() == 5); + REQUIRE(d16.dtype().decimal_scale() == 2); + + Scalar d32 = scalar::decimal(int32_t(1234), 5, 2); + REQUIRE(d32.dtype().variant() == DataTypeVariant::Decimal); + REQUIRE(d32.dtype().decimal_precision() == 5); + REQUIRE(d32.dtype().decimal_scale() == 2); + + Scalar d64 = scalar::decimal(99999, 12, 3); + REQUIRE(d64.dtype().variant() == DataTypeVariant::Decimal); + REQUIRE(d64.dtype().decimal_precision() == 12); + REQUIRE(d64.dtype().decimal_scale() == 3); +} + +TEST_CASE("Copy scalar", "[scalar]") { + Scalar a = scalar::of(42); + Scalar b = a; + REQUIRE(a.dtype().primitive_type() == I64); + REQUIRE(b.dtype().primitive_type() == I64); + + Scalar c = scalar::of(1); + c = b; + REQUIRE(c.dtype().primitive_type() == I64); +} +} // namespace diff --git a/lang/cpp/tests/scan.cpp b/lang/cpp/tests/scan.cpp new file mode 100644 index 00000000000..82854e11f2a --- /dev/null +++ b/lang/cpp/tests/scan.cpp @@ -0,0 +1,348 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#include +#include +#include +#include +#include +#include + +#include "common.hpp" +#include "vortex/estimate.hpp" + +using namespace vortex; +using Catch::Matchers::ContainsSubstring; +using vortex_test::SAMPLE_ROWS; +using vortex_test::TempPath; +using vortex_test::write_sample; + +namespace { +using enum vortex::PType; + +TEST_CASE("Non-existent data source", "[datasource]") { + Session session; + + try { + DataSource::open(session, {"nonexistent"}); + FAIL("expected exception"); + } catch (const VortexException &e) { + REQUIRE_THAT(std::string(e.what()), ContainsSubstring("No files matched")); + } +} + +TEST_CASE("Read dtype and row count", "[datasource]") { + Session session; + TempPath path = write_sample(session); + + std::array paths = {path.string()}; + + DataSource other = DataSource::open(session, paths); + DataSource ds(other); + + Estimate row_count = ds.row_count(); + REQUIRE(row_count.type() == EstimateType::Exact); + REQUIRE(row_count.value() == SAMPLE_ROWS); + + DataType dt = ds.dtype(); + REQUIRE(dt.variant() == DataTypeVariant::Struct); + const std::vector fields = dt.fields(); + REQUIRE(fields.size() == 2); + REQUIRE(fields[0].name == "age"); + REQUIRE(fields[1].name == "height"); +} + +void verify_age_field(const Session &session, + const Array &age, + uint8_t first = 0, + size_t rows = SAMPLE_ROWS) { + REQUIRE(age.is_primitive(U8)); + REQUIRE(age.size() == rows); + auto view = age.values(session); + for (size_t i = 0; i < rows; ++i) { + REQUIRE(view.values()[i] == static_cast(first + i)); + } +} + +void verify_sample_array(const Session &session, const Array &array) { + REQUIRE(array.size() == SAMPLE_ROWS); + REQUIRE(array.has_dtype(DataTypeVariant::Struct)); + verify_age_field(session, array.field(0)); + + Array height = array.field(1); + REQUIRE(height.is_primitive(U16)); + auto view = height.values(session); + for (size_t i = 0; i < SAMPLE_ROWS; ++i) { + REQUIRE(view.values()[i] == (i + 1) % 200); + } + + REQUIRE_THROWS_AS(array.field(2), VortexException); +} + +TEST_CASE("Basic scan", "[scan]") { + Session session; + TempPath path = write_sample(session); + DataSource ds = DataSource::open(session, {path.string()}); + + Scan scan = ds.scan(); + REQUIRE(scan.partition_count().type() == EstimateType::Exact); + REQUIRE(scan.partition_count().value() == 1); + REQUIRE(scan.dtype().variant() == DataTypeVariant::Struct); + + auto partition = scan.next_partition(); + REQUIRE(partition.has_value()); + + Estimate rows = partition->row_count(); + REQUIRE(rows.type() == EstimateType::Exact); + REQUIRE(rows.value() == SAMPLE_ROWS); + + REQUIRE_FALSE(scan.next_partition().has_value()); + + auto array = partition->next(); + REQUIRE(array.has_value()); + REQUIRE_FALSE(partition->next().has_value()); + + verify_sample_array(session, *array); +} + +TEST_CASE("Range-for over partitions and batches", "[scan]") { + Session session; + TempPath path = write_sample(session); + DataSource ds = DataSource::open(session, {path.string()}); + + Scan scan = ds.scan(); + size_t partitions = 0; + size_t rows = 0; + for (Partition &partition : scan.partitions()) { + ++partitions; + for (Array &batch : partition.batches()) { + rows += batch.size(); + } + } + REQUIRE(partitions == 1); + REQUIRE(rows == SAMPLE_ROWS); +} + +TEST_CASE("Scan from buffer", "[scan]") { + Session session; + TempPath path = write_sample(session); + + std::ifstream file(path.path(), std::ios::binary | std::ios::ate); + const auto size = file.tellg(); + file.seekg(0, std::ios::beg); + std::vector buffer(static_cast(size)); + REQUIRE(file.read(reinterpret_cast(buffer.data()), size)); + + DataSource ds = DataSource::from_buffer(session, buffer); + Scan scan = ds.scan(); + auto partition = scan.next_partition(); + REQUIRE(partition.has_value()); + auto array = partition->next(); + REQUIRE(array.has_value()); + verify_sample_array(session, *array); +} + +TEST_CASE("Multiple paths", "[scan]") { + Session session; + TempPath first = write_sample(session); + TempPath second = write_sample(session); + + const std::vector paths = {first.string(), second.string()}; + DataSource ds = DataSource::open(session, paths); + REQUIRE(ds.row_count().value_or(0) >= SAMPLE_ROWS); +} + +TEST_CASE("Multithreaded scan", "[scan]") { + Session session; + + constexpr size_t NUM_FILES = 8; + std::vector paths; + std::vector path_strings; + for (size_t i = 0; i < NUM_FILES; ++i) { + paths.push_back(write_sample(session)); + path_strings.push_back(paths.back().string()); + } + + DataSource ds = DataSource::open(session, path_strings); + Scan scan = ds.scan(); + + std::vector threads; + threads.reserve(NUM_FILES); + std::vector rows(NUM_FILES, 0); + + for (size_t i = 0; i < NUM_FILES; ++i) { + threads.emplace_back([&, i] { + while (auto partition = scan.next_partition()) { + while (auto batch = partition->next()) { + rows[i] += batch->size(); + } + } + }); + } + for (auto &t : threads) { + t.join(); + } + + size_t total = 0; + for (size_t n : rows) { + total += n; + } + REQUIRE(total == NUM_FILES * SAMPLE_ROWS); +} + +TEST_CASE("Project field", "[projection]") { + Session session; + TempPath path = write_sample(session); + DataSource ds = DataSource::open(session, {path.string()}); + + Scan scan = ds.scan({.projection = expr::col("age")}); + auto partition = scan.next_partition(); + REQUIRE(partition.has_value()); + auto array = partition->next(); + REQUIRE(array.has_value()); + verify_age_field(session, *array); +} + +TEST_CASE("Project none fields", "[projection]") { + Session session; + TempPath path = write_sample(session); + DataSource ds = DataSource::open(session, {path.string()}); + + Scan scan = ds.scan({.projection = expr::root().select({})}); + auto partition = scan.next_partition(); + REQUIRE(partition.has_value()); + auto array = partition->next(); + REQUIRE(array.has_value()); + REQUIRE(array->dtype().fields().empty()); +} + +TEST_CASE("Project multiple fields", "[projection]") { + Session session; + TempPath path = write_sample(session); + DataSource ds = DataSource::open(session, {path.string()}); + + Scan scan = ds.scan({.projection = expr::root().select({"age", "height"})}); + auto partition = scan.next_partition(); + REQUIRE(partition.has_value()); + auto array = partition->next(); + REQUIRE(array.has_value()); + verify_age_field(session, array->field("age")); +} + +TEST_CASE("Filter age", "[filter]") { + Session session; + TempPath path = write_sample(session); + DataSource ds = DataSource::open(session, {path.string()}); + + constexpr uint8_t threshold = 50; + Scan scan = ds.scan({ + .filter = expr::gte(expr::col("age"), expr::lit(threshold)), + }); + auto partition = scan.next_partition(); + REQUIRE(partition.has_value()); + auto array = partition->next(); + REQUIRE(array.has_value()); + + REQUIRE(array->size() == SAMPLE_ROWS - threshold); + verify_age_field(session, array->field(0), threshold, SAMPLE_ROWS - threshold); +} + +TEST_CASE("Filter invalid values", "[filter]") { + Session session; + TempPath path = write_sample(session); + DataSource ds = DataSource::open(session, {path.string()}); + + Scan scan = ds.scan({.filter = expr::col("age").is_null()}); + auto partition = scan.next_partition(); + REQUIRE(!partition.has_value()); +} + +TEST_CASE("Filter with operators", "[filter]") { + using namespace vortex::expr::ops; + Session session; + TempPath path = write_sample(session); + const std::string path_string = path.string(); + std::array paths = {path_string}; + DataSource ds = DataSource::open(session, paths); + + Scan scan1 = ds.scan({ + .filter = expr::col("age") >= expr::lit(90) && expr::col("age") < expr::lit(95), + }); + + Scan scan2 = ds.scan({ + .filter = !(expr::col("age") < expr::lit(90)) && expr::col("age") < expr::lit(95), + }); + + Scan scans[2] = {std::move(scan1), std::move(scan2)}; + for (Scan &scan : scans) { + auto partition = scan.next_partition(); + REQUIRE(partition.has_value()); + auto array = partition->next(); + REQUIRE(array.has_value()); + REQUIRE(array->size() == 5); + } +} + +TEST_CASE("Type-mismatched filter", "[filter]") { + Session session; + TempPath path = write_sample(session); + DataSource ds = DataSource::open(session, {path.string()}); + + Scan scan = ds.scan({ + .filter = expr::eq(expr::col("age"), expr::lit(67)), + }); + auto partition = scan.next_partition(); + REQUIRE(partition.has_value()); + REQUIRE_THROWS_AS(partition->next(), VortexException); +} + +TEST_CASE("Row range and limit", "[scan]") { + Session session; + TempPath path = write_sample(session); + DataSource ds = DataSource::open(session, {path.string()}); + + Scan scan = ds.scan({ + .row_range = RowRange {10, 25}, + .limit = 5, + }); + auto partition = scan.next_partition(); + REQUIRE(partition.has_value()); + auto array = partition->next(); + REQUIRE(array.has_value()); + + REQUIRE(array->size() == 5); + verify_age_field(session, array->field(0), 10, 5); +} + +TEST_CASE("Selection", "[scan]") { + using enum Selection::Kind; + Session session; + TempPath path = write_sample(session); + DataSource ds = DataSource::open(session, {path.string()}); + + Scan scan = ds.scan({ + .selection = {{Include, {3, 5, 8}}}, + }); + auto partition = scan.next_partition(); + REQUIRE(partition.has_value()); + auto array = partition->next(); + REQUIRE(array.has_value()); + REQUIRE(array->size() == 3); + + auto ages = array->field(0).values(session); + REQUIRE(ages.values()[0] == 3); + REQUIRE(ages.values()[1] == 5); + REQUIRE(ages.values()[2] == 8); + + scan = ds.scan({ + .selection = {{Exclude, {0, 1, 2}}}, + }); + partition = scan.next_partition(); + REQUIRE(partition.has_value()); + array = partition->next(); + REQUIRE(array.has_value()); + REQUIRE(array->size() == 97); + + ages = array->field(0).values(session); + REQUIRE(ages.values()[0] == 3); +} +} // namespace diff --git a/lang/cpp/tests/session.cpp b/lang/cpp/tests/session.cpp new file mode 100644 index 00000000000..f99d7d3bb19 --- /dev/null +++ b/lang/cpp/tests/session.cpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#include +#include + +using namespace vortex; + +namespace { +TEST_CASE("Session copy and move", "[session]") { + const Session s; + Session other; + other = s; + Session moved; + moved = std::move(other); + Session other2(std::move(moved)); +} +} // namespace diff --git a/lang/cpp/tests/string_binary.cpp b/lang/cpp/tests/string_binary.cpp new file mode 100644 index 00000000000..16ddb7eaa3c --- /dev/null +++ b/lang/cpp/tests/string_binary.cpp @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#include + +#include +#include + +typedef struct ArrowSchema FFI_ArrowSchema; +typedef struct ArrowArray FFI_ArrowArray; +typedef struct ArrowArrayStream FFI_ArrowArrayStream; +#define USE_OWN_ARROW 1 + +#include +#include +#include +#include + +#include "common.hpp" + +using namespace vortex; +using namespace std::string_view_literals; +using vortex_test::TempPath; + +namespace { + +Array strings_from_arrow(std::span values, bool with_null) { + nanoarrow::UniqueSchema schema; + REQUIRE(ArrowSchemaInitFromType(schema.get(), NANOARROW_TYPE_STRING) == NANOARROW_OK); + nanoarrow::UniqueArray arr; + REQUIRE(ArrowArrayInitFromSchema(arr.get(), schema.get(), nullptr) == NANOARROW_OK); + REQUIRE(ArrowArrayStartAppending(arr.get()) == NANOARROW_OK); + for (const auto &value : values) { + const ArrowStringView view {value.data(), static_cast(value.size())}; + REQUIRE(ArrowArrayAppendString(arr.get(), view) == NANOARROW_OK); + } + if (with_null) { + REQUIRE(ArrowArrayAppendNull(arr.get(), 1) == NANOARROW_OK); + } + REQUIRE(ArrowArrayFinishBuildingDefault(arr.get(), nullptr) == NANOARROW_OK); + + ArrowArray raw_arr = {}; + ArrowSchema raw_schema = {}; + ArrowArrayMove(arr.get(), &raw_arr); + ArrowSchemaMove(schema.get(), &raw_schema); + return Array::from_arrow(&raw_arr, &raw_schema, true); +} + +Array bytes_from_arrow(std::span values) { + nanoarrow::UniqueSchema schema; + REQUIRE(ArrowSchemaInitFromType(schema.get(), NANOARROW_TYPE_BINARY) == NANOARROW_OK); + nanoarrow::UniqueArray arr; + REQUIRE(ArrowArrayInitFromSchema(arr.get(), schema.get(), nullptr) == NANOARROW_OK); + REQUIRE(ArrowArrayStartAppending(arr.get()) == NANOARROW_OK); + for (const auto &value : values) { + const ArrowBufferView view {{reinterpret_cast(value.data())}, + static_cast(value.size())}; + REQUIRE(ArrowArrayAppendBytes(arr.get(), view) == NANOARROW_OK); + } + REQUIRE(ArrowArrayFinishBuildingDefault(arr.get(), nullptr) == NANOARROW_OK); + + ArrowArray raw_arr = {}; + ArrowSchema raw_schema = {}; + ArrowArrayMove(arr.get(), &raw_arr); + ArrowSchemaMove(schema.get(), &raw_schema); + return Array::from_arrow(&raw_arr, &raw_schema, true); +} + +TEST_CASE("String view over utf8 array", "[strings]") { + Session session; + + const std::string long1(40, 'x'); + const std::vector values = {"short"sv, "Широкая строка"sv, long1, ""sv}; + Array array = strings_from_arrow(values, true); + + StringView view = array.strings(session); + REQUIRE(view.size() == values.size() + 1); + for (size_t i = 0; i < values.size(); ++i) { + REQUIRE_FALSE(view.is_null(i)); + REQUIRE(view[i] == values[i]); + } + REQUIRE(view.is_null(values.size())); + REQUIRE_THROWS_AS(view[view.size()], VortexException); +} + +TEST_CASE("Bytes view over binary array", "[strings]") { + Session session; + + // Includes a NUL byte + const std::vector values = {std::string_view {"abc\0def", 7}, "ffff"sv}; + Array array = bytes_from_arrow(values); + + BytesView view = array.bytes(session); + REQUIRE(view.size() == 2); + for (size_t i = 0; i < values.size(); ++i) { + const auto bytes = view[i]; + REQUIRE(std::string_view(reinterpret_cast(bytes.data()), bytes.size()) == values[i]); + } +} + +TEST_CASE("Strings roundtrip", "[strings]") { + Session session; + TempPath path = TempPath::unique(); + + const std::string long1(64, 'y'); + const std::vector values = {"inlined"sv, long1}; + Array strings = strings_from_arrow(values, false); + + Writer writer = + Writer::open(session, path.string(), dtype::struct_({{"s", dtype::utf8(dtype::Nullable)}})); + writer.push(make_struct({{"s", strings}})); + writer.finish(); + + DataSource ds = DataSource::open(session, {path.string()}); + Scan scan = ds.scan(); + auto partition = scan.next_partition(); + REQUIRE(partition.has_value()); + auto batch = partition->next(); + REQUIRE(batch.has_value()); + + StringView view = batch->field(0).strings(session); + REQUIRE(view.size() == 2); + REQUIRE(view[0] == "inlined"sv); + REQUIRE(view[1] == long1); +} + +TEST_CASE("strings() on a non-utf8 array", "[strings]") { + Session session; + std::vector data = {1}; + Array a = Array::primitive(data); + REQUIRE_THROWS_AS(a.strings(session), VortexException); + REQUIRE_THROWS_AS(a.bytes(session), VortexException); +} +} // namespace diff --git a/lang/cpp/tests/writer.cpp b/lang/cpp/tests/writer.cpp new file mode 100644 index 00000000000..f8dc2676fa5 --- /dev/null +++ b/lang/cpp/tests/writer.cpp @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +#include + +#include "common.hpp" +#include "vortex/dtype.hpp" +#include "vortex/error.hpp" +#include "vortex/session.hpp" + +using namespace vortex; +using vortex_test::TempPath; + +TEST_CASE("Dangling session writer", "[writer]") { + TempPath path = TempPath::unique(); + + { + Writer writer = Writer::open(Session {}, path.string(), vortex_test::sample_dtype()); + writer.push(vortex_test::sample_array()); + } + + REQUIRE_THROWS_AS(DataSource::open(Session {}, {path.string()}), VortexException); +} + +TEST_CASE("Unfinished writer", "[writer]") { + Session session; + TempPath path = TempPath::unique(); + + { + Writer writer = Writer::open(session, path.string(), vortex_test::sample_dtype()); + writer.push({vortex_test::sample_array()}); + } + + REQUIRE_THROWS_AS(DataSource::open(session, {path.string()}), VortexException); +} + +TEST_CASE("Write finish() called twice", "[writer]") { + Session session; + TempPath path = TempPath::unique(); + + Writer writer = Writer::open(session, path.string(), vortex_test::sample_dtype()); + writer.push(vortex_test::sample_array()); + writer.finish(); + REQUIRE_THROWS_AS(writer.finish(), VortexException); + REQUIRE_THROWS_AS(writer.push(vortex_test::sample_array()), VortexException); +} + +TEST_CASE("Writer push with invalid dtype", "[writer]") { + Session session; + TempPath path = TempPath::unique(); + Writer writer = Writer::open(session, path.string(), dtype::null()); + const std::vector arrays = {vortex_test::sample_array(), vortex_test::sample_array()}; + REQUIRE_THROWS_AS(writer.push(arrays), VortexException); +} diff --git a/vortex-ffi/src/array.rs b/vortex-ffi/src/array.rs index 577bd4d8be5..7c20bb72d46 100644 --- a/vortex-ffi/src/array.rs +++ b/vortex-ffi/src/array.rs @@ -305,15 +305,18 @@ unsafe fn primitive_from_raw( ptr: *const T, len: usize, validity: &vx_validity, + error: *mut *mut vx_error, ) -> *const vx_array { - let slice = if ptr.is_null() { - unsafe { std::slice::from_raw_parts(NonNull::dangling().as_ptr(), len) } - } else { - unsafe { std::slice::from_raw_parts(ptr, len) } - }; - let buffer = Buffer::copy_from(slice); - let array = PrimitiveArray::new(buffer, validity.into()); - vx_array::new(Arc::new(array.into_array())) + try_or_default(error, || { + let slice = if ptr.is_null() { + unsafe { std::slice::from_raw_parts(NonNull::dangling().as_ptr(), len) } + } else { + unsafe { std::slice::from_raw_parts(ptr, len) } + }; + let buffer = Buffer::copy_from(slice); + let array = PrimitiveArray::try_new(buffer, validity.into())?; + Ok(vx_array::new(Arc::new(array.into_array()))) + }) } /// Create a new primitive array from an existing buffer. @@ -345,17 +348,35 @@ pub extern "C-unwind" fn vx_array_new_primitive( let validity = unsafe { &*validity }; match ptype { - vx_ptype::PTYPE_U8 => unsafe { primitive_from_raw(ptr as *const u8, len, validity) }, - vx_ptype::PTYPE_U16 => unsafe { primitive_from_raw(ptr as *const u16, len, validity) }, - vx_ptype::PTYPE_U32 => unsafe { primitive_from_raw(ptr as *const u32, len, validity) }, - vx_ptype::PTYPE_U64 => unsafe { primitive_from_raw(ptr as *const u64, len, validity) }, - vx_ptype::PTYPE_I8 => unsafe { primitive_from_raw(ptr as *const i8, len, validity) }, - vx_ptype::PTYPE_I16 => unsafe { primitive_from_raw(ptr as *const i16, len, validity) }, - vx_ptype::PTYPE_I32 => unsafe { primitive_from_raw(ptr as *const i32, len, validity) }, - vx_ptype::PTYPE_I64 => unsafe { primitive_from_raw(ptr as *const i64, len, validity) }, - vx_ptype::PTYPE_F16 => unsafe { primitive_from_raw(ptr as *const f16, len, validity) }, - vx_ptype::PTYPE_F32 => unsafe { primitive_from_raw(ptr as *const f32, len, validity) }, - vx_ptype::PTYPE_F64 => unsafe { primitive_from_raw(ptr as *const f64, len, validity) }, + vx_ptype::PTYPE_U8 => unsafe { primitive_from_raw(ptr as *const u8, len, validity, error) }, + vx_ptype::PTYPE_U16 => unsafe { + primitive_from_raw(ptr as *const u16, len, validity, error) + }, + vx_ptype::PTYPE_U32 => unsafe { + primitive_from_raw(ptr as *const u32, len, validity, error) + }, + vx_ptype::PTYPE_U64 => unsafe { + primitive_from_raw(ptr as *const u64, len, validity, error) + }, + vx_ptype::PTYPE_I8 => unsafe { primitive_from_raw(ptr as *const i8, len, validity, error) }, + vx_ptype::PTYPE_I16 => unsafe { + primitive_from_raw(ptr as *const i16, len, validity, error) + }, + vx_ptype::PTYPE_I32 => unsafe { + primitive_from_raw(ptr as *const i32, len, validity, error) + }, + vx_ptype::PTYPE_I64 => unsafe { + primitive_from_raw(ptr as *const i64, len, validity, error) + }, + vx_ptype::PTYPE_F16 => unsafe { + primitive_from_raw(ptr as *const f16, len, validity, error) + }, + vx_ptype::PTYPE_F32 => unsafe { + primitive_from_raw(ptr as *const f32, len, validity, error) + }, + vx_ptype::PTYPE_F64 => unsafe { + primitive_from_raw(ptr as *const f64, len, validity, error) + }, } } From d54f70d6a40f778a054cfb669e1cba4a65e34cfe Mon Sep 17 00:00:00 2001 From: jackylee Date: Thu, 16 Jul 2026 18:32:38 +0800 Subject: [PATCH 090/104] test(spark): characterize PartitionPathUtils partition discovery and materialization (#8783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Rationale for this change `PartitionPathUtils` (`java/vortex-spark/src/main/java/dev/vortex/spark/read/PartitionPathUtils.java`) discovers Hive-style partition columns from file paths and materializes them as constant column vectors on the read path. It currently has no test coverage, despite encoding several subtle behaviors: URL decoding of keys and values, the `__HIVE_DEFAULT_PARTITION__` null sentinel, first-`=` splitting, type inference precedence (int → long → double → boolean → string), and per-type parsing into `ConstantColumnVector`. This continues the characterization-test series from #8770 (read-path `ArrowUtils`) and #8782 (write-path `SparkToArrowSchema`). ## What changes are included in this PR? A new `PartitionPathUtilsTest` covering all three public methods: - **`parsePartitionValues`**: extracts ordered `key=value` segments from paths; ignores segments without the `key=value` shape (including empty keys or values); URL-decodes both keys and values (`New%20York` → `New York`); splits on the first `=` so values may themselves contain `=`. - **`inferPartitionColumnType`**: infers `Integer` for int-width values, `Long` for wider integrals, `Double` for decimal-point values, `Boolean` for case-insensitive true/false, and falls back to `String` (including for null and the `__HIVE_DEFAULT_PARTITION__` sentinel). - **`createConstantVector`**: null and the Hive default-partition sentinel produce null vectors; string/int/long/short/byte/boolean/float/double values are parsed into their target types; `DateType` parses as days-since-epoch (int) and both timestamp types as micros-since-epoch (long); unrecognized target types fall back to UTF8 strings. This is a **test-only** change; no production code is modified. 19 tests, all passing locally (`:vortex-spark_2.12:test --tests 'dev.vortex.spark.read.PartitionPathUtilsTest'`). ## What APIs are changed? Are there any user-facing changes? None. No production code or public API is touched. Signed-off-by: jackylee --- .../spark/read/PartitionPathUtilsTest.java | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 java/vortex-spark/src/test/java/dev/vortex/spark/read/PartitionPathUtilsTest.java diff --git a/java/vortex-spark/src/test/java/dev/vortex/spark/read/PartitionPathUtilsTest.java b/java/vortex-spark/src/test/java/dev/vortex/spark/read/PartitionPathUtilsTest.java new file mode 100644 index 00000000000..445fad2f56c --- /dev/null +++ b/java/vortex-spark/src/test/java/dev/vortex/spark/read/PartitionPathUtilsTest.java @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +package dev.vortex.spark.read; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import org.apache.spark.sql.execution.vectorized.ConstantColumnVector; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.unsafe.types.UTF8String; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link PartitionPathUtils}, which discovers and materializes Hive-style partition columns from file + * paths. + * + *

Characterizes partition-value parsing (including URL decoding and the {@code __HIVE_DEFAULT_PARTITION__} + * sentinel), partition column type inference, and constant-vector materialization for each supported type. + */ +final class PartitionPathUtilsTest { + // --- parsePartitionValues --- + + @Test + @DisplayName("Parses key=value segments from a Hive-style partition path") + void parsesKeyValueSegments() { + Map values = + PartitionPathUtils.parsePartitionValues("/data/warehouse/year=2024/month=12/part-0.vortex"); + assertEquals(Map.of("year", "2024", "month", "12"), values); + } + + @Test + @DisplayName("Preserves the order of partition segments in the path") + void preservesSegmentOrder() { + Map values = PartitionPathUtils.parsePartitionValues("/tbl/b=2/a=1/c=3/file.vortex"); + assertEquals(List.of("b", "a", "c"), List.copyOf(values.keySet())); + } + + @Test + @DisplayName("Ignores segments without key=value shape") + void ignoresNonPartitionSegments() { + Map values = PartitionPathUtils.parsePartitionValues("/data/plain/dir/file.vortex"); + assertTrue(values.isEmpty()); + } + + @Test + @DisplayName("Ignores segments with an empty key or empty value") + void ignoresEmptyKeyOrValue() { + assertTrue(PartitionPathUtils.parsePartitionValues("/tbl/=v/file").isEmpty()); + assertTrue(PartitionPathUtils.parsePartitionValues("/tbl/k=/file").isEmpty()); + } + + @Test + @DisplayName("URL-decodes both keys and values") + void urlDecodesKeysAndValues() { + Map values = PartitionPathUtils.parsePartitionValues("/tbl/city=New%20York/file.vortex"); + assertEquals(Map.of("city", "New York"), values); + } + + @Test + @DisplayName("Splits on the first '=' so values may contain '='") + void splitsOnFirstEquals() { + Map values = PartitionPathUtils.parsePartitionValues("/tbl/k=a=b/file.vortex"); + assertEquals(Map.of("k", "a=b"), values); + } + + // --- inferPartitionColumnType --- + + @Test + @DisplayName("Infers IntegerType for values that fit in an int") + void infersInteger() { + assertEquals(DataTypes.IntegerType, PartitionPathUtils.inferPartitionColumnType("42")); + assertEquals(DataTypes.IntegerType, PartitionPathUtils.inferPartitionColumnType("-7")); + } + + @Test + @DisplayName("Infers LongType for integral values wider than an int") + void infersLong() { + assertEquals(DataTypes.LongType, PartitionPathUtils.inferPartitionColumnType("9999999999")); + } + + @Test + @DisplayName("Infers DoubleType for decimal-point values") + void infersDouble() { + assertEquals(DataTypes.DoubleType, PartitionPathUtils.inferPartitionColumnType("3.14")); + } + + @Test + @DisplayName("Infers BooleanType for true/false in any case") + void infersBoolean() { + assertEquals(DataTypes.BooleanType, PartitionPathUtils.inferPartitionColumnType("true")); + assertEquals(DataTypes.BooleanType, PartitionPathUtils.inferPartitionColumnType("FALSE")); + } + + @Test + @DisplayName("Falls back to StringType for everything else") + void fallsBackToString() { + assertEquals(DataTypes.StringType, PartitionPathUtils.inferPartitionColumnType("hello")); + assertEquals(DataTypes.StringType, PartitionPathUtils.inferPartitionColumnType("2024-12-01")); + } + + @Test + @DisplayName("Null and __HIVE_DEFAULT_PARTITION__ infer StringType") + void nullAndDefaultPartitionInferString() { + assertEquals(DataTypes.StringType, PartitionPathUtils.inferPartitionColumnType(null)); + assertEquals(DataTypes.StringType, PartitionPathUtils.inferPartitionColumnType("__HIVE_DEFAULT_PARTITION__")); + } + + // --- createConstantVector --- + + @Test + @DisplayName("Null and __HIVE_DEFAULT_PARTITION__ materialize as null vectors") + void nullValuesMaterializeAsNull() { + ConstantColumnVector fromNull = PartitionPathUtils.createConstantVector(3, DataTypes.StringType, null); + assertTrue(fromNull.isNullAt(0)); + + ConstantColumnVector fromSentinel = + PartitionPathUtils.createConstantVector(3, DataTypes.StringType, "__HIVE_DEFAULT_PARTITION__"); + assertTrue(fromSentinel.isNullAt(0)); + } + + @Test + @DisplayName("String values materialize as UTF8 strings") + void stringMaterializes() { + ConstantColumnVector vec = PartitionPathUtils.createConstantVector(2, DataTypes.StringType, "us-east"); + assertFalse(vec.isNullAt(0)); + assertEquals(UTF8String.fromString("us-east"), vec.getUTF8String(0)); + } + + @Test + @DisplayName("Integral values materialize with the width of the target type") + void integralTypesMaterialize() { + assertEquals( + 42, + PartitionPathUtils.createConstantVector(1, DataTypes.IntegerType, "42") + .getInt(0)); + assertEquals( + 9999999999L, + PartitionPathUtils.createConstantVector(1, DataTypes.LongType, "9999999999") + .getLong(0)); + assertEquals( + (short) 7, + PartitionPathUtils.createConstantVector(1, DataTypes.ShortType, "7") + .getShort(0)); + assertEquals( + (byte) 3, + PartitionPathUtils.createConstantVector(1, DataTypes.ByteType, "3") + .getByte(0)); + } + + @Test + @DisplayName("DateType parses the value as days since epoch (int)") + void dateMaterializesAsInt() { + assertEquals( + 19000, + PartitionPathUtils.createConstantVector(1, DataTypes.DateType, "19000") + .getInt(0)); + } + + @Test + @DisplayName("Timestamp types parse the value as micros since epoch (long)") + void timestampMaterializesAsLong() { + assertEquals( + 1700000000000000L, + PartitionPathUtils.createConstantVector(1, DataTypes.TimestampType, "1700000000000000") + .getLong(0)); + assertEquals( + 1700000000000000L, + PartitionPathUtils.createConstantVector(1, DataTypes.TimestampNTZType, "1700000000000000") + .getLong(0)); + } + + @Test + @DisplayName("Boolean, float, and double values materialize with their target types") + void booleanAndFloatingPointMaterialize() { + assertTrue(PartitionPathUtils.createConstantVector(1, DataTypes.BooleanType, "true") + .getBoolean(0)); + assertEquals( + 1.5f, + PartitionPathUtils.createConstantVector(1, DataTypes.FloatType, "1.5") + .getFloat(0)); + assertEquals( + 2.25, + PartitionPathUtils.createConstantVector(1, DataTypes.DoubleType, "2.25") + .getDouble(0)); + } + + @Test + @DisplayName("Unrecognized target types fall back to UTF8 strings") + void unrecognizedTypeFallsBackToString() { + ConstantColumnVector vec = + PartitionPathUtils.createConstantVector(1, DataTypes.CalendarIntervalType, "whatever"); + assertEquals(UTF8String.fromString("whatever"), vec.getUTF8String(0)); + } +} From 1c419e530cd9d6ead013454463468885b0591614 Mon Sep 17 00:00:00 2001 From: myrrc Date: Thu, 16 Jul 2026 11:47:21 +0100 Subject: [PATCH 091/104] Own SQL benchmark suite (#8719) Testing SQL queries' correctness is done in vortex-sqllogictest, but we also want to test relative performance of queries not covered in existing benchmarks. One example is aggregation with filter which should be much more performant once we use zone maps statistics, but this type of query isn't present is existing benchmarks. This PR introduces a new query-per-file dataset and support for running it in CI Signed-off-by: Mikhail Kot --- .github/workflows/bench.yml | 28 +++ .github/workflows/sql-vortex-pr.yml | 47 +++++ .../bench_orchestrator/config.py | 1 + vortex-bench/sql/vortex/0_sum-with-filter.sql | 4 + vortex-bench/sql/vortex/1_sum.sql | 3 + vortex-bench/sql/vortex/init.sql | 8 + vortex-bench/src/datasets/mod.rs | 6 + vortex-bench/src/lib.rs | 11 ++ vortex-bench/src/utils/file.rs | 6 +- vortex-bench/src/v3.rs | 2 + vortex-bench/src/vortex_queries.rs | 179 ++++++++++++++++++ 11 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/sql-vortex-pr.yml create mode 100644 vortex-bench/sql/vortex/0_sum-with-filter.sql create mode 100644 vortex-bench/sql/vortex/1_sum.sql create mode 100644 vortex-bench/sql/vortex/init.sql create mode 100644 vortex-bench/src/vortex_queries.rs diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 4459726348b..b1e29277f00 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -171,3 +171,31 @@ jobs: secrets: inherit with: mode: "develop" + + sql-vortex: + uses: ./.github/workflows/sql-benchmarks.yml + secrets: inherit + with: + mode: "develop" + benchmark_matrix: | + [ + { + "id": "vortex-queries", + "subcommand": "vortex", + "name": "Vortex queries", + "data_formats": ["parquet", "vortex"], + "pr_targets": [ + {"engine": "datafusion", "format": "parquet"}, + {"engine": "datafusion", "format": "vortex"}, + {"engine": "duckdb", "format": "parquet"}, + {"engine": "duckdb", "format": "vortex"} + ], + "develop_targets": [ + {"engine": "datafusion", "format": "parquet"}, + {"engine": "datafusion", "format": "vortex"}, + {"engine": "duckdb", "format": "parquet"}, + {"engine": "duckdb", "format": "vortex"} + ], + "iterations": "100" + } + ] diff --git a/.github/workflows/sql-vortex-pr.yml b/.github/workflows/sql-vortex-pr.yml new file mode 100644 index 00000000000..2b99eb0e4d9 --- /dev/null +++ b/.github/workflows/sql-vortex-pr.yml @@ -0,0 +1,47 @@ +# Run Vortex SQL benchmark on every commit in a PR. +# This is not gated behind action/benchmark-sql + +name: PR Vortex Query Benchmark + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: false + +on: + pull_request: + types: [opened, synchronize, reopened] + branches: ["develop"] + +permissions: + contents: read + pull-requests: write + id-token: write + +jobs: + sql: + uses: ./.github/workflows/sql-benchmarks.yml + secrets: inherit + with: + mode: "pr" + benchmark_matrix: | + [ + { + "id": "vortex-queries", + "subcommand": "vortex", + "name": "Vortex queries", + "data_formats": ["parquet", "vortex"], + "pr_targets": [ + {"engine": "datafusion", "format": "parquet"}, + {"engine": "datafusion", "format": "vortex"}, + {"engine": "duckdb", "format": "parquet"}, + {"engine": "duckdb", "format": "vortex"} + ], + "develop_targets": [ + {"engine": "datafusion", "format": "parquet"}, + {"engine": "datafusion", "format": "vortex"}, + {"engine": "duckdb", "format": "parquet"}, + {"engine": "duckdb", "format": "vortex"} + ], + "iterations": "100" + } + ] diff --git a/bench-orchestrator/bench_orchestrator/config.py b/bench-orchestrator/bench_orchestrator/config.py index cd4c6c81bfe..4c9df79fef1 100644 --- a/bench-orchestrator/bench_orchestrator/config.py +++ b/bench-orchestrator/bench_orchestrator/config.py @@ -54,6 +54,7 @@ class Benchmark(Enum): PUBLIC_BI = "public-bi" STATPOPGEN = "statpopgen" SPATIALBENCH = "spatialbench" + VORTEX_QUERIES = "vortex" # Engine to supported formats mapping. diff --git a/vortex-bench/sql/vortex/0_sum-with-filter.sql b/vortex-bench/sql/vortex/0_sum-with-filter.sql new file mode 100644 index 00000000000..355f9569a92 --- /dev/null +++ b/vortex-bench/sql/vortex/0_sum-with-filter.sql @@ -0,0 +1,4 @@ +-- As this aggregation has a filter, Vortex has to use a linear scan. Once stats +-- are propagated to arrays, this should use zone maps to aggregate instead of +-- decoding and processing each row. +SELECT sum(col) FROM test WHERE col2 > 0 AND col2 < 1000; diff --git a/vortex-bench/sql/vortex/1_sum.sql b/vortex-bench/sql/vortex/1_sum.sql new file mode 100644 index 00000000000..7640c0d3394 --- /dev/null +++ b/vortex-bench/sql/vortex/1_sum.sql @@ -0,0 +1,3 @@ +-- When Footer changes land, vortex-duckdb should populate statistics from +-- Footer without loading and decoding the data. +SELECT sum(col) FROM test; diff --git a/vortex-bench/sql/vortex/init.sql b/vortex-bench/sql/vortex/init.sql new file mode 100644 index 00000000000..90002573995 --- /dev/null +++ b/vortex-bench/sql/vortex/init.sql @@ -0,0 +1,8 @@ +-- Script that prepares Parquet data for our SQL microbenchmarks. + +COPY ( + SELECT + i % 1000 AS col, + (i * 2654435761) % 100000 AS col2 + FROM range(25000000) t(i) +) TO 'test.parquet' (FORMAT parquet); diff --git a/vortex-bench/src/datasets/mod.rs b/vortex-bench/src/datasets/mod.rs index a550a712eba..7eb341edbb7 100644 --- a/vortex-bench/src/datasets/mod.rs +++ b/vortex-bench/src/datasets/mod.rs @@ -81,6 +81,8 @@ pub enum BenchmarkDataset { Fineweb, #[serde(rename = "gharchive")] GhArchive, + #[serde(rename = "vortex")] + VortexQueries, } impl BenchmarkDataset { @@ -97,6 +99,7 @@ impl BenchmarkDataset { BenchmarkDataset::PolarSignals { .. } => "polarsignals", BenchmarkDataset::Fineweb => "fineweb", BenchmarkDataset::GhArchive => "gharchive", + BenchmarkDataset::VortexQueries => "vortex", } } } @@ -122,6 +125,7 @@ impl Display for BenchmarkDataset { } BenchmarkDataset::Fineweb => write!(f, "fineweb"), BenchmarkDataset::GhArchive => write!(f, "gharchive"), + BenchmarkDataset::VortexQueries => write!(f, "vortex"), } } } @@ -179,6 +183,8 @@ impl BenchmarkDataset { BenchmarkDataset::PolarSignals { .. } => &["stacktraces"], BenchmarkDataset::Fineweb => &["fineweb"], BenchmarkDataset::GhArchive => &["events"], + // See VortexBenchmark::table_specs + BenchmarkDataset::VortexQueries => &[], } } } diff --git a/vortex-bench/src/lib.rs b/vortex-bench/src/lib.rs index f245d24e738..abf4e1ccea3 100644 --- a/vortex-bench/src/lib.rs +++ b/vortex-bench/src/lib.rs @@ -36,6 +36,7 @@ use vortex::file::WriteStrategyBuilder; use vortex::utils::aliases::hash_map::HashMap; use crate::spatialbench::SpatialBenchBenchmark; +use crate::vortex_queries::VortexBenchmark; pub mod appian; pub mod benchmark; @@ -61,6 +62,7 @@ pub mod tpch; pub mod utils; pub mod v3; pub mod vector_dataset; +pub mod vortex_queries; pub use benchmark::Benchmark; pub use benchmark::TableSpec; @@ -281,6 +283,8 @@ pub enum BenchmarkArg { PublicBi, #[clap(name = "spatialbench")] SpatialBench, + #[clap(name = "vortex")] + VortexQueries, } /// Default scale factor for TPC-related benchmarks @@ -353,6 +357,13 @@ pub fn create_benchmark(b: BenchmarkArg, opts: &Opts) -> anyhow::Result { + let mut benchmark = VortexBenchmark::new()?; + if let Some(query) = opts.get("query") { + benchmark = benchmark.with_query(query)?; + } + Ok(Box::new(benchmark) as _) + } } } diff --git a/vortex-bench/src/utils/file.rs b/vortex-bench/src/utils/file.rs index f05113963f7..4af68c029ab 100644 --- a/vortex-bench/src/utils/file.rs +++ b/vortex-bench/src/utils/file.rs @@ -56,8 +56,12 @@ pub trait IdempotentPath { fn to_data_path(&self) -> PathBuf; } +pub fn bench_dir() -> PathBuf { + workspace_root().join("vortex-bench") +} + pub fn data_dir() -> PathBuf { - workspace_root().join("vortex-bench").join("data") + bench_dir().join("data") } /// Find the workspace's root by looking for Cargo's lock file diff --git a/vortex-bench/src/v3.rs b/vortex-bench/src/v3.rs index a7529866f80..4bdd67ed18f 100644 --- a/vortex-bench/src/v3.rs +++ b/vortex-bench/src/v3.rs @@ -296,6 +296,7 @@ fn canonical_tpc_scale_factor(scale_factor: &str) -> String { /// | `Appian` | `appian` | `None` | `None` | Static dataset; no scale factor. | /// | `PublicBi { name }` | `public-bi` | dataset name (e.g. `cms-provider`) | `None` | Sub-dataset name lives in `dataset_variant`. | /// | `SpatialBench { scale_factor }` | `spatialbench` | `None` | SF as string | Same canonicalization as TPC-H; no historical v2 records to merge with. | +/// | `VortexQueries` | `vortex` | `None` | `None` | Own microbenchmarks | pub fn benchmark_dataset_dims(d: &BenchmarkDataset) -> (String, Option, Option) { match d { BenchmarkDataset::TpcH { scale_factor } => ( @@ -331,6 +332,7 @@ pub fn benchmark_dataset_dims(d: &BenchmarkDataset) -> (String, Option, BenchmarkDataset::Fineweb => ("fineweb".to_string(), None, None), BenchmarkDataset::GhArchive => ("gharchive".to_string(), None, None), BenchmarkDataset::Appian => ("appian".to_string(), None, None), + BenchmarkDataset::VortexQueries => ("vortex".to_string(), None, None), } } diff --git a/vortex-bench/src/vortex_queries.rs b/vortex-bench/src/vortex_queries.rs new file mode 100644 index 00000000000..f611238bb6b --- /dev/null +++ b/vortex-bench/src/vortex_queries.rs @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Benchmark that runs queries which aren't part of any existing benchmark +//! suite but which performance we want to track. + +use std::fs; +use std::path::PathBuf; +use std::process::Command; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use anyhow::bail; +use glob::Pattern; +use tracing::debug; +use url::Url; +use vortex::error::VortexExpect; + +use crate::Benchmark; +use crate::BenchmarkDataset; +use crate::Format; +use crate::TableSpec; +use crate::bench_dir; +use crate::resolve_data_url; + +// Path to script that creates Parquet data +const INIT_SQL: &str = "init.sql"; + +pub struct VortexBenchmark { + data_url: Url, + queries_dir: PathBuf, + query: Option, +} + +impl VortexBenchmark { + pub fn new() -> Result { + let queries_dir = bench_dir().join("sql").join("vortex"); + let data_url = resolve_data_url(None, "vortex")?; + Ok(Self { + data_url, + queries_dir, + query: None, + }) + } + + // Same as new(), but run only for "query" SQL file + pub fn with_query(mut self, query: &str) -> Result { + let as_path = PathBuf::from(query); + let path = if as_path.is_file() { + as_path + } else if query.ends_with(".sql") { + self.queries_dir.join(query) + } else { + self.queries_dir.join(format!("{query}.sql")) + }; + if !path.is_file() { + bail!("{query} file not found in {}", self.queries_dir.display()); + } + self.query = Some(path); + Ok(self) + } + + fn query_files(&self) -> Result> { + if let Some(query) = &self.query { + return Ok(vec![query.clone()]); + } + + let entries = fs::read_dir(&self.queries_dir) + .with_context(|| format!("cannot list queries in {}", self.queries_dir.display()))? + .collect::>>()?; + + let mut files: Vec = entries + .into_iter() + .map(|entry| entry.path()) + .filter(|path| { + path.extension().is_some_and(|ext| ext == "sql") + && path.file_name().is_some_and(|name| name != INIT_SQL) + }) + .collect(); + files.sort(); + + if files.is_empty() { + bail!("no query files found in {}", self.queries_dir.display()); + } + Ok(files) + } +} + +#[async_trait::async_trait] +impl Benchmark for VortexBenchmark { + fn queries(&self) -> Result> { + self.query_files()? + .iter() + .map(|path| { + let idx = path + .file_name() + .vortex_expect("no file name") + .to_str() + .vortex_expect("not utf-8") + .split_once("_") + .vortex_expect("query without a number") + .0 + .parse::()?; + let query = fs::read_to_string(path) + .with_context(|| format!("cannot read query {}", path.display()))?; + debug!(idx, file = %path.display(), "Loaded vortex query"); + Ok((idx, query)) + }) + .collect() + } + + async fn generate_base_data(&self) -> Result<()> { + let data_dir = self + .data_url + .to_file_path() + .map_err(|_| anyhow!("Invalid file URL: {}", self.data_url.as_str()))?; + let parquet_dir = data_dir.join(Format::Parquet.name()); + fs::create_dir_all(&parquet_dir)?; + let parquet_file = parquet_dir.join("test.parquet"); + + if parquet_file.exists() { + debug!("Parquet data present in {}", parquet_dir.display()); + return Ok(()); + } + + let init_path = self.queries_dir.join(INIT_SQL); + let script = fs::read_to_string(&init_path) + .with_context(|| format!("cannot read {}", init_path.display()))?; + + let output = Command::new("duckdb") + .current_dir(&parquet_dir) + .arg("-c") + .arg(&script) + .output() + .context("cannot run duckdb")?; + if !output.status.success() { + bail!( + "duckdb {INIT_SQL} failed: stdout={:?} stderr={:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + + if !parquet_file.exists() { + bail!("{INIT_SQL} did not create Parquet files"); + } + + debug!("Parquet data generated in {}", parquet_dir.display()); + Ok(()) + } + + fn dataset(&self) -> BenchmarkDataset { + BenchmarkDataset::VortexQueries + } + + fn dataset_name(&self) -> &str { + "vortex" + } + + fn dataset_display(&self) -> String { + "vortex".to_owned() + } + + fn data_url(&self) -> &Url { + &self.data_url + } + + fn table_specs(&self) -> Vec { + vec![TableSpec::new("test", None)] + } + + fn pattern(&self, table_name: &str, format: Format) -> Option { + Some( + Pattern::new(&format!("{table_name}.{}", format.ext())) + .expect("table name is a valid identifier"), + ) + } +} From 8d52fffd363c03c2cc7a081bf6b6527ea1e662e0 Mon Sep 17 00:00:00 2001 From: jackylee Date: Thu, 16 Jul 2026 19:13:23 +0800 Subject: [PATCH 092/104] test(spark): characterize SparkToArrowSchema Spark-to-Arrow conversion (#8782) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Rationale for this change `SparkToArrowSchema` (`java/vortex-spark/src/main/java/dev/vortex/spark/write/SparkToArrowSchema.java`) converts Spark SQL schemas to Arrow schemas on the **write path**, and currently has no test coverage. It is the mirror image of `ArrowUtils` on the read path, which gained characterization tests in #8770. Like that mapping table, this conversion is exactly the kind of code that regresses quietly: a wrong bit width, a dropped timezone, or lost nullability would silently corrupt written files rather than fail loudly. ## What changes are included in this PR? A new `SparkToArrowSchemaTest` that characterizes the conversion: - **Primitive mappings**: `Boolean` → `Bool`; `Byte/Short/Integer/Long` → signed `Int` 8/16/32/64; `Float/Double` → `SINGLE/DOUBLE` floating point; `String` → `Utf8`; `Binary` → `Binary`; `Decimal` → 128-bit Arrow `Decimal` preserving precision and scale; `Date` → `Date(DAY)`; `Timestamp` → `Timestamp(MICROSECOND, "UTC")` and `TimestampNTZ` → `Timestamp(MICROSECOND, null)`. - **Schema structure**: field names and ordering are preserved; field-level nullability is carried over to the Arrow field. - **Nested types**: `StructType` converts to a `Struct` field with recursively converted children (including per-child nullability); `ArrayType` converts to a `List` whose `element` child carries `containsNull`. - **Rejected types**: unsupported Spark types (e.g. `CalendarIntervalType`) raise `UnsupportedOperationException` naming the offending type. This is a **test-only** change; no production code is modified. 12 tests, all passing locally (`:vortex-spark_2.12:test --tests 'dev.vortex.spark.write.SparkToArrowSchemaTest'`). ## What APIs are changed? Are there any user-facing changes? None. No production code or public API is touched. Signed-off-by: jackylee --- .../spark/write/SparkToArrowSchemaTest.java | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 java/vortex-spark/src/test/java/dev/vortex/spark/write/SparkToArrowSchemaTest.java diff --git a/java/vortex-spark/src/test/java/dev/vortex/spark/write/SparkToArrowSchemaTest.java b/java/vortex-spark/src/test/java/dev/vortex/spark/write/SparkToArrowSchemaTest.java new file mode 100644 index 00000000000..cd9b3ca184c --- /dev/null +++ b/java/vortex-spark/src/test/java/dev/vortex/spark/write/SparkToArrowSchemaTest.java @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +package dev.vortex.spark.write; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.vortex.relocated.org.apache.arrow.vector.types.DateUnit; +import dev.vortex.relocated.org.apache.arrow.vector.types.FloatingPointPrecision; +import dev.vortex.relocated.org.apache.arrow.vector.types.TimeUnit; +import dev.vortex.relocated.org.apache.arrow.vector.types.pojo.ArrowType; +import dev.vortex.relocated.org.apache.arrow.vector.types.pojo.Field; +import dev.vortex.relocated.org.apache.arrow.vector.types.pojo.Schema; +import java.util.List; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link SparkToArrowSchema}, which converts Spark SQL schemas to Arrow schemas on the write path. + * + *

Characterizes the supported conversions (including field-level nullability and nested types) and the Spark types + * that are explicitly rejected, mirroring the read-path coverage in {@code ArrowUtilsTest}. + */ +final class SparkToArrowSchemaTest { + private static Field single(String name, org.apache.spark.sql.types.DataType type, boolean nullable) { + StructType schema = new StructType(new StructField[] {new StructField(name, type, nullable, Metadata.empty())}); + Schema arrowSchema = SparkToArrowSchema.convert(schema); + assertEquals(1, arrowSchema.getFields().size()); + return arrowSchema.getFields().get(0); + } + + @Test + @DisplayName("BooleanType converts to Arrow Bool") + void booleanConvertsToBool() { + assertEquals( + new ArrowType.Bool(), single("b", DataTypes.BooleanType, true).getType()); + } + + @Test + @DisplayName("Integral types convert to signed Arrow Ints of matching width") + void integralTypesConvertBySignedWidth() { + assertEquals( + new ArrowType.Int(8, true), + single("v", DataTypes.ByteType, true).getType()); + assertEquals( + new ArrowType.Int(16, true), + single("v", DataTypes.ShortType, true).getType()); + assertEquals( + new ArrowType.Int(32, true), + single("v", DataTypes.IntegerType, true).getType()); + assertEquals( + new ArrowType.Int(64, true), + single("v", DataTypes.LongType, true).getType()); + } + + @Test + @DisplayName("Float/Double convert to single/double precision Arrow FloatingPoint") + void floatingPointConvertsByPrecision() { + assertEquals( + new ArrowType.FloatingPoint(FloatingPointPrecision.SINGLE), + single("v", DataTypes.FloatType, true).getType()); + assertEquals( + new ArrowType.FloatingPoint(FloatingPointPrecision.DOUBLE), + single("v", DataTypes.DoubleType, true).getType()); + } + + @Test + @DisplayName("String and Binary convert to Utf8 and Binary") + void stringAndBinaryConvert() { + assertEquals( + new ArrowType.Utf8(), single("v", DataTypes.StringType, true).getType()); + assertEquals( + new ArrowType.Binary(), single("v", DataTypes.BinaryType, true).getType()); + } + + @Test + @DisplayName("DecimalType preserves precision and scale as 128-bit Arrow Decimal") + void decimalPreservesPrecisionAndScale() { + assertEquals( + new ArrowType.Decimal(20, 4, 128), + single("v", DataTypes.createDecimalType(20, 4), true).getType()); + } + + @Test + @DisplayName("DateType converts to Date(DAY)") + void dateConvertsToDayUnit() { + assertEquals( + new ArrowType.Date(DateUnit.DAY), + single("v", DataTypes.DateType, true).getType()); + } + + @Test + @DisplayName("Timestamp converts to microsecond precision, with UTC tz only for the tz-aware type") + void timestampConvertsByTimezoneAwareness() { + assertEquals( + new ArrowType.Timestamp(TimeUnit.MICROSECOND, "UTC"), + single("v", DataTypes.TimestampType, true).getType()); + assertEquals( + new ArrowType.Timestamp(TimeUnit.MICROSECOND, null), + single("v", DataTypes.TimestampNTZType, true).getType()); + } + + @Test + @DisplayName("Field nullability is carried over to the Arrow field") + void nullabilityIsPreserved() { + assertTrue(single("v", DataTypes.IntegerType, true).isNullable()); + assertFalse(single("v", DataTypes.IntegerType, false).isNullable()); + } + + @Test + @DisplayName("Field names and ordering are preserved") + void fieldNamesAndOrderArePreserved() { + StructType schema = new StructType() + .add("first", DataTypes.IntegerType) + .add("second", DataTypes.StringType) + .add("third", DataTypes.DoubleType); + + Schema arrowSchema = SparkToArrowSchema.convert(schema); + assertEquals( + List.of("first", "second", "third"), + arrowSchema.getFields().stream().map(Field::getName).toList()); + } + + @Test + @DisplayName("Nested StructType converts to a Struct field with converted children") + void structConvertsWithChildren() { + StructType inner = + new StructType().add("a", DataTypes.IntegerType, true).add("b", DataTypes.StringType, false); + Field structField = single("s", inner, true); + + assertEquals(new ArrowType.Struct(), structField.getType()); + assertEquals(2, structField.getChildren().size()); + + Field a = structField.getChildren().get(0); + assertEquals("a", a.getName()); + assertEquals(new ArrowType.Int(32, true), a.getType()); + assertTrue(a.isNullable()); + + Field b = structField.getChildren().get(1); + assertEquals("b", b.getName()); + assertEquals(new ArrowType.Utf8(), b.getType()); + assertFalse(b.isNullable()); + } + + @Test + @DisplayName("ArrayType converts to a List field whose element carries containsNull") + void arrayConvertsWithElementNullability() { + Field withNulls = single("l", DataTypes.createArrayType(DataTypes.IntegerType, true), true); + assertEquals(new ArrowType.List(), withNulls.getType()); + assertEquals(1, withNulls.getChildren().size()); + + Field element = withNulls.getChildren().get(0); + assertEquals("element", element.getName()); + assertEquals(new ArrowType.Int(32, true), element.getType()); + assertTrue(element.isNullable()); + + Field withoutNulls = single("l", DataTypes.createArrayType(DataTypes.StringType, false), true); + assertFalse(withoutNulls.getChildren().get(0).isNullable()); + } + + @Test + @DisplayName("Unsupported Spark types raise UnsupportedOperationException naming the type") + void unsupportedTypeIsRejected() { + StructType schema = new StructType().add("v", DataTypes.CalendarIntervalType); + UnsupportedOperationException e = + assertThrows(UnsupportedOperationException.class, () -> SparkToArrowSchema.convert(schema)); + assertTrue(e.getMessage().contains("CalendarIntervalType")); + } +} From e727c46d53813746b26a98e9267b976c806004e4 Mon Sep 17 00:00:00 2001 From: Onur Satici Date: Thu, 16 Jul 2026 13:43:52 +0100 Subject: [PATCH 093/104] Expose CUDA-compatible file sink through FFI (#8784) ## Rationale for this change cuda compatible vortex files currently can't be written over ffi ## What changes are included in this PR? ffi to allow writing with the cuda compatible strategy, which only includes encodings that we have gpu kernels for, and also uses the cuda flat layout that uses the array tree from the footer. Also exclude alp-rd and pco from the cuda compatible encoding allowlist for completeness Signed-off-by: Onur Satici --- vortex-btrblocks/src/builder.rs | 31 ++++++++++- vortex-cuda/ffi/README.md | 3 ++ vortex-cuda/ffi/cinclude/vortex_cuda.h | 12 +++++ vortex-cuda/ffi/src/lib.rs | 42 ++++++++++++++- vortex-ffi/src/lib.rs | 6 ++- vortex-ffi/src/sink.rs | 75 ++++++++++++++++++-------- 6 files changed, 142 insertions(+), 27 deletions(-) diff --git a/vortex-btrblocks/src/builder.rs b/vortex-btrblocks/src/builder.rs index 26632e70860..e51fac9a9d5 100644 --- a/vortex-btrblocks/src/builder.rs +++ b/vortex-btrblocks/src/builder.rs @@ -165,10 +165,14 @@ impl BtrBlocksCompressorBuilder { // dictionary expansion at decode time, which is incompatible with // pure-GPU decompression paths. Strip whichever string-fragment // scheme is enabled by feature. - #[cfg_attr(not(feature = "unstable_encodings"), allow(unused_mut))] + #[cfg_attr( + not(any(feature = "pco", feature = "unstable_encodings")), + allow(unused_mut) + )] let mut excluded: Vec = vec![ integer::SparseScheme.id(), integer::IntRLEScheme.id(), + float::ALPRDScheme.id(), float::FloatRLEScheme.id(), float::NullDominatedSparseScheme.id(), string::StringDictScheme.id(), @@ -181,6 +185,8 @@ impl BtrBlocksCompressorBuilder { // is incompatible with pure-GPU decompression paths. #[cfg(feature = "unstable_encodings")] excluded.push(integer::DeltaScheme::default().id()); + #[cfg(feature = "pco")] + excluded.extend([integer::PcoScheme.id(), float::PcoScheme.id()]); let builder = self.exclude_schemes(excluded); #[cfg(all(feature = "zstd", feature = "unstable_encodings"))] @@ -223,4 +229,27 @@ mod tests { let builder = BtrBlocksCompressorBuilder::default(); assert_eq!(builder.schemes.len(), ALL_SCHEMES.len()); } + + #[test] + fn cuda_compatible_excludes_alprd() { + let builder = BtrBlocksCompressorBuilder::default().only_cuda_compatible(); + assert!( + !builder + .schemes + .iter() + .any(|s| s.id() == float::ALPRDScheme.id()) + ); + } + + #[test] + #[cfg(feature = "pco")] + fn cuda_compatible_excludes_pco() { + let builder = BtrBlocksCompressorBuilder::default() + .with_new_scheme(&integer::PcoScheme) + .with_new_scheme(&float::PcoScheme) + .only_cuda_compatible(); + for scheme in [integer::PcoScheme.id(), float::PcoScheme.id()] { + assert!(!builder.schemes.iter().any(|s| s.id() == scheme)); + } + } } diff --git a/vortex-cuda/ffi/README.md b/vortex-cuda/ffi/README.md index 3c030095374..9cc6b30dd94 100644 --- a/vortex-cuda/ffi/README.md +++ b/vortex-cuda/ffi/README.md @@ -16,3 +16,6 @@ Use this crate as the CUDA-enabled FFI artifact. Include both headers: and link the CUDA FFI library (`vortex_cuda_ffi`). Do not pass Vortex handles between independently linked Rust FFI libraries. Use `vx_cuda_session_new` to initialize CUDA once and reuse it across exports. + +Use `vx_cuda_array_sink_open_file` to open a standard Vortex file sink configured to produce CUDA-readable files. +Push host-resident arrays and close the sink using the standard `vx_array_sink_*` APIs. diff --git a/vortex-cuda/ffi/cinclude/vortex_cuda.h b/vortex-cuda/ffi/cinclude/vortex_cuda.h index 0b06a660afa..d3ca8293f34 100644 --- a/vortex-cuda/ffi/cinclude/vortex_cuda.h +++ b/vortex-cuda/ffi/cinclude/vortex_cuda.h @@ -63,6 +63,18 @@ struct ArrowDeviceArrayStream { */ vx_session *vx_cuda_session_new(vx_error **error_out); +/** + * Open a Vortex file sink configured to produce CUDA-readable files. + * + * Push host-resident arrays and close or abort the returned sink with the standard + * `vx_array_sink_*` functions. This API configures the on-disk encodings and layout; it does not + * move arrays to the GPU during the write. + */ +vx_array_sink *vx_cuda_array_sink_open_file(const vx_session *session, + vx_view path, + const vx_dtype *dtype, + vx_error **error_out); + /** * Export a borrowed Vortex array for cuDF's Arrow Device import path. * diff --git a/vortex-cuda/ffi/src/lib.rs b/vortex-cuda/ffi/src/lib.rs index 611f644e535..cfc76ef354a 100644 --- a/vortex-cuda/ffi/src/lib.rs +++ b/vortex-cuda/ffi/src/lib.rs @@ -10,10 +10,13 @@ use std::os::raw::c_int; use std::ptr; +use std::sync::Arc; use arrow_schema::ffi::FFI_ArrowSchema; +use vortex::compressor::BtrBlocksCompressorBuilder; use vortex::error::VortexResult; use vortex::error::vortex_ensure; +use vortex::file::WriteStrategyBuilder; use vortex::session::SessionExt; use vortex::session::VortexSession; use vortex_cuda::CudaSession; @@ -21,16 +24,22 @@ use vortex_cuda::arrow::ArrowDeviceArray; use vortex_cuda::arrow::ArrowDeviceArrayStream; use vortex_cuda::arrow::DeviceArrayExt; use vortex_cuda::arrow::DeviceArrayStreamExt; +use vortex_cuda::layout::CudaFlatLayoutStrategy; +use vortex_cuda::layout::register_cuda_layout; use vortex_ffi::ffi_runtime; use vortex_ffi::try_or; use vortex_ffi::vx_array; use vortex_ffi::vx_array_ref; +use vortex_ffi::vx_array_sink; +use vortex_ffi::vx_array_sink_open_file_with_strategy; +use vortex_ffi::vx_dtype; use vortex_ffi::vx_error; use vortex_ffi::vx_partition; use vortex_ffi::vx_partition_into_array_stream; use vortex_ffi::vx_session; use vortex_ffi::vx_session_new_with; use vortex_ffi::vx_session_ref; +use vortex_ffi::vx_view; const VX_CUDA_OK: c_int = 0; const VX_CUDA_ERR: c_int = 1; @@ -41,6 +50,7 @@ const VX_CUDA_ERR: c_int = 1; /// returns a new session cloned from `session` with a default [`CudaSession`] attached. fn session_with_cuda(session: &VortexSession) -> VortexResult { session.get::(); + register_cuda_layout(session); Ok(session.clone()) } @@ -59,11 +69,41 @@ pub unsafe extern "C-unwind" fn vx_cuda_session_new( try_or(error_out, ptr::null_mut(), || { let cuda_session = CudaSession::try_default()?; Ok(vx_session_new_with(|session| { - session.with_some(cuda_session) + let session = session.with_some(cuda_session); + register_cuda_layout(&session); + session })) }) } +/// Open a Vortex file sink configured to produce CUDA-readable files. +/// +/// Push host-resident arrays and close or abort the returned sink with the standard +/// `vx_array_sink_*` functions. This function configures the on-disk encodings and layout; it does +/// not move arrays to the GPU during the write. +/// +/// # Safety +/// +/// `session`, `path`, and `dtype` must satisfy the same requirements as +/// `vx_array_sink_open_file`. If `error_out` is non-null, it must be valid for writing one error +/// pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C-unwind" fn vx_cuda_array_sink_open_file( + session: *const vx_session, + path: vx_view, + dtype: *const vx_dtype, + error_out: *mut *mut vx_error, +) -> *mut vx_array_sink { + try_or(error_out, ptr::null_mut(), || { + session_with_cuda(unsafe { vx_session_ref(session) }?)?; + let strategy = WriteStrategyBuilder::default() + .with_btrblocks_builder(BtrBlocksCompressorBuilder::default().only_cuda_compatible()) + .with_flat_strategy(Arc::new(CudaFlatLayoutStrategy::default())) + .build(); + unsafe { vx_array_sink_open_file_with_strategy(session, path, dtype, strategy) } + }) +} + /// Export a borrowed Vortex array for cuDF's Arrow Device import path. /// /// On success returns `0` and writes independently releasable `out_schema` and `out_array`; the diff --git a/vortex-ffi/src/lib.rs b/vortex-ffi/src/lib.rs index ef493a27b03..151359096d4 100644 --- a/vortex-ffi/src/lib.rs +++ b/vortex-ffi/src/lib.rs @@ -27,6 +27,7 @@ use std::sync::LazyLock; pub use array::vx_array; pub use array::vx_array_ref; +pub use dtype::vx_dtype; pub use error::try_or; pub use error::vx_error; pub use error::vx_error_free; @@ -37,13 +38,14 @@ pub use session::vx_session; pub use session::vx_session_free; pub use session::vx_session_new_with; pub use session::vx_session_ref; +pub use sink::vx_array_sink; +pub use sink::vx_array_sink_open_file_with_strategy; +pub use string::vx_view; use vortex::dtype::FieldName; use vortex::error::VortexResult; use vortex::error::vortex_ensure; use vortex::io::runtime::current::CurrentThreadRuntime; -use crate::string::vx_view; - #[cfg(all(feature = "mimalloc", not(miri)))] #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; diff --git a/vortex-ffi/src/sink.rs b/vortex-ffi/src/sink.rs index f307cfc8bc0..e7a53a0c8dd 100644 --- a/vortex-ffi/src/sink.rs +++ b/vortex-ffi/src/sink.rs @@ -1,6 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +use std::sync::Arc; + use futures::SinkExt; use futures::TryStreamExt; use futures::channel::mpsc; @@ -14,10 +16,12 @@ use vortex::error::vortex_ensure; use vortex::error::vortex_err; use vortex::file::VortexFile; use vortex::file::WriteOptionsSessionExt; +use vortex::file::WriteStrategyBuilder; use vortex::file::WriteSummary; use vortex::io::runtime::BlockingRuntime; use vortex::io::runtime::Task; use vortex::io::session::RuntimeSessionExt; +use vortex::layout::LayoutStrategy; use crate::RUNTIME; use crate::arc_wrapper; @@ -54,6 +58,52 @@ pub struct vx_array_sink { dtype: DType, } +/// Open a file sink with an explicit layout strategy. +/// +/// This is a Rust API for FFI crates layered on top of `vortex-ffi`; the base C API continues to +/// use the default write strategy. File creation and write errors are reported when the returned +/// sink is closed. +/// +/// # Safety +/// +/// `session`, `path`, and `dtype` must satisfy the same requirements as +/// `vx_array_sink_open_file`. +pub unsafe fn vx_array_sink_open_file_with_strategy( + session: *const vx_session, + path: vx_view, + dtype: *const vx_dtype, + strategy: Arc, +) -> VortexResult<*mut vx_array_sink> { + let session = vx_session::as_ref(session).clone(); + + if path.ptr.is_null() { + vortex_bail!("null path"); + } + let path = unsafe { path.as_str() }?.to_string(); + + let file_dtype = vx_dtype::as_ref(dtype); + // The channel size 32 was chosen arbitrarily. + let (sink, rx) = mpsc::channel(32); + let dtype = file_dtype.clone(); + let array_stream = ArrayStreamAdapter::new(dtype.clone(), rx.into_stream()); + + let writer_session = session.clone(); + let writer = session.handle().spawn(async move { + let mut file = async_fs::File::create(path).await?; + writer_session + .write_options() + .with_strategy(strategy) + .write(&mut file, array_stream) + .await + }); + + Ok(Box::into_raw(Box::new(vx_array_sink { + sink, + writer, + dtype, + }))) +} + /// Opens a writable array stream, where sink is used to push values into the stream. /// To close the stream close the sink with `vx_array_sink_close`. /// "path" is copied. @@ -65,29 +115,8 @@ pub unsafe extern "C-unwind" fn vx_array_sink_open_file( error_out: *mut *mut vx_error, ) -> *mut vx_array_sink { try_or_default(error_out, || { - let session = vx_session::as_ref(session).clone(); - - if path.ptr.is_null() { - vortex_bail!("null path"); - } - let path = unsafe { path.as_str() }?.to_string(); - - let file_dtype = vx_dtype::as_ref(dtype); - // The channel size 32 was chosen arbitrarily. - let (sink, rx) = mpsc::channel(32); - let dtype = file_dtype.clone(); - let array_stream = ArrayStreamAdapter::new(dtype.clone(), rx.into_stream()); - - let writer = session.handle().spawn(async move { - let mut file = async_fs::File::create(path).await?; - session.write_options().write(&mut file, array_stream).await - }); - - Ok(Box::into_raw(Box::new(vx_array_sink { - sink, - writer, - dtype, - }))) + let strategy = WriteStrategyBuilder::default().build(); + unsafe { vx_array_sink_open_file_with_strategy(session, path, dtype, strategy) } }) } From 01e5eb017bc01f3109114ec2aedb70a91c1bcd69 Mon Sep 17 00:00:00 2001 From: Onur Satici Date: Thu, 16 Jul 2026 14:32:43 +0100 Subject: [PATCH 094/104] Expose pooled CUDA file scans through FFI (#8788) ## Rationale for this change We don't expose a way in ffi to use the pinned buffer pool on reading vortex files into the GPU ## What changes are included in this PR? cuda session now owns the buffer pool, and that is shared on scans done with this context --------- Signed-off-by: Onur Satici --- vortex-cuda/ffi/README.md | 4 ++ vortex-cuda/ffi/cinclude/vortex_cuda.h | 15 +++++++ vortex-cuda/ffi/src/lib.rs | 58 ++++++++++++++++++++++++++ vortex-cuda/gpu-scan-cli/src/main.rs | 12 +++--- vortex-cuda/src/pinned.rs | 9 ++++ vortex-cuda/src/session.rs | 9 ++++ vortex-ffi/src/string.rs | 4 +- 7 files changed, 102 insertions(+), 9 deletions(-) diff --git a/vortex-cuda/ffi/README.md b/vortex-cuda/ffi/README.md index 9cc6b30dd94..1b07e12f0df 100644 --- a/vortex-cuda/ffi/README.md +++ b/vortex-cuda/ffi/README.md @@ -19,3 +19,7 @@ Use `vx_cuda_session_new` to initialize CUDA once and reuse it across exports. Use `vx_cuda_array_sink_open_file` to open a standard Vortex file sink configured to produce CUDA-readable files. Push host-resident arrays and close the sink using the standard `vx_array_sink_*` APIs. + +Use `vx_cuda_scan_path_arrow_device_stream` to read such a local file through pinned host buffers +and receive an Arrow C Device stream. Reuse the same CUDA session across scans so the pinned buffer +pool and CUDA state are reused as well. diff --git a/vortex-cuda/ffi/cinclude/vortex_cuda.h b/vortex-cuda/ffi/cinclude/vortex_cuda.h index d3ca8293f34..971ad9872f4 100644 --- a/vortex-cuda/ffi/cinclude/vortex_cuda.h +++ b/vortex-cuda/ffi/cinclude/vortex_cuda.h @@ -75,6 +75,21 @@ vx_array_sink *vx_cuda_array_sink_open_file(const vx_session *session, const vx_dtype *dtype, vx_error **error_out); +/** + * Scan a local CUDA-compatible Vortex file as an Arrow C Device stream. + * + * Files written by `vx_cuda_array_sink_open_file` are compatible with this path. Reusing the same + * CUDA session across calls also reuses the pinned host buffers used to stage file reads. + * + * On success returns 0 and writes an owned `ArrowDeviceArrayStream` to `out_stream`. The caller + * must release the stream and each produced `ArrowDeviceArray` through their embedded Arrow + * release callbacks. On error returns 1 and writes a `vx_error` to `error_out` when non-NULL. + */ +int vx_cuda_scan_path_arrow_device_stream(const vx_session *session, + vx_view path, + struct ArrowDeviceArrayStream *out_stream, + vx_error **error_out); + /** * Export a borrowed Vortex array for cuDF's Arrow Device import path. * diff --git a/vortex-cuda/ffi/src/lib.rs b/vortex-cuda/ffi/src/lib.rs index cfc76ef354a..12bc584dad6 100644 --- a/vortex-cuda/ffi/src/lib.rs +++ b/vortex-cuda/ffi/src/lib.rs @@ -13,13 +13,19 @@ use std::ptr; use std::sync::Arc; use arrow_schema::ffi::FFI_ArrowSchema; +use vortex::array::stream::ArrayStreamExt; use vortex::compressor::BtrBlocksCompressorBuilder; use vortex::error::VortexResult; use vortex::error::vortex_ensure; +use vortex::file::OpenOptionsSessionExt; use vortex::file::WriteStrategyBuilder; +use vortex::io::VortexReadAt; +use vortex::io::runtime::BlockingRuntime; +use vortex::io::session::RuntimeSessionExt; use vortex::session::SessionExt; use vortex::session::VortexSession; use vortex_cuda::CudaSession; +use vortex_cuda::PooledFileReadAt; use vortex_cuda::arrow::ArrowDeviceArray; use vortex_cuda::arrow::ArrowDeviceArrayStream; use vortex_cuda::arrow::DeviceArrayExt; @@ -104,6 +110,58 @@ pub unsafe extern "C-unwind" fn vx_cuda_array_sink_open_file( }) } +/// Scan a local Vortex file through pinned host buffers and export an Arrow C Device stream. +/// +/// The file must use encodings and layouts supported by the CUDA execution path, such as files +/// written by [`vx_cuda_array_sink_open_file`]. Pinned staging buffers are reused across scans made +/// with the same CUDA session. +/// +/// On success returns `0` and writes an owned [`ArrowDeviceArrayStream`] to `out_stream`. The +/// caller must release the stream and each array produced by it through their embedded Arrow +/// release callbacks. +/// +/// On error returns `1` and, when `error_out` is non-null, writes a `vx_error` (free with +/// `vx_error_free`). +/// +/// # Safety +/// +/// `session` must be a valid borrowed handle created by `vortex-ffi`. `path` must be valid for the +/// duration of this call and contain UTF-8. `out_stream` must be a valid writable pointer. If +/// `error_out` is non-null, it must be valid for writing one error pointer. +#[unsafe(no_mangle)] +pub unsafe extern "C-unwind" fn vx_cuda_scan_path_arrow_device_stream( + session: *const vx_session, + path: vx_view, + out_stream: *mut ArrowDeviceArrayStream, + error_out: *mut *mut vx_error, +) -> c_int { + try_or(error_out, VX_CUDA_ERR, || { + vortex_ensure!(!out_stream.is_null(), "null ArrowDeviceArrayStream output"); + + let path = unsafe { path.as_str() }?.to_owned(); + let session = session_with_cuda(unsafe { vx_session_ref(session) }?)?; + let cuda_session = session.get::(); + let stream = cuda_session.stream()?; + let pool = Arc::clone(cuda_session.pinned_buffer_pool()); + drop(cuda_session); + + let reader: Arc = Arc::new(PooledFileReadAt::open( + path, + session.handle(), + pool, + stream, + )?); + let array_stream = ffi_runtime().block_on(async { + let file = session.open_options().open(reader).await?; + Ok::<_, vortex::error::VortexError>(file.scan()?.into_array_stream()?.boxed()) + })?; + let device_stream = array_stream.export_device_array_stream(&session, ffi_runtime())?; + + unsafe { ptr::write(out_stream, device_stream) }; + Ok(VX_CUDA_OK) + }) +} + /// Export a borrowed Vortex array for cuDF's Arrow Device import path. /// /// On success returns `0` and writes independently releasable `out_schema` and `out_array`; the diff --git a/vortex-cuda/gpu-scan-cli/src/main.rs b/vortex-cuda/gpu-scan-cli/src/main.rs index 9078e30c691..00f6cd1ac1c 100644 --- a/vortex-cuda/gpu-scan-cli/src/main.rs +++ b/vortex-cuda/gpu-scan-cli/src/main.rs @@ -29,13 +29,12 @@ use vortex::file::OpenOptionsSessionExt; use vortex::file::WriteOptionsSessionExt; use vortex::file::WriteStrategyBuilder; use vortex::io::session::RuntimeSessionExt; +use vortex::session::SessionExt; use vortex::session::VortexSession; use vortex_cuda::CudaSession; -use vortex_cuda::PinnedByteBufferPool; use vortex_cuda::PooledByteBufferReadAt; use vortex_cuda::PooledFileReadAt; use vortex_cuda::TracingLaunchStrategy; -use vortex_cuda::VortexCudaStreamPool; use vortex_cuda::executor::CudaArrayExt; use vortex_cuda::layout::CudaFlatLayoutStrategy; use vortex_cuda::layout::register_cuda_layout; @@ -155,11 +154,10 @@ async fn cmd_scan(path: PathBuf, gpu_file: bool, json_output: bool) -> VortexRes let mut cuda_ctx = CudaSession::create_execution_ctx(&session)? .with_launch_strategy(Arc::new(TracingLaunchStrategy)); - let pool = Arc::new(PinnedByteBufferPool::new(Arc::clone( - cuda_ctx.stream().context(), - ))); - let cuda_stream = - VortexCudaStreamPool::new(Arc::clone(cuda_ctx.stream().context()), 1).stream()?; + let cuda_session = session.get::(); + let pool = Arc::clone(cuda_session.pinned_buffer_pool()); + let cuda_stream = cuda_session.stream()?; + drop(cuda_session); let handle = session.handle(); let gpu_file_handle = if gpu_file { diff --git a/vortex-cuda/src/pinned.rs b/vortex-cuda/src/pinned.rs index cd97db63164..52b1aa46e2a 100644 --- a/vortex-cuda/src/pinned.rs +++ b/vortex-cuda/src/pinned.rs @@ -113,6 +113,15 @@ pub struct PinnedByteBufferPool { puts: AtomicU64, } +impl std::fmt::Debug for PinnedByteBufferPool { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PinnedByteBufferPool") + .field("max_keep_per_size", &self.max_keep_per_size) + .field("stats", &self.stats()) + .finish_non_exhaustive() + } +} + struct InflightPinnedBuffer { event: Arc, buffer: PinnedByteBuffer, diff --git a/vortex-cuda/src/session.rs b/vortex-cuda/src/session.rs index 5bc4e55de69..df3e3c13d55 100644 --- a/vortex-cuda/src/session.rs +++ b/vortex-cuda/src/session.rs @@ -23,6 +23,7 @@ use crate::executor::CudaExecute; pub use crate::executor::CudaExecutionCtx; use crate::initialize_cuda; use crate::kernel::KernelLoader; +use crate::pinned::PinnedByteBufferPool; use crate::stream::VortexCudaStream; use crate::stream_pool::VortexCudaStreamPool; @@ -40,6 +41,7 @@ pub struct CudaSession { export_device_array: Arc, kernel_loader: Arc, stream_pool: Arc, + pinned_buffer_pool: Arc, } impl CudaSession { @@ -57,12 +59,14 @@ impl CudaSession { Arc::clone(&context), stream_pool_capacity, )); + let pinned_buffer_pool = Arc::new(PinnedByteBufferPool::new(Arc::clone(&context))); Self { context, kernels: Arc::new(DashMap::default()), kernel_loader: Arc::new(KernelLoader::new()), export_device_array: Arc::new(CanonicalDeviceArrayExport), stream_pool, + pinned_buffer_pool, } } @@ -105,6 +109,11 @@ impl CudaSession { self.stream_pool.stream() } + /// Returns the session-scoped pool used for staging file reads in pinned host memory. + pub fn pinned_buffer_pool(&self) -> &Arc { + &self.pinned_buffer_pool + } + /// Registers CUDA support for an array encoding. /// /// # Arguments diff --git a/vortex-ffi/src/string.rs b/vortex-ffi/src/string.rs index dd77508f048..8e0dfd71900 100644 --- a/vortex-ffi/src/string.rs +++ b/vortex-ffi/src/string.rs @@ -61,8 +61,8 @@ impl vx_view { /// /// # Safety /// - /// Same requirements as in as_bytes - pub(crate) unsafe fn as_str<'a>(&self) -> VortexResult<&'a str> { + /// `self.ptr` must be valid for `self.len` reads, or null when `self.len` is zero. + pub unsafe fn as_str<'a>(&self) -> VortexResult<&'a str> { str::from_utf8(unsafe { self.as_bytes() }?).map_err(|e| vortex_err!("invalid utf-8: {e}")) } } From 54b045b3de28f1ea82608553c14409b14f3c6727 Mon Sep 17 00:00:00 2001 From: Robert Kruszewski Date: Thu, 16 Jul 2026 16:24:36 +0100 Subject: [PATCH 095/104] feat(jni): bridge caller-provided Java I/O into VortexReadAt/VortexWrite (#8748) Implement VortexReadAt and VortexWrite using jvm native interfaces to be able to use java readers and writers natively in vortex --- Cargo.lock | 2 + .../main/java/dev/vortex/api/DataSource.java | 46 +++ .../java/dev/vortex/api/VortexWriter.java | 28 ++ .../java/dev/vortex/io/NativeReadable.java | 47 +++ .../java/dev/vortex/io/NativeWritable.java | 26 ++ .../java/dev/vortex/jni/NativeDataSource.java | 15 + .../java/dev/vortex/jni/NativeWriter.java | 6 + .../dev/vortex/io/NativeIOBridgeTest.java | 332 ++++++++++++++++++ vortex-jni/Cargo.toml | 2 + vortex-jni/src/data_source.rs | 73 ++++ vortex-jni/src/errors.rs | 10 + vortex-jni/src/io/mod.rs | 163 +++++++++ vortex-jni/src/io/read_at.rs | 293 ++++++++++++++++ vortex-jni/src/io/write.rs | 166 +++++++++ vortex-jni/src/lib.rs | 1 + vortex-jni/src/writer.rs | 56 +++ vortex-utils/src/aliases/hash_map.rs | 2 + 17 files changed, 1268 insertions(+) create mode 100644 java/vortex-jni/src/main/java/dev/vortex/io/NativeReadable.java create mode 100644 java/vortex-jni/src/main/java/dev/vortex/io/NativeWritable.java create mode 100644 java/vortex-jni/src/test/java/dev/vortex/io/NativeIOBridgeTest.java create mode 100644 vortex-jni/src/io/mod.rs create mode 100644 vortex-jni/src/io/read_at.rs create mode 100644 vortex-jni/src/io/write.rs diff --git a/Cargo.lock b/Cargo.lock index ca59b40e9dc..e23ac0d40f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10326,6 +10326,8 @@ dependencies = [ "arrow-array 58.3.0", "arrow-schema 58.3.0", "async-fs", + "async-lock", + "async-trait", "futures", "jni", "object_store", diff --git a/java/vortex-jni/src/main/java/dev/vortex/api/DataSource.java b/java/vortex-jni/src/main/java/dev/vortex/api/DataSource.java index 93c5df2c61a..9a888f224f9 100644 --- a/java/vortex-jni/src/main/java/dev/vortex/api/DataSource.java +++ b/java/vortex-jni/src/main/java/dev/vortex/api/DataSource.java @@ -5,6 +5,7 @@ import com.google.common.base.Preconditions; import dev.vortex.VortexCleaner; +import dev.vortex.io.NativeReadable; import dev.vortex.jni.NativeDataSource; import java.util.Arrays; import java.util.Collections; @@ -71,6 +72,51 @@ public static DataSource open(Session session, List uris, Map readables) { + return open(session, readables, 0); + } + + /** + * Open one or more files through caller-provided byte sources instead of native storage clients. Every read the + * scan performs becomes an upcall into the corresponding {@link NativeReadable}, so this is the integration point + * for external I/O abstractions (for example Iceberg's {@code FileIO}). Each file is identified by + * {@link NativeReadable#name()}, which must be unique within {@code readables}. + * + *

The readables must remain open until this data source and all scans created from it are closed; native code + * never closes them. + * + * @param session open session + * @param readables byte sources + * @param readConcurrency maximum in-flight {@code readFully} calls across all files of this data source; {@code 0} + * selects the default. Each in-flight read occupies one native thread and typically one stream on its readable. + */ + public static DataSource open(Session session, List readables, int readConcurrency) { + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(readables, "readables"); + Preconditions.checkArgument(!readables.isEmpty(), "at least one readable is required"); + Preconditions.checkArgument(readConcurrency >= 0, "readConcurrency must not be negative"); + Object[] readableArray = readables.toArray(); + String[] names = new String[readableArray.length]; + long[] lengths = new long[readableArray.length]; + for (int i = 0; i < readableArray.length; i++) { + Preconditions.checkArgument(readableArray[i] != null, "readables must not contain null values"); + NativeReadable readable = readables.get(i); + names[i] = readable.name(); + Preconditions.checkArgument(names[i] != null, "readable at index %s returned a null name", i); + lengths[i] = readable.length(); + Preconditions.checkArgument(lengths[i] >= 0, "readable for %s reported negative length", names[i]); + } + long pointer = + NativeDataSource.openFiles(session.nativePointer(), readableArray, names, lengths, readConcurrency); + return new DataSource(session, pointer); + } + /** Arrow schema of the data source (and of scans produced from it). */ public Schema arrowSchema(BufferAllocator allocator) { try (ArrowSchema schema = ArrowSchema.allocateNew(allocator)) { diff --git a/java/vortex-jni/src/main/java/dev/vortex/api/VortexWriter.java b/java/vortex-jni/src/main/java/dev/vortex/api/VortexWriter.java index e883e7911f7..a62243aab07 100644 --- a/java/vortex-jni/src/main/java/dev/vortex/api/VortexWriter.java +++ b/java/vortex-jni/src/main/java/dev/vortex/api/VortexWriter.java @@ -5,6 +5,7 @@ import com.google.common.base.Preconditions; import dev.vortex.VortexCleaner; +import dev.vortex.io.NativeWritable; import dev.vortex.jni.NativeWriter; import java.io.IOException; import java.util.Map; @@ -66,6 +67,33 @@ public static VortexWriter create( } } + /** + * Create a writer that streams the file into a caller-provided byte sink instead of a native storage client. This + * is the integration point for external I/O abstractions (for example Iceberg's {@code PositionOutputStream}). + * + *

The native side writes and flushes the sink but never closes it: after {@link #close()} returns, all bytes + * have been written and flushed, and the caller must close the sink to finalize the file. + */ + public static VortexWriter create( + Session session, NativeWritable writable, Schema arrowSchema, BufferAllocator allocator) + throws IOException { + Objects.requireNonNull(session, "session"); + Objects.requireNonNull(writable, "writable"); + Objects.requireNonNull(arrowSchema, "arrowSchema"); + Objects.requireNonNull(allocator, "allocator"); + ArrowSchema ffi = ArrowSchema.allocateNew(allocator); + try { + Data.exportSchema(allocator, arrowSchema, null, ffi); + long ptr = NativeWriter.createStream(session.nativePointer(), writable, ffi.memoryAddress()); + if (ptr <= 0) { + throw new IOException("failed to create stream writer (ptr=" + ptr + ")"); + } + return new VortexWriter(ptr); + } finally { + ffi.close(); + } + } + /** Write a batch directly from Arrow C Data Interface addresses. */ public void writeBatch(long arrowArrayAddr, long arrowSchemaAddr) throws IOException { Preconditions.checkState(!closed.get(), "writer already closed"); diff --git a/java/vortex-jni/src/main/java/dev/vortex/io/NativeReadable.java b/java/vortex-jni/src/main/java/dev/vortex/io/NativeReadable.java new file mode 100644 index 00000000000..84f264cc952 --- /dev/null +++ b/java/vortex-jni/src/main/java/dev/vortex/io/NativeReadable.java @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +package dev.vortex.io; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; + +/** + * A random-access byte source supplied by the caller and read from native code. + * + *

Implementations bridge external I/O abstractions — for example Iceberg's {@code FileIO} input streams — into the + * Vortex native reader. The native side invokes {@link #readFully(long, ByteBuffer)} from its own worker threads, + * potentially many calls concurrently, so implementations must be safe for concurrent positional reads (typically by + * opening one underlying stream per concurrent call, or delegating to a positional-read API). + * + *

Lifecycle: the creator owns this object. Native code never calls {@link #close()}; close it only after every data + * source or scan built on top of it has been closed. + */ +public interface NativeReadable extends Closeable { + /** + * Unique name of this source, typically its original location (URI or file path). Used to key per-session caches + * and in native error messages, so it must be stable and unique across sources; it must not contain glob characters + * ({@code *?[}). + */ + String name(); + + /** Total length of the source in bytes. Must be cheap; called once when the source is registered. */ + long length(); + + /** + * Read exactly {@code buffer.remaining()} bytes starting at absolute {@code position} into {@code buffer}. + * + *

Unlike {@link java.io.InputStream#read}, short reads are not permitted: implementations must either fill the + * buffer completely ({@code buffer.remaining() == 0} on return) or throw. + * + *

{@code buffer} is a direct {@link ByteBuffer} wrapping native memory owned by the caller, so bytes + * written to it land in the native reader without an extra copy. It is valid only for the duration of this call: + * implementations must not retain a reference to it after returning. Implementations backed by {@code byte[]} APIs + * can bulk-{@link ByteBuffer#put(byte[], int, int) put} into it. + * + * @throws java.io.EOFException if the range extends past the end of the source + * @throws IOException if the underlying storage fails + */ + void readFully(long position, ByteBuffer buffer) throws IOException; +} diff --git a/java/vortex-jni/src/main/java/dev/vortex/io/NativeWritable.java b/java/vortex-jni/src/main/java/dev/vortex/io/NativeWritable.java new file mode 100644 index 00000000000..a86d127dad6 --- /dev/null +++ b/java/vortex-jni/src/main/java/dev/vortex/io/NativeWritable.java @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +package dev.vortex.io; + +import java.io.Closeable; +import java.io.IOException; + +/** + * A sequential byte sink supplied by the caller and written to from native code. + * + *

Implementations bridge external output abstractions — for example Iceberg's {@code PositionOutputStream} — into + * the Vortex native writer. Writes are sequential and single-threaded: the native side never issues concurrent + * {@code write} calls against the same instance. + * + *

Lifecycle: the creator owns this object. Native code calls {@link #write} and {@link #flush} but never + * {@link #close()}; after {@link dev.vortex.api.VortexWriter#close()} returns, all bytes have been written and flushed, + * and the creator must close the underlying stream to finalize it. + */ +public interface NativeWritable extends Closeable { + /** Append {@code length} bytes from {@code buffer} starting at {@code offset}. */ + void write(byte[] buffer, int offset, int length) throws IOException; + + /** Flush buffered bytes to the underlying storage. */ + void flush() throws IOException; +} diff --git a/java/vortex-jni/src/main/java/dev/vortex/jni/NativeDataSource.java b/java/vortex-jni/src/main/java/dev/vortex/jni/NativeDataSource.java index cc2aa163cee..76d09baf42e 100644 --- a/java/vortex-jni/src/main/java/dev/vortex/jni/NativeDataSource.java +++ b/java/vortex-jni/src/main/java/dev/vortex/jni/NativeDataSource.java @@ -22,6 +22,21 @@ private NativeDataSource() {} */ public static native long open(long sessionPointer, String[] uris, Map options); + /** + * Open a data source over caller-provided {@link dev.vortex.io.NativeReadable} objects. All reads become upcalls + * into the supplied readables; no native storage client is created. + * + * @param sessionPointer pointer from {@link NativeSession#newSession()} + * @param readables one {@link dev.vortex.io.NativeReadable} per file + * @param names unique name per file (from {@link dev.vortex.io.NativeReadable#name()}), parallel to + * {@code readables} + * @param lengths file sizes in bytes, parallel to {@code readables} + * @param readConcurrency maximum in-flight {@code readFully} upcalls across all files of this data source; + * {@code <= 0} selects the default + */ + public static native long openFiles( + long sessionPointer, Object[] readables, String[] names, long[] lengths, int readConcurrency); + /** Free a data source pointer. */ public static native void free(long pointer); diff --git a/java/vortex-jni/src/main/java/dev/vortex/jni/NativeWriter.java b/java/vortex-jni/src/main/java/dev/vortex/jni/NativeWriter.java index 7536712665a..4f61a381438 100644 --- a/java/vortex-jni/src/main/java/dev/vortex/jni/NativeWriter.java +++ b/java/vortex-jni/src/main/java/dev/vortex/jni/NativeWriter.java @@ -18,6 +18,12 @@ private NativeWriter() {} public static native long create( long sessionPointer, String uri, long arrowSchemaAddress, Map options); + /** + * Open a writer that streams the file into a caller-provided {@link dev.vortex.io.NativeWritable}. The native side + * writes and flushes but never closes the writable; the caller must close it after {@link #close}. + */ + public static native long createStream(long sessionPointer, Object writable, long arrowSchemaAddress); + /** * Write a batch directly from Arrow C Data Interface addresses. * diff --git a/java/vortex-jni/src/test/java/dev/vortex/io/NativeIOBridgeTest.java b/java/vortex-jni/src/test/java/dev/vortex/io/NativeIOBridgeTest.java new file mode 100644 index 00000000000..5a6d40e05db --- /dev/null +++ b/java/vortex-jni/src/test/java/dev/vortex/io/NativeIOBridgeTest.java @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +package dev.vortex.io; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.vortex.api.DataSource; +import dev.vortex.api.Partition; +import dev.vortex.api.Scan; +import dev.vortex.api.ScanOptions; +import dev.vortex.api.Session; +import dev.vortex.api.VortexWriteSummary; +import dev.vortex.api.VortexWriter; +import dev.vortex.arrow.ArrowAllocation; +import dev.vortex.jni.NativeLoader; +import java.io.EOFException; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.List; +import org.apache.arrow.c.ArrowArray; +import org.apache.arrow.c.ArrowSchema; +import org.apache.arrow.c.Data; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.VarCharVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.ViewVarCharVector; +import org.apache.arrow.vector.ipc.ArrowReader; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.Schema; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Round-trip tests for the caller-provided I/O bridge ({@link NativeReadable} / {@link NativeWritable}). */ +public final class NativeIOBridgeTest { + @TempDir + Path tempDir; + + @BeforeAll + public static void loadLibrary() { + NativeLoader.loadJni(); + } + + private static Schema personSchema() { + return new Schema(List.of( + Field.notNullable("name", new ArrowType.Utf8()), + Field.notNullable("age", new ArrowType.Int(32, true)))); + } + + /** A {@link NativeReadable} over a local file, safe for concurrent positional reads. */ + private static final class FileChannelReadable implements NativeReadable { + private final String name; + private final FileChannel channel; + private final long length; + + FileChannelReadable(Path path) throws IOException { + this(path.toString(), path); + } + + FileChannelReadable(String name, Path path) throws IOException { + this.name = name; + this.channel = FileChannel.open(path, StandardOpenOption.READ); + this.length = channel.size(); + } + + @Override + public String name() { + return name; + } + + @Override + public long length() { + return length; + } + + @Override + public void readFully(long position, ByteBuffer buffer) throws IOException { + int len = buffer.remaining(); + long pos = position; + while (buffer.hasRemaining()) { + int read = channel.read(buffer, pos); + if (read < 0) { + throw new EOFException("EOF reading " + len + " bytes at position " + position); + } + pos += read; + } + } + + @Override + public void close() throws IOException { + channel.close(); + } + } + + /** A {@link NativeWritable} over a local file. */ + private static final class StreamWritable implements NativeWritable { + private final OutputStream out; + + StreamWritable(Path path) throws IOException { + this.out = Files.newOutputStream( + path, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + } + + @Override + public void write(byte[] buffer, int offset, int length) throws IOException { + out.write(buffer, offset, length); + } + + @Override + public void flush() throws IOException { + out.flush(); + } + + @Override + public void close() throws IOException { + out.close(); + } + } + + private void writePeopleFile(Session session, BufferAllocator allocator, Path path) throws IOException { + Schema schema = personSchema(); + VortexWriteSummary summary; + try (StreamWritable writable = new StreamWritable(path)) { + try (VortexWriter writer = VortexWriter.create(session, writable, schema, allocator); + VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { + VarCharVector nameVec = (VarCharVector) root.getVector("name"); + IntVector ageVec = (IntVector) root.getVector("age"); + nameVec.allocateNew(3); + ageVec.allocateNew(3); + nameVec.setSafe(0, "Alice".getBytes(UTF_8)); + nameVec.setSafe(1, "Bob".getBytes(UTF_8)); + nameVec.setSafe(2, "Carol".getBytes(UTF_8)); + ageVec.setSafe(0, 30); + ageVec.setSafe(1, 25); + ageVec.setSafe(2, 40); + root.setRowCount(3); + + try (ArrowArray arrowArray = ArrowArray.allocateNew(allocator); + ArrowSchema arrowSchemaFfi = ArrowSchema.allocateNew(allocator)) { + Data.exportVectorSchemaRoot(allocator, root, null, arrowArray, arrowSchemaFfi); + writer.writeBatch(arrowArray.memoryAddress(), arrowSchemaFfi.memoryAddress()); + } + + // The byte counter must be live for stream writers too: bounded while + // the write task is still flushing, exact once finished. + long bytesWhileOpen = writer.bytesWritten(); + summary = writer.finish(); + assertTrue(bytesWhileOpen >= 0 && bytesWhileOpen <= summary.fileSize()); + assertEquals(summary.fileSize(), writer.bytesWritten()); + } + } + // Everything the writer counted must have reached the caller-provided sink. + assertEquals(Files.size(path), summary.fileSize()); + } + + @Test + public void testWritableThenReadableRoundTrip() throws IOException { + Path path = tempDir.resolve("bridge_roundtrip.vortex"); + BufferAllocator allocator = ArrowAllocation.rootAllocator(); + Session session = Session.create(); + + writePeopleFile(session, allocator, path); + assertTrue(Files.size(path) > 0, "stream writer should have produced bytes"); + + try (FileChannelReadable readable = new FileChannelReadable(path)) { + DataSource ds = DataSource.open(session, readable); + assertEquals(new DataSource.RowCount.Exact(3L), ds.rowCount()); + + Scan scan = ds.scan(ScanOptions.of()); + int rows = 0; + while (scan.hasNext()) { + Partition p = scan.next(); + try (ArrowReader reader = p.scanArrow(allocator)) { + while (reader.loadNextBatch()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + ViewVarCharVector nameOut = (ViewVarCharVector) root.getVector("name"); + IntVector ageOut = (IntVector) root.getVector("age"); + if (rows == 0) { + assertEquals("Alice", nameOut.getObject(0).toString()); + assertEquals(30, ageOut.get(0)); + } + rows += root.getRowCount(); + } + } + } + assertEquals(3, rows); + } + } + + @Test + public void testMultipleReadables() throws IOException { + Path first = tempDir.resolve("bridge_a.vortex"); + Path second = tempDir.resolve("bridge_b.vortex"); + BufferAllocator allocator = ArrowAllocation.rootAllocator(); + Session session = Session.create(); + + writePeopleFile(session, allocator, first); + writePeopleFile(session, allocator, second); + + try (FileChannelReadable firstReadable = new FileChannelReadable(first); + FileChannelReadable secondReadable = new FileChannelReadable(second)) { + DataSource ds = DataSource.open(session, List.of(firstReadable, secondReadable)); + // Only the first file is opened eagerly, so the count is an estimate until scanned. + assertEquals(6L, ds.rowCount().asOptional().orElseThrow()); + + Scan scan = ds.scan(ScanOptions.of()); + long rows = 0; + while (scan.hasNext()) { + Partition p = scan.next(); + try (ArrowReader reader = p.scanArrow(allocator)) { + while (reader.loadNextBatch()) { + rows += reader.getVectorSchemaRoot().getRowCount(); + } + } + } + assertEquals(6, rows); + } + } + + @Test + public void testDuplicateReadableNamesAreRejected() throws IOException { + Path path = tempDir.resolve("bridge_duplicate.vortex"); + BufferAllocator allocator = ArrowAllocation.rootAllocator(); + Session session = Session.create(); + + writePeopleFile(session, allocator, path); + + try (FileChannelReadable first = new FileChannelReadable("/bridge_duplicate.vortex", path); + FileChannelReadable second = new FileChannelReadable("bridge_duplicate.vortex", path)) { + RuntimeException thrown = + assertThrows(RuntimeException.class, () -> DataSource.open(session, List.of(first, second))); + assertTrue( + thrown.getMessage().contains("multiple Java readables normalize to path"), + "error should identify the normalized path collision, got: " + thrown.getMessage()); + } + } + + @Test + public void testLargeRoundTrip() throws IOException { + Path path = tempDir.resolve("bridge_large.vortex"); + BufferAllocator allocator = ArrowAllocation.rootAllocator(); + Session session = Session.create(); + Schema schema = personSchema(); + + int batches = 20; + int rowsPerBatch = 5_000; + try (StreamWritable writable = new StreamWritable(path)) { + try (VortexWriter writer = VortexWriter.create(session, writable, schema, allocator); + VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { + VarCharVector nameVec = (VarCharVector) root.getVector("name"); + IntVector ageVec = (IntVector) root.getVector("age"); + for (int batch = 0; batch < batches; batch++) { + nameVec.allocateNew(rowsPerBatch); + ageVec.allocateNew(rowsPerBatch); + for (int row = 0; row < rowsPerBatch; row++) { + nameVec.setSafe(row, ("name-" + batch + "-" + row).getBytes(UTF_8)); + ageVec.setSafe(row, batch * rowsPerBatch + row); + } + root.setRowCount(rowsPerBatch); + try (ArrowArray arrowArray = ArrowArray.allocateNew(allocator); + ArrowSchema arrowSchemaFfi = ArrowSchema.allocateNew(allocator)) { + Data.exportVectorSchemaRoot(allocator, root, null, arrowArray, arrowSchemaFfi); + writer.writeBatch(arrowArray.memoryAddress(), arrowSchemaFfi.memoryAddress()); + } + } + } + } + + try (FileChannelReadable readable = new FileChannelReadable(path)) { + DataSource ds = DataSource.open(session, List.of(readable), 4); + Scan scan = ds.scan(ScanOptions.of()); + long rows = 0; + long ageSum = 0; + while (scan.hasNext()) { + Partition p = scan.next(); + try (ArrowReader reader = p.scanArrow(allocator)) { + while (reader.loadNextBatch()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + IntVector ageOut = (IntVector) root.getVector("age"); + for (int i = 0; i < root.getRowCount(); i++) { + ageSum += ageOut.get(i); + } + rows += root.getRowCount(); + } + } + } + long total = (long) batches * rowsPerBatch; + assertEquals(total, rows); + assertEquals(total * (total - 1) / 2, ageSum, "ages should be 0..N-1 exactly once"); + } + } + + @Test + public void testReadableExceptionPropagates() { + Session session = Session.create(); + NativeReadable failing = new NativeReadable() { + @Override + public String name() { + return "memory/failing.vortex"; + } + + @Override + public long length() { + return 1024; + } + + @Override + public void readFully(long position, ByteBuffer buffer) throws IOException { + throw new IOException("boom: injected read failure"); + } + + @Override + public void close() {} + }; + + RuntimeException thrown = assertThrows(RuntimeException.class, () -> DataSource.open(session, failing)); + assertTrue( + thrown.getMessage().contains("boom: injected read failure"), + "error should carry the Java exception message, got: " + thrown.getMessage()); + } +} diff --git a/vortex-jni/Cargo.toml b/vortex-jni/Cargo.toml index 767770db46f..6bb50cf097d 100644 --- a/vortex-jni/Cargo.toml +++ b/vortex-jni/Cargo.toml @@ -20,6 +20,8 @@ categories = { workspace = true } arrow-array = { workspace = true, features = ["ffi"] } arrow-schema = { workspace = true } async-fs = { workspace = true } +async-lock = { workspace = true } +async-trait = { workspace = true } futures = { workspace = true } jni = { workspace = true } object_store = { workspace = true, features = ["aws", "azure", "gcp"] } diff --git a/vortex-jni/src/data_source.rs b/vortex-jni/src/data_source.rs index ab86e78a056..47d9d83c577 100644 --- a/vortex-jni/src/data_source.rs +++ b/vortex-jni/src/data_source.rs @@ -16,6 +16,7 @@ use jni::objects::JLongArray; use jni::objects::JObject; use jni::objects::JObjectArray; use jni::objects::JString; +use jni::sys::jint; use jni::sys::jlong; use url::Url; use vortex::error::VortexResult; @@ -33,6 +34,7 @@ use crate::RUNTIME; use crate::dtype::export_dtype_to_arrow; use crate::errors::try_or_throw; use crate::file::extract_properties; +use crate::io::JavaFileSystem; use crate::object_store::object_store_fs; use crate::session::session_ref; @@ -115,6 +117,77 @@ pub extern "system" fn Java_dev_vortex_jni_NativeDataSource_open( }) } +/// Open a data source over caller-provided `dev.vortex.io.NativeReadable` objects. +/// +/// Unlike [`Java_dev_vortex_jni_NativeDataSource_open`], no storage client is created +/// on the native side: every read is an upcall into the corresponding Java object. +/// `paths` are opaque identifiers (typically the original file locations) used for +/// debugging and deduplication, `lengths` are the known file sizes in bytes. +/// `read_concurrency` caps in-flight `readFully` upcalls across *all* files of the +/// data source; values `<= 0` select the library default. +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_vortex_jni_NativeDataSource_openFiles( + mut env: EnvUnowned, + _class: JClass, + session_ptr: jlong, + readables: JObjectArray, + paths: JObjectArray, + lengths: JLongArray, + read_concurrency: jint, +) -> jlong { + try_or_throw(&mut env, |env| { + let session = unsafe { session_ref(session_ptr) }; + + let count = readables.len(env)?; + if count == 0 { + throw_runtime!("no readables provided"); + } + if paths.len(env)? != count || lengths.len(env)? != count { + throw_runtime!("readables, paths, and lengths must have equal length"); + } + + let mut sizes = vec![0 as jlong; count]; + lengths.get_region(env, 0, &mut sizes)?; + + let vm = env.get_java_vm()?; + let concurrency = usize::try_from(read_concurrency).ok().filter(|c| *c > 0); + let mut fs = JavaFileSystem::new(vm, session.handle(), concurrency); + let mut ordered_paths = Vec::with_capacity(count); + for idx in 0..count { + let path_obj = paths.get_element(env, idx)?; + let path: String = env.cast_local::(path_obj)?.try_to_string(env)?; + if path.contains(['*', '?', '[']) { + throw_runtime!("path '{path}' contains glob characters, which are unsupported"); + } + let size = u64::try_from(sizes[idx]) + .map_err(|_| vortex_err!("negative length for path '{path}'"))?; + + let readable = readables.get_element(env, idx)?; + if readable.is_null() { + throw_runtime!("null readable for path '{path}'"); + } + let readable = Arc::new(env.new_global_ref(&readable)?); + + // `MultiFileDataSource::with_glob` strips leading slashes (object-store paths + // are bucket-relative), so key the registry by the same normalized form. + let key = path.trim_start_matches('/').to_string(); + fs.insert(key.clone(), readable, size)?; + ordered_paths.push(key); + } + + let fs: FileSystemRef = Arc::new(fs); + let mut builder = MultiFileDataSource::new(session.clone()); + for path in ordered_paths { + builder = builder.with_glob(path, Some(Arc::clone(&fs))); + } + + let inner = RUNTIME + .block_on(builder.build()) + .map(|ds| Arc::new(ds) as DataSourceRef)?; + Ok(Box::new(NativeDataSource { inner }).into_raw()) + }) +} + /// URL with the path cleared, used as a cache key for filesystem reuse. fn base_url(url: &Url) -> Url { let mut base = url.clone(); diff --git a/vortex-jni/src/errors.rs b/vortex-jni/src/errors.rs index 4cf45a14667..0e903406ae0 100644 --- a/vortex-jni/src/errors.rs +++ b/vortex-jni/src/errors.rs @@ -11,6 +11,7 @@ use jni::sys::JNI_FALSE; use jni::sys::jboolean; use jni::sys::jobject; use vortex::error::VortexError; +use vortex::error::vortex_err; #[derive(Debug, thiserror::Error)] pub enum JNIError { @@ -38,6 +39,15 @@ impl From for JNIError { } } +impl From for VortexError { + fn from(error: JNIError) -> Self { + match error { + JNIError::Vortex(error) => error, + JNIError::Custom(error) => vortex_err!("JNI: {error}"), + } + } +} + /// Types that have a reasonable default value to use /// across the FFI. pub trait JNIDefault { diff --git a/vortex-jni/src/io/mod.rs b/vortex-jni/src/io/mod.rs new file mode 100644 index 00000000000..fa20a6a4685 --- /dev/null +++ b/vortex-jni/src/io/mod.rs @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Rust implementations of Vortex I/O traits backed by Java objects (upcalls). +//! +//! These bridges let Java callers supply their own I/O — e.g. Iceberg's `FileIO` +//! streams — instead of having the native side open storage itself. Java objects +//! are held as JNI global references and their methods are invoked from whichever +//! runtime thread executes the I/O. +//! +//! # Threading +//! +//! Vortex runtime threads (smol workers and the `blocking` pool used by +//! [`Handle::spawn_blocking`](vortex::io::runtime::Handle::spawn_blocking)) are not +//! JVM threads. Every upcall goes through [`with_jvm`], which attaches the current +//! thread on first use (detached automatically at thread exit) — re-attachment is a +//! cheap thread-local lookup. +//! +//! # JavaVM lifetime +//! +//! Each bridge struct stores the process-wide [`JavaVM`] pointer, captured once at +//! construction from the [`Env`] of the JNI entry point that created it. +//! This is safe with respect to serialization: only *Java* objects are serialized +//! (e.g. Iceberg's `FileIO` shipped to executors), and they reconstruct their native +//! state through fresh JNI calls after deserialization, at which point the entry +//! point's `Env` provides the VM again. Native bridge objects never outlive the +//! process, and JNI guarantees a single VM per process for its entire lifetime. + +mod read_at; +mod write; + +use jni::Env; +use jni::JavaVM; +pub(crate) use read_at::JavaFileSystem; +use vortex::error::VortexError; +use vortex::error::VortexResult; +pub(crate) use write::JavaWrite; + +use crate::errors::JNIError; + +/// Run `f` on the current thread with a JVM attachment. +/// +/// Attaching is a thread-local lookup when the thread is already attached; threads +/// attached here stay attached until they exit. A Java exception thrown inside `f` +/// is caught, cleared, and surfaced as a `VortexError` carrying the exception's +/// class, message, and stack trace. +pub(crate) fn with_jvm( + vm: &JavaVM, + f: impl FnOnce(&mut Env) -> Result, +) -> VortexResult { + vm.attach_current_thread(f).map_err(VortexError::from) +} + +#[cfg(test)] +pub(crate) mod tests { + use std::sync::Arc; + use std::sync::LazyLock; + use std::time::Duration; + + use jni::InitArgsBuilder; + use jni::JValue; + use jni::JavaVM; + use jni::errors::StartJvmError; + use jni::objects::JObject; + use jni::refs::Global; + use vortex::error::VortexResult; + use vortex::error::vortex_bail; + use vortex::io::filesystem::FileSystem; + use vortex::io::runtime::BlockingRuntime; + use vortex::io::runtime::current::CurrentThreadRuntime; + + use super::JavaFileSystem; + use super::with_jvm; + + /// Launch (or reuse) the process-wide JVM backing upcall tests. Returns `None` + /// when no JVM installation can be located, so tests skip on machines without a + /// JDK; any other launch failure panics. + pub(crate) fn test_vm() -> Option<&'static JavaVM> { + static VM: LazyLock> = LazyLock::new(|| { + let args = InitArgsBuilder::new() + .option("-Xcheck:jni") + .build() + .expect("valid JVM init args"); + match JavaVM::new(args) { + Ok(vm) => Some(vm), + Err(StartJvmError::NotFound(_)) => { + eprintln!("skipping JNI upcall test: no JVM found (set JAVA_HOME to run it)"); + None + } + Err(e) => panic!("failed to launch test JVM: {e}"), + } + }); + VM.as_ref() + } + + /// A fresh `java.lang.Object` global ref plus a `java.lang.ref.WeakReference` + /// observing the same object, so tests can detect when the global ref is deleted. + pub(crate) fn object_with_weak_ref( + vm: &JavaVM, + ) -> VortexResult<(Global>, Global>)> { + with_jvm(vm, |env| { + let obj = + env.new_object(jni::jni_str!("java/lang/Object"), jni::jni_sig!("()V"), &[])?; + let weak = env.new_object( + jni::jni_str!("java/lang/ref/WeakReference"), + jni::jni_sig!("(Ljava/lang/Object;)V"), + &[JValue::Object(&obj)], + )?; + Ok((env.new_global_ref(&obj)?, env.new_global_ref(&weak)?)) + }) + } + + /// Assert that the referent observed by `weak` becomes collectible, i.e. that no + /// JNI global reference pins it anymore. + pub(crate) fn assert_weak_ref_clears( + vm: &JavaVM, + weak: &Global>, + ) -> VortexResult<()> { + for _ in 0..100 { + let cleared = with_jvm(vm, |env| { + env.call_static_method( + jni::jni_str!("java/lang/System"), + jni::jni_str!("gc"), + jni::jni_sig!("()V"), + &[], + )?; + let referent = env + .call_method( + weak.as_ref(), + jni::jni_str!("get"), + jni::jni_sig!("()Ljava/lang/Object;"), + &[], + )? + .l()?; + Ok(referent.is_null()) + })?; + if cleared { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(10)); + } + vortex_bail!("JNI global reference leaked: weak reference still has a referent") + } + + #[test] + fn test_file_system_drop_releases_global_refs() -> VortexResult<()> { + let Some(vm) = test_vm() else { + return Ok(()); + }; + let (readable, weak) = object_with_weak_ref(vm)?; + + let runtime = CurrentThreadRuntime::new(); + let mut fs = JavaFileSystem::new(vm.clone(), runtime.handle(), None); + fs.insert("data/file.vortex".to_string(), Arc::new(readable), 4)?; + // `open_read` clones the global ref into a `JavaReadable`; both it and the + // file system must let go of the Java object once dropped. + let reader = runtime.block_on(fs.open_read("data/file.vortex"))?; + drop(fs); + drop(reader); + + assert_weak_ref_clears(vm, &weak) + } +} diff --git a/vortex-jni/src/io/read_at.rs b/vortex-jni/src/io/read_at.rs new file mode 100644 index 00000000000..a08652c7cc1 --- /dev/null +++ b/vortex-jni/src/io/read_at.rs @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use std::fmt; +use std::fmt::Debug; +use std::sync::Arc; + +use async_lock::Semaphore; +use async_trait::async_trait; +use futures::FutureExt; +use futures::StreamExt; +use futures::future::BoxFuture; +use futures::stream; +use futures::stream::BoxStream; +use jni::JValue; +use jni::JavaVM; +use jni::objects::JObject; +use jni::refs::Global; +use vortex::array::buffer::BufferHandle; +use vortex::buffer::Alignment; +use vortex::buffer::ByteBufferMut; +use vortex::error::VortexResult; +use vortex::error::vortex_bail; +use vortex::error::vortex_err; +use vortex::io::CoalesceConfig; +use vortex::io::VortexReadAt; +use vortex::io::filesystem::FileListing; +use vortex::io::filesystem::FileSystem; +use vortex::io::runtime::Handle; +use vortex::utils::aliases::hash_map::EntryRef; +use vortex::utils::aliases::hash_map::HashMap; + +use crate::io::with_jvm; + +/// Default number of concurrent `readFully` upcalls to allow across all files of one +/// [`JavaFileSystem`]. Matches the object-store default since the backing storage is +/// typically remote. +const DEFAULT_CONCURRENCY: usize = 192; + +/// Shared cap on in-flight `readFully` upcalls across every file of one +/// [`JavaFileSystem`], so a wide scan cannot pin `files x concurrency` blocking +/// threads and Java streams. +#[derive(Clone)] +struct UpcallLimiter { + semaphore: Arc, + concurrency: usize, +} + +impl UpcallLimiter { + fn new(concurrency: usize) -> Self { + Self { + semaphore: Arc::new(Semaphore::new(concurrency)), + concurrency, + } + } +} + +/// A [`VortexReadAt`] backed by a Java object implementing `dev.vortex.io.NativeReadable`. +/// +/// Positional reads are forwarded as blocking `readFully(long, ByteBuffer)` upcalls +/// executed on the runtime's blocking pool. The destination is a direct `ByteBuffer` +/// wrapping the Rust-side allocation, so Java writes land in the buffer handed to the +/// scan without a copy through a heap `byte[]`. The file size is captured at +/// construction time (Java callers know it from metadata), so `size()` never crosses +/// the JNI boundary. +pub(crate) struct JavaReadable { + vm: JavaVM, + readable: Arc>>, + len: u64, + handle: Handle, + limiter: UpcallLimiter, +} + +impl JavaReadable { + fn new( + vm: JavaVM, + readable: Arc>>, + len: u64, + handle: Handle, + limiter: UpcallLimiter, + ) -> Self { + Self { + vm, + readable, + len, + handle, + limiter, + } + } +} + +impl VortexReadAt for JavaReadable { + fn coalesce_config(&self) -> Option { + // Upcalls have a fixed JNI overhead and the backing storage is usually + // remote, so favor fewer, larger reads. + Some(CoalesceConfig::object_storage()) + } + + fn concurrency(&self) -> usize { + self.limiter.concurrency + } + + fn size(&self) -> BoxFuture<'static, VortexResult> { + let len = self.len; + async move { Ok(len) }.boxed() + } + + fn read_at( + &self, + offset: u64, + length: usize, + alignment: Alignment, + ) -> BoxFuture<'static, VortexResult> { + let vm = self.vm.clone(); + let readable = Arc::clone(&self.readable); + let len = self.len; + let handle = self.handle.clone(); + let semaphore = Arc::clone(&self.limiter.semaphore); + + async move { + // Take a permit before occupying a blocking thread. The lock-free + // `try_acquire_arc` fast path deliberately barges ahead of queued + // waiters: slight unfairness is fine, the limiter must never become + // the bottleneck itself. + let permit = match semaphore.try_acquire_arc() { + Some(permit) => permit, + None => semaphore.acquire_arc().await, + }; + + handle + .spawn_blocking(move || { + // Keep the permit with the blocking work. Dropping the read future can + // cancel its task handle, but it cannot interrupt a `readFully` upcall + // that has already started. + let _permit = permit; + let end = offset + .checked_add(length as u64) + .ok_or_else(|| vortex_err!("read {offset}+{length} overflows u64"))?; + if end > len { + vortex_bail!("read {offset}..{end} out of bounds for file of length {len}"); + } + // `java.nio.Buffer` capacities are Java ints. + if i32::try_from(length).is_err() { + vortex_bail!("read length {length} exceeds ByteBuffer limit"); + } + let joffset = i64::try_from(offset) + .map_err(|_| vortex_err!("read offset {offset} exceeds i64"))?; + + let mut buffer = ByteBufferMut::with_capacity_aligned(length, alignment); + with_jvm(&vm, |env| { + // SAFETY: the pointer covers `length` bytes of live allocation, and + // the direct buffer wrapping it does not outlive this call — + // `readFully` is synchronous and the interface forbids retaining + // the buffer after it returns. + let dst = unsafe { + env.new_direct_byte_buffer( + buffer.spare_capacity_mut().as_mut_ptr().cast(), + length, + )? + }; + env.call_method( + readable.as_ref(), + jni::jni_str!("readFully"), + jni::jni_sig!("(JLjava/nio/ByteBuffer;)V"), + &[JValue::Long(joffset), JValue::Object(dst.as_ref())], + )?; + // Guard against implementations that return without filling the + // buffer, which would hand uninitialized memory to the scan. + let remaining = env + .call_method( + &dst, + jni::jni_str!("remaining"), + jni::jni_sig!("()I"), + &[], + )? + .i()?; + if remaining != 0 { + return Err(vortex_err!( + "readFully returned with {remaining} of {length} bytes unfilled" + ) + .into()); + } + Ok(()) + }) + .map_err(|e| e.with_context("readFully upcall failed"))?; + + // SAFETY: `readFully` wrote all `length` bytes through the direct + // buffer, verified by the `remaining()` check above. + unsafe { buffer.set_len(length) }; + Ok(BufferHandle::new_host(buffer.freeze())) + }) + .await + } + .boxed() + } +} + +struct JavaFileEntry { + readable: Arc>>, + size: u64, +} + +/// A [`FileSystem`] over a fixed set of Java-provided readables, keyed by path. +/// +/// Built by `NativeDataSource.openFiles`: every file the data source may touch is +/// registered up front together with its size, so `head` (and therefore exact-path +/// glob resolution) is answered without any upcall. `open_read` wraps the registered +/// Java object in a [`JavaReadable`]. +pub(crate) struct JavaFileSystem { + vm: JavaVM, + files: HashMap, + handle: Handle, + limiter: UpcallLimiter, +} + +impl JavaFileSystem { + pub(crate) fn new(vm: JavaVM, handle: Handle, concurrency: Option) -> Self { + Self { + vm, + files: HashMap::new(), + handle, + limiter: UpcallLimiter::new(concurrency.unwrap_or(DEFAULT_CONCURRENCY)), + } + } + + /// Register a Java readable for `path` with a known size. + pub(crate) fn insert( + &mut self, + path: String, + readable: Arc>>, + size: u64, + ) -> VortexResult<()> { + match self.files.entry_ref(&path) { + EntryRef::Occupied(_) => { + vortex_bail!("multiple Java readables normalize to path '{path}'"); + } + EntryRef::Vacant(v) => v.insert(JavaFileEntry { readable, size }), + }; + + Ok(()) + } +} + +impl Debug for JavaFileSystem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("JavaFileSystem") + .field("files", &self.files.keys()) + .finish() + } +} + +#[async_trait] +impl FileSystem for JavaFileSystem { + fn list(&self, prefix: &str) -> BoxStream<'_, VortexResult> { + let listings: Vec> = self + .files + .iter() + .filter(|(path, _)| path.starts_with(prefix)) + .map(|(path, entry)| { + Ok(FileListing { + path: path.clone(), + size: Some(entry.size), + }) + }) + .collect(); + stream::iter(listings).boxed() + } + + async fn head(&self, path: &str) -> VortexResult> { + Ok(self.files.get(path).map(|entry| FileListing { + path: path.to_string(), + size: Some(entry.size), + })) + } + + async fn open_read(&self, path: &str) -> VortexResult> { + let entry = self + .files + .get(path) + .ok_or_else(|| vortex_err!("no Java readable registered for path '{path}'"))?; + Ok(Arc::new(JavaReadable::new( + self.vm.clone(), + Arc::clone(&entry.readable), + entry.size, + self.handle.clone(), + self.limiter.clone(), + ))) + } + + async fn delete(&self, path: &str) -> VortexResult<()> { + vortex_bail!("delete('{path}') is not supported by a Java-readable file system") + } +} diff --git a/vortex-jni/src/io/write.rs b/vortex-jni/src/io/write.rs new file mode 100644 index 00000000000..ffadf400e17 --- /dev/null +++ b/vortex-jni/src/io/write.rs @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use std::io; +use std::sync::Arc; + +use jni::JValue; +use jni::JavaVM; +use jni::objects::JObject; +use jni::refs::Global; +use vortex::io::IoBuf; +use vortex::io::VortexWrite; + +use crate::io::with_jvm; + +/// Largest chunk pushed through a single `write` upcall. Java arrays are indexed by +/// `int`, and keeping chunks bounded also keeps per-call array allocations modest. +const MAX_WRITE_CHUNK: usize = 1 << 30; + +/// A [`VortexWrite`] backed by a Java object implementing `dev.vortex.io.NativeWritable`. +/// +/// Bytes are forwarded as blocking `write(byte[], int, int)` upcalls, `flush` maps to +/// `flush()`, and `shutdown` maps to `flush()` as well: the Java caller created the +/// underlying stream and remains responsible for closing it once the writer finishes. +/// Writes run inline on the runtime thread driving the write task, which is attached +/// to the JVM on first use. +pub(crate) struct JavaWrite { + vm: JavaVM, + writable: Arc>>, +} + +impl JavaWrite { + pub(crate) fn new(vm: JavaVM, writable: Arc>>) -> Self { + Self { vm, writable } + } + + fn write_slice(&self, bytes: &[u8]) -> io::Result<()> { + for chunk in bytes.chunks(MAX_WRITE_CHUNK) { + let jlen = i32::try_from(chunk.len()).map_err(io::Error::other)?; + with_jvm(&self.vm, |env| { + let array = env.byte_array_from_slice(chunk)?; + env.call_method( + self.writable.as_ref(), + jni::jni_str!("write"), + jni::jni_sig!("([BII)V"), + &[ + JValue::Object(array.as_ref()), + JValue::Int(0), + JValue::Int(jlen), + ], + )?; + Ok(()) + }) + .map_err(io::Error::other)?; + } + Ok(()) + } + + fn flush_upcall(&self) -> io::Result<()> { + with_jvm(&self.vm, |env| { + env.call_method( + self.writable.as_ref(), + jni::jni_str!("flush"), + jni::jni_sig!("()V"), + &[], + )?; + Ok(()) + }) + .map_err(io::Error::other) + } +} + +impl VortexWrite for JavaWrite { + async fn write_all(&mut self, buffer: B) -> io::Result { + self.write_slice(buffer.as_slice())?; + Ok(buffer) + } + + async fn flush(&mut self) -> io::Result<()> { + self.flush_upcall() + } + + async fn shutdown(&mut self) -> io::Result<()> { + self.flush_upcall() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use jni::JValue; + use jni::objects::JByteArray; + use vortex::error::VortexResult; + use vortex::error::vortex_err; + + use super::JavaWrite; + use crate::io::tests::assert_weak_ref_clears; + use crate::io::tests::test_vm; + use crate::io::with_jvm; + + /// Write upcalls from a fresh native thread must reach the Java object, and once + /// the writer (and every other native reference) is dropped, its JNI global ref + /// must be deleted so the Java object becomes collectible. + #[test] + fn test_write_upcalls_release_global_ref() -> VortexResult<()> { + let Some(vm) = test_vm() else { + return Ok(()); + }; + + // `ByteArrayOutputStream` has the same `write([BII)V`/`flush()V` shape as + // `dev.vortex.io.NativeWritable`, so it stands in for a caller-provided sink. + let (writable, content, weak) = with_jvm(vm, |env| { + let sink = env.new_object( + jni::jni_str!("java/io/ByteArrayOutputStream"), + jni::jni_sig!("()V"), + &[], + )?; + let weak = env.new_object( + jni::jni_str!("java/lang/ref/WeakReference"), + jni::jni_sig!("(Ljava/lang/Object;)V"), + &[JValue::Object(&sink)], + )?; + Ok(( + env.new_global_ref(&sink)?, + env.new_global_ref(&sink)?, + env.new_global_ref(&weak)?, + )) + })?; + + let thread_vm = vm.clone(); + std::thread::spawn(move || { + let write = JavaWrite::new(thread_vm.clone(), Arc::new(writable)); + write.write_slice(b"vortex")?; + write.flush_upcall()?; + // `with_jvm` attaches permanently: the thread stays attached until it + // exits, at which point the attachment is torn down automatically. + let attached = thread_vm + .is_thread_attached() + .map_err(|e| vortex_err!("is_thread_attached failed: {e}"))?; + assert!(attached, "write upcalls should leave the thread attached"); + VortexResult::Ok(()) + }) + .join() + .map_err(|_| vortex_err!("writer thread panicked"))??; + + let written = with_jvm(vm, |env| { + let array = env + .call_method( + content.as_ref(), + jni::jni_str!("toByteArray"), + jni::jni_sig!("()[B"), + &[], + )? + .l()?; + let array = env.cast_local::(array)?; + let mut bytes = vec![0i8; array.len(env)?]; + array.get_region(env, 0, &mut bytes)?; + Ok(bytes.into_iter().map(|b| b as u8).collect::>()) + })?; + assert_eq!(written, b"vortex"); + + drop(content); + assert_weak_ref_clears(vm, &weak) + } +} diff --git a/vortex-jni/src/lib.rs b/vortex-jni/src/lib.rs index a7d988c3c6a..c8369c28bff 100644 --- a/vortex-jni/src/lib.rs +++ b/vortex-jni/src/lib.rs @@ -23,6 +23,7 @@ mod dtype; mod errors; mod expression; mod file; +mod io; mod logging; mod object_store; mod runtime; diff --git a/vortex-jni/src/writer.rs b/vortex-jni/src/writer.rs index 309248fd73c..c1ffea2f14f 100644 --- a/vortex-jni/src/writer.rs +++ b/vortex-jni/src/writer.rs @@ -68,6 +68,7 @@ use crate::dtype::import_arrow_schema; use crate::errors::JNIError; use crate::errors::try_or_throw; use crate::file::extract_properties; +use crate::io::JavaWrite; use crate::object_store::make_object_store; use crate::session::session_ref; @@ -455,6 +456,61 @@ pub extern "system" fn Java_dev_vortex_jni_NativeWriter_create( }) } +/// Create a writer that streams the file into a caller-provided +/// `dev.vortex.io.NativeWritable` instead of a native storage client. +/// +/// Bytes are pushed through blocking `write`/`flush` upcalls on the runtime thread +/// driving the write task. The Java caller owns the underlying stream and must close +/// it after `NativeWriter.close` returns; the native side only flushes. +#[unsafe(no_mangle)] +pub extern "system" fn Java_dev_vortex_jni_NativeWriter_createStream( + mut env: EnvUnowned, + _class: JClass, + session_ptr: jlong, + writable: JObject, + arrow_schema_addr: jlong, +) -> jlong { + try_or_throw(&mut env, |env| { + if session_ptr == 0 { + throw_runtime!("null session pointer"); + } + if arrow_schema_addr == 0 { + throw_runtime!("null arrow schema address"); + } + if writable.is_null() { + throw_runtime!("null writable"); + } + let session = unsafe { session_ref(session_ptr) }; + + let arrow_schema = Arc::new(import_arrow_schema(arrow_schema_addr)?); + let write_schema = session.arrow().from_arrow_schema(arrow_schema.as_ref())?; + + let vm = env.get_java_vm()?; + let writable = Arc::new(env.new_global_ref(&writable)?); + let (tx, rx) = mpsc::channel(WRITE_CHANNEL_CAPACITY); + let stream = ArrayStreamAdapter::new(write_schema.clone(), rx); + let write_options = write_options_for_schema(session, &write_schema); + + let mut write = CountingVortexWrite::new(JavaWrite::new(vm, writable)); + let bytes_written = write.counter(); + let handle = session.handle().spawn(async move { + let summary = write_options.write(&mut write, stream).await?; + write.shutdown().await?; + Ok(summary) + }); + + Ok(Box::new(NativeWriter::new( + session.clone(), + arrow_schema, + write_schema, + bytes_written, + handle, + tx, + )) + .into_raw()) + }) +} + /// Write a batch to the Vortex file directly from Arrow C Data Interface pointers. #[unsafe(no_mangle)] pub extern "system" fn Java_dev_vortex_jni_NativeWriter_writeBatch( diff --git a/vortex-utils/src/aliases/hash_map.rs b/vortex-utils/src/aliases/hash_map.rs index 07047562b46..5937e2c1fc1 100644 --- a/vortex-utils/src/aliases/hash_map.rs +++ b/vortex-utils/src/aliases/hash_map.rs @@ -9,6 +9,8 @@ pub type RandomState = hashbrown::DefaultHashBuilder; pub type HashMap = hashbrown::HashMap; /// Entry type for HashMap. pub type Entry<'a, K, V, S> = hashbrown::hash_map::Entry<'a, K, V, S>; +/// Entry type for HashMap. +pub type EntryRef<'a, 'b, K, Q, V, S, A> = hashbrown::hash_map::EntryRef<'a, 'b, K, Q, V, S, A>; /// IntoIter type for HashMap. pub type IntoIter = hashbrown::hash_map::IntoIter; /// HashTable type alias. From f742e8a5d82dc2fa71ea3d4eac9025a084eb1337 Mon Sep 17 00:00:00 2001 From: Alexander Droste Date: Thu, 16 Jul 2026 17:03:43 +0100 Subject: [PATCH 096/104] feat(cuda): export FSST directly to Arrow varbin (#8787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Decode CUDA FSST arrays directly into Arrow’s standard offset-based `Utf8`/`Binary` layout: `i32` offsets plus one contiguous device values buffer. Previously, FSST decoded to `Utf8View`/`BinaryView`. Since cuDF does not consume view arrays, it then had to materialize the same offset-based representation itself. Direct varbin export avoids that additional conversion and enables BenchPress GPU reads for Vortex string columns. ## Behavior changes - CUDA sessions now default variable-length exports to standard Arrow `Utf8`/`Binary`. - Consumers that support views can opt in with: ```rust CudaSession::with_varbin_export_layout(VarBinExportLayout::VarBinView) --------- Signed-off-by: Alexander Droste --- vortex-cuda/benches/fsst_cuda.rs | 126 +++++- vortex-cuda/kernels/src/fsst.cu | 134 ++++-- vortex-cuda/src/arrow/canonical.rs | 530 +++++++++++++++++++---- vortex-cuda/src/arrow/mod.rs | 30 +- vortex-cuda/src/kernel/encodings/fsst.rs | 421 ++++++++++++++++-- vortex-cuda/src/kernel/encodings/mod.rs | 2 + vortex-cuda/src/lib.rs | 1 + vortex-cuda/src/session.rs | 23 + 8 files changed, 1091 insertions(+), 176 deletions(-) diff --git a/vortex-cuda/benches/fsst_cuda.rs b/vortex-cuda/benches/fsst_cuda.rs index 21db8744919..49930c9a30a 100644 --- a/vortex-cuda/benches/fsst_cuda.rs +++ b/vortex-cuda/benches/fsst_cuda.rs @@ -4,7 +4,6 @@ //! CUDA benchmarks for FSST decompression. #![expect(clippy::unwrap_used)] -#![expect(clippy::cast_possible_truncation)] #[allow(dead_code)] mod bench_config; @@ -18,12 +17,20 @@ use criterion::BenchmarkId; use criterion::Criterion; use criterion::Throughput; use futures::executor::block_on; +use vortex::array::ArrayRef; use vortex::array::IntoArray; use vortex::array::arrays::PrimitiveArray; use vortex::array::match_each_integer_ptype; +use vortex::dtype::DType; +use vortex::dtype::Nullability; +use vortex::encodings::fsst::FSST; use vortex::encodings::fsst::FSSTArrayExt; use vortex::error::VortexExpect; +use vortex_cuda::CudaDispatchMode; use vortex_cuda::CudaSession; +use vortex_cuda::VarBinExportLayout; +use vortex_cuda::arrow::DeviceArrayExt; +use vortex_cuda::arrow::release_device_array; use vortex_cuda::executor::CudaArrayExt; use vortex_cuda_macros::cuda_available; use vortex_cuda_macros::cuda_not_available; @@ -36,30 +43,63 @@ use crate::timed_launch_strategy::TimedLaunchStrategy; // other kernels benchmark. const BENCH_SIZES: &[(usize, &str)] = &[(10_000_000, "10M")]; +fn session_with_varbin_layout(layout: VarBinExportLayout) -> vortex::session::VortexSession { + vortex::array::array_session().with_some( + CudaSession::try_default() + .vortex_expect("failed to create CUDA session") + .with_varbin_export_layout(layout), + ) +} + +struct FSSTBenchFixture { + utf8: ArrayRef, + binary: ArrayRef, + uncompressed_size: u64, +} + +fn make_fixture(n: usize) -> FSSTBenchFixture { + let mut setup_ctx = CudaSession::create_execution_ctx(&vortex_cuda::cuda_session()) + .vortex_expect("failed to create execution context"); + let fsst = make_fsst_clickbench_urls(n, setup_ctx.execution_ctx()); + + let lens = fsst + .uncompressed_lengths() + .clone() + .execute::(setup_ctx.execution_ctx()) + .vortex_expect("canonicalize uncompressed_lengths"); + #[allow(clippy::unnecessary_cast)] + let uncompressed_size = match_each_integer_ptype!(lens.ptype(), |P| { + lens.as_slice::

().iter().map(|x| *x as u64).sum() + }); + + let binary = FSST::try_new( + DType::Binary(Nullability::NonNullable), + fsst.symbols().clone(), + fsst.symbol_lengths().clone(), + fsst.codes(), + fsst.uncompressed_lengths().clone(), + setup_ctx.execution_ctx(), + ) + .vortex_expect("rebuild FSST fixture with Binary dtype") + .into_array(); + + FSSTBenchFixture { + utf8: fsst.into_array(), + binary, + uncompressed_size, + } +} + fn benchmark_fsst_cuda_decompress(c: &mut Criterion) { let mut group = c.benchmark_group("cuda"); for &(n, len_str) in BENCH_SIZES { - let mut setup_ctx = CudaSession::create_execution_ctx(&vortex_cuda::cuda_session()) - .vortex_expect("failed to create execution context"); - let fsst = make_fsst_clickbench_urls(n, setup_ctx.execution_ctx()); - - let lens = fsst - .uncompressed_lengths() - .clone() - .execute::(setup_ctx.execution_ctx()) - .vortex_expect("canonicalize uncompressed_lengths"); - let total_size: usize = match_each_integer_ptype!(lens.ptype(), |P| { - lens.as_slice::

().iter().map(|x| *x as usize).sum() - }); - let uncompressed_size = total_size as u64; - - let fsst_array = fsst.into_array(); - - group.throughput(Throughput::Bytes(uncompressed_size)); + let fixture = make_fixture(n); + + group.throughput(Throughput::Bytes(fixture.uncompressed_size)); group.bench_with_input( - BenchmarkId::new("cuda/fsst/decompress", len_str), - &fsst_array, + BenchmarkId::new("cuda/fsst/decompress_to_varbinview", len_str), + &fixture.utf8, |b, fsst_array| { b.iter_custom(|iters| { let timed = TimedLaunchStrategy::default(); @@ -68,6 +108,7 @@ fn benchmark_fsst_cuda_decompress(c: &mut Criterion) { let mut cuda_ctx = CudaSession::create_execution_ctx(&vortex_cuda::cuda_session()) .vortex_expect("failed to create execution context") + .with_dispatch_mode(CudaDispatchMode::StandaloneOnly) .with_launch_strategy(Arc::new(timed)); for _ in 0..iters { @@ -77,6 +118,51 @@ fn benchmark_fsst_cuda_decompress(c: &mut Criterion) { }); }, ); + + for (name, array, layout) in [ + ( + "cuda/fsst/decompress_to_varbin", + &fixture.utf8, + VarBinExportLayout::VarBin, + ), + ( + "cuda/fsst/export_binary", + &fixture.binary, + VarBinExportLayout::VarBin, + ), + ( + "cuda/fsst/export_utf8_view", + &fixture.utf8, + VarBinExportLayout::VarBinView, + ), + ( + "cuda/fsst/export_binary_view", + &fixture.binary, + VarBinExportLayout::VarBinView, + ), + ] { + group.bench_with_input(BenchmarkId::new(name, len_str), array, |b, array| { + b.iter_custom(|iters| { + let timed = TimedLaunchStrategy::default(); + let timer = timed.timer(); + + let session = session_with_varbin_layout(layout); + let mut cuda_ctx = CudaSession::create_execution_ctx(&session) + .vortex_expect("failed to create execution context") + .with_dispatch_mode(CudaDispatchMode::StandaloneOnly) + .with_launch_strategy(Arc::new(timed)); + + for _ in 0..iters { + let mut exported = + block_on((*array).clone().export_device_array(&mut cuda_ctx)) + .vortex_expect("export FSST device array"); + release_device_array(&mut exported); + } + + Duration::from_nanos(timer.load(Ordering::Relaxed)) + }); + }); + } } group.finish(); diff --git a/vortex-cuda/kernels/src/fsst.cu b/vortex-cuda/kernels/src/fsst.cu index 938f262c3da..22e8790a479 100644 --- a/vortex-cuda/kernels/src/fsst.cu +++ b/vortex-cuda/kernels/src/fsst.cu @@ -53,7 +53,9 @@ // the next add (≤ 8 bytes) within the 24-byte capacity. // // `codes_offsets` is templated over the four unsigned integer widths -// (u8/u16/u32/u64). `output_offsets` is uint64_t. +// (u8/u16/u32/u64). `output_offsets` is uint64_t for the view kernels +// (`fsst_*`, which also take an optional views pointer) and int32_t Arrow +// varbin offsets for the bytes-only varbin kernels (`fsst_varbin_*`). // 24-byte scratch buffer split across three u64 lanes. `cursor` is the // number of bytes currently buffered and the next-push offset. @@ -123,13 +125,18 @@ struct Scratch { } }; -template +// Arrow/Vortex variable-length view records are 16 bytes. Values up to 12 +// bytes are stored inline after the u32 length. Longer values store their +// first four bytes, backing-buffer index, and byte offset. +constexpr uint32_t MAX_INLINED_SIZE = 12; + +template struct FSSTArgs { // Compressed FSST code stream, contiguous across all strings. String // `sid`'s codes live in `[codes_offsets[sid], codes_offsets[sid + 1])`. const uint8_t *__restrict codes_bytes; // Per-string offsets into `codes_bytes`, length `num_strings + 1`. - const OffT *__restrict codes_offsets; + const CodeOffsetT *__restrict codes_offsets; // FSST symbol table. const uint64_t *__restrict symbols; // Length in bytes (1..=8) of each entry in `symbols`. The remaining bits @@ -138,19 +145,57 @@ struct FSSTArgs { // Buffer to write decoded data into. uint8_t *__restrict output_bytes; // Per-string offsets into `output_bytes`, length `num_strings + 1`. - const uint64_t *__restrict output_offsets; + const OutputOffsetT *__restrict output_offsets; // Validity of each string. const uint8_t *__restrict validity_bits; + // Bit offset of string zero within the first validity byte (0..7). + uint64_t validity_bit_offset; + // Optional output views, one 16-byte uint4 per string. A null pointer + // requests bytes-only decoding for heaps that need the host rollover path. + uint4 *__restrict output_views; }; -template -__device__ inline void fsst_decode_string(const FSSTArgs &args, uint64_t sid) { - if (((args.validity_bits[sid >> 3] >> (sid & 7u)) & 1u) == 0u) { +// Build one BinaryView from the bytes this thread just decoded. The Rust +// caller only provides output_views when every offset fits in the view's u32 +// fields and the decoded heap is exposed as backing buffer zero. +template +__device__ inline void fsst_write_view(const FSSTArgs &args, uint64_t sid) { + if (args.output_views == nullptr) { return; } - OffT in_pos = args.codes_offsets[sid]; - const OffT in_end = args.codes_offsets[sid + 1]; + const uint64_t start = args.output_offsets[sid]; + const uint32_t len = (uint32_t)(args.output_offsets[sid + 1] - start); + if (len <= MAX_INLINED_SIZE) { + uint32_t words[3] = {0, 0, 0}; +#pragma unroll + for (uint32_t i = 0; i < MAX_INLINED_SIZE; i++) { + if (i < len) { + words[i >> 2] |= (uint32_t)args.output_bytes[start + i] << (8u * (i & 3u)); + } + } + args.output_views[sid] = make_uint4(len, words[0], words[1], words[2]); + return; + } + + const uint32_t prefix = + (uint32_t)args.output_bytes[start] | ((uint32_t)args.output_bytes[start + 1] << 8u) | + ((uint32_t)args.output_bytes[start + 2] << 16u) | ((uint32_t)args.output_bytes[start + 3] << 24u); + args.output_views[sid] = make_uint4(len, prefix, 0, (uint32_t)start); +} + +template +__device__ inline void fsst_decode_string(const FSSTArgs &args, uint64_t sid) { + const uint64_t validity_index = sid + args.validity_bit_offset; + if (((args.validity_bits[validity_index >> 3] >> (validity_index & 7u)) & 1u) == 0u) { + if (args.output_views != nullptr) { + args.output_views[sid] = make_uint4(0, 0, 0, 0); + } + return; + } + + CodeOffsetT in_pos = args.codes_offsets[sid]; + const CodeOffsetT in_end = args.codes_offsets[sid + 1]; uint64_t out_pos = args.output_offsets[sid]; const uint64_t out_end = args.output_offsets[sid + 1]; @@ -181,25 +226,38 @@ __device__ inline void fsst_decode_string(const FSSTArgs &args, uint64_t s sym &= mask; scratch.push(sym, len); - in_pos += (OffT)consumed; + in_pos += (CodeOffsetT)consumed; } // Epilogue: drain everything that's left. while (scratch.cursor > 0) { scratch.drain(args.output_bytes, out_pos, out_end); } + + fsst_write_view(args, sid); } -#define GENERATE_FSST_KERNEL(suffix, OffT) \ +#define FSST_GRID_STRIDE_LOOP(CodeOffsetT, OutputOffsetT, args) \ + const uint64_t elements_per_block = (uint64_t)blockDim.x * ELEMENTS_PER_THREAD; \ + const uint64_t block_start = (uint64_t)blockIdx.x * elements_per_block; \ + const uint64_t block_end = \ + (block_start + elements_per_block < num_strings) ? (block_start + elements_per_block) : num_strings; \ + for (uint64_t sid = block_start + threadIdx.x; sid < block_end; sid += blockDim.x) { \ + fsst_decode_string(args, sid); \ + } + +#define GENERATE_FSST_VIEW_KERNEL(suffix, CodeOffsetT) \ extern "C" __global__ void fsst_##suffix(const uint8_t *__restrict codes_bytes, \ - const OffT *__restrict codes_offsets, \ + const CodeOffsetT *__restrict codes_offsets, \ const uint64_t *__restrict symbols, \ const uint8_t *__restrict symbol_lengths, \ const uint64_t *__restrict output_offsets, \ const uint8_t *__restrict validity_bits, \ + uint64_t validity_bit_offset, \ uint8_t *__restrict output_bytes, \ + uint4 *__restrict output_views, \ uint64_t num_strings) { \ - const FSSTArgs args = { \ + const FSSTArgs args = { \ codes_bytes, \ codes_offsets, \ symbols, \ @@ -207,20 +265,42 @@ __device__ inline void fsst_decode_string(const FSSTArgs &args, uint64_t s output_bytes, \ output_offsets, \ validity_bits, \ + validity_bit_offset, \ + output_views, \ }; \ - \ - const uint64_t elements_per_block = (uint64_t)blockDim.x * ELEMENTS_PER_THREAD; \ - const uint64_t block_start = (uint64_t)blockIdx.x * elements_per_block; \ - const uint64_t block_end = (block_start + elements_per_block < num_strings) \ - ? (block_start + elements_per_block) \ - : num_strings; \ - \ - for (uint64_t sid = block_start + threadIdx.x; sid < block_end; sid += blockDim.x) { \ - fsst_decode_string(args, sid); \ - } \ + FSST_GRID_STRIDE_LOOP(CodeOffsetT, uint64_t, args) \ } -GENERATE_FSST_KERNEL(u8, uint8_t) -GENERATE_FSST_KERNEL(u16, uint16_t) -GENERATE_FSST_KERNEL(u32, uint32_t) -GENERATE_FSST_KERNEL(u64, uint64_t) +#define GENERATE_FSST_VARBIN_KERNEL(suffix, CodeOffsetT) \ + extern "C" __global__ void fsst_varbin_##suffix(const uint8_t *__restrict codes_bytes, \ + const CodeOffsetT *__restrict codes_offsets, \ + const uint64_t *__restrict symbols, \ + const uint8_t *__restrict symbol_lengths, \ + const int32_t *__restrict output_offsets, \ + const uint8_t *__restrict validity_bits, \ + uint64_t validity_bit_offset, \ + uint8_t *__restrict output_bytes, \ + uint64_t num_strings) { \ + const FSSTArgs args = { \ + codes_bytes, \ + codes_offsets, \ + symbols, \ + symbol_lengths, \ + output_bytes, \ + output_offsets, \ + validity_bits, \ + validity_bit_offset, \ + nullptr, \ + }; \ + FSST_GRID_STRIDE_LOOP(CodeOffsetT, int32_t, args) \ + } + +GENERATE_FSST_VIEW_KERNEL(u8, uint8_t) +GENERATE_FSST_VIEW_KERNEL(u16, uint16_t) +GENERATE_FSST_VIEW_KERNEL(u32, uint32_t) +GENERATE_FSST_VIEW_KERNEL(u64, uint64_t) + +GENERATE_FSST_VARBIN_KERNEL(u8, uint8_t) +GENERATE_FSST_VARBIN_KERNEL(u16, uint16_t) +GENERATE_FSST_VARBIN_KERNEL(u32, uint32_t) +GENERATE_FSST_VARBIN_KERNEL(u64, uint64_t) diff --git a/vortex-cuda/src/arrow/canonical.rs b/vortex-cuda/src/arrow/canonical.rs index af141bfe548..66c50a0413c 100644 --- a/vortex-cuda/src/arrow/canonical.rs +++ b/vortex-cuda/src/arrow/canonical.rs @@ -54,6 +54,8 @@ use vortex::dtype::NativeDecimalType; use vortex::dtype::Nullability; use vortex::dtype::PType; use vortex::dtype::i256; +use vortex::encodings::fsst::FSST; +use vortex::encodings::fsst::FSSTArray; use vortex::error::VortexResult; use vortex::error::vortex_bail; use vortex::error::vortex_ensure; @@ -63,6 +65,7 @@ use vortex::extension::datetime::AnyTemporal; use crate::CudaBufferExt; use crate::CudaDeviceBuffer; use crate::CudaExecutionCtx; +use crate::VarBinExportLayout; use crate::arrow::ARROW_DEVICE_CUDA; use crate::arrow::ArrowArray; use crate::arrow::ArrowDeviceArray; @@ -78,6 +81,8 @@ use crate::cub::exclusive_sum_i32; use crate::device_buffer::CUDF_VALIDITY_BUFFER_PADDING; use crate::executor::CudaArrayExt; use crate::executor::execute_validity_cuda; +use crate::kernel::FSSTVarBin; +use crate::kernel::decode_fsst_varbin; /// An implementation of `ExportDeviceArray` that exports Vortex arrays to `ArrowDeviceArray` by /// first decoding the array on the GPU and then converting the canonical type to the nearest @@ -220,6 +225,15 @@ fn export_array( Ok(list_view) => return export_list_view(list_view, ctx).await, Err(array) => array, }; + // The offset-based FSST export always uses the standalone varbin kernel; + // `CudaDispatchMode` only governs `execute_cuda`'s fused-vs-standalone planning. + let array = match array.try_downcast::() { + Ok(fsst) if ctx.cuda_session().varbin_export_layout() == VarBinExportLayout::VarBin => { + return export_fsst_varbin(fsst, ctx).await; + } + Ok(fsst) => fsst.into_array(), + Err(array) => array, + }; let cuda_array = array.execute_cuda(ctx).await?; export_canonical(cuda_array, ctx).await @@ -303,60 +317,10 @@ fn export_canonical( export_fixed_size_list(fixed_size_list, ctx).await } Canonical::VarBinView(varbinview) => { - if matches!(varbinview.dtype(), DType::Binary(_)) { - return export_binary(varbinview, ctx).await; + if ctx.cuda_session().varbin_export_layout() == VarBinExportLayout::VarBin { + return export_varbin(varbinview, ctx).await; } - - let len = varbinview.len(); - let VarBinViewDataParts { - views, - buffers: data_buffers, - validity, - .. - } = varbinview.into_data_parts(); - - let (validity_buffer, null_count) = - export_arrow_validity_buffer(validity, len, 0, ctx).await?; - - let views = ctx.ensure_on_device(views).await?; - let mut buffers = Vec::with_capacity(data_buffers.len() + 3); - buffers.push(validity_buffer); - buffers.push(Some(views)); - for buffer in data_buffers.iter() { - buffers.push(Some(ctx.ensure_on_device(buffer.clone()).await?)); - } - // Nanoarrow's Utf8View/BinaryView C layout stores the variadic data buffer sizes - // as the final buffer slot, after the null bitmap, views, and data buffers. - let variadic_buffer_sizes = data_buffers - .iter() - .map(|buffer| i64::try_from(buffer.len())) - .collect::, _>>()?; - buffers.push(Some( - ctx.ensure_on_device(BufferHandle::new_host( - Buffer::from(variadic_buffer_sizes).into_byte_buffer(), - )) - .await?, - )); - - let n_buffers = i64::try_from(buffers.len())?; - let mut private_data = PrivateData::new(buffers, vec![], ctx)?; - let sync_event = private_data.sync_event(); - let arrow_array = ArrowArray { - length: len as i64, - null_count, - offset: 0, - // Arrow Utf8View/BinaryView layout: optional null bitmap, views, data buffers, - // and trailing variadic buffer sizes. - n_buffers, - buffers: private_data.buffer_ptrs.as_mut_ptr(), - n_children: 0, - children: ptr::null_mut(), - release: Some(release_array), - dictionary: ptr::null_mut(), - private_data: Box::into_raw(private_data).cast(), - }; - - Ok((arrow_array, sync_event)) + export_varbinview(varbinview, ctx).await } c => vortex_bail!("unsupported Arrow Device export for {} array", c.dtype()), } @@ -541,8 +505,64 @@ where Ok(BufferHandle::new_device(Arc::new(output_device))) } -/// Export Vortex binary views as an Arrow Device array with standard `Binary` layout. -async fn export_binary( +/// Export Vortex binary views as an Arrow Device array with `Utf8View`/`BinaryView` layout. +async fn export_varbinview( + varbinview: VarBinViewArray, + ctx: &mut CudaExecutionCtx, +) -> VortexResult<(ArrowArray, SyncEvent)> { + let len = varbinview.len(); + let VarBinViewDataParts { + views, + buffers: data_buffers, + validity, + .. + } = varbinview.into_data_parts(); + + let (validity_buffer, null_count) = export_arrow_validity_buffer(validity, len, 0, ctx).await?; + + let views = ctx.ensure_on_device(views).await?; + let mut buffers = Vec::with_capacity(data_buffers.len() + 3); + buffers.push(validity_buffer); + buffers.push(Some(views)); + for buffer in data_buffers.iter() { + buffers.push(Some(ctx.ensure_on_device(buffer.clone()).await?)); + } + // Nanoarrow's Utf8View/BinaryView C layout stores the variadic data buffer sizes + // as the final buffer slot, after the null bitmap, views, and data buffers. + let variadic_buffer_sizes = data_buffers + .iter() + .map(|buffer| i64::try_from(buffer.len())) + .collect::, _>>()?; + buffers.push(Some( + ctx.ensure_on_device(BufferHandle::new_host( + Buffer::from(variadic_buffer_sizes).into_byte_buffer(), + )) + .await?, + )); + + let n_buffers = i64::try_from(buffers.len())?; + let mut private_data = PrivateData::new(buffers, vec![], ctx)?; + let sync_event = private_data.sync_event(); + let arrow_array = ArrowArray { + length: len as i64, + null_count, + offset: 0, + // Arrow Utf8View/BinaryView layout: optional null bitmap, views, data buffers, + // and trailing variadic buffer sizes. + n_buffers, + buffers: private_data.buffer_ptrs.as_mut_ptr(), + n_children: 0, + children: ptr::null_mut(), + release: Some(release_array), + dictionary: ptr::null_mut(), + private_data: Box::into_raw(private_data).cast(), + }; + + Ok((arrow_array, sync_event)) +} + +/// Export Vortex binary views as an Arrow Device array with standard `Utf8`/`Binary` layout. +async fn export_varbin( varbinview: VarBinViewArray, ctx: &mut CudaExecutionCtx, ) -> VortexResult<(ArrowArray, SyncEvent)> { @@ -558,16 +578,44 @@ async fn export_binary( let views = ctx.ensure_on_device(views).await?; let (offsets, values) = export_binary_buffers(&views, &data_buffers, validity_buffer.as_ref(), len, ctx).await?; + export_varbin_buffers(len, validity_buffer, null_count, offsets, values, ctx) +} - let buffers = vec![validity_buffer, Some(offsets), Some(values)]; +async fn export_fsst_varbin( + fsst: FSSTArray, + ctx: &mut CudaExecutionCtx, +) -> VortexResult<(ArrowArray, SyncEvent)> { + let FSSTVarBin { + dtype, + len, + offsets, + values, + validity, + } = decode_fsst_varbin(fsst, ctx).await?; + vortex_ensure!( + matches!(dtype, DType::Utf8(_) | DType::Binary(_)), + "FSST produced invalid variable-length dtype {dtype}" + ); + let (validity_buffer, null_count) = export_arrow_validity_buffer(validity, len, 0, ctx).await?; + export_varbin_buffers(len, validity_buffer, null_count, offsets, values, ctx) +} +fn export_varbin_buffers( + len: usize, + validity_buffer: Option, + null_count: i64, + offsets: BufferHandle, + values: BufferHandle, + ctx: &mut CudaExecutionCtx, +) -> VortexResult<(ArrowArray, SyncEvent)> { + let buffers = vec![validity_buffer, Some(offsets), Some(values)]; let mut private_data = PrivateData::new(buffers, vec![], ctx)?; let sync_event = private_data.sync_event(); let arrow_array = ArrowArray { length: len as i64, null_count, offset: 0, - // Arrow Binary layout: optional null bitmap, i32 offsets, contiguous bytes. + // Arrow Utf8/Binary layout: optional null bitmap, i32 offsets, contiguous bytes. n_buffers: 3, buffers: private_data.buffer_ptrs.as_mut_ptr(), n_children: 0, @@ -1393,6 +1441,7 @@ mod tests { use vortex::array::ArrayRef; use vortex::array::IntoArray; use vortex::array::arrays::BoolArray; + use vortex::array::arrays::ChunkedArray; use vortex::array::arrays::DecimalArray; use vortex::array::arrays::DictArray; use vortex::array::arrays::FixedSizeListArray; @@ -1402,6 +1451,7 @@ mod tests { use vortex::array::arrays::PrimitiveArray; use vortex::array::arrays::StructArray; use vortex::array::arrays::TemporalArray; + use vortex::array::arrays::VarBinArray; use vortex::array::arrays::VarBinViewArray; use vortex::array::arrays::primitive::PrimitiveArrayExt; use vortex::array::arrays::varbinview::BinaryView; @@ -1419,6 +1469,10 @@ mod tests { use vortex::dtype::PType; use vortex::dtype::half::f16; use vortex::dtype::i256; + use vortex::encodings::fsst::FSST; + use vortex::encodings::fsst::FSSTArrayExt; + use vortex::encodings::fsst::fsst_compress; + use vortex::encodings::fsst::fsst_train_compressor; use vortex::error::VortexExpect; use vortex::error::VortexResult; use vortex::error::vortex_bail; @@ -1431,11 +1485,13 @@ mod tests { use crate::arrow::ArrowDeviceArray; use crate::arrow::DeviceArrayExt; use crate::arrow::PrivateData; + use crate::arrow::arrow_schema_for_array; use crate::arrow::canonical::export_arrow_validity_buffer; use crate::arrow::canonical::repack_arrow_validity_buffer; use crate::device_buffer::CUDF_VALIDITY_BUFFER_PADDING; use crate::device_buffer::cuda_backing_allocation; use crate::session::CudaSession; + use crate::session::VarBinExportLayout; unsafe fn release_exported_array(array: *mut ArrowArray) { unsafe { @@ -1445,6 +1501,58 @@ mod tests { } } + fn cuda_ctx_with_varbin_layout(layout: VarBinExportLayout) -> VortexResult { + let session = vortex::array::array_session() + .with_some(CudaSession::try_default()?.with_varbin_export_layout(layout)); + CudaSession::create_execution_ctx(&session) + } + + // Compress values into a concrete FSST array so exports take the direct FSST path. + fn fsst_array_from( + values: &[Option<&'static [u8]>], + dtype: DType, + ctx: &mut CudaExecutionCtx, + ) -> VortexResult { + let varbin = VarBinArray::from_iter(values.iter().copied(), dtype).into_array(); + let compressor = fsst_train_compressor(&varbin, ctx.execution_ctx())?; + Ok(fsst_compress(&varbin, &compressor, ctx.execution_ctx())?.into_array()) + } + + // Assert an exported varbin array against the logical values: offsets are the prefix sum + // of lengths (nulls contribute zero), values are the non-null bytes concatenated, and the + // Arrow null bitmap marks exactly the null slots. + fn assert_varbin_contents( + array: &ArrowArray, + values: &[Option<&'static [u8]>], + ) -> VortexResult<()> { + let mut expected_offsets = vec![0i32]; + let mut expected_values = Vec::new(); + for value in values { + if let Some(value) = value { + expected_values.extend_from_slice(value); + } + expected_offsets.push(i32::try_from(expected_values.len())?); + } + let null_count = values.iter().filter(|value| value.is_none()).count(); + + assert_binary_layout( + array, + i64::try_from(values.len())?, + i64::try_from(null_count)?, + &expected_offsets, + &expected_values, + )?; + + if null_count > 0 { + let bitmap = private_data_buffer_bytes(array, 0)?; + for (index, value) in values.iter().enumerate() { + let bit = (bitmap.as_ref()[index / 8] >> (index % 8)) & 1; + assert_eq!(bit == 1, value.is_some(), "validity bit {index}"); + } + } + Ok(()) + } + // Assert Arrow Device metadata that consumers use before reading buffers. fn assert_device_metadata( device_array: &ArrowDeviceArray, @@ -1812,7 +1920,7 @@ mod tests { field, Field::new( "", - DataType::Dictionary(Box::new(DataType::Int16), Box::new(DataType::Utf8View)), + DataType::Dictionary(Box::new(DataType::Int16), Box::new(DataType::Utf8)), true, ) ); @@ -1833,7 +1941,13 @@ mod tests { ); let dictionary = unsafe { &*exported.array.array.dictionary }; - assert_varbinview_layout(dictionary, 2, 0, &[out_of_line.len()])?; + assert_binary_layout( + dictionary, + 2, + 0, + &[0, 5, i32::try_from(5 + out_of_line.len())?], + ["alpha", out_of_line].concat().as_bytes(), + )?; unsafe { release_exported_array(&raw mut exported.array.array) }; Ok(()) @@ -1894,7 +2008,7 @@ mod tests { "", DataType::Struct(Fields::from(vec![Field::new( "dict", - DataType::Dictionary(Box::new(DataType::Int16), Box::new(DataType::Utf8View)), + DataType::Dictionary(Box::new(DataType::Int16), Box::new(DataType::Utf8)), true, )])), false, @@ -2182,9 +2296,8 @@ mod tests { } #[crate::test] - async fn test_export_varbinview() -> VortexResult<()> { - let mut ctx = CudaSession::create_execution_ctx(&crate::cuda_session()) - .vortex_expect("failed to create execution context"); + async fn test_export_varbinview_opt_in() -> VortexResult<()> { + let mut ctx = cuda_ctx_with_varbin_layout(VarBinExportLayout::VarBinView)?; let out_of_line = "this is a longer string for out-of-line storage"; let array = VarBinViewArray::from_iter_str(["hello", "world", out_of_line]).into_array(); @@ -2345,6 +2458,230 @@ mod tests { Ok(()) } + // Regression test: when the export schema is derived from the dtype alone (the top-level + // array is not one of the concretely-handled encodings), list element fields must still + // reflect the session's varbin export layout, or consumers would read the offset-based + // element data through a view-typed schema. + #[rstest] + #[case::varbin(VarBinExportLayout::VarBin, DataType::Utf8, 3)] + #[case::varbin_view(VarBinExportLayout::VarBinView, DataType::Utf8View, 4)] + #[crate::test] + async fn test_export_chunked_list_utf8_element_matches_schema( + #[case] layout: VarBinExportLayout, + #[case] expected_element_type: DataType, + #[case] expected_element_n_buffers: i64, + ) -> VortexResult<()> { + let mut ctx = cuda_ctx_with_varbin_layout(layout)?; + + let elements = VarBinViewArray::from_iter_str([ + "hello", + "world", + "this is a longer string for out-of-line storage", + ]) + .into_array(); + let chunk = ListArray::try_new( + elements, + PrimitiveArray::from_iter([0i32, 2, 3]).into_array(), + Validity::NonNullable, + )? + .into_array(); + let dtype = chunk.dtype().clone(); + let chunked = ChunkedArray::try_new(vec![chunk], dtype)?.into_array(); + + let mut exported = chunked.export_device_array_with_schema(&mut ctx).await?; + + let field = Field::try_from(&exported.schema)?; + let DataType::List(element_field) = field.data_type() else { + vortex_bail!("expected List schema, got {:?}", field.data_type()); + }; + assert_eq!(element_field.data_type(), &expected_element_type); + + assert_eq!(exported.array.array.n_children, 1); + let children = unsafe { std::slice::from_raw_parts(exported.array.array.children, 1) }; + let element_array = unsafe { &*children[0] }; + assert_eq!(element_array.n_buffers, expected_element_n_buffers); + + unsafe { release_exported_array(&raw mut exported.array.array) }; + Ok(()) + } + + // Standard Arrow Utf8/Binary uses i32 offsets. Oversized FSST exports keep that stable schema + // and return a clear error rather than changing layout based on batch contents. + #[crate::test] + async fn test_oversized_fsst_varbin_export_errors() -> VortexResult<()> { + let mut ctx = cuda_ctx_with_varbin_layout(VarBinExportLayout::VarBin)?; + + let varbin = VarBinArray::from_iter( + [Some(&b"short"[..]), Some(&b"another value"[..])], + DType::Utf8(Nullability::NonNullable), + ) + .into_array(); + let compressor = fsst_train_compressor(&varbin, ctx.execution_ctx())?; + let fsst = fsst_compress(&varbin, &compressor, ctx.execution_ctx())?; + + // Same codes, but uncompressed lengths whose sum exceeds i32::MAX. + let oversized = FSST::try_new( + DType::Utf8(Nullability::NonNullable), + fsst.symbols().clone(), + fsst.symbol_lengths().clone(), + fsst.codes(), + PrimitiveArray::from_iter([i32::MAX, i32::MAX]).into_array(), + ctx.execution_ctx(), + )? + .into_array(); + let schema = arrow_schema_for_array(&oversized, &mut ctx)?; + assert_eq!( + Field::try_from(&schema)?, + Field::new("", DataType::Utf8, false) + ); + + let error = oversized + .export_device_array(&mut ctx) + .await + .err() + .vortex_expect("oversized FSST varbin export must fail"); + assert!( + error + .to_string() + .contains("FSST decoded size exceeds Arrow i32 offset range") + ); + Ok(()) + } + + // Content coverage for the direct FSST varbin export: offsets, values, and null bitmap, + // not just schema shape. + #[rstest] + #[case::utf8_inline_and_outlined( + vec![Some(&b""[..]), + Some(&b"short"[..]), + Some(&b"this value is stored out of line in the heap"[..])], + DType::Utf8(Nullability::NonNullable), + )] + #[case::partial_nulls( + vec![Some(&b"alpha"[..]), None, Some(&b"gamma"[..]), None, Some(&b"epsilon"[..])], + DType::Utf8(Nullability::Nullable), + )] + #[case::all_nulls( + vec![None, None, None, None, None], + DType::Binary(Nullability::Nullable), + )] + #[case::all_empty( + vec![Some(&b""[..]), Some(&b""[..]), Some(&b""[..])], + DType::Binary(Nullability::NonNullable), + )] + #[case::empty(vec![], DType::Utf8(Nullability::NonNullable))] + #[crate::test] + async fn test_export_fsst_varbin_contents( + #[case] values: Vec>, + #[case] dtype: DType, + ) -> VortexResult<()> { + let mut ctx = cuda_ctx_with_varbin_layout(VarBinExportLayout::VarBin)?; + let fsst = fsst_array_from(&values, dtype.clone(), &mut ctx)?; + + let mut exported = fsst.export_device_array_with_schema(&mut ctx).await?; + let expected_data_type = if matches!(dtype, DType::Utf8(_)) { + DataType::Utf8 + } else { + DataType::Binary + }; + assert_eq!( + Field::try_from(&exported.schema)?, + Field::new("", expected_data_type, dtype.is_nullable()) + ); + assert_varbin_contents(&exported.array.array, &values)?; + + unsafe { release_exported_array(&raw mut exported.array.array) }; + Ok(()) + } + + // A sliced FSST array keeps its encoding, so the direct varbin export must respect the + // slice's codes offsets and validity. + #[crate::test] + async fn test_export_sliced_fsst_varbin() -> VortexResult<()> { + let mut ctx = cuda_ctx_with_varbin_layout(VarBinExportLayout::VarBin)?; + let values: &[Option<&'static [u8]>] = &[ + Some(&b"alpha"[..]), + Some(&b"this value is stored out of line in the heap"[..]), + None, + Some(&b"delta"[..]), + Some(&b"echo"[..]), + ]; + let fsst = fsst_array_from(values, DType::Utf8(Nullability::Nullable), &mut ctx)?; + let sliced = fsst.slice(1..4)?; + assert!(sliced.as_opt::().is_some()); + + let mut exported = sliced.export_device_array_with_schema(&mut ctx).await?; + assert_eq!( + Field::try_from(&exported.schema)?, + Field::new("", DataType::Utf8, true) + ); + assert_varbin_contents(&exported.array.array, &values[1..4])?; + + unsafe { release_exported_array(&raw mut exported.array.array) }; + Ok(()) + } + + // FSST fields inside a struct take the direct varbin export path through the child + // recursion, and the struct schema reflects the offset-based layout. + #[crate::test] + async fn test_export_struct_with_fsst_field() -> VortexResult<()> { + let mut ctx = cuda_ctx_with_varbin_layout(VarBinExportLayout::VarBin)?; + let values: &[Option<&'static [u8]>] = &[ + Some(&b"short"[..]), + Some(&b"this value is stored out of line in the heap"[..]), + ]; + let fsst = fsst_array_from(values, DType::Utf8(Nullability::NonNullable), &mut ctx)?; + let array = StructArray::new( + FieldNames::from_iter(["s"]), + vec![fsst], + values.len(), + Validity::NonNullable, + ) + .into_array(); + + let mut exported = array.export_device_array_with_schema(&mut ctx).await?; + assert_eq!( + Schema::try_from(&exported.schema)?, + Schema::new(vec![Field::new("s", DataType::Utf8, false)]) + ); + assert_eq!(exported.array.array.n_children, 1); + let children = unsafe { std::slice::from_raw_parts(exported.array.array.children, 1) }; + assert_varbin_contents(unsafe { &*children[0] }, values)?; + + unsafe { release_exported_array(&raw mut exported.array.array) }; + Ok(()) + } + + // FSST dictionary values take the direct varbin export path, and the dictionary schema + // reflects the offset-based layout. + #[crate::test] + async fn test_export_dict_with_fsst_values() -> VortexResult<()> { + let mut ctx = cuda_ctx_with_varbin_layout(VarBinExportLayout::VarBin)?; + let values: &[Option<&'static [u8]>] = &[ + Some(&b"alpha"[..]), + Some(&b"this dictionary value is stored out of line"[..]), + ]; + let fsst = fsst_array_from(values, DType::Utf8(Nullability::NonNullable), &mut ctx)?; + let array = DictArray::try_new(PrimitiveArray::from_iter([0u8, 1, 0]).into_array(), fsst)? + .into_array(); + + let mut exported = array.export_device_array_with_schema(&mut ctx).await?; + assert_eq!( + Field::try_from(&exported.schema)?, + Field::new( + "", + DataType::Dictionary(Box::new(DataType::Int16), Box::new(DataType::Utf8)), + false, + ) + ); + assert!(!exported.array.array.dictionary.is_null()); + let dictionary = unsafe { &*exported.array.array.dictionary }; + assert_varbin_contents(dictionary, values)?; + + unsafe { release_exported_array(&raw mut exported.array.array) }; + Ok(()) + } + #[crate::test] async fn test_export_host_contiguous_list_view() -> VortexResult<()> { let mut ctx = CudaSession::create_execution_ctx(&crate::cuda_session()) @@ -2561,27 +2898,38 @@ mod tests { #[rstest] #[case::utf8( multi_buffer_varbinview(DType::Utf8(Nullability::NonNullable)), - DataType::Utf8View + VarBinExportLayout::VarBin, + DataType::Utf8 )] #[case::binary( multi_buffer_varbinview(DType::Binary(Nullability::NonNullable)), + VarBinExportLayout::VarBin, DataType::Binary )] + #[case::utf8_view( + multi_buffer_varbinview(DType::Utf8(Nullability::NonNullable)), + VarBinExportLayout::VarBinView, + DataType::Utf8View + )] + #[case::binary_view( + multi_buffer_varbinview(DType::Binary(Nullability::NonNullable)), + VarBinExportLayout::VarBinView, + DataType::BinaryView + )] #[crate::test] async fn test_export_varbinview_multiple_variadic_buffers( #[case] fixture: (ArrayRef, [usize; 2]), + #[case] layout: VarBinExportLayout, #[case] expected_data_type: DataType, ) -> VortexResult<()> { - let mut ctx = CudaSession::create_execution_ctx(&crate::cuda_session()) - .vortex_expect("failed to create execution context"); + let mut ctx = cuda_ctx_with_varbin_layout(layout)?; let (array, expected_data_buffer_lengths) = fixture; let mut exported = array.export_device_array_with_schema(&mut ctx).await?; let field = Field::try_from(&exported.schema)?; - let is_binary = expected_data_type == DataType::Binary; assert_eq!(field, Field::new("", expected_data_type, false)); - if is_binary { + if layout == VarBinExportLayout::VarBin { assert_binary_layout( &exported.array.array, 3, @@ -2981,8 +3329,15 @@ mod tests { .slice(1..4)?; let mut exported = utf8.export_device_array_with_schema(&mut ctx).await?; let field = Field::try_from(&exported.schema)?; - assert_eq!(field, Field::new("", DataType::Utf8View, true)); - assert_varbinview_shape(&exported.array.array, 3, 1)?; + assert_eq!(field, Field::new("", DataType::Utf8, true)); + let sliced_out_of_line = "this out-of-line value remains in the slice"; + assert_binary_layout( + &exported.array.array, + 3, + 1, + &[0, 5, 5, i32::try_from(5 + sliced_out_of_line.len())?], + ["hello", sliced_out_of_line].concat().as_bytes(), + )?; assert_eq!(exported.array.device_type, ARROW_DEVICE_CUDA); let private_data = unsafe { &*exported.array.array.private_data.cast::() }; @@ -3389,7 +3744,7 @@ mod tests { Ok(()) } - // Check nullable string-view exports include Arrow null bitmaps. + // Check nullable variable-length exports include Arrow null bitmaps. #[crate::test] async fn test_export_nullable_varbinview() -> VortexResult<()> { let mut ctx = CudaSession::create_execution_ctx(&crate::cuda_session()) @@ -3402,7 +3757,7 @@ mod tests { Some("this is a longer string for out-of-line storage"), ]) .into_array(), - 4, + 3, 1, &mut ctx, ) @@ -3503,7 +3858,7 @@ mod tests { assert_eq!(nested_primitive_child.n_children, 0); let string_child = unsafe { &*nested_children[1] }; - assert_eq!(string_child.n_buffers, 4); + assert_eq!(string_child.n_buffers, 3); assert_eq!(string_child.n_children, 0); let string_buffers = unsafe { std::slice::from_raw_parts( @@ -3514,7 +3869,6 @@ mod tests { assert!(string_buffers[0].is_null()); assert!(!string_buffers[1].is_null()); assert!(!string_buffers[2].is_null()); - assert!(!string_buffers[3].is_null()); unsafe { release_exported_array(&raw mut device_array.array) }; Ok(()) @@ -3623,7 +3977,7 @@ mod tests { Schema::new(vec![ Field::new("a", DataType::UInt32, false), Field::new("b", DataType::Int64, false), - Field::new("c", DataType::Utf8View, false), + Field::new("c", DataType::Utf8, false), ]) ); assert_eq!(exported.array.array.length, 5); @@ -3654,7 +4008,7 @@ mod tests { "nested", DataType::Struct(Fields::from(vec![ Field::new("b", DataType::Int64, false), - Field::new("c", DataType::Utf8View, false), + Field::new("c", DataType::Utf8, false), ])), false, ), @@ -3749,23 +4103,35 @@ mod tests { } #[crate::test] - async fn test_export_varbinview_with_schema_uses_utf8_view_layout() -> VortexResult<()> { + async fn test_export_varbinview_with_schema_uses_utf8_layout() -> VortexResult<()> { let mut ctx = CudaSession::create_execution_ctx(&crate::cuda_session()) .vortex_expect("failed to create execution context"); let japanese = "こんにちは"; let long_emoji = "🦀 and 🚀 make this string out-of-line"; - let array = VarBinViewArray::from_iter_str(["", "hello", "é", "🦀", japanese, long_emoji]) - .into_array(); + let values = ["", "hello", "é", "🦀", japanese, long_emoji]; + let array = VarBinViewArray::from_iter_str(values).into_array(); let mut exported = array.export_device_array_with_schema(&mut ctx).await?; let field = Field::try_from(&exported.schema)?; - assert_eq!(field, Field::new("", DataType::Utf8View, false)); - assert_varbinview_layout( + assert_eq!(field, Field::new("", DataType::Utf8, false)); + let mut expected_offsets = Vec::with_capacity(values.len() + 1); + expected_offsets.push(0i32); + for value in values { + expected_offsets.push( + expected_offsets + .last() + .copied() + .vortex_expect("offsets is non-empty") + + i32::try_from(value.len())?, + ); + } + assert_binary_layout( &exported.array.array, 6, 0, - &[japanese.len() + long_emoji.len()], + &expected_offsets, + values.concat().as_bytes(), )?; assert_eq!(exported.array.device_type, ARROW_DEVICE_CUDA); diff --git a/vortex-cuda/src/arrow/mod.rs b/vortex-cuda/src/arrow/mod.rs index aff582159b9..071e22085c4 100644 --- a/vortex-cuda/src/arrow/mod.rs +++ b/vortex-cuda/src/arrow/mod.rs @@ -58,6 +58,7 @@ use vortex_arrow::ArrowSessionExt; use crate::CudaBufferExt; use crate::CudaExecutionCtx; +use crate::VarBinExportLayout; mod arrow_c_abi { #![allow(dead_code)] @@ -814,14 +815,27 @@ fn arrow_device_export_field( .arrow() .to_arrow_field(name.as_ref(), dtype)?; - let data_type = match dtype { - DType::Binary(_) => DataType::Binary, - DType::Decimal(decimal_dtype, _) => arrow_device_export_decimal_data_type(*decimal_dtype), - DType::Struct(struct_dtype, _) => { - DataType::Struct(arrow_device_export_struct_fields(struct_dtype, ctx)?.into()) - } - _ => return Ok(field), - }; + let data_type = + match (ctx.cuda_session().varbin_export_layout(), dtype) { + (VarBinExportLayout::VarBin, DType::Utf8(_)) => DataType::Utf8, + (VarBinExportLayout::VarBin, DType::Binary(_)) => DataType::Binary, + (VarBinExportLayout::VarBinView, DType::Utf8(_)) => DataType::Utf8View, + (VarBinExportLayout::VarBinView, DType::Binary(_)) => DataType::BinaryView, + (_, DType::Decimal(decimal_dtype, _)) => { + arrow_device_export_decimal_data_type(*decimal_dtype) + } + (_, DType::Struct(struct_dtype, _)) => { + DataType::Struct(arrow_device_export_struct_fields(struct_dtype, ctx)?.into()) + } + // List elements are exported through the same layout-dependent paths as top-level + // arrays, so their fields must be rebuilt recursively. Without this, an element such + // as Utf8 would keep `to_arrow_field`'s Utf8View mapping while the data is exported + // with the offset-based layout. + (_, DType::List(element_dtype, _)) => DataType::List(Arc::new( + arrow_device_export_field(Field::LIST_FIELD_DEFAULT_NAME, element_dtype, ctx)?, + )), + _ => return Ok(field), + }; Ok( Field::new(field.name().clone(), data_type, field.is_nullable()) diff --git a/vortex-cuda/src/kernel/encodings/fsst.rs b/vortex-cuda/src/kernel/encodings/fsst.rs index eef0db049b8..de46d0b8fa1 100644 --- a/vortex-cuda/src/kernel/encodings/fsst.rs +++ b/vortex-cuda/src/kernel/encodings/fsst.rs @@ -17,14 +17,16 @@ use vortex::array::arrays::PrimitiveArray; use vortex::array::arrays::VarBinViewArray; use vortex::array::arrays::primitive::PrimitiveDataParts; use vortex::array::arrays::varbin::VarBinArrayExt; -use vortex::array::arrays::varbinview::BinaryView; use vortex::array::arrays::varbinview::build_views::MAX_BUFFER_LEN; use vortex::array::arrays::varbinview::build_views::build_views; +use vortex::array::buffer::BufferHandle; use vortex::array::buffer::DeviceBuffer; use vortex::array::match_each_integer_ptype; use vortex::array::match_each_unsigned_integer_ptype; +use vortex::array::validity::Validity; use vortex::buffer::Alignment; -use vortex::buffer::Buffer; +use vortex::buffer::ByteBuffer; +use vortex::dtype::DType; use vortex::dtype::NativePType; use vortex::encodings::fsst::FSST; use vortex::encodings::fsst::FSSTArray; @@ -38,6 +40,34 @@ use crate::CudaDeviceBuffer; use crate::executor::CudaExecute; use crate::executor::CudaExecutionCtx; +/// Device-resident offset-based result of FSST decompression. +pub(crate) struct FSSTVarBin { + pub(crate) dtype: DType, + pub(crate) len: usize, + pub(crate) offsets: BufferHandle, + pub(crate) values: BufferHandle, + pub(crate) validity: Validity, +} + +/// Returns validity backing bytes and the bit offset of the first row for the FSST kernels. +/// +/// `BitBuffer` slices normalize whole-byte offsets but can retain a sub-byte offset. Passing that +/// offset to CUDA lets the kernels address sliced validity directly without repacking it on host. +fn cuda_validity( + validity: &Validity, + len: usize, + ctx: &mut CudaExecutionCtx, +) -> VortexResult<(u64, ByteBuffer)> { + let bits = validity + .clone() + .execute_mask(len, ctx.execution_ctx())? + .into_bit_buffer(); + let (bit_offset, bit_len, bytes) = bits.into_inner(); + debug_assert_eq!(bit_len, len); + let byte_len = (bit_offset + bit_len).div_ceil(8); + Ok((bit_offset as u64, bytes.slice(0..byte_len))) +} + /// CUDA decoder for FSST. #[derive(Debug)] pub(crate) struct FSSTExecutor; @@ -61,16 +91,15 @@ impl CudaExecute for FSSTExecutor { let dtype = fsst.dtype().clone(); let validity = fsst.codes().validity()?; - if fsst.is_empty() || validity.definitely_all_null() { - let empty = unsafe { - VarBinViewArray::new_unchecked( - Buffer::::zeroed(fsst.len()), - Arc::from([]), - dtype, - validity, - ) - }; - return Ok(Canonical::VarBinView(empty)); + if fsst.is_empty() { + return Ok(Canonical::empty(&dtype)); + } + + if validity.definitely_all_null() { + let views = ctx.copy_to_device(vec![0i128; fsst.len()])?.await?; + return Ok(Canonical::VarBinView(unsafe { + VarBinViewArray::new_handle_unchecked(views, Arc::from([]), dtype, validity) + })); } let lens = fsst @@ -105,6 +134,138 @@ impl CudaExecute for FSSTExecutor { } } +/// Decode FSST directly into Arrow-compatible i32 offsets and contiguous values on device. +pub(crate) async fn decode_fsst_varbin( + fsst: FSSTArray, + ctx: &mut CudaExecutionCtx, +) -> VortexResult { + let dtype = fsst.dtype().clone(); + let validity = fsst.codes().validity()?; + let len = fsst.len(); + let lens = fsst + .uncompressed_lengths() + .clone() + .execute::(ctx.execution_ctx())?; + let codes_offsets = fsst + .codes() + .offsets() + .clone() + .execute::(ctx.execution_ctx())?; + + let output_offsets = match_each_integer_ptype!(lens.ptype(), |P| { + let mut offsets = Vec::with_capacity(lens.len() + 1); + let mut acc = 0u64; + offsets.push(0i32); + #[allow(clippy::unnecessary_cast)] + for &length in lens.as_slice::

() { + let length = u64::try_from(length as i128) + .map_err(|_| vortex_err!("FSST uncompressed length cannot be negative"))?; + acc = acc + .checked_add(length) + .ok_or_else(|| vortex_err!("FSST decoded size overflow"))?; + offsets + .push(i32::try_from(acc).map_err(|_| { + vortex_err!("FSST decoded size exceeds Arrow i32 offset range") + })?); + } + VortexResult::Ok(offsets) + })?; + let total_size = usize::try_from( + *output_offsets + .last() + .vortex_expect("output_offsets has at least one entry"), + )?; + + if total_size == 0 { + let offsets = ctx.copy_to_device(output_offsets)?.await?; + let allocation = CudaDeviceBuffer::new(ctx.device_alloc::(1)?); + let values = BufferHandle::new_device(allocation.slice(0..0)); + return Ok(FSSTVarBin { + dtype, + len, + offsets, + values, + validity, + }); + } + + match_each_unsigned_integer_ptype!(codes_offsets.ptype().to_unsigned(), |U| { + decode_fsst_varbin_typed::(fsst, codes_offsets, output_offsets, total_size, ctx).await + }) +} + +async fn decode_fsst_varbin_typed( + fsst: FSSTArray, + codes_offsets: PrimitiveArray, + output_offsets: Vec, + total_size: usize, + ctx: &mut CudaExecutionCtx, +) -> VortexResult +where + U: NativePType + DeviceRepr + Send + Sync + 'static, +{ + let dtype = fsst.dtype().clone(); + let validity = fsst.codes().validity()?; + let len = fsst.len(); + let len_u64 = len as u64; + let symbols_u64: Vec = fsst.symbols().iter().map(|s| s.to_u64()).collect(); + let symbol_lengths = fsst.symbol_lengths().clone(); + let codes_bytes_handle = fsst.codes_bytes_handle().clone(); + let PrimitiveDataParts { + buffer: codes_offsets_buffer, + .. + } = codes_offsets.into_data_parts(); + let (validity_bit_offset, validity_bits) = cuda_validity(&validity, len, ctx)?; + + let (symbols, symbol_lengths, output_offsets, validity_device, codes_bytes, codes_offsets) = futures::try_join!( + ctx.copy_to_device(symbols_u64)?, + ctx.copy_to_device(symbol_lengths)?, + ctx.copy_to_device(output_offsets)?, + ctx.copy_to_device(validity_bits)?, + ctx.ensure_on_device(codes_bytes_handle), + ctx.ensure_on_device(codes_offsets_buffer), + )?; + + // The kernel gates store widths on `out_pos % N` relative to the base, so the base must + // satisfy the widest store (u128 → 16). + let output = ctx.device_alloc::(total_size)?; + let (output_base_ptr, _) = output.device_ptr(ctx.stream()); + assert_eq!( + output_base_ptr % 16, + 0, + "output base not 16-aligned: {output_base_ptr:#x}", + ); + + let codes_bytes_view = codes_bytes.cuda_view::()?; + let codes_offsets_view = codes_offsets.cuda_view::()?; + let symbols_view = symbols.cuda_view::()?; + let symbol_lengths_view = symbol_lengths.cuda_view::()?; + let output_offsets_view = output_offsets.cuda_view::()?; + let validity_view = validity_device.cuda_view::()?; + let ptype = U::PTYPE.to_string(); + let cuda_function = ctx.load_function_with_suffixes("fsst", &["varbin", &ptype])?; + + ctx.launch_kernel(&cuda_function, len, |args| { + args.arg(&codes_bytes_view) + .arg(&codes_offsets_view) + .arg(&symbols_view) + .arg(&symbol_lengths_view) + .arg(&output_offsets_view) + .arg(&validity_view) + .arg(&validity_bit_offset) + .arg(&output) + .arg(&len_u64); + })?; + + Ok(FSSTVarBin { + dtype, + len, + offsets: output_offsets, + values: BufferHandle::new_device(Arc::new(CudaDeviceBuffer::new(output))), + validity, + }) +} + async fn decode_fsst( fsst: FSSTArray, codes_offsets: PrimitiveArray, @@ -126,6 +287,13 @@ where ) .vortex_expect("total_size fits in usize"); + if total_size == 0 { + let views = ctx.copy_to_device(vec![0i128; num_strings])?.await?; + return Ok(Canonical::VarBinView(unsafe { + VarBinViewArray::new_handle_unchecked(views, Arc::from([]), dtype, validity) + })); + } + let symbols_u64: Vec = fsst.symbols().iter().map(|s| s.to_u64()).collect(); let symbol_lengths = fsst.symbol_lengths().clone(); let codes_bytes_handle = fsst.codes_bytes_handle().clone(); @@ -134,18 +302,13 @@ where .. } = codes_offsets.into_data_parts(); - let (.., validity_bits) = validity - .clone() - .execute_mask(num_strings, ctx.execution_ctx())? - .into_bit_buffer() - .sliced() - .into_inner(); + let (validity_bit_offset, validity_bits) = cuda_validity(&validity, num_strings, ctx)?; let (symbols, symbol_lengths, output_offsets, validity_device, codes_bytes, codes_offsets) = futures::try_join!( ctx.copy_to_device(symbols_u64)?, ctx.copy_to_device(symbol_lengths)?, ctx.copy_to_device(output_offsets)?, - ctx.copy_to_device(validity_bits.to_vec())?, + ctx.copy_to_device(validity_bits)?, ctx.ensure_on_device(codes_bytes_handle), ctx.ensure_on_device(codes_offsets_buffer), )?; @@ -153,6 +316,9 @@ where // The kernel checks store alignment relative to the base via // `out_pos % N`, so the base must satisfy the widest store (u128 → 16). let device_output = ctx.device_alloc::(total_size)?; + let device_views = (total_size <= MAX_BUFFER_LEN) + .then(|| ctx.device_alloc::(num_strings)) + .transpose()?; let (output_base_ptr, _) = device_output.device_ptr(ctx.stream()); assert_eq!( output_base_ptr % 16, @@ -168,6 +334,7 @@ where let validity_view = validity_device.cuda_view::()?; let cuda_function = ctx.load_function("fsst", &[U::PTYPE])?; + let null_views = 0u64; ctx.launch_kernel(&cuda_function, num_strings, |args| { args.arg(&codes_bytes_view) .arg(&codes_offsets_view) @@ -175,10 +342,29 @@ where .arg(&symbol_lengths_view) .arg(&output_offsets_view) .arg(&validity_view) - .arg(&device_output) - .arg(&num_strings_u64); + .arg(&validity_bit_offset) + .arg(&device_output); + if let Some(device_views) = device_views.as_ref() { + args.arg(device_views); + } else { + args.arg(&null_views); + } + args.arg(&num_strings_u64); })?; + // Fast path: the decoded heap fits in one BinaryView backing buffer, so the kernel wrote + // views directly and both buffers can remain on-device. Larger heaps use the host rollover + // path below to split decoded bytes across multiple backing buffers. + if let Some(device_views) = device_views { + let views = BufferHandle::new_device(Arc::new(CudaDeviceBuffer::new(device_views))); + let bytes = BufferHandle::new_device(Arc::new(CudaDeviceBuffer::new(device_output))); + return Ok(Canonical::VarBinView(unsafe { + VarBinViewArray::new_handle_unchecked(views, Arc::from([bytes]), dtype, validity) + })); + } + + // BinaryView offsets are u32. Retain the host rollover path for decoded heaps + // that need multiple backing buffers; ordinary batches stay entirely on-device. let host_bytes = CudaDeviceBuffer::new(device_output) .copy_to_host(Alignment::new(1))? .await?; @@ -200,10 +386,13 @@ where #[cfg(test)] mod tests { + use arrow_schema::DataType; + use arrow_schema::Field; use rstest::rstest; use vortex::array::IntoArray; use vortex::array::arrays::VarBinArray; use vortex::array::assert_arrays_eq; + use vortex::buffer::Buffer; use vortex::dtype::DType; use vortex::dtype::Nullability; use vortex::encodings::fsst::fsst_compress; @@ -213,34 +402,73 @@ mod tests { use super::*; use crate::CanonicalCudaExt; + use crate::arrow::DeviceArrayExt; + use crate::arrow::release_device_array; + use crate::arrow::release_schema; use crate::session::CudaSession; + use crate::session::VarBinExportLayout; + + fn cuda_ctx_with_varbin_layout(layout: VarBinExportLayout) -> VortexResult { + let session = vortex::array::array_session() + .with_some(CudaSession::try_default()?.with_varbin_export_layout(layout)); + CudaSession::create_execution_ctx(&session) + } + + fn assert_device_resident(canonical: &Canonical) { + let varbinview = canonical.as_varbinview(); + assert!(varbinview.views_handle().is_on_device()); + assert!( + varbinview + .data_buffers() + .iter() + .all(BufferHandle::is_on_device) + ); + } #[rstest] - #[case::non_null( + #[case::binary_non_null( vec![Some(&b"the quick brown fox"[..]), Some(&b"jumps over the lazy dog"[..]), Some(&b"hello world"[..]), Some(&b"vortex fsst test string"[..])], - Nullability::NonNullable, + DType::Binary(Nullability::NonNullable), )] - #[case::partial_nulls( + #[case::utf8_non_null( + vec![Some(&b"the quick brown fox"[..]), + Some(&b"jumps over the lazy dog"[..]), + Some(&b"hello world"[..]), + Some(&b"vortex fsst test string"[..])], + DType::Utf8(Nullability::NonNullable), + )] + #[case::utf8_inline_boundary( + vec![Some(&b""[..]), + Some(&b"123456789012"[..]), + Some(&b"1234567890123"[..]), + Some(&b"this is another outlined value"[..])], + DType::Utf8(Nullability::NonNullable), + )] + #[case::utf8_partial_nulls( vec![Some(&b"alpha"[..]), None, Some(&b"gamma"[..]), None, Some(&b"epsilon"[..])], - Nullability::Nullable, + DType::Utf8(Nullability::Nullable), + )] + #[case::binary_all_empty( + vec![Some(&b""[..]), Some(&b""[..]), Some(&b""[..])], + DType::Binary(Nullability::NonNullable), )] - #[case::all_nulls( + #[case::binary_all_nulls( vec![None, None, None, None, None], - Nullability::Nullable, + DType::Binary(Nullability::Nullable), )] #[crate::test] async fn test_cuda_fsst_decompression_roundtrip( #[case] strings: Vec>, - #[case] nullability: Nullability, + #[case] dtype: DType, ) -> VortexResult<()> { let mut ctx = vortex_array::array_session().create_execution_ctx(); let mut cuda_ctx = CudaSession::create_execution_ctx(&crate::cuda_session()) .vortex_expect("failed to create execution context"); - let varbin = VarBinArray::from_iter(strings, DType::Binary(nullability)).into_array(); + let varbin = VarBinArray::from_iter(strings, dtype.clone()).into_array(); let compressor = fsst_train_compressor(&varbin, cuda_ctx.execution_ctx())?; let fsst_array = fsst_compress(&varbin, &compressor, cuda_ctx.execution_ctx())?.into_array(); @@ -248,12 +476,128 @@ mod tests { let gpu_result = FSSTExecutor .execute(fsst_array.clone(), &mut cuda_ctx) .await - .vortex_expect("GPU decompression failed") - .into_host() - .await? - .into_array(); + .vortex_expect("GPU decompression failed"); + assert_eq!(gpu_result.dtype(), &dtype); + assert_device_resident(&gpu_result); + + let host_result = gpu_result.into_host().await?.into_array(); + assert_arrays_eq!(fsst_array, host_result, &mut ctx); + Ok(()) + } + + /// Verifies that the view kernel applies a sliced validity bitmap's nonzero bit offset. + #[crate::test] + async fn test_cuda_fsst_decompression_sliced_validity() -> VortexResult<()> { + let mut ctx = vortex_array::array_session().create_execution_ctx(); + let mut cuda_ctx = CudaSession::create_execution_ctx(&crate::cuda_session()) + .vortex_expect("failed to create execution context"); + let values = [ + Some(&b"before"[..]), + None, + Some(&b"gamma"[..]), + None, + Some(&b"after"[..]), + ]; + let varbin = + VarBinArray::from_iter(values, DType::Utf8(Nullability::Nullable)).into_array(); + let compressor = fsst_train_compressor(&varbin, cuda_ctx.execution_ctx())?; + let fsst = fsst_compress(&varbin, &compressor, cuda_ctx.execution_ctx())?.into_array(); + let sliced = fsst.slice(1..4)?; + + let gpu_result = FSSTExecutor.execute(sliced.clone(), &mut cuda_ctx).await?; + assert_device_resident(&gpu_result); + let host_result = gpu_result.into_host().await?.into_array(); + assert_arrays_eq!(sliced, host_result, &mut ctx); + Ok(()) + } + + #[crate::test] + async fn test_cuda_fsst_direct_varbin_output() -> VortexResult<()> { + let mut cuda_ctx = cuda_ctx_with_varbin_layout(VarBinExportLayout::VarBin)?; + let values: [&[u8]; 3] = [ + b"", + b"short", + b"this value is stored directly in the values buffer", + ]; + let varbin = VarBinArray::from_iter( + values.into_iter().map(Some), + DType::Utf8(Nullability::NonNullable), + ) + .into_array(); + let compressor = fsst_train_compressor(&varbin, cuda_ctx.execution_ctx())?; + let fsst = fsst_compress(&varbin, &compressor, cuda_ctx.execution_ctx())?; + + let output = decode_fsst_varbin(fsst, &mut cuda_ctx).await?; + assert_eq!(output.dtype, DType::Utf8(Nullability::NonNullable)); + assert_eq!(output.len, values.len()); + assert!(output.offsets.is_on_device()); + assert!(output.values.is_on_device()); + + let offsets = Buffer::::from_byte_buffer(output.offsets.try_to_host()?.await?); + assert_eq!( + offsets.as_slice(), + &[0, 0, 5, i32::try_from(5 + values[2].len())?,] + ); + assert_eq!( + output.values.try_to_host()?.await?.as_ref(), + values.concat() + ); + Ok(()) + } + + #[rstest] + #[case::binary( + DType::Binary(Nullability::NonNullable), + VarBinExportLayout::VarBin, + DataType::Binary, + 3 + )] + #[case::utf8( + DType::Utf8(Nullability::NonNullable), + VarBinExportLayout::VarBin, + DataType::Utf8, + 3 + )] + #[case::binary_view( + DType::Binary(Nullability::NonNullable), + VarBinExportLayout::VarBinView, + DataType::BinaryView, + 4 + )] + #[case::utf8_view( + DType::Utf8(Nullability::NonNullable), + VarBinExportLayout::VarBinView, + DataType::Utf8View, + 4 + )] + #[crate::test] + async fn test_cuda_fsst_arrow_export_uses_dtype_layout( + #[case] dtype: DType, + #[case] layout: VarBinExportLayout, + #[case] expected_data_type: DataType, + #[case] expected_n_buffers: i64, + ) -> VortexResult<()> { + let mut cuda_ctx = cuda_ctx_with_varbin_layout(layout)?; + let values = [ + Some(&b"short"[..]), + Some(&b"this value is stored out of line"[..]), + ]; + let varbin = VarBinArray::from_iter(values, dtype).into_array(); + let compressor = fsst_train_compressor(&varbin, cuda_ctx.execution_ctx())?; + let fsst_array = + fsst_compress(&varbin, &compressor, cuda_ctx.execution_ctx())?.into_array(); - assert_arrays_eq!(fsst_array, gpu_result, &mut ctx); + let mut exported = fsst_array + .export_device_array_with_schema(&mut cuda_ctx) + .await?; + assert_eq!( + Field::try_from(&exported.schema)?, + Field::new("", expected_data_type, false) + ); + assert_eq!(exported.array.array.n_buffers, expected_n_buffers); + + release_device_array(&mut exported.array); + release_schema(&mut exported.schema); Ok(()) } @@ -271,12 +615,11 @@ mod tests { let gpu_result = FSSTExecutor .execute(fsst_array.clone(), &mut cuda_ctx) .await - .vortex_expect("GPU decompression failed") - .into_host() - .await? - .into_array(); + .vortex_expect("GPU decompression failed"); + assert_device_resident(&gpu_result); - assert_arrays_eq!(fsst_array, gpu_result, &mut ctx); + let host_result = gpu_result.into_host().await?.into_array(); + assert_arrays_eq!(fsst_array, host_result, &mut ctx); Ok(()) } } diff --git a/vortex-cuda/src/kernel/encodings/mod.rs b/vortex-cuda/src/kernel/encodings/mod.rs index b97cba13412..6ad37e7ead1 100644 --- a/vortex-cuda/src/kernel/encodings/mod.rs +++ b/vortex-cuda/src/kernel/encodings/mod.rs @@ -21,6 +21,8 @@ pub(crate) use date_time_parts::DateTimePartsExecutor; pub(crate) use decimal_byte_parts::DecimalBytePartsExecutor; pub(crate) use for_::FoRExecutor; pub(crate) use fsst::FSSTExecutor; +pub(crate) use fsst::FSSTVarBin; +pub(crate) use fsst::decode_fsst_varbin; pub(crate) use runend::RunEndExecutor; pub(crate) use sequence::SequenceExecutor; pub(crate) use zigzag::ZigZagExecutor; diff --git a/vortex-cuda/src/lib.rs b/vortex-cuda/src/lib.rs index 07cfe40b06f..5817e15436b 100644 --- a/vortex-cuda/src/lib.rs +++ b/vortex-cuda/src/lib.rs @@ -62,6 +62,7 @@ pub use pooled_read_at::PooledFileReadAt; pub use pooled_read_at::PooledObjectStoreReadAt; pub use session::CudaSession; pub use session::CudaSessionExt; +pub use session::VarBinExportLayout; pub use stream::VortexCudaStream; pub use stream_pool::VortexCudaStreamPool; use vortex::array::ArrayVTable; diff --git a/vortex-cuda/src/session.rs b/vortex-cuda/src/session.rs index df3e3c13d55..a5410db0c99 100644 --- a/vortex-cuda/src/session.rs +++ b/vortex-cuda/src/session.rs @@ -30,6 +30,16 @@ use crate::stream_pool::VortexCudaStreamPool; /// Default maximum number of streams in the pool. const DEFAULT_STREAM_POOL_CAPACITY: usize = 4; +/// Arrow Device layout used when exporting variable-length UTF-8 and binary arrays. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum VarBinExportLayout { + /// Offset-based Arrow `Utf8`/`Binary` with one contiguous values buffer. + #[default] + VarBin, + /// Arrow `Utf8View`/`BinaryView` with 16-byte views and variadic data buffers. + VarBinView, +} + /// CUDA session for GPU accelerated execution. /// /// Maintains a registry of CUDA kernel implementations for array encodings. @@ -39,6 +49,7 @@ pub struct CudaSession { context: Arc, kernels: Arc>, export_device_array: Arc, + varbin_export_layout: VarBinExportLayout, kernel_loader: Arc, stream_pool: Arc, pinned_buffer_pool: Arc, @@ -65,11 +76,23 @@ impl CudaSession { kernels: Arc::new(DashMap::default()), kernel_loader: Arc::new(KernelLoader::new()), export_device_array: Arc::new(CanonicalDeviceArrayExport), + varbin_export_layout: VarBinExportLayout::default(), stream_pool, pinned_buffer_pool, } } + /// Selects the Arrow Device layout for variable-length UTF-8 and binary exports. + pub fn with_varbin_export_layout(mut self, layout: VarBinExportLayout) -> Self { + self.varbin_export_layout = layout; + self + } + + /// Returns the Arrow Device layout used for variable-length UTF-8 and binary exports. + pub fn varbin_export_layout(&self) -> VarBinExportLayout { + self.varbin_export_layout + } + /// Creates a default CUDA session using device 0, with all GPU array kernels preloaded. /// /// Unlike [`Default::default`], this returns an error instead of panicking when CUDA cannot be From 8f75dbeb200a9187e4f8053f806c314412ed1939 Mon Sep 17 00:00:00 2001 From: Joe Isaacs Date: Thu, 16 Jul 2026 17:12:10 +0100 Subject: [PATCH 097/104] feat[buffer]: specialized `collect_bool` per arch (#8749) Currently LLVM cannot create a `bitcast -> iW` instruction (https://llvm.org/docs/LangRef.html#bitcast-to-instruction), this may be added going forwards to support byte->bit packing case `collect_bool`. This means we must add aarch specific collect_bool impls as done here. Gives between 10 and 25x improvements Signed-off-by: Claude Co-authored-by: Claude --- .gitignore | 1 + encodings/bytebool/src/compute.rs | 7 +- .../src/arrays/decimal/compute/between.rs | 6 +- .../src/arrays/primitive/compute/between.rs | 2 +- vortex-buffer/Cargo.toml | 4 + vortex-buffer/benches/collect_bool.rs | 209 +++++++ vortex-buffer/src/bit/buf.rs | 32 ++ vortex-buffer/src/bit/buf_mut.rs | 63 ++- vortex-buffer/src/bit/mod.rs | 44 +- vortex-buffer/src/bit/pack.rs | 521 ++++++++++++++++++ 10 files changed, 860 insertions(+), 29 deletions(-) create mode 100644 vortex-buffer/benches/collect_bool.rs create mode 100644 vortex-buffer/src/bit/pack.rs diff --git a/.gitignore b/.gitignore index bcc8ef746ee..f9613807332 100644 --- a/.gitignore +++ b/.gitignore @@ -224,6 +224,7 @@ compile_commands.json # AI Agents .agents/settings.local.json +.agents/scheduled_tasks.lock .claude/settings.local.json .opencode diff --git a/encodings/bytebool/src/compute.rs b/encodings/bytebool/src/compute.rs index d68154b6f2b..7f9a4e803de 100644 --- a/encodings/bytebool/src/compute.rs +++ b/encodings/bytebool/src/compute.rs @@ -158,7 +158,12 @@ impl BooleanKernel for ByteBool { fn truthy_bit_buffer(array: ArrayView<'_, ByteBool>) -> BitBuffer { let bytes = array.truthy_bytes(); - BitBuffer::collect_bool(bytes.len(), |idx| bytes[idx] != 0) + // SAFETY: `collect_bool_multiversioned` invokes the predicate with indices `0..bytes.len()` only; + // the trivially cheap unchecked gather vectorizes inside the wide pack loop. + BitBuffer::collect_bool_multiversioned( + bytes.len(), + |idx| unsafe { *bytes.get_unchecked(idx) } != 0, + ) } #[cfg(test)] diff --git a/vortex-array/src/arrays/decimal/compute/between.rs b/vortex-array/src/arrays/decimal/compute/between.rs index 75c2da923ea..50623b077ee 100644 --- a/vortex-array/src/arrays/decimal/compute/between.rs +++ b/vortex-array/src/arrays/decimal/compute/between.rs @@ -132,8 +132,10 @@ fn between_impl( ) -> ArrayRef { let buffer = arr.buffer::(); BoolArray::new( - BitBuffer::collect_bool(buffer.len(), |idx| { - let value = buffer[idx]; + BitBuffer::collect_bool_multiversioned(buffer.len(), |idx| { + // SAFETY: `collect_bool_multiversioned` invokes the predicate with indices + // `0..buffer.len()` only. + let value = unsafe { *buffer.get_unchecked(idx) }; lower.is_none_or(|l| lower_op(l, value)) & upper.is_none_or(|u| upper_op(value, u)) }), arr.validity() diff --git a/vortex-array/src/arrays/primitive/compute/between.rs b/vortex-array/src/arrays/primitive/compute/between.rs index 9a6c7f60c1b..85f2b3d42e3 100644 --- a/vortex-array/src/arrays/primitive/compute/between.rs +++ b/vortex-array/src/arrays/primitive/compute/between.rs @@ -105,7 +105,7 @@ where { let slice = arr.as_slice::(); BoolArray::new( - BitBuffer::collect_bool(slice.len(), |idx| { + BitBuffer::collect_bool_multiversioned(slice.len(), |idx| { // We only iterate upto arr len and |arr| == |slice|. let i = unsafe { *slice.get_unchecked(idx) }; lower_fn(lower, i) & upper_fn(i, upper) diff --git a/vortex-buffer/Cargo.toml b/vortex-buffer/Cargo.toml index ae9d7e6cc05..3d072145c8a 100644 --- a/vortex-buffer/Cargo.toml +++ b/vortex-buffer/Cargo.toml @@ -48,3 +48,7 @@ harness = false [[bench]] name = "vortex_bitbuffer" harness = false + +[[bench]] +name = "collect_bool" +harness = false diff --git a/vortex-buffer/benches/collect_bool.rs b/vortex-buffer/benches/collect_bool.rs new file mode 100644 index 00000000000..3bfe9a55b69 --- /dev/null +++ b/vortex-buffer/benches/collect_bool.rs @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Benchmarks for `collect_bool` bit packing. +//! +//! Three groups, each with a scalar baseline that replicates the previous bit-at-a-time +//! implementation. Under CodSpeed only the shipped entry points are tracked +//! (`from_bool_slice`, `collect_bool_u32_gt`); the baselines, the dispatch loop, +//! per-SIMD-level loops, and the portable SWAR kernel compile out and are for local A/B runs: +//! +//! - `pack_words_*`: the portable SWAR kernel vs the scalar loop, word by word. The SIMD +//! kernels are covered by `words_gather_*` instead, where they can inline. +//! - `words_gather_*`: each multiversioned word loop with a bounds-check-free bool gather, +//! measuring the fused fill + pack pipeline per SIMD level. +//! - `collect_bool_*` / `from_bool_slice`: the public entry points end to end, with a +//! boolean-gather predicate and a `u32` comparison predicate. + +use divan::Bencher; +use vortex_buffer::BitBuffer; +#[cfg(not(codspeed))] +use vortex_buffer::collect_bool_word_scalar; +#[cfg(not(codspeed))] +use vortex_buffer::pack_bool_word_swar; + +fn main() { + // Pre-warm CPUID feature detection so the one-time probe cost is never + // included in any benchmark iteration. + #[cfg(target_arch = "x86_64")] + { + let _ = is_x86_feature_detected!("avx2"); + let _ = is_x86_feature_detected!("avx512f"); + let _ = is_x86_feature_detected!("avx512bw"); + } + + divan::main(); +} + +const INPUT_SIZE: &[usize] = &[1024, 65_536]; + +/// Deterministic pseudo-random words (LCG), the source for all benchmark inputs. +fn make_words(len: usize) -> impl Iterator { + let mut state = 0x9E37_79B9_7F4A_7C15u64; + (0..len).map(move |_| { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + state + }) +} + +fn make_bools(len: usize) -> Vec { + make_words(len).map(|w| (w >> 33) & 1 == 1).collect() +} + +fn make_u32s(len: usize) -> Vec { + make_words(len).map(|w| (w >> 32) as u32).collect() +} + +/// Benchmark a per-word packing kernel over `len` pre-materialized bools. +#[cfg(not(codspeed))] +fn bench_pack_words(bencher: Bencher, len: usize, pack: impl Fn(&[bool; 64]) -> u64 + Sync) { + let bools = make_bools(len); + bencher + .with_inputs(|| vec![0u64; len / 64]) + .bench_refs(|out| { + let (chunks, _) = bools.as_chunks::<64>(); + for (word, chunk) in out.iter_mut().zip(chunks) { + *word = pack(chunk); + } + }); +} + +#[cfg(not(codspeed))] +#[divan::bench(args = INPUT_SIZE)] +fn pack_words_scalar(bencher: Bencher, len: usize) { + bench_pack_words(bencher, len, |chunk| { + collect_bool_word_scalar(64, |i| chunk[i]) + }); +} + +#[cfg(not(codspeed))] +#[divan::bench(args = INPUT_SIZE)] +fn pack_words_swar(bencher: Bencher, len: usize) { + bench_pack_words(bencher, len, pack_bool_word_swar); +} + +/// Benchmark a full multiversioned word loop with a bounds-check-free bool gather, measuring +/// the packing pipeline itself (fill + pack fully inlined) rather than predicate evaluation. +#[cfg(not(codspeed))] +fn bench_words_gather( + bencher: Bencher, + len: usize, + collect: impl Fn(&mut [u64], usize, &[bool]) + Sync, +) { + let bools = make_bools(len); + bencher + .with_inputs(|| vec![0u64; len.div_ceil(64)]) + .bench_refs(|words| collect(words, len, &bools)); +} + +#[cfg(not(codspeed))] +#[divan::bench(args = INPUT_SIZE)] +fn words_gather_dispatch(bencher: Bencher, len: usize) { + bench_words_gather(bencher, len, |words, len, bools| { + // SAFETY: `collect_bool_words` invokes the predicate with indices `0..len` only. + vortex_buffer::collect_bool_words(words, len, |i| unsafe { *bools.get_unchecked(i) }) + }); +} + +#[cfg(not(codspeed))] +#[divan::bench(args = INPUT_SIZE)] +fn words_gather_scalar(bencher: Bencher, len: usize) { + bench_words_gather(bencher, len, |words, len, bools| { + // SAFETY: `collect_bool_words_old` invokes the predicate with indices `0..len` only. + collect_bool_words_old(words, len, |i| unsafe { *bools.get_unchecked(i) }) + }); +} + +#[cfg(target_arch = "x86_64")] +#[cfg(not(codspeed))] +#[divan::bench(args = INPUT_SIZE)] +fn words_gather_sse2(bencher: Bencher, len: usize) { + bench_words_gather(bencher, len, |words, len, bools| { + // SAFETY: SSE2 is part of the x86-64 baseline; indices passed are `0..len`. + unsafe { vortex_buffer::collect_bool_words_sse2(words, len, |i| *bools.get_unchecked(i)) } + }); +} + +#[cfg(target_arch = "x86_64")] +#[cfg(not(codspeed))] +#[divan::bench(args = INPUT_SIZE)] +fn words_gather_avx2(bencher: Bencher, len: usize) { + if !is_x86_feature_detected!("avx2") { + return; + } + bench_words_gather(bencher, len, |words, len, bools| { + // SAFETY: runtime detection guarantees AVX2; indices passed are `0..len`. + unsafe { vortex_buffer::collect_bool_words_avx2(words, len, |i| *bools.get_unchecked(i)) } + }); +} + +#[cfg(target_arch = "x86_64")] +#[cfg(not(codspeed))] +#[divan::bench(args = INPUT_SIZE)] +fn words_gather_avx512(bencher: Bencher, len: usize) { + if !(is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512bw")) { + return; + } + bench_words_gather(bencher, len, |words, len, bools| { + // SAFETY: runtime detection guarantees AVX-512F/BW; indices passed are `0..len`. + unsafe { vortex_buffer::collect_bool_words_avx512(words, len, |i| *bools.get_unchecked(i)) } + }); +} + +#[cfg(target_arch = "aarch64")] +#[cfg(not(codspeed))] +#[divan::bench(args = INPUT_SIZE)] +fn words_gather_neon(bencher: Bencher, len: usize) { + bench_words_gather(bencher, len, |words, len, bools| { + // SAFETY: NEON is part of the aarch64 baseline; indices passed are `0..len`. + unsafe { vortex_buffer::collect_bool_words_neon(words, len, |i| *bools.get_unchecked(i)) } + }); +} + +/// Faithful copy of the previous scalar-only `collect_bool_words` word loop, used as the +/// baseline for the end-to-end comparison. +#[cfg(not(codspeed))] +fn collect_bool_words_old(words: &mut [u64], len: usize, mut f: impl FnMut(usize) -> bool) { + let full = len / 64; + let remainder = len % 64; + for word_idx in 0..full { + let offset = word_idx * 64; + words[word_idx] = collect_bool_word_scalar(64, |bit_idx| f(offset + bit_idx)); + } + if remainder != 0 { + let offset = full * 64; + words[full] = collect_bool_word_scalar(remainder, |bit_idx| f(offset + bit_idx)); + } +} + +#[cfg(not(codspeed))] +#[divan::bench(args = INPUT_SIZE)] +fn from_bool_slice_old_scalar(bencher: Bencher, len: usize) { + let bools = make_bools(len); + bencher + .with_inputs(|| vec![0u64; len.div_ceil(64)]) + .bench_refs(|words| collect_bool_words_old(words, len, |i| bools[i])); +} + +#[divan::bench(args = INPUT_SIZE)] +fn from_bool_slice(bencher: Bencher, len: usize) { + let bools = make_bools(len); + bencher.bench(|| vortex_buffer::BitBufferMut::from(bools.as_slice())); +} + +#[cfg(not(codspeed))] +#[divan::bench(args = INPUT_SIZE)] +fn collect_bool_u32_gt_old_scalar(bencher: Bencher, len: usize) { + let values = make_u32s(len); + bencher + .with_inputs(|| vec![0u64; len.div_ceil(64)]) + .bench_refs(|words| collect_bool_words_old(words, len, |i| values[i] > u32::MAX / 2)); +} + +#[divan::bench(args = INPUT_SIZE)] +fn collect_bool_u32_gt(bencher: Bencher, len: usize) { + let values = make_u32s(len); + bencher.bench(|| BitBuffer::collect_bool(len, |i| values[i] > u32::MAX / 2)); +} diff --git a/vortex-buffer/src/bit/buf.rs b/vortex-buffer/src/bit/buf.rs index d229329fcd2..8dc5e38a42c 100644 --- a/vortex-buffer/src/bit/buf.rs +++ b/vortex-buffer/src/bit/buf.rs @@ -185,11 +185,43 @@ impl BitBuffer { } /// Invokes `f` with indexes `0..len` collecting the boolean results into a new [`BitBuffer`]. + /// + /// `f` is invoked exactly once per index, in ascending order, and the results are packed + /// with the baseline SIMD byte→bit instruction of the target. + /// + /// # Performance + /// + /// The packing is a few instructions per 64 bits, so evaluating `f` is usually the + /// bottleneck. In particular, a bounds-checked slice access in `f` (`|i| values[i] > x`) + /// blocks vectorization of the gather and can cost ~10x the packing itself. Since `f` only + /// ever sees indices `0..len`, callers reading from a slice with `len <= values.len()` may + /// soundly use `|i| unsafe { *values.get_unchecked(i) }`. + /// + /// Prefer this entry point for every predicate. Only switch to + /// [`Self::collect_bool_multiversioned`] after carefully checking that your specific `f` + /// meets its contract (a trivially cheap, bounds-check-free gather or comparison) — + /// ideally with a benchmark. #[inline] pub fn collect_bool bool>(len: usize, f: F) -> Self { BitBufferMut::collect_bool(len, f).freeze() } + /// Like [`Self::collect_bool`], but compiles the packing loop — with `f` inside it — once + /// per CPU feature level (AVX-512BW/AVX2/baseline) and selects a clone by runtime feature + /// detection. + /// + /// Calling this asserts that `f` is small and simple enough (e.g. a bounds-check-free slice + /// gather or comparison) that duplicating it per feature level and paying a + /// `#[target_feature]` call boundary beats inlining it once into your function. For any + /// non-trivial `f` that assertion is false — the boundary deoptimizes the predicate — so + /// unless you have carefully checked (ideally benchmarked) that your specific `f` + /// qualifies, use [`Self::collect_bool`]. See + /// [`collect_bool_words_multiversioned`](crate::bit::collect_bool_words_multiversioned). + #[inline] + pub fn collect_bool_multiversioned bool>(len: usize, f: F) -> Self { + BitBufferMut::collect_bool_multiversioned(len, f).freeze() + } + /// Maps over each bit in this buffer, calling `f(index, bit_value)` and collecting results. /// /// This is more efficient than `collect_bool` when you need to read the current bit value, diff --git a/vortex-buffer/src/bit/buf_mut.rs b/vortex-buffer/src/bit/buf_mut.rs index 1964aa664d4..b3e7207a640 100644 --- a/vortex-buffer/src/bit/buf_mut.rs +++ b/vortex-buffer/src/bit/buf_mut.rs @@ -11,6 +11,7 @@ use crate::ByteBufferMut; use crate::bit::collect_bool_words; use crate::bit::get_bit_unchecked; use crate::bit::ops; +use crate::bit::pack::collect_bool_words_multiversioned; use crate::bit::set_bit_unchecked; use crate::bit::unset_bit_unchecked; use crate::buffer_mut; @@ -184,15 +185,56 @@ impl BitBufferMut { } /// Invokes `f` with indexes `0..len` collecting the boolean results into a new `BitBufferMut` + /// + /// `f` is invoked exactly once per index, in ascending order, and the results are packed + /// with the baseline SIMD byte→bit instruction of the target. + /// + /// # Performance + /// + /// The packing is a few instructions per 64 bits, so evaluating `f` is usually the + /// bottleneck. In particular, a bounds-checked slice access in `f` (`|i| values[i] > x`) + /// blocks vectorization of the gather and can cost ~10x the packing itself. Since `f` only + /// ever sees indices `0..len`, callers reading from a slice with `len <= values.len()` may + /// soundly use `|i| unsafe { *values.get_unchecked(i) }`. + /// + /// Prefer this entry point for every predicate. Only switch to + /// [`Self::collect_bool_multiversioned`] after carefully checking that your specific `f` + /// meets its contract (a trivially cheap, bounds-check-free gather or comparison) — + /// ideally with a benchmark. #[inline] pub fn collect_bool bool>(len: usize, f: F) -> Self { + Self::collect_words(len, |words| collect_bool_words(words, len, f)) + } + + /// Like [`Self::collect_bool`], but compiles the packing loop — with `f` inside it — once + /// per CPU feature level (AVX-512BW/AVX2/baseline) and selects a clone by runtime feature + /// detection. + /// + /// Calling this asserts that `f` is small and simple enough (e.g. a bounds-check-free slice + /// gather or comparison) that duplicating it per feature level and paying a + /// `#[target_feature]` call boundary beats inlining it once into your function. For any + /// non-trivial `f` that assertion is false — the boundary deoptimizes the predicate — so + /// unless you have carefully checked (ideally benchmarked) that your specific `f` + /// qualifies, use [`Self::collect_bool`]. See + /// [`collect_bool_words_multiversioned`]. + #[inline] + pub fn collect_bool_multiversioned bool>(len: usize, f: F) -> Self { + Self::collect_words(len, |words| { + collect_bool_words_multiversioned(words, len, f) + }) + } + + /// Allocate a zero-copy word buffer for `len` bits, let `fill` populate it, and wrap it as a + /// `BitBufferMut`. + #[inline] + fn collect_words(len: usize, fill: impl FnOnce(&mut [u64])) -> Self { let num_words = len.div_ceil(64); let mut buffer: BufferMut = BufferMut::with_capacity(num_words); - // SAFETY: `collect_bool_words` writes every word in `0..num_words` below - // before any read; `u64` has no invalid bit patterns and the assignments - // inside `collect_bool_words` are pure writes. + // SAFETY: `fill` (a `collect_bool_words` variant) writes every word in `0..num_words` + // below before any read; `u64` has no invalid bit patterns and the assignments inside + // `collect_bool_words` are pure writes. unsafe { buffer.set_len(num_words) }; - collect_bool_words(buffer.as_mut_slice(), len, f); + fill(buffer.as_mut_slice()); let mut bytes = buffer.into_byte_buffer(); bytes.truncate(len.div_ceil(8)); @@ -601,14 +643,23 @@ impl Not for BitBufferMut { impl From<&[bool]> for BitBufferMut { fn from(value: &[bool]) -> Self { - BitBufferMut::collect_bool(value.len(), |i| value[i]) + // SAFETY: the predicate is invoked with indices `0..value.len()` only. + // Skipping the bounds check lets the gather loop vectorize. + BitBufferMut::collect_bool_multiversioned(value.len(), |i| unsafe { + *value.get_unchecked(i) + }) } } // allow building a buffer from a set of truthy byte values. impl From<&[u8]> for BitBufferMut { fn from(value: &[u8]) -> Self { - BitBufferMut::collect_bool(value.len(), |i| value[i] > 0) + // SAFETY: the predicate is invoked with indices `0..value.len()` only. + // Skipping the bounds check lets the gather loop vectorize. + BitBufferMut::collect_bool_multiversioned( + value.len(), + |i| unsafe { *value.get_unchecked(i) } > 0, + ) } } diff --git a/vortex-buffer/src/bit/mod.rs b/vortex-buffer/src/bit/mod.rs index a39c93e3f25..46d1bee89be 100644 --- a/vortex-buffer/src/bit/mod.rs +++ b/vortex-buffer/src/bit/mod.rs @@ -14,6 +14,7 @@ mod count_ones; mod macros; mod meta; mod ops; +mod pack; mod select; mod view; @@ -27,29 +28,45 @@ pub use arrow_buffer::bit_iterator::BitSliceIterator; pub use buf::*; pub use buf_mut::*; pub use meta::*; +pub use pack::*; pub use view::*; /// Packs up to 64 boolean values into a little-endian `u64` word. +/// +/// This is [`collect_bool_words`] for a single word: a full 64-bit word is materialized as a +/// `[bool; 64]` and packed with the baseline SIMD byte→bit instruction of the target; shorter +/// lengths fall back to the bit-at-a-time [`collect_bool_word_scalar`] loop. #[inline] -pub fn collect_bool_word(len: usize, mut f: F) -> u64 +pub fn collect_bool_word(len: usize, f: F) -> u64 where F: FnMut(usize) -> bool, { assert!(len <= 64, "cannot pack {len} bits into a u64 word"); - let mut packed = 0; - for bit_idx in 0..len { - packed |= (f(bit_idx) as u64) << bit_idx; - } - packed + let mut word = [0u64; 1]; + collect_bool_words_inline(&mut word, len, f); + word[0] } /// Pack `len` boolean values returned by `f` into the prefix of `words`, LSB-first, /// 64 bits per `u64`. `words` must have capacity for at least `len.div_ceil(64)` entries. /// +/// `f` is invoked exactly once per index, in ascending order `0..len`. +/// /// Writes via `=` (not `|=`), so the destination need not be zero-initialised. +/// +/// The word loop packs with the baseline SIMD kernel of the target (SSE2 on x86-64, NEON on +/// aarch64), which inlines fully into the caller together with the predicate and the +/// `[bool; 64]` materialization — wider kernels would sit behind a non-inlinable +/// `#[target_feature]` boundary that deoptimizes expensive predicates. See +/// [`BitBuffer::collect_bool`] for the performance note on +/// avoiding bounds checks in `f`. +/// +/// Prefer this entry point for every predicate; only switch to +/// [`collect_bool_words_multiversioned`] after carefully checking that your specific `f` +/// meets its contract. #[inline] -pub fn collect_bool_words(words: &mut [u64], len: usize, mut f: F) +pub fn collect_bool_words(words: &mut [u64], len: usize, f: F) where F: FnMut(usize) -> bool, { @@ -60,18 +77,7 @@ where words.len(), ); - let full = len / 64; - let remainder = len % 64; - - for word_idx in 0..full { - let offset = word_idx * 64; - words[word_idx] = collect_bool_word(64, |bit_idx| f(offset + bit_idx)); - } - - if remainder != 0 { - let offset = full * 64; - words[full] = collect_bool_word(remainder, |bit_idx| f(offset + bit_idx)); - } + collect_bool_words_inline(words, len, f) } /// Read up to 8 bytes as a little-endian `u64`, zero-padding the high bytes when fewer than 8 diff --git a/vortex-buffer/src/bit/pack.rs b/vortex-buffer/src/bit/pack.rs new file mode 100644 index 00000000000..565ce1e8345 --- /dev/null +++ b/vortex-buffer/src/bit/pack.rs @@ -0,0 +1,521 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Kernels for packing boolean values into bitmap words. +//! +//! `collect_bool` materializes each full 64-bit chunk as a `[bool; 64]` (a loop the +//! auto-vectorizer turns into vector stores for simple predicates) and then packs the 64 bytes +//! into a single `u64` with a byte→bit kernel: +//! +//! - x86-64 AVX-512BW: one `vptestmb` produces the full 64-bit mask. +//! - x86-64 AVX2: two `vpmovmskb` halves. +//! - x86-64 SSE2 (baseline): four `pmovmskb` quarters. +//! - aarch64 NEON (baseline): per-lane `ushl` by the bit position, then an `addp` reduction tree. +//! - elsewhere (and under Miri): a branch-free SWAR multiply. +//! +//! There are two tiers. The default ([`collect_bool_words_inline`]) compiles the loop once +//! with the widest *statically-enabled* kernel (SSE2 / NEON / SWAR on stock targets) and +//! inlines fully into the caller — safe for arbitrary predicates. The opt-in tier +//! ([`collect_bool_words_multiversioned`]) compiles the loop — with `f` inside — once per CPU +//! feature level and selects a clone by runtime detection; only for predicates small and +//! simple enough that the per-level duplication and its `#[target_feature]` call boundary pay +//! off. +//! +//! The bit-at-a-time loop lives on as [`collect_bool_word_scalar`], used for tail chunks and as +//! the reference implementation for tests and benchmarks. + +/// Packs up to 64 boolean values into a little-endian `u64` word one bit at a time. +/// +/// This is the scalar reference implementation behind +/// [`collect_bool_word`](crate::bit::collect_bool_word); prefer calling that entry point, which +/// takes the SIMD fast path for full 64-bit words. +#[inline] +pub fn collect_bool_word_scalar(len: usize, mut f: F) -> u64 +where + F: FnMut(usize) -> bool, +{ + assert!(len <= 64, "cannot pack {len} bits into a u64 word"); + + let mut packed = 0; + for bit_idx in 0..len { + packed |= (f(bit_idx) as u64) << bit_idx; + } + packed +} + +/// Body of [`collect_bool_words`](crate::bit::collect_bool_words) (and, via a one-word slice, +/// of [`collect_bool_word`](crate::bit::collect_bool_word)): the word loop with the widest +/// pack kernel enabled *at compile time* — SSE2 on stock x86-64 (AVX2/AVX-512BW when built +/// with e.g. `-C target-cpu=native`), NEON on aarch64, SWAR elsewhere and under Miri. +/// +/// Statically-enabled kernels are part of every function's feature set, so this loop +/// (predicate, the `[bool; 64]` materialization, and the pack) inlines fully into the caller +/// with no `#[target_feature]` boundary. That boundary is why *runtime*-detected wider kernels +/// are not used here: hiding an expensive, non-vectorizable predicate (e.g. FSST's per-string +/// DFA scan) behind a non-inlinable AVX-512 loop copy costs far more (~30% end to end) than +/// the wider pack saves — and an indirect call per word is worse still (~4x on cheap +/// predicates), since an opaque call target blocks fill/pack fusion regardless of how cheap +/// the kernel *selection* is. For provably cheap predicates, use [`collect_bool_words_multiversioned`]. +#[inline(always)] +pub(crate) fn collect_bool_words_inline(words: &mut [u64], len: usize, f: F) +where + F: FnMut(usize) -> bool, +{ + #[cfg(all( + target_arch = "x86_64", + target_feature = "avx512f", + target_feature = "avx512bw", + not(miri) + ))] + { + // SAFETY: AVX-512F/BW are statically enabled for this build (e.g. -C + // target-cpu=native), so they are in every function's feature set and the kernel + // inlines here like any other function. + collect_bool_words_with(words, len, f, |bools| unsafe { + pack_bool_word_avx512(bools) + }) + } + #[cfg(all( + target_arch = "x86_64", + target_feature = "avx2", + not(all(target_feature = "avx512f", target_feature = "avx512bw")), + not(miri) + ))] + { + // SAFETY: AVX2 is statically enabled for this build. + collect_bool_words_with(words, len, f, |bools| unsafe { pack_bool_word_avx2(bools) }) + } + #[cfg(all(target_arch = "x86_64", not(target_feature = "avx2"), not(miri)))] + { + // SAFETY: SSE2 is part of the x86-64 baseline instruction set. + collect_bool_words_with(words, len, f, |bools| unsafe { pack_bool_word_sse2(bools) }) + } + #[cfg(all(target_arch = "aarch64", not(miri)))] + { + // SAFETY: NEON is part of the aarch64 baseline instruction set. + collect_bool_words_with(words, len, f, |bools| unsafe { pack_bool_word_neon(bools) }) + } + #[cfg(any(not(any(target_arch = "x86_64", target_arch = "aarch64")), miri))] + collect_bool_words_with(words, len, f, pack_bool_word_swar) +} + +/// Word loop with the *widest* pack kernel the CPU offers (AVX-512BW, then AVX2, then the +/// baseline), for predicates known to be cheap. +/// +/// The wide loop copies live behind a `#[target_feature]` function boundary that cannot inline +/// into the caller, which deoptimizes expensive predicates (see the module docs and +/// `collect_bool_words_inline`). Only route a predicate here when its evaluation is trivial +/// — e.g. the bounds-check-free slice gathers in the `From<&[bool]>` / `From<&[u8]>` +/// conversions, or unchecked slice comparisons like the `between` kernels — where the fused +/// AVX-512 loop is worth another ~2x over the baseline kernel. +/// +/// `words` must hold at least `len.div_ceil(64)` entries and `f` is invoked with `0..len`, +/// exactly once per index in ascending order. +/// +/// Panics if `words` is too short. +#[inline] +pub fn collect_bool_words_multiversioned(words: &mut [u64], len: usize, f: F) +where + F: FnMut(usize) -> bool, +{ + let num_words = len.div_ceil(64); + assert!( + words.len() >= num_words, + "words slice has {} entries, need at least {num_words}", + words.len(), + ); + + // Without a full 64-bit word only the scalar tail would run; skip feature detection and + // the `#[target_feature]` call boundary entirely. + if len < 64 { + return collect_bool_words_inline(words, len, f); + } + + #[cfg(all(target_arch = "x86_64", not(miri)))] + { + if is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512bw") { + // SAFETY: runtime detection guarantees the required target features. + return unsafe { collect_bool_words_avx512(words, len, f) }; + } + if is_x86_feature_detected!("avx2") { + // SAFETY: runtime detection guarantees the required target features. + return unsafe { collect_bool_words_avx2(words, len, f) }; + } + } + collect_bool_words_inline(words, len, f) +} + +/// Shared word loop: materialize each full 64-bit chunk as a `[bool; 64]` and pack it with +/// `pack`; the tail chunk goes through the scalar loop. +/// +/// Marked `#[inline(always)]` so each `#[target_feature]` wrapper gets its own fully-inlined +/// copy compiled with that feature set. +#[inline(always)] +fn collect_bool_words_with(words: &mut [u64], len: usize, mut f: F, pack: P) +where + F: FnMut(usize) -> bool, + P: Fn(&[bool; 64]) -> u64, +{ + let full = len / 64; + let remainder = len % 64; + + for (word_idx, word) in words[..full].iter_mut().enumerate() { + let offset = word_idx * 64; + let mut bools = [false; 64]; + for (bit_idx, b) in bools.iter_mut().enumerate() { + *b = f(offset + bit_idx); + } + *word = pack(&bools); + } + + if remainder != 0 { + let offset = full * 64; + words[full] = collect_bool_word_scalar(remainder, |bit_idx| f(offset + bit_idx)); + } +} + +/// SSE2 copy of the [`collect_bool_words`](crate::bit::collect_bool_words) word loop. +/// +/// `words` must hold at least `len.div_ceil(64)` entries and `f` is invoked with `0..len`. +/// +/// # Safety +/// +/// The caller must ensure the CPU supports SSE2 (always true on x86-64). +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "sse2")] +pub unsafe fn collect_bool_words_sse2 bool>( + words: &mut [u64], + len: usize, + f: F, +) { + // SAFETY: the caller guarantees SSE2 support. + collect_bool_words_with(words, len, f, |bools| unsafe { pack_bool_word_sse2(bools) }) +} + +/// AVX2 copy of the [`collect_bool_words`](crate::bit::collect_bool_words) word loop. +/// +/// `words` must hold at least `len.div_ceil(64)` entries and `f` is invoked with `0..len`. +/// +/// # Safety +/// +/// The caller must ensure the CPU supports AVX2. +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "avx2")] +pub unsafe fn collect_bool_words_avx2 bool>( + words: &mut [u64], + len: usize, + f: F, +) { + // SAFETY: the caller guarantees AVX2 support. + collect_bool_words_with(words, len, f, |bools| unsafe { pack_bool_word_avx2(bools) }) +} + +/// AVX-512BW copy of the [`collect_bool_words`](crate::bit::collect_bool_words) word loop. +/// +/// `words` must hold at least `len.div_ceil(64)` entries and `f` is invoked with `0..len`. +/// +/// # Safety +/// +/// The caller must ensure the CPU supports AVX-512F and AVX-512BW. +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "avx512f,avx512bw")] +pub unsafe fn collect_bool_words_avx512 bool>( + words: &mut [u64], + len: usize, + f: F, +) { + // SAFETY: the caller guarantees AVX-512F and AVX-512BW support. + collect_bool_words_with(words, len, f, |bools| unsafe { + pack_bool_word_avx512(bools) + }) +} + +/// NEON copy of the [`collect_bool_words`](crate::bit::collect_bool_words) word loop. +/// +/// `words` must hold at least `len.div_ceil(64)` entries and `f` is invoked with `0..len`. +/// +/// # Safety +/// +/// The caller must ensure the CPU supports NEON (always true on aarch64). +#[cfg(target_arch = "aarch64")] +#[target_feature(enable = "neon")] +pub unsafe fn collect_bool_words_neon bool>( + words: &mut [u64], + len: usize, + f: F, +) { + // SAFETY: the caller guarantees NEON support. + collect_bool_words_with(words, len, f, |bools| unsafe { pack_bool_word_neon(bools) }) +} + +/// Portable branch-free byte→bit pack kernel, used when no SIMD kernel is available. +/// +/// Reads the bools eight at a time as a `u64` and gathers the eight `0x00`/`0x01` bytes into +/// eight contiguous bits with a single multiply: byte `i` contributes `2^(8i)`, and the magic +/// constant `0x0102_0408_1020_4080 = Σ 2^(56-7i)` shifts each contribution to bit `56 + i` +/// without any cross-term collisions, so the mask falls out of the top byte of the product. +#[inline] +pub fn pack_bool_word_swar(bools: &[bool; 64]) -> u64 { + const MAGIC: u64 = 0x0102_0408_1020_4080; + + let (chunks, rest) = bools.as_chunks::<8>(); + debug_assert!(rest.is_empty()); + + let mut packed = 0u64; + for (chunk_idx, chunk) in chunks.iter().enumerate() { + let word = u64::from_le_bytes(chunk.map(|b| b as u8)); + packed |= (word.wrapping_mul(MAGIC) >> 56) << (8 * chunk_idx); + } + packed +} + +/// SSE2 byte→bit pack kernel: four 16-byte `pcmpeqb`-against-zero + `pmovmskb` rounds. +/// +/// # Safety +/// +/// The caller must ensure the CPU supports SSE2 (always true on x86-64). +#[cfg(target_arch = "x86_64")] +#[inline] +#[target_feature(enable = "sse2")] +pub unsafe fn pack_bool_word_sse2(bools: &[bool; 64]) -> u64 { + use std::arch::x86_64::__m128i; + use std::arch::x86_64::_mm_cmpeq_epi8; + use std::arch::x86_64::_mm_loadu_si128; + use std::arch::x86_64::_mm_movemask_epi8; + use std::arch::x86_64::_mm_setzero_si128; + + let ptr = bools.as_ptr().cast::(); + let zero = _mm_setzero_si128(); + + let mut packed = 0u64; + for lane in 0..4 { + // SAFETY: `lane * 16 + 16 <= 64`, so the 16-byte load is in bounds. + let chunk = unsafe { _mm_loadu_si128(ptr.add(lane * 16).cast::<__m128i>()) }; + // `cmpeq` against zero sets 0xFF for *false* bytes; invert to get the truthy mask. + let zero_mask = _mm_movemask_epi8(_mm_cmpeq_epi8(chunk, zero)) as u32 as u64; + packed |= (!zero_mask & 0xFFFF) << (16 * lane); + } + packed +} + +/// AVX2 byte→bit pack kernel: two 32-byte `vpcmpeqb`-against-zero + `vpmovmskb` rounds. +/// +/// # Safety +/// +/// The caller must ensure the CPU supports AVX2. +#[cfg(target_arch = "x86_64")] +#[inline] +#[target_feature(enable = "avx2")] +pub unsafe fn pack_bool_word_avx2(bools: &[bool; 64]) -> u64 { + use std::arch::x86_64::__m256i; + use std::arch::x86_64::_mm256_cmpeq_epi8; + use std::arch::x86_64::_mm256_loadu_si256; + use std::arch::x86_64::_mm256_movemask_epi8; + use std::arch::x86_64::_mm256_setzero_si256; + + let ptr = bools.as_ptr().cast::(); + let zero = _mm256_setzero_si256(); + + // SAFETY: both 32-byte loads are within the 64-byte array. + let lo = unsafe { _mm256_loadu_si256(ptr.cast::<__m256i>()) }; + // SAFETY: see above. + let hi = unsafe { _mm256_loadu_si256(ptr.add(32).cast::<__m256i>()) }; + + // `cmpeq` against zero sets 0xFF for *false* bytes; invert to get the truthy mask. + let lo_mask = !(_mm256_movemask_epi8(_mm256_cmpeq_epi8(lo, zero)) as u32) as u64; + let hi_mask = !(_mm256_movemask_epi8(_mm256_cmpeq_epi8(hi, zero)) as u32) as u64; + lo_mask | (hi_mask << 32) +} + +/// AVX-512BW byte→bit pack kernel: a single 64-byte `vptestmb` produces the whole word. +/// +/// # Safety +/// +/// The caller must ensure the CPU supports AVX-512F and AVX-512BW. +#[cfg(target_arch = "x86_64")] +#[inline] +#[target_feature(enable = "avx512f,avx512bw")] +pub unsafe fn pack_bool_word_avx512(bools: &[bool; 64]) -> u64 { + use std::arch::x86_64::__m512i; + use std::arch::x86_64::_mm512_loadu_si512; + use std::arch::x86_64::_mm512_test_epi8_mask; + + // SAFETY: the 64-byte load covers exactly the `[bool; 64]` array. + let chunk = unsafe { _mm512_loadu_si512(bools.as_ptr().cast::<__m512i>()) }; + // Mask bit `i` is set iff byte `i` AND byte `i` is nonzero, i.e. iff `bools[i]`. + _mm512_test_epi8_mask(chunk, chunk) +} + +/// NEON byte→bit pack kernel: shift each `0x00`/`0x01` byte left by its bit position +/// (`ushl`), then fold the four vectors into one `u64` with a pairwise-add (`addp`) tree. +/// +/// # Safety +/// +/// The caller must ensure the CPU supports NEON (always true on aarch64). +#[cfg(target_arch = "aarch64")] +#[inline] +#[target_feature(enable = "neon")] +pub unsafe fn pack_bool_word_neon(bools: &[bool; 64]) -> u64 { + use std::arch::aarch64::vgetq_lane_u64; + use std::arch::aarch64::vld1q_s8; + use std::arch::aarch64::vld1q_u8; + use std::arch::aarch64::vpaddq_u8; + use std::arch::aarch64::vreinterpretq_u64_u8; + use std::arch::aarch64::vshlq_u8; + + const BIT_SHIFTS: [i8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 0, 1, 2, 3, 4, 5, 6, 7]; + + let ptr = bools.as_ptr().cast::(); + // SAFETY: loading 16 constant bytes from `BIT_SHIFTS`; the four 16-byte data loads below are + // all within the 64-byte array. + unsafe { + let shifts = vld1q_s8(BIT_SHIFTS.as_ptr()); + + // Byte j of each vector becomes `bools[16v + j] << (j % 8)`. + let m0 = vshlq_u8(vld1q_u8(ptr), shifts); + let m1 = vshlq_u8(vld1q_u8(ptr.add(16)), shifts); + let m2 = vshlq_u8(vld1q_u8(ptr.add(32)), shifts); + let m3 = vshlq_u8(vld1q_u8(ptr.add(48)), shifts); + + // Three rounds of pairwise adds sum each group of 8 weighted bytes into one mask byte, + // yielding the 8 mask bytes in order in the low half of the final vector. + let sum01 = vpaddq_u8(m0, m1); + let sum23 = vpaddq_u8(m2, m3); + let sum = vpaddq_u8(sum01, sum23); + let sum = vpaddq_u8(sum, sum); + vgetq_lane_u64::<0>(vreinterpretq_u64_u8(sum)) + } +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + + use super::collect_bool_word_scalar; + use super::pack_bool_word_swar; + + fn patterns() -> Vec<[bool; 64]> { + let mut patterns = vec![ + [false; 64], + [true; 64], + std::array::from_fn(|i| i % 2 == 0), + std::array::from_fn(|i| i % 3 == 0), + std::array::from_fn(|i| i < 32), + std::array::from_fn(|i| i == 0 || i == 63), + ]; + // A few deterministic pseudo-random patterns. + let mut state = 0x9E37_79B9_7F4A_7C15u64; + for _ in 0..8 { + patterns.push(std::array::from_fn(|_| { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (state >> 33) & 1 == 1 + })); + } + patterns + } + + fn reference(bools: &[bool; 64]) -> u64 { + collect_bool_word_scalar(64, |i| bools[i]) + } + + #[test] + fn swar_matches_scalar() { + for bools in patterns() { + assert_eq!(pack_bool_word_swar(&bools), reference(&bools)); + } + } + + #[test] + fn dispatch_matches_scalar() { + for bools in patterns() { + assert_eq!( + crate::bit::collect_bool_word(64, |i| bools[i]), + reference(&bools) + ); + } + } + + #[cfg(all(target_arch = "x86_64", not(miri)))] + #[test] + fn sse2_matches_scalar() { + for bools in patterns() { + // SAFETY: SSE2 is part of the x86-64 baseline instruction set. + assert_eq!( + unsafe { super::pack_bool_word_sse2(&bools) }, + reference(&bools) + ); + } + } + + #[cfg(all(target_arch = "x86_64", not(miri)))] + #[test] + fn avx2_matches_scalar() { + if !is_x86_feature_detected!("avx2") { + return; + } + for bools in patterns() { + // SAFETY: runtime detection guarantees AVX2. + assert_eq!( + unsafe { super::pack_bool_word_avx2(&bools) }, + reference(&bools) + ); + } + } + + #[cfg(all(target_arch = "x86_64", not(miri)))] + #[test] + fn avx512_matches_scalar() { + if !(is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512bw")) { + return; + } + for bools in patterns() { + // SAFETY: runtime detection guarantees AVX-512F and AVX-512BW. + assert_eq!( + unsafe { super::pack_bool_word_avx512(&bools) }, + reference(&bools) + ); + } + } + + #[cfg(all(target_arch = "aarch64", not(miri)))] + #[test] + fn neon_matches_scalar() { + for bools in patterns() { + // SAFETY: NEON is part of the aarch64 baseline instruction set. + assert_eq!( + unsafe { super::pack_bool_word_neon(&bools) }, + reference(&bools) + ); + } + } + + #[rstest] + #[case(0)] + #[case(1)] + #[case(63)] + #[case(64)] + #[case(65)] + #[case(200)] + fn multiversioned_matches_inline(#[case] len: usize) { + let pattern = |i: usize| i.is_multiple_of(3) || i.is_multiple_of(7); + let num_words = len.div_ceil(64); + let mut multiversioned = vec![0u64; num_words]; + super::collect_bool_words_multiversioned(&mut multiversioned, len, pattern); + let mut inline = vec![0u64; num_words]; + super::collect_bool_words_inline(&mut inline, len, pattern); + assert_eq!(multiversioned, inline); + } + + #[rstest] + #[case(0)] + #[case(1)] + #[case(5)] + #[case(63)] + #[case(64)] + fn collect_bool_word_partial_lens_match(#[case] len: usize) { + let expected = collect_bool_word_scalar(len, |i| i % 3 == 0); + assert_eq!(crate::bit::collect_bool_word(len, |i| i % 3 == 0), expected); + } +} From 9c6919585ceae81260c7b0d6555ed2aadce2779c Mon Sep 17 00:00:00 2001 From: Joe Isaacs Date: Thu, 16 Jul 2026 17:33:26 +0100 Subject: [PATCH 098/104] feat: arch intrinsic feature discovery pattern (#8750) Currently we have a few places where we have arch/feature specific kernels, they current use slower feature detection for each call. This PR add a cache for the kernel to use and uses this pattern in the codebase. I think we would later want to register different kernels in the vortex session instead of this, but that can wait. Signed-off-by: Joe Isaacs Co-authored-by: Claude Fable 5 --- encodings/fastlanes/src/bit_transpose/mod.rs | 88 +++++---- .../src/arrays/bool/compute/filter.rs | 20 +- vortex-buffer/Cargo.toml | 4 + vortex-buffer/benches/cpu_dispatch.rs | 160 ++++++++++++++++ vortex-buffer/benches/vortex_bitbuffer.rs | 6 + vortex-buffer/src/bit/count_ones.rs | 66 +++++-- vortex-buffer/src/bit/select.rs | 97 ++++++---- vortex-buffer/src/dispatch.rs | 177 ++++++++++++++++++ vortex-buffer/src/lib.rs | 2 + vortex-mask/benches/rank.rs | 5 + vortex-mask/src/intersect_by_rank.rs | 35 ++-- 11 files changed, 556 insertions(+), 104 deletions(-) create mode 100644 vortex-buffer/benches/cpu_dispatch.rs create mode 100644 vortex-buffer/src/dispatch.rs diff --git a/encodings/fastlanes/src/bit_transpose/mod.rs b/encodings/fastlanes/src/bit_transpose/mod.rs index 99b01bc242c..26e8b595195 100644 --- a/encodings/fastlanes/src/bit_transpose/mod.rs +++ b/encodings/fastlanes/src/bit_transpose/mod.rs @@ -28,6 +28,10 @@ mod x86; mod validity; pub use validity::*; +use vortex_buffer::CpuKernel; + +/// Signature shared by all 1024-bit transpose kernels. +type TransposeKernel = unsafe fn(&[u8; 128], &mut [u8; 128]); /// Base indices for the first 64 output bytes (lanes 0-7). /// Each entry indicates the starting input byte index for that output byte group. @@ -45,56 +49,64 @@ const TRANSPOSE_8X8: u64 = 0x0000_0000_F0F0_F0F0; /// Transpose 1024-bits into FastLanes layout. /// -/// Dispatch to the best available implementation at runtime. +/// Dispatch to the best available implementation, selected once on first call. #[inline] pub fn transpose_bits(input: &[u8; 128], output: &mut [u8; 128]) { - #[cfg(target_arch = "x86_64")] - { - // VBMI is fastest - if x86::has_vbmi() { - unsafe { x86::transpose_bits_vbmi(input, output) }; - return; + static KERNEL: CpuKernel = CpuKernel::new(|| { + #[cfg(target_arch = "x86_64")] + { + // VBMI is fastest + if x86::has_vbmi() { + return x86::transpose_bits_vbmi; + } + if x86::has_bmi2() { + return x86::transpose_bits_bmi2; + } } - if x86::has_bmi2() { - unsafe { x86::transpose_bits_bmi2(input, output) }; - return; + // NEON is architecturally guaranteed on aarch64, so it needs no probe. + #[cfg(target_arch = "aarch64")] + return aarch64::transpose_bits_neon; + // The aarch64 arm above returns unconditionally, making this portable default + // unreachable there. + #[allow(unreachable_code)] + { + scalar::transpose_bits_scalar } - // Fall back to scalar - scalar::transpose_bits_scalar(input, output); - } - #[cfg(target_arch = "aarch64")] - { - unsafe { aarch64::transpose_bits_neon(input, output) }; - } - #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] - scalar::transpose_bits_scalar(input, output); + }); + // SAFETY: the selector only returns kernels that are safe or whose required CPU + // features were probed before selection. + unsafe { KERNEL.get()(input, output) } } /// Untranspose 1024-bits from FastLanes layout. /// -/// Dispatch untranspose to the best available implementation at runtime. +/// Dispatch untranspose to the best available implementation, selected once on first call. #[inline] pub fn untranspose_bits(input: &[u8; 128], output: &mut [u8; 128]) { - #[cfg(target_arch = "x86_64")] - { - // VBMI is fastest - if x86::has_vbmi() { - unsafe { x86::untranspose_bits_vbmi(input, output) }; - return; + static KERNEL: CpuKernel = CpuKernel::new(|| { + #[cfg(target_arch = "x86_64")] + { + // VBMI is fastest + if x86::has_vbmi() { + return x86::untranspose_bits_vbmi; + } + if x86::has_bmi2() { + return x86::untranspose_bits_bmi2; + } } - if x86::has_bmi2() { - unsafe { x86::untranspose_bits_bmi2(input, output) }; - return; + // NEON is architecturally guaranteed on aarch64, so it needs no probe. + #[cfg(target_arch = "aarch64")] + return aarch64::untranspose_bits_neon; + // The aarch64 arm above returns unconditionally, making this portable default + // unreachable there. + #[allow(unreachable_code)] + { + scalar::untranspose_bits_scalar } - // Fall back to scalar - scalar::untranspose_bits_scalar(input, output); - } - #[cfg(target_arch = "aarch64")] - { - unsafe { aarch64::untranspose_bits_neon(input, output) }; - } - #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] - scalar::untranspose_bits_scalar(input, output); + }); + // SAFETY: the selector only returns kernels that are safe or whose required CPU + // features were probed before selection. + unsafe { KERNEL.get()(input, output) } } #[cfg(test)] diff --git a/vortex-array/src/arrays/bool/compute/filter.rs b/vortex-array/src/arrays/bool/compute/filter.rs index 2759c6e3077..98f52ed11ee 100644 --- a/vortex-array/src/arrays/bool/compute/filter.rs +++ b/vortex-array/src/arrays/bool/compute/filter.rs @@ -3,6 +3,7 @@ use vortex_buffer::BitBuffer; use vortex_buffer::BitBufferMut; +use vortex_buffer::CpuKernel; use vortex_buffer::get_bit; use vortex_error::VortexExpect; use vortex_error::VortexResult; @@ -90,14 +91,19 @@ pub fn filter_bitbuffer_by_mask( mask_buf: &BitBuffer, true_count: usize, ) -> BitBuffer { - #[cfg(target_arch = "x86_64")] - { - if std::arch::is_x86_feature_detected!("bmi2") { - // SAFETY: BMI2 confirmed available; the inner function is compiled with BMI2. - return unsafe { filter_pext_bmi2(src, mask_buf, true_count) }; + type FilterKernel = unsafe fn(&BitBuffer, &BitBuffer, usize) -> BitBuffer; + static KERNEL: CpuKernel = CpuKernel::new(|| { + #[cfg(target_arch = "x86_64")] + { + if std::arch::is_x86_feature_detected!("bmi2") { + return filter_pext_bmi2; + } } - } - filter_pext_fallback(src, mask_buf, true_count) + filter_pext_fallback + }); + // SAFETY: the selector only returns kernels that are safe or whose required CPU + // features were probed before selection. + unsafe { KERNEL.get()(src, mask_buf, true_count) } } /// BMI2-native filter: entire function compiled with BMI2+POPCNT enabled. diff --git a/vortex-buffer/Cargo.toml b/vortex-buffer/Cargo.toml index 3d072145c8a..ed9df797535 100644 --- a/vortex-buffer/Cargo.toml +++ b/vortex-buffer/Cargo.toml @@ -49,6 +49,10 @@ harness = false name = "vortex_bitbuffer" harness = false +[[bench]] +name = "cpu_dispatch" +harness = false + [[bench]] name = "collect_bool" harness = false diff --git a/vortex-buffer/benches/cpu_dispatch.rs b/vortex-buffer/benches/cpu_dispatch.rs new file mode 100644 index 00000000000..6891dd22119 --- /dev/null +++ b/vortex-buffer/benches/cpu_dispatch.rs @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Overhead comparison of one-time CPU-feature dispatch mechanisms. +//! +//! Every variant ends in a call to the same `#[inline(never)]` kernel, so the +//! difference between rows is pure dispatch overhead: +//! +//! * `direct`: plain call — the floor. +//! * `cpu_kernel`: [`CpuKernel`] — relaxed load + null check + indirect call. +//! * `cpu_kernel_unconditional`: a selector with no probes (the aarch64 shape) — +//! same steady state as `cpu_kernel`. +//! * `lazy_lock`: `std::sync::LazyLock` — Once-state check + value load + +//! indirect call. +//! * `feature_detect_each_call_1/_3`: the pre-`CpuKernel` pattern — 1 or 3 +//! `is_x86_feature_detected!` cached lookups per call, then a direct call. + +use std::sync::LazyLock; + +use divan::Bencher; +use divan::black_box; +use divan::counter::ItemsCount; +use vortex_buffer::CpuKernel; + +fn main() { + // Resolve every dispatcher and warm the std feature-detection cache so no + // benchmark iteration pays a one-time probe. + let seed = black_box(7); + let _ = via_direct(1, seed) + + via_cpu_kernel(1, seed) + + via_cpu_kernel_unconditional(1, seed) + + via_lazy_lock(1, seed); + #[cfg(target_arch = "x86_64")] + let _ = via_feature_detect_1(1, seed) + via_feature_detect_3(1, seed); + + divan::main(); +} + +type Kernel = fn(u64, u64) -> u64; + +#[inline(never)] +fn kernel_a(x: u64, seed: u64) -> u64 { + x.wrapping_mul(31).wrapping_add(seed) +} + +#[inline(never)] +fn kernel_b(x: u64, seed: u64) -> u64 { + x.wrapping_mul(33).wrapping_add(seed) +} + +fn has_bmi2() -> bool { + #[cfg(target_arch = "x86_64")] + { + std::arch::is_x86_feature_detected!("bmi2") + } + #[cfg(not(target_arch = "x86_64"))] + { + false + } +} + +// ── dispatch variants ─────────────────────────────────────────────────── + +#[inline(never)] +fn via_direct(x: u64, seed: u64) -> u64 { + kernel_a(x, seed) +} + +#[cfg(target_arch = "x86_64")] +#[inline(never)] +fn via_feature_detect_1(x: u64, seed: u64) -> u64 { + if std::arch::is_x86_feature_detected!("bmi2") { + kernel_a(x, seed) + } else { + kernel_b(x, seed) + } +} + +#[cfg(target_arch = "x86_64")] +#[inline(never)] +fn via_feature_detect_3(x: u64, seed: u64) -> u64 { + // Non-baseline features that are present on any recent x86_64 part, so all + // three cached lookups actually execute (mirrors the old select_in_chunk). + if std::arch::is_x86_feature_detected!("avx") + && std::arch::is_x86_feature_detected!("avx2") + && std::arch::is_x86_feature_detected!("bmi2") + { + kernel_a(x, seed) + } else { + kernel_b(x, seed) + } +} + +static LAZY: LazyLock = LazyLock::new(|| if has_bmi2() { kernel_a } else { kernel_b }); + +#[inline(never)] +fn via_lazy_lock(x: u64, seed: u64) -> u64 { + (*LAZY)(x, seed) +} + +static CPU_KERNEL: CpuKernel = + CpuKernel::new(|| if has_bmi2() { kernel_a } else { kernel_b }); + +#[inline(never)] +fn via_cpu_kernel(x: u64, seed: u64) -> u64 { + CPU_KERNEL.get()(x, seed) +} + +static CPU_KERNEL_UNCONDITIONAL: CpuKernel = CpuKernel::new(|| kernel_a); + +#[inline(never)] +fn via_cpu_kernel_unconditional(x: u64, seed: u64) -> u64 { + CPU_KERNEL_UNCONDITIONAL.get()(x, seed) +} + +// ── benches ───────────────────────────────────────────────────────────── + +const CALLS: u64 = 1024; + +fn bench_via(bencher: Bencher, via: fn(u64, u64) -> u64) { + bencher.counter(ItemsCount::new(CALLS)).bench(|| { + let mut acc = 0u64; + for i in 0..CALLS { + acc = acc.wrapping_add(via(black_box(i), black_box(7))); + } + acc + }) +} + +#[divan::bench] +fn direct(bencher: Bencher) { + bench_via(bencher, via_direct); +} + +#[divan::bench] +fn cpu_kernel_unconditional(bencher: Bencher) { + bench_via(bencher, via_cpu_kernel_unconditional); +} + +#[divan::bench] +fn cpu_kernel(bencher: Bencher) { + bench_via(bencher, via_cpu_kernel); +} + +#[divan::bench] +fn lazy_lock(bencher: Bencher) { + bench_via(bencher, via_lazy_lock); +} + +#[cfg(target_arch = "x86_64")] +#[divan::bench] +fn feature_detect_each_call_1(bencher: Bencher) { + bench_via(bencher, via_feature_detect_1); +} + +#[cfg(target_arch = "x86_64")] +#[divan::bench] +fn feature_detect_each_call_3(bencher: Bencher) { + bench_via(bencher, via_feature_detect_3); +} diff --git a/vortex-buffer/benches/vortex_bitbuffer.rs b/vortex-buffer/benches/vortex_bitbuffer.rs index 923ef2aa98e..b67b3f1bf39 100644 --- a/vortex-buffer/benches/vortex_bitbuffer.rs +++ b/vortex-buffer/benches/vortex_bitbuffer.rs @@ -19,6 +19,12 @@ fn main() { let _ = is_x86_feature_detected!("avx512vpopcntdq"); } + // Pre-resolve the one-time CpuKernel selections for the count/select paths so + // no benchmark iteration pays the first-call cost. + let warm = BitBuffer::from_iter((0..512).map(|i| i % 3 == 0)); + let _ = warm.true_count(); + let _ = warm.select(1); + divan::main(); } diff --git a/vortex-buffer/src/bit/count_ones.rs b/vortex-buffer/src/bit/count_ones.rs index df5844a2914..b977600cbe5 100644 --- a/vortex-buffer/src/bit/count_ones.rs +++ b/vortex-buffer/src/bit/count_ones.rs @@ -4,6 +4,8 @@ #[cfg(target_arch = "x86_64")] use vortex_error::VortexExpect; +use crate::dispatch::CpuKernel; + #[inline] pub fn count_ones(bytes: &[u8], offset: usize, len: usize) -> usize { if bytes.is_empty() { @@ -70,24 +72,66 @@ fn mask_byte(byte: u8, bit_offset: usize, bit_len: usize) -> u8 { shifted & mask } +type CountOnes = unsafe fn(&[u8]) -> usize; + #[inline] fn count_ones_aligned(bytes: &[u8]) -> usize { - #[cfg(target_arch = "x86_64")] - { - if bytes.len() >= 64 - && is_x86_feature_detected!("avx512f") - && is_x86_feature_detected!("avx512vpopcntdq") + // SIMD kernels only pay off from 32 bytes. Below that, call the scalar kernel + // directly: it stays inlinable and skips the dispatch indirection, which would + // otherwise dominate the couple of word popcounts. + if bytes.len() < 32 { + return count_ones_aligned_scalar(bytes); + } + + static KERNEL: CpuKernel = CpuKernel::new(|| { + #[cfg(target_arch = "x86_64")] { - // SAFETY: Runtime detection guarantees the required target features. - return unsafe { count_ones_aligned_avx512(bytes) }; + if is_x86_feature_detected!("avx512f") + && is_x86_feature_detected!("avx512vpopcntdq") + && is_x86_feature_detected!("avx2") + { + return count_ones_aligned_avx512_any_len; + } + if is_x86_feature_detected!("avx2") { + return count_ones_aligned_avx2_any_len; + } } + count_ones_aligned_scalar + }); + // SAFETY: the selector only returns kernels that are safe or whose required CPU + // features were probed before selection. + unsafe { KERNEL.get()(bytes) } +} - if bytes.len() >= 32 && is_x86_feature_detected!("avx2") { - // SAFETY: Runtime detection guarantees the required target features. - return unsafe { count_ones_aligned_avx2(bytes) }; - } +/// Length-aware AVX-512 entry: SIMD only pays off from 64 (AVX-512) / 32 (AVX2) bytes. +/// +/// # Safety +/// Requires AVX-512F, AVX-512VPOPCNTDQ, and AVX2. +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "avx512f,avx512vpopcntdq,avx2")] +unsafe fn count_ones_aligned_avx512_any_len(bytes: &[u8]) -> usize { + if bytes.len() >= 64 { + // SAFETY: the caller guarantees the required target features. + return unsafe { count_ones_aligned_avx512(bytes) }; + } + if bytes.len() >= 32 { + // SAFETY: every AVX-512F CPU also supports AVX2. + return unsafe { count_ones_aligned_avx2(bytes) }; } + count_ones_aligned_scalar(bytes) +} +/// Length-aware AVX2 entry: SIMD only pays off from 32 bytes. +/// +/// # Safety +/// Requires AVX2. +#[cfg(target_arch = "x86_64")] +#[target_feature(enable = "avx2")] +unsafe fn count_ones_aligned_avx2_any_len(bytes: &[u8]) -> usize { + if bytes.len() >= 32 { + // SAFETY: the caller guarantees AVX2. + return unsafe { count_ones_aligned_avx2(bytes) }; + } count_ones_aligned_scalar(bytes) } diff --git a/vortex-buffer/src/bit/select.rs b/vortex-buffer/src/bit/select.rs index 53eded23dd4..deae368ddfb 100644 --- a/vortex-buffer/src/bit/select.rs +++ b/vortex-buffer/src/bit/select.rs @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors use super::count_ones::align_offset_len; +use crate::dispatch::CpuKernel; /// Returns the position of the `nth` set bit (0-indexed) within the logical range /// `[offset, offset + len)` of the given byte slice. @@ -82,32 +83,47 @@ pub fn bit_select(bytes: &[u8], offset: usize, len: usize, nth: usize) -> Option /// /// If `chunk_index < chunks.len()`, the target bit is inside that chunk and `remaining` /// is the rank *within* that chunk. Otherwise all chunks were consumed. -#[inline] -fn scan_chunks(chunks: &[[u8; 64]], remaining: usize, pos: usize) -> (usize, usize, usize) { - scan_chunks_impl(chunks, remaining, pos) -} +type ScanChunks = unsafe fn(&[[u8; 64]], usize, usize) -> (usize, usize, usize); -#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] -#[inline] -fn scan_chunks_impl(chunks: &[[u8; 64]], remaining: usize, pos: usize) -> (usize, usize, usize) { - scan_chunks_scalar(chunks, remaining, pos) -} - -#[cfg(target_arch = "x86_64")] #[inline] -fn scan_chunks_impl(chunks: &[[u8; 64]], remaining: usize, pos: usize) -> (usize, usize, usize) { - if is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512vpopcntdq") { - // SAFETY: runtime detection guarantees the required target features. - return unsafe { scan_chunks_avx512_vpopcnt(chunks, remaining, pos) }; +fn scan_chunks(chunks: &[[u8; 64]], remaining: usize, pos: usize) -> (usize, usize, usize) { + // Scans of a couple of chunks don't amortize the dispatch indirection: call the + // per-architecture unconditional kernel directly so it stays inlinable (see the + // size-gating note in the CpuKernel docs). + if chunks.len() <= 2 { + #[cfg(target_arch = "aarch64")] + return scan_chunks_neon(chunks, remaining, pos); + #[allow(unreachable_code)] + { + return scan_chunks_scalar(chunks, remaining, pos); + } } - scan_chunks_scalar(chunks, remaining, pos) + static KERNEL: CpuKernel = CpuKernel::new(|| { + #[cfg(target_arch = "x86_64")] + { + if is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512vpopcntdq") { + return scan_chunks_avx512_vpopcnt; + } + } + #[cfg(target_arch = "aarch64")] + return scan_chunks_neon; + // The aarch64 arm above returns unconditionally (NEON needs no probe), making + // this portable default unreachable there. + #[allow(unreachable_code)] + { + scan_chunks_scalar + } + }); + // SAFETY: the selector only returns kernels that are safe or whose required CPU + // features were probed before selection. + unsafe { KERNEL.get()(chunks, remaining, pos) } } #[cfg(target_arch = "aarch64")] #[allow(clippy::cast_possible_truncation)] // u64 → usize is lossless on aarch64 (64-bit) #[inline] -fn scan_chunks_impl( +fn scan_chunks_neon( chunks: &[[u8; 64]], mut remaining: usize, mut pos: usize, @@ -185,7 +201,6 @@ unsafe fn scan_chunks_avx512_vpopcnt( (remaining, pos, chunks.len()) } -#[cfg(not(target_arch = "aarch64"))] #[inline] fn scan_chunks_scalar( chunks: &[[u8; 64]], @@ -280,21 +295,26 @@ fn scan_words_scalar( // ── In-chunk select ───────────────────────────────────────────────────── +type SelectInChunk = unsafe fn(&[u8; 64], usize) -> usize; + /// Position of the `nth` set bit inside a 64-byte chunk (0-indexed). #[inline] fn select_in_chunk(chunk: &[u8; 64], nth: usize) -> usize { - #[cfg(target_arch = "x86_64")] - { - if is_x86_feature_detected!("avx512f") - && is_x86_feature_detected!("avx512vpopcntdq") - && is_x86_feature_detected!("avx512vbmi2") + static KERNEL: CpuKernel = CpuKernel::new(|| { + #[cfg(target_arch = "x86_64")] { - // SAFETY: runtime detection guarantees the required target features. - return unsafe { select_in_chunk_vbmi2(chunk, nth) }; + if is_x86_feature_detected!("avx512f") + && is_x86_feature_detected!("avx512vpopcntdq") + && is_x86_feature_detected!("avx512vbmi2") + { + return select_in_chunk_vbmi2; + } } - } - - select_in_chunk_scalar(chunk, nth) + select_in_chunk_scalar + }); + // SAFETY: the selector only returns kernels that are safe or whose required CPU + // features were probed before selection. + unsafe { KERNEL.get()(chunk, nth) } } #[cfg(target_arch = "x86_64")] @@ -343,7 +363,6 @@ fn select_in_chunk_scalar(chunk: &[u8; 64], mut nth: usize) -> usize { unreachable!("select_in_chunk: nth exceeds popcount") } -#[cfg(not(target_arch = "aarch64"))] #[inline] fn count_ones_chunk(chunk: &[u8; 64]) -> usize { let words = chunk.as_chunks::<8>().0; @@ -359,17 +378,23 @@ fn count_ones_chunk(chunk: &[u8; 64]) -> usize { // ── In-word select ────────────────────────────────────────────────────── +type SelectInWord = unsafe fn(u64, usize) -> usize; + /// Position of the `nth` set bit inside a u64 (0-indexed, little-endian bit order). #[inline] fn select_in_word(word: u64, nth: usize) -> usize { - #[cfg(target_arch = "x86_64")] - { - if is_x86_feature_detected!("bmi2") { - // SAFETY: runtime detection guarantees the required target feature. - return unsafe { select_in_word_bmi2(word, nth) }; + static KERNEL: CpuKernel = CpuKernel::new(|| { + #[cfg(target_arch = "x86_64")] + { + if is_x86_feature_detected!("bmi2") { + return select_in_word_bmi2; + } } - } - select_in_word_scalar(word, nth) + select_in_word_scalar + }); + // SAFETY: the selector only returns kernels that are safe or whose required CPU + // features were probed before selection. + unsafe { KERNEL.get()(word, nth) } } /// BMI2: deposit a single bit at the nth set-bit position, then count trailing zeros. diff --git a/vortex-buffer/src/dispatch.rs b/vortex-buffer/src/dispatch.rs new file mode 100644 index 00000000000..6040bf0c885 --- /dev/null +++ b/vortex-buffer/src/dispatch.rs @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! One-time CPU-feature-based function dispatch. +//! +//! [`CpuKernel`] holds a function pointer chosen by a *selector* exactly once, on the +//! first call. Every later call is a relaxed atomic load, a never-taken predicted +//! branch, and an indirect call — measured indistinguishable from a direct call (see +//! `benches/cpu_dispatch.rs`). +//! +//! The selector is ordinary code that models both dispatch dimensions: +//! +//! * **Compile time** (x86_64 vs aarch64): one `#[cfg(target_arch = ...)]` block per +//! architecture, each *returning early* with its chosen kernel. +//! * **Runtime** (AVX-512 vs BMI2 vs ...): an if-chain of feature probes inside that +//! block. +//! * The **portable default** is the plain tail expression. Because the architecture +//! arms return early, no `#[cfg(not(any(...)))]` negation is ever needed. An arm +//! that returns unconditionally (e.g. NEON, architecturally guaranteed on aarch64) +//! makes the tail unreachable on that architecture — wrap just the tail in an +//! `#[allow(unreachable_code)]` block. +//! +//! When kernels are `unsafe` `#[target_feature]` functions, make `F` an +//! `unsafe fn(...)` pointer type: the bare kernel names then coerce directly (no +//! closure wrappers), and the one dispatched call is wrapped in `unsafe` with a +//! SAFETY comment stating that the selector probed the required features. +//! +//! Races are benign: the slot only ever holds valid function pointers of the same +//! type, and every candidate must compute the same result. +//! +//! # When NOT to use it +//! +//! Do not put the dispatched call inside a per-element hot loop: the indirect call +//! blocks inlining. Hoist the decision to the outermost per-buffer entry point and +//! monomorphize the loop instead, like the `Bmi2`/`Portable` type-parameter pattern in +//! `vortex-mask::intersect_by_rank`. +//! +//! For the same reason, gate on input size *before* [`get`](CpuKernel::get) when tiny +//! inputs are common: call the portable kernel directly below the size where SIMD pays +//! off, so those calls stay inlinable and skip the dispatch entirely (see +//! `count_ones_aligned`). + +use core::mem::transmute_copy; +use core::ptr; +use core::sync::atomic::AtomicPtr; +use core::sync::atomic::Ordering; + +/// A function pointer selected by CPU-feature detection once, on first use. +/// +/// `F` must be a plain function-pointer type (`fn(...) -> ...`). The selector passed +/// to [`new`](Self::new) runs at most once per process (once per racing thread in the +/// worst case), on the first [`get`](Self::get), and its result is cached. +/// Non-capturing closures coerce to function pointers, so the selector can be written +/// inline in the `static`, like `LazyLock`. +/// +/// # Example +/// +/// ``` +/// use vortex_buffer::CpuKernel; +/// +/// /// Sums a slice, using the best kernel for the current CPU. +/// fn sum(values: &[u64]) -> u64 { +/// static KERNEL: CpuKernel u64> = CpuKernel::new(|| { +/// // Compile-time arm per architecture; runtime probes inside it. +/// #[cfg(target_arch = "x86_64")] +/// { +/// if std::arch::is_x86_feature_detected!("avx2") { +/// // return |values| unsafe { sum_avx2(values) }; +/// } +/// } +/// // Portable default: plain tail, no cfg(not(...)) needed. +/// |values| values.iter().sum() +/// }); +/// KERNEL.get()(values) +/// } +/// +/// assert_eq!(sum(&[1, 2, 3]), 6); +/// ``` +pub struct CpuKernel { + selected: AtomicPtr<()>, + select: fn() -> F, +} + +impl CpuKernel { + /// Create a kernel slot whose kernel is chosen by `select` on the first + /// [`get`](Self::get). + pub const fn new(select: fn() -> F) -> Self { + assert!( + size_of::() == size_of::<*mut ()>(), + "CpuKernel requires a function-pointer type" + ); + Self { + selected: AtomicPtr::new(ptr::null_mut()), + select, + } + } + + /// Return the selected kernel, running the selector on the first call. + /// + /// Steady state is a relaxed load plus a never-taken predicted branch. + #[inline] + pub fn get(&self) -> F { + let fn_ptr = self.selected.load(Ordering::Relaxed); + if fn_ptr.is_null() { + return self.select_slow(); + } + // SAFETY: non-null values in `selected` are always the bits of an `F` stored + // by `select_slow`, and `F` is pointer-sized (asserted in `new`). Function + // pointers are never null, so the null sentinel stays unambiguous. + unsafe { transmute_copy::<*mut (), F>(&fn_ptr) } + } + + #[cold] + fn select_slow(&self) -> F { + let kernel = (self.select)(); + // SAFETY: `F` is pointer-sized (asserted in `new`); a bitwise copy into a raw + // pointer preserves the function pointer for the transmute back in `get`. + let fn_ptr = unsafe { transmute_copy::(&kernel) }; + self.selected.store(fn_ptr, Ordering::Relaxed); + kernel + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + use super::CpuKernel; + + static SELECT_CALLS: AtomicUsize = AtomicUsize::new(0); + + fn add_one(x: u64) -> u64 { + static KERNEL: CpuKernel u64> = CpuKernel::new(|| { + SELECT_CALLS.fetch_add(1, Ordering::Relaxed); + |x| x + 1 + }); + KERNEL.get()(x) + } + + #[test] + fn selects_once_then_dispatches() { + assert_eq!(add_one(41), 42); + assert_eq!(add_one(1), 2); + assert_eq!(add_one(2), 3); + assert_eq!(SELECT_CALLS.load(Ordering::Relaxed), 1); + } + + #[test] + fn selector_early_returns_skip_the_default() { + static KERNEL: CpuKernel u64> = CpuKernel::new(|| { + if 1 + 1 == 2 { + return |x| x + 2; + } + |x| x + 100 + }); + assert_eq!(KERNEL.get()(0), 2); + } + + type XorFn = fn(&[u8; 4], &mut [u8; 4]); + + #[test] + fn supports_reference_arguments() { + static KERNEL: CpuKernel = CpuKernel::new(|| { + |input, output| { + for (o, i) in output.iter_mut().zip(input) { + *o ^= *i; + } + } + }); + let selected = KERNEL.get(); + let input = [1, 2, 3, 4]; + let mut output = [0, 0, 0, 0]; + selected(&input, &mut output); + assert_eq!(output, input); + } +} diff --git a/vortex-buffer/src/lib.rs b/vortex-buffer/src/lib.rs index 8319fffa387..ee113481353 100644 --- a/vortex-buffer/src/lib.rs +++ b/vortex-buffer/src/lib.rs @@ -52,6 +52,7 @@ pub use buffer::*; pub use buffer_mut::*; pub use bytes::*; pub use r#const::*; +pub use dispatch::*; pub use string::*; mod alignment; #[cfg(feature = "arrow")] @@ -62,6 +63,7 @@ mod buffer_mut; mod bytes; mod r#const; mod debug; +mod dispatch; mod macros; #[cfg(feature = "memmap2")] mod memmap2; diff --git a/vortex-mask/benches/rank.rs b/vortex-mask/benches/rank.rs index cd22f9ffd1f..fa2b820f75d 100644 --- a/vortex-mask/benches/rank.rs +++ b/vortex-mask/benches/rank.rs @@ -10,6 +10,11 @@ use vortex_buffer::BitBuffer; use vortex_mask::Mask; fn main() { + // Pre-resolve the one-time CpuKernel selections on the rank/select path so no + // benchmark iteration pays the first-call cost. + let warm = create_mask(512, 0.5); + let _ = warm.rank(warm.true_count() / 2); + divan::main(); } diff --git a/vortex-mask/src/intersect_by_rank.rs b/vortex-mask/src/intersect_by_rank.rs index 9b7da4a35dc..e5a8d6a08e5 100644 --- a/vortex-mask/src/intersect_by_rank.rs +++ b/vortex-mask/src/intersect_by_rank.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use vortex_buffer::BitBuffer; use vortex_buffer::BitChunkIterator; use vortex_buffer::BufferMut; +use vortex_buffer::CpuKernel; use vortex_error::VortexExpect; use crate::Mask; @@ -396,22 +397,32 @@ fn intersect_bit_buffers_dispatch( mask_buffer: &BitBuffer, true_count: usize, ) -> Mask { - #[cfg(target_arch = "x86_64")] - if std::arch::is_x86_feature_detected!("bmi2") { - return intersect_bit_buffers::(self_buffer, mask_buffer, true_count); - } - - intersect_bit_buffers::(self_buffer, mask_buffer, true_count) + type IntersectBuffers = fn(&BitBuffer, &BitBuffer, usize) -> Mask; + static KERNEL: CpuKernel = CpuKernel::new(|| { + #[cfg(target_arch = "x86_64")] + { + if std::arch::is_x86_feature_detected!("bmi2") { + return intersect_bit_buffers::; + } + } + intersect_bit_buffers:: + }); + KERNEL.get()(self_buffer, mask_buffer, true_count) } #[inline] fn intersect_rank_indices_dispatch(self_buffer: &BitBuffer, mask_indices: &[usize]) -> Mask { - #[cfg(target_arch = "x86_64")] - if std::arch::is_x86_feature_detected!("bmi2") { - return intersect_bit_buffer_by_rank_indices::(self_buffer, mask_indices); - } - - intersect_bit_buffer_by_rank_indices::(self_buffer, mask_indices) + type IntersectRankIndices = fn(&BitBuffer, &[usize]) -> Mask; + static KERNEL: CpuKernel = CpuKernel::new(|| { + #[cfg(target_arch = "x86_64")] + { + if std::arch::is_x86_feature_detected!("bmi2") { + return intersect_bit_buffer_by_rank_indices::; + } + } + intersect_bit_buffer_by_rank_indices:: + }); + KERNEL.get()(self_buffer, mask_indices) } #[inline] From 17f843ad7e279953034be16e9571147c4977d159 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Thu, 16 Jul 2026 12:48:36 -0400 Subject: [PATCH 099/104] Use u8 Union type IDs (#8790) Tracking issue: https://github.com/vortex-data/vortex/issues/8769 and https://github.com/vortex-data/vortex/issues/7882 Splits the type-ID foundation for #8769 into its own change. - changes Union variant type IDs from `i8` to `u8` across the dtype APIs, protobuf, FlatBuffers, and generated bindings - supports the full `u8` tag range - supports up to 256 Union variants with consecutive tags `0..=255` Signed-off-by: Connor Tsui --- vortex-array/src/dtype/serde/flatbuffers.rs | 17 +++- vortex-array/src/dtype/serde/proto.rs | 10 +-- vortex-array/src/dtype/serde/serde.rs | 2 +- vortex-array/src/dtype/union.rs | 89 +++++++++---------- .../flatbuffers/vortex-dtype/dtype.fbs | 2 +- vortex-proto/proto/dtype.proto | 2 +- vortex-proto/src/generated/vortex.dtype.rs | 2 +- 7 files changed, 65 insertions(+), 59 deletions(-) diff --git a/vortex-array/src/dtype/serde/flatbuffers.rs b/vortex-array/src/dtype/serde/flatbuffers.rs index eef15d59c57..6610ad5a580 100644 --- a/vortex-array/src/dtype/serde/flatbuffers.rs +++ b/vortex-array/src/dtype/serde/flatbuffers.rs @@ -117,10 +117,11 @@ impl UnionVariants { }) .collect::>(); - let type_ids: Vec = fb_union + let type_ids: Vec = fb_union .type_ids() .ok_or_else(|| vortex_err!("failed to parse union type_ids from flatbuffer"))? .iter() + .map(i8::cast_unsigned) .collect(); UnionVariants::try_from_fields(names, dtypes, type_ids) @@ -394,7 +395,15 @@ impl WriteFlatBuffer for DType { .collect::>>()?; let dtypes = Some(fbb.create_vector(&dtypes)); - let type_ids = Some(fbb.create_vector(uv.type_ids())); + // The FlatBuffers schema retains its original signed byte wire type for backwards + // compatibility. Reinterpreting the bits preserves the full u8 tag range. + let type_ids = uv + .type_ids() + .iter() + .copied() + .map(u8::cast_signed) + .collect_vec(); + let type_ids = Some(fbb.create_vector(&type_ids)); fb::Union::create( fbb, @@ -602,7 +611,7 @@ mod test { DType::Utf8(Nullability::NonNullable), DType::Bool(Nullability::NonNullable), ], - vec![0, 5, 7], + vec![0, 5, u8::MAX], ) .unwrap(), ); @@ -620,7 +629,7 @@ mod test { let DType::Union(uv) = &deserialized else { panic!("Expected Union"); }; - assert_eq!(uv.type_ids(), &[0, 5, 7]); + assert_eq!(uv.type_ids(), &[0, 5, u8::MAX]); } #[test] diff --git a/vortex-array/src/dtype/serde/proto.rs b/vortex-array/src/dtype/serde/proto.rs index e3dfb90557a..a0c27cb298b 100644 --- a/vortex-array/src/dtype/serde/proto.rs +++ b/vortex-array/src/dtype/serde/proto.rs @@ -98,8 +98,8 @@ impl DType { .type_ids .iter() .map(|t| { - i8::try_from(*t).map_err(|_| { - vortex_err!("Union type_id {t} somehow does not fit in i8") + u8::try_from(*t).map_err(|_| { + vortex_err!("Union type_id {t} somehow does not fit in u8") }) }) .collect::>>()?; @@ -469,8 +469,8 @@ mod tests { pb::DType::try_from(&DType::Primitive(PType::I32, Nullability::NonNullable)) .unwrap(), ], - // 200 does not fit in i8. - type_ids: vec![200], + // 256 does not fit in u8. + type_ids: vec![256], })), }; @@ -480,7 +480,7 @@ mod tests { result .unwrap_err() .to_string() - .contains("does not fit in i8") + .contains("does not fit in u8") ); } diff --git a/vortex-array/src/dtype/serde/serde.rs b/vortex-array/src/dtype/serde/serde.rs index 1d386a34cbf..7f360382d7e 100644 --- a/vortex-array/src/dtype/serde/serde.rs +++ b/vortex-array/src/dtype/serde/serde.rs @@ -431,7 +431,7 @@ impl<'de> DeserializeSeed<'de> for DTypeSerde<'_, UnionVariants> { { let mut names: Option = None; let mut dtypes: Option> = None; - let mut type_ids: Option> = None; + let mut type_ids: Option> = None; while let Some(key) = map.next_key::>()? { match key.as_ref() { diff --git a/vortex-array/src/dtype/union.rs b/vortex-array/src/dtype/union.rs index faec75751c5..b14a3b84d21 100644 --- a/vortex-array/src/dtype/union.rs +++ b/vortex-array/src/dtype/union.rs @@ -19,13 +19,11 @@ use crate::dtype::Nullability; /// Type information for a union array. /// /// A `UnionVariants` describes the possible alternative types for each row of a union, along with a -/// per-variant `i8` type tag. We use the term **variants** (rather than "fields") because a union +/// per-variant `u8` type tag. We use the term **variants** (rather than "fields") because a union /// is a sum type: each row chooses exactly one alternative. /// -/// Per Arrow's spec, the per-row type tag is an `int8`. By default, tag `i` selects the child at -/// offset `i` (`type_ids = [0, 1, ..., N-1]`). -/// -/// Type IDs are restricted to the Arrow-compatible range `0..=127`; negative tags are rejected. +/// By default, tag `i` selects the child at offset `i` (`type_ids = [0, 1, ..., N-1]`). Vortex uses +/// unsigned tags and supports up to 256 variants. /// /// Schemas may also use non-consecutive tags (e.g. `[0, 5, 7]`), in which case the value of /// `type_ids[i]` is the tag used in the data to select the child at offset `i`. Supporting @@ -114,11 +112,11 @@ struct UnionVariantsInner { /// consecutive offsets is `[0, 1, ..., N-1]`. /// /// For schemas with explicit `typeIds` indirection (e.g. `[0, 5, 7]`), this stores those tags. - type_ids: Arc<[i8]>, + type_ids: Arc<[u8]>, } impl UnionVariantsInner { - fn from_fields(names: FieldNames, dtypes: Arc<[FieldDType]>, type_ids: Arc<[i8]>) -> Self { + fn from_fields(names: FieldNames, dtypes: Arc<[FieldDType]>, type_ids: Arc<[u8]>) -> Self { Self { names, dtypes, @@ -160,7 +158,7 @@ impl UnionVariants { } /// Validate that `names`, `dtypes`, and `type_ids` are mutually consistent. - fn validate_shape(names: &FieldNames, n_dtypes: usize, type_ids: &[i8]) -> VortexResult<()> { + fn validate_shape(names: &FieldNames, n_dtypes: usize, type_ids: &[u8]) -> VortexResult<()> { vortex_ensure_eq!( names.len(), n_dtypes, @@ -182,9 +180,10 @@ impl UnionVariants { type_ids ); vortex_ensure!( - type_ids.iter().all(|type_id| *type_id >= 0), - "type_ids must be non-negative, got {:?}", - type_ids + type_ids.len() <= u8::MAX as usize + 1, + "union supports at most {} variants, got {}", + u8::MAX as usize + 1, + type_ids.len() ); vortex_ensure!( names.iter().all_unique(), @@ -200,12 +199,12 @@ impl UnionVariants { /// # Errors /// /// Returns an error if names, dtypes, or type IDs do not all have the same length, or if there - /// are any duplicate names or type ids, or if a type ID is negative. - pub fn try_new(names: FieldNames, dtypes: Vec, type_ids: Vec) -> VortexResult { + /// are any duplicate names or type IDs, or if there are more than 256 variants. + pub fn try_new(names: FieldNames, dtypes: Vec, type_ids: Vec) -> VortexResult { Self::validate_shape(&names, dtypes.len(), &type_ids)?; let dtypes: Arc<[FieldDType]> = dtypes.into_iter().map(FieldDType::from).collect(); - let type_ids: Arc<[i8]> = Arc::from(type_ids); + let type_ids: Arc<[u8]> = Arc::from(type_ids); Ok(Self(Arc::new(UnionVariantsInner::from_fields( names, dtypes, type_ids, @@ -217,21 +216,21 @@ impl UnionVariants { /// # Errors /// /// `names` and `dtypes` must have the same length, and `names.len()` cannot be more than - /// `i8::MAX as usize + 1` (128). + /// `u8::MAX as usize + 1` (256). pub fn new(names: FieldNames, dtypes: Vec) -> VortexResult { - const MAX_CONSECUTIVE: usize = i8::MAX as usize + 1; + const MAX_VARIANTS: usize = u8::MAX as usize + 1; vortex_ensure!( - names.len() <= MAX_CONSECUTIVE, + names.len() <= MAX_VARIANTS, "union supports at most {} consecutive variants, got {}", - MAX_CONSECUTIVE, + MAX_VARIANTS, names.len() ); #[expect( clippy::cast_possible_truncation, - reason = "the MAX_CONSECUTIVE bound above guarantees `i as i8` is in range" + reason = "the MAX_VARIANTS bound above guarantees `i as u8` is in range" )] - let type_ids: Vec = (0..names.len()).map(|i| i as i8).collect(); + let type_ids: Vec = (0..names.len()).map(|i| i as u8).collect(); Self::try_new(names, dtypes, type_ids) } @@ -244,11 +243,11 @@ impl UnionVariants { /// # Errors /// /// Returns an error if names, dtypes, or type IDs do not all have the same length, or if there - /// are any duplicate names or type ids, or if a type ID is negative. + /// are any duplicate names or type IDs, or if there are more than 256 variants. pub(crate) fn try_from_fields( names: FieldNames, dtypes: Vec, - type_ids: Vec, + type_ids: Vec, ) -> VortexResult { Self::validate_shape(&names, dtypes.len(), &type_ids)?; @@ -276,7 +275,7 @@ impl UnionVariants { /// Returns the per-variant type tag vector. Entry `i` is the tag that the data uses to /// select the variant at offset `i`. - pub fn type_ids(&self) -> &[i8] { + pub fn type_ids(&self) -> &[u8] { &self.0.type_ids } @@ -286,12 +285,12 @@ impl UnionVariants { .type_ids .iter() .enumerate() - .all(|(i, &tag)| i8::try_from(i).is_ok_and(|i| i == tag)) + .all(|(i, &tag)| u8::try_from(i).is_ok_and(|i| i == tag)) } /// Find the offset of a variant by name. Returns `None` if no variant has the name. /// - /// This is a linear scan over [`Self::names`]. A union has at most 128 variants (and usually + /// This is a linear scan over [`Self::names`]. A union has at most 256 variants (and usually /// far fewer), so a linear scan beats building and probing a `HashMap` in practice. pub fn find(&self, name: impl AsRef) -> Option { let name = name.as_ref(); @@ -331,9 +330,9 @@ impl UnionVariants { /// `type_ids`. /// /// This is a linear scan over [`Self::type_ids`]. The number of variants is bounded by - /// `i8::MAX + 1 = 128`, which fits in two cache lines, so a linear scan is faster than a - /// `HashMap` lookup in practice. - pub fn tag_to_child_index(&self, tag: i8) -> Option { + /// 256, so a linear scan is faster than a `HashMap` lookup in practice for the + /// small unions typically encountered. + pub fn tag_to_child_index(&self, tag: u8) -> Option { self.0.type_ids.iter().position(|&t| t == tag) } @@ -342,7 +341,7 @@ impl UnionVariants { /// # Panics /// /// Panics if `index >= self.len()`. - pub fn child_index_to_tag(&self, index: usize) -> i8 { + pub fn child_index_to_tag(&self, index: usize) -> u8 { self.0.type_ids[index] } @@ -437,14 +436,14 @@ mod tests { } #[test] - fn test_negative_type_ids_rejected() { - let result = UnionVariants::try_new( - ["negative"].into(), + fn test_high_type_id() { + let variants = UnionVariants::try_new( + ["high"].into(), vec![DType::Primitive(PType::I32, Nullability::NonNullable)], - vec![-1], - ); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("non-negative")); + vec![u8::MAX], + ) + .unwrap(); + assert_eq!(variants.type_ids(), &[u8::MAX]); } #[test] @@ -575,23 +574,21 @@ mod tests { #[test] fn test_new_max_size() { - // 128 variants is the maximum for consecutive type_ids: tags 0..=127 all fit in i8. - let names: Vec = (0..128).map(|i| format!("v{i}")).collect(); - let dtypes: Vec = (0..128) + let names: Vec = (0..256).map(|i| format!("v{i}")).collect(); + let dtypes: Vec = (0..256) .map(|_| DType::Primitive(PType::I32, Nullability::NonNullable)) .collect(); let names: FieldNames = names.into_iter().collect(); let variants = UnionVariants::new(names, dtypes).unwrap(); - assert_eq!(variants.len(), 128); - assert_eq!(variants.type_ids()[127], 127); + assert_eq!(variants.len(), 256); + assert_eq!(variants.type_ids()[255], 255); assert!(variants.is_consecutive()); } #[test] fn test_new_too_large_rejected() { - // 129 variants exceeds i8::MAX + 1 = 128. - let names: Vec = (0..129).map(|i| format!("v{i}")).collect(); - let dtypes: Vec = (0..129) + let names: Vec = (0..257).map(|i| format!("v{i}")).collect(); + let dtypes: Vec = (0..257) .map(|_| DType::Primitive(PType::I32, Nullability::NonNullable)) .collect(); let names: FieldNames = names.into_iter().collect(); @@ -601,7 +598,7 @@ mod tests { result .unwrap_err() .to_string() - .contains("at most 128 consecutive variants") + .contains("at most 256 consecutive variants") ); } @@ -610,7 +607,7 @@ mod tests { let v = UnionVariants::empty(); assert!(v.is_empty()); assert_eq!(v.len(), 0); - assert_eq!(v.type_ids(), &[] as &[i8]); + assert_eq!(v.type_ids(), &[] as &[u8]); assert!(v.is_consecutive()); } } diff --git a/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs b/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs index add1ae63e2e..f3b51c845b1 100644 --- a/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs +++ b/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs @@ -70,7 +70,7 @@ table Variant { table Union { names: [string]; dtypes: [DType]; - type_ids: [byte]; // length must equal dtypes.len() + type_ids: [byte]; // length must equal dtypes.len(); interpreted as unsigned } union Type { diff --git a/vortex-proto/proto/dtype.proto b/vortex-proto/proto/dtype.proto index decc2179080..675a9156f22 100644 --- a/vortex-proto/proto/dtype.proto +++ b/vortex-proto/proto/dtype.proto @@ -77,7 +77,7 @@ message Variant { message Union { repeated string names = 1; repeated DType dtypes = 2; - repeated int32 type_ids = 3; // length must equal dtypes.len(); each value must fit in int8 + repeated int32 type_ids = 3; // length must equal dtypes.len(); each value must fit in uint8 } message DType { diff --git a/vortex-proto/src/generated/vortex.dtype.rs b/vortex-proto/src/generated/vortex.dtype.rs index 4bffca278e2..984b4b6b71e 100644 --- a/vortex-proto/src/generated/vortex.dtype.rs +++ b/vortex-proto/src/generated/vortex.dtype.rs @@ -77,7 +77,7 @@ pub struct Union { pub names: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, #[prost(message, repeated, tag = "2")] pub dtypes: ::prost::alloc::vec::Vec, - /// length must equal dtypes.len(); each value must fit in int8 + /// length must equal dtypes.len(); each value must fit in uint8 #[prost(int32, repeated, tag = "3")] pub type_ids: ::prost::alloc::vec::Vec, } From 407720c740b8d5a29972193fa7f30e0664dd1694 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Thu, 16 Jul 2026 16:20:36 -0400 Subject: [PATCH 100/104] Add outer nullability to Union (#8798) Tracking issue: https://github.com/vortex-data/vortex/issues/8769 Adds the breaking Union dtype foundation before scalar and array support. ### Changes - changes `DType::Union` to carry independent outer nullability - keeps outer nullability separate from the nullability of each variant - removes runtime-derived Union nullability - updates dtype coercion, serialization, display, and downstream matches This changes the serialized Serde representation of Union dtypes and will require the compatibility override to be reviewed explicitly. Signed-off-by: Connor Tsui --- .../fns/uncompressed_size_in_bytes/mod.rs | 2 +- vortex-array/src/dtype/coercion.rs | 38 +++------- vortex-array/src/dtype/dtype_impl.rs | 23 ++++-- vortex-array/src/dtype/mod.rs | 12 ++- vortex-array/src/dtype/serde/flatbuffers.rs | 12 ++- vortex-array/src/dtype/serde/mod.rs | 3 +- vortex-array/src/dtype/serde/proto.rs | 20 ++--- vortex-array/src/dtype/serde/serde.rs | 58 ++++++++++++++- vortex-array/src/dtype/union.rs | 73 ++++++------------- vortex-datafusion/src/convert/scalars.rs | 3 +- .../flatbuffers/vortex-dtype/dtype.fbs | 1 + vortex-flatbuffers/src/generated/dtype.rs | 17 +++++ vortex-flatbuffers/src/generated/message.rs | 2 +- vortex-proto/proto/dtype.proto | 1 + vortex-proto/src/generated/vortex.dtype.rs | 2 + 15 files changed, 153 insertions(+), 114 deletions(-) diff --git a/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs b/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs index 6b2feb380a7..1d04a074d6f 100644 --- a/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs +++ b/vortex-array/src/aggregate_fn/fns/uncompressed_size_in_bytes/mod.rs @@ -289,7 +289,7 @@ fn supports_uncompressed_size_in_bytes(dtype: &DType) -> bool { DType::Struct(fields, _) => fields .fields() .all(|field| supports_uncompressed_size_in_bytes(&field)), - DType::Union(variants) => variants + DType::Union(variants, _) => variants .variants() .all(|variant| supports_uncompressed_size_in_bytes(&variant)), DType::Variant(_) => false, diff --git a/vortex-array/src/dtype/coercion.rs b/vortex-array/src/dtype/coercion.rs index 3df356d93af..4d30a80c216 100644 --- a/vortex-array/src/dtype/coercion.rs +++ b/vortex-array/src/dtype/coercion.rs @@ -79,23 +79,8 @@ impl DType { /// The core primitive — what type can hold both `self` and `other`? /// Returns `None` if no common supertype exists. pub fn least_supertype(&self, other: &DType) -> Option { - match (self, other) { - (DType::Union(lhs), DType::Union(rhs)) => { - return (lhs == rhs).then(|| self.clone()); - } - (DType::Null, DType::Union(variants)) => { - return variants - .derived_nullability() - .is_nullable() - .then(|| other.clone()); - } - (DType::Union(variants), DType::Null) => { - return variants - .derived_nullability() - .is_nullable() - .then(|| self.clone()); - } - _ => {} + if let (DType::Union(lhs, lhs_null), DType::Union(rhs, rhs_null)) = (self, other) { + return (lhs == rhs).then(|| DType::Union(lhs.clone(), *lhs_null | *rhs_null)); } let union_null = self.nullability() | other.nullability(); @@ -377,6 +362,7 @@ mod tests { vec![DType::Primitive(PType::I32, NonNullable)], ) .unwrap(), + NonNullable, ); let nullable = DType::Union( UnionVariants::new( @@ -384,6 +370,7 @@ mod tests { vec![DType::Primitive(PType::I32, Nullable)], ) .unwrap(), + NonNullable, ); assert_eq!( @@ -395,29 +382,22 @@ mod tests { } #[test] - fn least_supertype_null_requires_nullable_union() { + fn least_supertype_null_makes_union_outer_nullable() { let nonnullable = DType::Union( UnionVariants::new( ["value"].into(), vec![DType::Primitive(PType::I32, NonNullable)], ) .unwrap(), + NonNullable, ); - let nullable = DType::Union( - UnionVariants::new( - ["value"].into(), - vec![DType::Primitive(PType::I32, Nullable)], - ) - .unwrap(), - ); + let nullable = nonnullable.as_nullable(); - assert!(DType::Null.least_supertype(&nonnullable).is_none()); - assert!(nonnullable.least_supertype(&DType::Null).is_none()); assert_eq!( - DType::Null.least_supertype(&nullable), + DType::Null.least_supertype(&nonnullable), Some(nullable.clone()) ); - assert_eq!(nullable.least_supertype(&DType::Null), Some(nullable)); + assert_eq!(nonnullable.least_supertype(&DType::Null), Some(nullable)); } #[test] diff --git a/vortex-array/src/dtype/dtype_impl.rs b/vortex-array/src/dtype/dtype_impl.rs index 5767a124667..20ff3d6a182 100644 --- a/vortex-array/src/dtype/dtype_impl.rs +++ b/vortex-array/src/dtype/dtype_impl.rs @@ -64,8 +64,8 @@ impl DType { | List(_, null) | FixedSizeList(_, _, null) | Struct(_, null) + | Union(_, null) | Variant(null) => matches!(null, Nullability::Nullable), - Union(variants) => variants.derived_nullability().is_nullable(), Extension(ext_dtype) => ext_dtype.storage_dtype().is_nullable(), } } @@ -82,8 +82,7 @@ impl DType { /// Get a new DType with the given nullability (but otherwise the same as `self`). /// - /// [`DType::Null`] and [`DType::Union`] have intrinsic nullability and are returned unchanged. - /// To change a union's nullability, construct different [`UnionVariants`]. + /// [`DType::Null`] has intrinsic nullability and is returned unchanged. pub fn with_nullability(&self, nullability: Nullability) -> Self { match self { Null => Null, @@ -95,7 +94,7 @@ impl DType { List(edt, _) => List(Arc::clone(edt), nullability), FixedSizeList(edt, size, _) => FixedSizeList(Arc::clone(edt), *size, nullability), Struct(sf, _) => Struct(sf.clone(), nullability), - Union(vs) => Union(vs.clone()), + Union(vs, _) => Union(vs.clone(), nullability), Variant(_) => Variant(nullability), Extension(ext) => Extension(ext.with_nullability(nullability)), } @@ -127,7 +126,7 @@ impl DType { .zip_eq(rhs_dtype.fields()) .all(|(l, r)| l.eq_ignore_nullability(&r))) } - (Union(lhs), Union(rhs)) => { + (Union(lhs, _), Union(rhs, _)) => { // Equal `names` implies equal length by FieldNames equality. lhs.names() == rhs.names() && lhs.type_ids() == rhs.type_ids() @@ -439,12 +438,20 @@ impl DType { /// Get the [`UnionVariants`] if `self` is a [`DType::Union`], otherwise `None`. pub fn as_union_variants_opt(&self) -> Option<&UnionVariants> { - if let Union(uv) = self { Some(uv) } else { None } + if let Union(uv, _) = self { + Some(uv) + } else { + None + } } /// Owned version of [Self::as_union_variants_opt]. pub fn into_union_variants_opt(self) -> Option { - if let Union(uv) = self { Some(uv) } else { None } + if let Union(uv, _) = self { + Some(uv) + } else { + None + } } /// Downcast a `DType` to an `ExtDType` @@ -498,7 +505,7 @@ impl Display for DType { .map(|(field_null, dt)| format!("{field_null}={dt}")) .join(", "), ), - Union(uv) => write!(f, "union({uv}){}", uv.derived_nullability()), + Union(uv, null) => write!(f, "union({uv}){null}"), Variant(null) => write!(f, "variant{null}"), Extension(ext) => write!(f, "{}", ext), } diff --git a/vortex-array/src/dtype/mod.rs b/vortex-array/src/dtype/mod.rs index 0160b4c36c7..8b579c70d39 100644 --- a/vortex-array/src/dtype/mod.rs +++ b/vortex-array/src/dtype/mod.rs @@ -111,15 +111,13 @@ pub enum DType { /// A logical union (sum) type. /// /// A `Union` is composed of one or more **variants**, each with a name and a `DType`. A per-row - /// `i8` tag selects which variant is "live" at that row. + /// `u8` tag selects which variant is "live" at that row. /// - /// Unlike other nested types, a union has no independent outer nullability. Its nullability is - /// derived at runtime from its variants: a union is nullable when any variant can contain null. - /// A concrete union array has no parent validity bitmap; a row's validity is the validity of - /// its selected child. + /// A union has independent outer nullability in addition to the nullability of each variant. + /// This distinguishes a null union from a non-null union whose selected child is null. /// /// See [`UnionVariants`] for the type-tag conventions and accessors. - Union(UnionVariants), + Union(UnionVariants, Nullability), /// Dynamically typed values stored as Vortex scalars. /// @@ -152,7 +150,7 @@ impl PartialEq for DType { // StructFields handles its own Arc::ptr_eq in its PartialEq impl. (Self::Struct(a, na), Self::Struct(b, nb)) => na == nb && a == b, // UnionVariants handles its own Arc::ptr_eq in its PartialEq impl. - (Self::Union(a), Self::Union(b)) => a == b, + (Self::Union(a, na), Self::Union(b, nb)) => na == nb && a == b, (Self::Variant(a), Self::Variant(b)) => a == b, (Self::Extension(a), Self::Extension(b)) => a == b, // Every variant is listed in the first position so that adding a new diff --git a/vortex-array/src/dtype/serde/flatbuffers.rs b/vortex-array/src/dtype/serde/flatbuffers.rs index 6610ad5a580..a796490f58a 100644 --- a/vortex-array/src/dtype/serde/flatbuffers.rs +++ b/vortex-array/src/dtype/serde/flatbuffers.rs @@ -235,7 +235,7 @@ impl TryFrom for DType { .ok_or_else(|| vortex_err!("failed to parse union from flatbuffer"))?; let variants = UnionVariants::from_fb(fb_union, vfdt.buffer().clone(), vfdt.session.clone())?; - Ok(Self::Union(variants)) + Ok(Self::Union(variants, fb_union.nullable().into())) } fb::Type::Variant => { let fb_variant = fb @@ -381,7 +381,7 @@ impl WriteFlatBuffer for DType { ) .as_union_value() } - Self::Union(uv) => { + Self::Union(uv, n) => { let names = uv .names() .iter() @@ -411,6 +411,7 @@ impl WriteFlatBuffer for DType { names, dtypes, type_ids, + nullable: (*n).into(), }, ) .as_union_value() @@ -581,6 +582,7 @@ mod test { ], ) .unwrap(), + Nullability::NonNullable, ) } @@ -597,6 +599,7 @@ mod test { vec![DType::Null, DType::Utf8(Nullability::NonNullable)], ) .unwrap(), + Nullability::NonNullable, ); roundtrip_dtype(dtype); } @@ -614,6 +617,7 @@ mod test { vec![0, 5, u8::MAX], ) .unwrap(), + Nullability::Nullable, ); let bytes = dtype.write_flatbuffer_bytes().unwrap(); @@ -626,7 +630,7 @@ mod test { let deserialized = DType::try_from(view).unwrap(); assert_eq!(dtype, deserialized); - let DType::Union(uv) = &deserialized else { + let DType::Union(uv, _) = &deserialized else { panic!("Expected Union"); }; assert_eq!(uv.type_ids(), &[0, 5, u8::MAX]); @@ -649,6 +653,7 @@ mod test { vec![DType::Utf8(Nullability::NonNullable), struct_with_union], ) .unwrap(), + Nullability::Nullable, ); roundtrip_dtype(outer_union); @@ -704,6 +709,7 @@ mod test { names: Some(names), dtypes: Some(dtypes), type_ids: Some(type_ids), + nullable: false, }, ); diff --git a/vortex-array/src/dtype/serde/mod.rs b/vortex-array/src/dtype/serde/mod.rs index 17e07c244cb..c6837f4ab2d 100644 --- a/vortex-array/src/dtype/serde/mod.rs +++ b/vortex-array/src/dtype/serde/mod.rs @@ -187,12 +187,13 @@ mod test { fn test_serde_union_dtype_json_roundtrip() { let dtype = DType::Union( UnionVariants::new(["value"].into(), vec![DType::Utf8(Nullability::Nullable)]).unwrap(), + Nullability::Nullable, ); let json = serde_json::to_string(&dtype).unwrap(); assert_eq!( json, - r#"{"Union":{"names":["value"],"dtypes":[{"Utf8":true}],"type_ids":[0]}}"# + r#"{"Union":[{"names":["value"],"dtypes":[{"Utf8":true}],"type_ids":[0]},true]}"# ); let mut deserializer = serde_json::Deserializer::from_str(&json); let deserialized: DType = DTypeSerde::::new(&SESSION) diff --git a/vortex-array/src/dtype/serde/proto.rs b/vortex-array/src/dtype/serde/proto.rs index a0c27cb298b..8389a90200a 100644 --- a/vortex-array/src/dtype/serde/proto.rs +++ b/vortex-array/src/dtype/serde/proto.rs @@ -104,7 +104,7 @@ impl DType { }) .collect::>>()?; let variants = UnionVariants::try_new(names, dtypes, type_ids)?; - Ok(Self::Union(variants)) + Ok(Self::Union(variants, u.nullable.into())) } DtypeType::Variant(v) => Ok(Self::Variant(v.nullable.into())), DtypeType::Extension(e) => { @@ -173,13 +173,14 @@ impl TryFrom<&DType> for pb::DType { .collect::>>()?, nullable: (*null).into(), }), - DType::Union(uv) => DtypeType::Union(pb::Union { + DType::Union(uv, null) => DtypeType::Union(pb::Union { names: uv.names().iter().map(|n| n.as_ref().to_string()).collect(), dtypes: uv .variants() .map(|d| Self::try_from(&d)) .collect::>>()?, type_ids: uv.type_ids().iter().map(|t| *t as i32).collect(), + nullable: (*null).into(), }), DType::Variant(null) => DtypeType::Variant(pb::Variant { nullable: (*null).into(), @@ -416,22 +417,20 @@ mod tests { ], ) .unwrap(); - let dtype = DType::Union(variants); + let dtype = DType::Union(variants, Nullability::NonNullable); let converted = round_trip_dtype(&dtype); assert_eq!(dtype, converted); } #[test] - fn test_union_round_trip_proto_with_nullable_variant() { + fn test_union_round_trip_proto_with_inner_and_outer_nullability() { let variants = UnionVariants::new( ["null_variant", "str"].into(), vec![DType::Null, DType::Utf8(Nullability::NonNullable)], ) .unwrap(); - assert_eq!(variants.derived_nullability(), Nullability::Nullable); - - let dtype = DType::Union(variants); + let dtype = DType::Union(variants, Nullability::Nullable); let converted = round_trip_dtype(&dtype); assert_eq!(dtype, converted); } @@ -449,11 +448,11 @@ mod tests { ) .unwrap(); - let dtype = DType::Union(variants); + let dtype = DType::Union(variants, Nullability::Nullable); let converted = round_trip_dtype(&dtype); assert_eq!(dtype, converted); - let DType::Union(uv) = &converted else { + let DType::Union(uv, _) = &converted else { panic!("Expected Union"); }; assert_eq!(uv.type_ids(), &[0, 5, 7]); @@ -471,6 +470,7 @@ mod tests { ], // 256 does not fit in u8. type_ids: vec![256], + nullable: false, })), }; @@ -495,6 +495,7 @@ mod tests { ], ) .unwrap(), + Nullability::NonNullable, ); let struct_with_union = DType::Struct( @@ -511,6 +512,7 @@ mod tests { vec![DType::Utf8(Nullability::NonNullable), struct_with_union], ) .unwrap(), + Nullability::Nullable, ); let converted = round_trip_dtype(&outer_union); diff --git a/vortex-array/src/dtype/serde/serde.rs b/vortex-array/src/dtype/serde/serde.rs index 7f360382d7e..b3870b8c03e 100644 --- a/vortex-array/src/dtype/serde/serde.rs +++ b/vortex-array/src/dtype/serde/serde.rs @@ -116,7 +116,12 @@ impl Serialize for DType { state.serialize_field(n)?; state.end() } - DType::Union(uv) => serializer.serialize_newtype_variant("DType", 11, "Union", uv), + DType::Union(uv, n) => { + let mut state = serializer.serialize_tuple_variant("DType", 11, "Union", 2)?; + state.serialize_field(uv)?; + state.serialize_field(n)?; + state.end() + } DType::Variant(n) => serializer.serialize_newtype_variant("DType", 10, "Variant", n), DType::Extension(ext) => { serializer.serialize_newtype_variant("DType", 9, "Extension", ext) @@ -232,9 +237,9 @@ impl<'de> DeserializeSeed<'de> for DTypeSerde<'_, DType> { "Struct" => access.newtype_variant_seed(StructFieldsSeed { session: self.session, }), - "Union" => access - .newtype_variant_seed(DTypeSerde::::new(self.session)) - .map(DType::Union), + "Union" => access.newtype_variant_seed(UnionFieldsSeed { + session: self.session, + }), "Variant" => { let n = access.newtype_variant()?; Ok(DType::Variant(n)) @@ -405,6 +410,51 @@ impl<'de> DeserializeSeed<'de> for StructFieldsSeed<'_> { } } +struct UnionFieldsSeed<'a> { + session: &'a VortexSession, +} + +impl<'de> DeserializeSeed<'de> for UnionFieldsSeed<'_> { + type Value = DType; + + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct UnionVisitor<'a> { + session: &'a VortexSession, + } + + impl<'de> Visitor<'de> for UnionVisitor<'_> { + type Value = DType; + + fn expecting(&self, f: &mut Formatter) -> fmt::Result { + f.write_str("Union tuple (variants, nullability)") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let variants = seq + .next_element_seed(DTypeSerde::::new(self.session))? + .ok_or_else(|| de::Error::invalid_length(0, &self))?; + let nullability = seq + .next_element()? + .ok_or_else(|| de::Error::invalid_length(1, &self))?; + Ok(DType::Union(variants, nullability)) + } + } + + deserializer.deserialize_tuple( + 2, + UnionVisitor { + session: self.session, + }, + ) + } +} + impl<'de> DeserializeSeed<'de> for DTypeSerde<'_, UnionVariants> { type Value = UnionVariants; diff --git a/vortex-array/src/dtype/union.rs b/vortex-array/src/dtype/union.rs index b14a3b84d21..7ce53155adc 100644 --- a/vortex-array/src/dtype/union.rs +++ b/vortex-array/src/dtype/union.rs @@ -14,7 +14,6 @@ use vortex_error::vortex_ensure_eq; use crate::dtype::DType; use crate::dtype::FieldDType; use crate::dtype::FieldNames; -use crate::dtype::Nullability; /// Type information for a union array. /// @@ -344,20 +343,10 @@ impl UnionVariants { pub fn child_index_to_tag(&self, index: usize) -> u8 { self.0.type_ids[index] } - - /// Returns the runtime-derived nullability of the union. - /// - /// This is not a zero-cost accessor: it scans every variant and materializes each - /// [`FieldDType`], which may be expensive when variants are still flatbuffer-backed views. - pub fn derived_nullability(&self) -> Nullability { - self.variants().any(|dtype| dtype.is_nullable()).into() - } } #[cfg(test)] mod tests { - use rstest::rstest; - use crate::dtype::DType; use crate::dtype::FieldNames; use crate::dtype::Nullability; @@ -476,39 +465,11 @@ mod tests { assert!(result.is_err()); } - #[rstest] - #[case::nullable_with_null_child( - vec![ - DType::Null, - DType::Primitive(PType::I32, Nullability::NonNullable), - ], - Nullability::Nullable, - )] - #[case::nullable_with_nullable_child( - vec![ - DType::Primitive(PType::I32, Nullability::NonNullable), - DType::Utf8(Nullability::Nullable), - ], - Nullability::Nullable, - )] - #[case::nonnullable( - vec![ - DType::Primitive(PType::I32, Nullability::NonNullable), - DType::Utf8(Nullability::NonNullable), - ], - Nullability::NonNullable, - )] - #[case::empty(vec![], Nullability::NonNullable)] - fn test_derived_nullability(#[case] dtypes: Vec, #[case] expected: Nullability) { - let names: Vec<&str> = (0..dtypes.len()).map(|i| ["a", "b", "c", "d"][i]).collect(); - let variants = UnionVariants::new(names.as_slice().into(), dtypes).unwrap(); - assert_eq!(variants.derived_nullability(), expected); - } - #[test] - fn test_nested_union_nullability() { + fn test_nested_union_nullability_is_independent() { let inner = DType::Union( UnionVariants::new(["value"].into(), vec![DType::Utf8(Nullability::Nullable)]).unwrap(), + Nullability::NonNullable, ); let outer = DType::Union( UnionVariants::new( @@ -519,28 +480,35 @@ mod tests { ], ) .unwrap(), + Nullability::Nullable, ); assert_eq!(outer.nullability(), Nullability::Nullable); + let variants = outer.as_union_variants_opt().unwrap(); + assert_eq!( + variants.variant_by_index(0).unwrap().nullability(), + Nullability::NonNullable + ); } #[test] - fn test_with_nullability_does_not_change_union_variants() { - let nonnullable = DType::Union(i32_variants()); - assert_eq!(nonnullable.as_nullable(), nonnullable); + fn test_with_nullability_changes_only_outer_union() { + let nonnullable = DType::Union(i32_variants(), Nullability::NonNullable); assert_eq!(nonnullable.nullability(), Nullability::NonNullable); - let nullable = DType::Union( - UnionVariants::new(["value"].into(), vec![DType::Utf8(Nullability::Nullable)]).unwrap(), - ); - assert_eq!(nullable.as_nonnullable(), nullable); + let nullable = nonnullable.as_nullable(); assert_eq!(nullable.nullability(), Nullability::Nullable); + assert_eq!( + nullable.as_union_variants_opt(), + nonnullable.as_union_variants_opt() + ); + assert_eq!(nullable.as_nonnullable(), nonnullable); } #[test] fn test_display() { let variants = i32_variants(); - let dtype = DType::Union(variants); + let dtype = DType::Union(variants, Nullability::NonNullable); assert_eq!(dtype.to_string(), "union(int=i32, str=utf8)"); let nullable = DType::Union( @@ -552,8 +520,13 @@ mod tests { ], ) .unwrap(), + Nullability::Nullable, ); assert_eq!(nullable.to_string(), "union(int=i32, maybe_str=utf8?)?"); + assert_eq!( + nullable.as_nonnullable().to_string(), + "union(int=i32, maybe_str=utf8?)" + ); } #[test] @@ -568,7 +541,7 @@ mod tests { vec![0, 5, 7], ) .unwrap(); - let dtype = DType::Union(variants); + let dtype = DType::Union(variants, Nullability::NonNullable); assert_eq!(dtype.to_string(), "union(a@0=i32, b@5=utf8, c@7=bool)"); } diff --git a/vortex-datafusion/src/convert/scalars.rs b/vortex-datafusion/src/convert/scalars.rs index e72a1f9ace3..ec262b479d9 100644 --- a/vortex-datafusion/src/convert/scalars.rs +++ b/vortex-datafusion/src/convert/scalars.rs @@ -832,7 +832,8 @@ mod tests { ["a"].into(), vec![DType::Primitive(PType::I32, Nullability::Nullable)], ) - .unwrap() + .unwrap(), + Nullability::Nullable, )))] fn unsupported_vortex_scalars_return_errors(#[case] scalar: Scalar) { let err = scalar.try_to_df().unwrap_err(); diff --git a/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs b/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs index f3b51c845b1..fb3b19513de 100644 --- a/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs +++ b/vortex-flatbuffers/flatbuffers/vortex-dtype/dtype.fbs @@ -71,6 +71,7 @@ table Union { names: [string]; dtypes: [DType]; type_ids: [byte]; // length must equal dtypes.len(); interpreted as unsigned + nullable: bool; } union Type { diff --git a/vortex-flatbuffers/src/generated/dtype.rs b/vortex-flatbuffers/src/generated/dtype.rs index 953e826da82..56536ad0b94 100644 --- a/vortex-flatbuffers/src/generated/dtype.rs +++ b/vortex-flatbuffers/src/generated/dtype.rs @@ -1480,6 +1480,7 @@ impl<'a> Union<'a> { pub const VT_NAMES: ::flatbuffers::VOffsetT = 4; pub const VT_DTYPES: ::flatbuffers::VOffsetT = 6; pub const VT_TYPE_IDS: ::flatbuffers::VOffsetT = 8; + pub const VT_NULLABLE: ::flatbuffers::VOffsetT = 10; #[inline] pub unsafe fn init_from_table(table: ::flatbuffers::Table<'a>) -> Self { @@ -1494,6 +1495,7 @@ impl<'a> Union<'a> { if let Some(x) = args.type_ids { builder.add_type_ids(x); } if let Some(x) = args.dtypes { builder.add_dtypes(x); } if let Some(x) = args.names { builder.add_names(x); } + builder.add_nullable(args.nullable); builder.finish() } @@ -1519,6 +1521,13 @@ impl<'a> Union<'a> { // which contains a valid value in this slot unsafe { self._tab.get::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'a, i8>>>(Union::VT_TYPE_IDS, None)} } + #[inline] + pub fn nullable(&self) -> bool { + // Safety: + // Created from valid Table for this object + // which contains a valid value in this slot + unsafe { self._tab.get::(Union::VT_NULLABLE, Some(false)).unwrap()} + } } impl ::flatbuffers::Verifiable for Union<'_> { @@ -1530,6 +1539,7 @@ impl ::flatbuffers::Verifiable for Union<'_> { .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset<&'_ str>>>>("names", Self::VT_NAMES, false)? .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, ::flatbuffers::ForwardsUOffset>>>("dtypes", Self::VT_DTYPES, false)? .visit_field::<::flatbuffers::ForwardsUOffset<::flatbuffers::Vector<'_, i8>>>("type_ids", Self::VT_TYPE_IDS, false)? + .visit_field::("nullable", Self::VT_NULLABLE, false)? .finish(); Ok(()) } @@ -1538,6 +1548,7 @@ pub struct UnionArgs<'a> { pub names: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset<&'a str>>>>, pub dtypes: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, ::flatbuffers::ForwardsUOffset>>>>, pub type_ids: Option<::flatbuffers::WIPOffset<::flatbuffers::Vector<'a, i8>>>, + pub nullable: bool, } impl<'a> Default for UnionArgs<'a> { #[inline] @@ -1546,6 +1557,7 @@ impl<'a> Default for UnionArgs<'a> { names: None, dtypes: None, type_ids: None, + nullable: false, } } } @@ -1568,6 +1580,10 @@ impl<'a: 'b, 'b, A: ::flatbuffers::Allocator + 'a> UnionBuilder<'a, 'b, A> { self.fbb_.push_slot_always::<::flatbuffers::WIPOffset<_>>(Union::VT_TYPE_IDS, type_ids); } #[inline] + pub fn add_nullable(&mut self, nullable: bool) { + self.fbb_.push_slot::(Union::VT_NULLABLE, nullable, false); + } + #[inline] pub fn new(_fbb: &'b mut ::flatbuffers::FlatBufferBuilder<'a, A>) -> UnionBuilder<'a, 'b, A> { let start = _fbb.start_table(); UnionBuilder { @@ -1588,6 +1604,7 @@ impl ::core::fmt::Debug for Union<'_> { ds.field("names", &self.names()); ds.field("dtypes", &self.dtypes()); ds.field("type_ids", &self.type_ids()); + ds.field("nullable", &self.nullable()); ds.finish() } } diff --git a/vortex-flatbuffers/src/generated/message.rs b/vortex-flatbuffers/src/generated/message.rs index b3e4be76ef7..9cc48fe6be6 100644 --- a/vortex-flatbuffers/src/generated/message.rs +++ b/vortex-flatbuffers/src/generated/message.rs @@ -2,8 +2,8 @@ // @generated extern crate alloc; -use crate::array::*; use crate::dtype::*; +use crate::array::*; #[deprecated(since = "2.0.0", note = "Use associated constants instead. This will no longer be generated in 2021.")] pub const ENUM_MIN_MESSAGE_VERSION: u8 = 0; diff --git a/vortex-proto/proto/dtype.proto b/vortex-proto/proto/dtype.proto index 675a9156f22..11feef63a8c 100644 --- a/vortex-proto/proto/dtype.proto +++ b/vortex-proto/proto/dtype.proto @@ -78,6 +78,7 @@ message Union { repeated string names = 1; repeated DType dtypes = 2; repeated int32 type_ids = 3; // length must equal dtypes.len(); each value must fit in uint8 + bool nullable = 4; } message DType { diff --git a/vortex-proto/src/generated/vortex.dtype.rs b/vortex-proto/src/generated/vortex.dtype.rs index 984b4b6b71e..16188687ee5 100644 --- a/vortex-proto/src/generated/vortex.dtype.rs +++ b/vortex-proto/src/generated/vortex.dtype.rs @@ -80,6 +80,8 @@ pub struct Union { /// length must equal dtypes.len(); each value must fit in uint8 #[prost(int32, repeated, tag = "3")] pub type_ids: ::prost::alloc::vec::Vec, + #[prost(bool, tag = "4")] + pub nullable: bool, } #[derive(Clone, PartialEq, ::prost::Message)] pub struct DType { From b51140adb7139a339136fb3ebfd85e736b45cc2b Mon Sep 17 00:00:00 2001 From: Matthew Katz <87445739+mhk197@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:24:09 -0700 Subject: [PATCH 101/104] `ListLayout` baseline (#8706) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # List Layout Decomposes a `list` column into three independently-written sub-columns — `elements`, `offsets`, and (when nullable) `validity` — each backed by its own layout, under a single `vortex.list` node. ``` vortex.list (ListLayout) ├── elements : element space — one entry per list *element* ├── offsets : primitive u64 row space + 1 — n+1 entries for n lists, monotonic └── validity : bool row space — present iff the list is nullable ``` There are three pieces: the **dispatcher** enables using `ListLayout` for arrays with `DType::List`, the **writer** shreds a list stream into the three child streams, and the **reader** reassembles only the children an expression actually needs. Note that this a prototype and is not a performance improvement over flattening lists... yet. As such, this PR does not yet enable writing `ListLayout` for benchmarks. --- ## 1. `Recursive Writer Dispatch` `TableStrategy` inspects the dtype of the stream it's handed and routes: | dtype | written by | | -------- | ------------------------------------------------------ | | struct | `StructStrategy` | | list | `ListLayoutStrategy` (when list decomposition is on) | | anything | the configured leaf strategy | To enable recursive handling of nested lists (elements are lists) or structs it hands **a descended copy of itself** as the child strategy. So a `list }>>` recurses with no manual wiring — each level dispatches on its own dtype. List decomposition is gated (`use_experimental_list_layout()` / `VORTEX_EXPERIMENTAL_LIST_LAYOUT`, or an explicit builder call). When off, lists fall through to the leaf strategy unchanged. --- ## 2. `ListLayoutStrategy` A *structural* writer: it does not compress or inspect element dtypes; it shreds a list stream into its three sub-streams and hands each to its own downstream strategy, producing one `ListLayout`. To do so it must: 1. **Canonicalize** each incoming chunk to `List` parts. A gapped/reordered `ListView` is rebuilt into a gapless `List` first (`list_from_list_view`). 2. **Rebase offsets to global `u64`.** Each chunk's local offsets are shifted by `element_base` (the running count of elements emitted so far) and widened to `u64`, and the duplicated boundary offset is dropped on every chunk after the first — so the concatenation of all chunks is one monotonic `[0 … total_elements]` array of length `row_count + 1` that indexes into the single concatenated `elements` child. 3. **Fan out** `elements`, `offsets`, and `validity` onto three bounded (capacity-1) channels; each child is written concurrently by its own strategy via `spawn_nested`. The producer future is joined with the child writers (`try_join`) so a producer error surfaces instead of being hidden as an early channel close. Non-list input is forwarded to a `fallback` strategy unchanged. Because each child is written through an *independent* strategy, the children's physical shape is decoupled: routing `elements` through the recursive dispatcher (→ repartition → chunked) makes it a `ChunkedLayout` chunked in element space, while `offsets`/`validity` can be written with a different (e.g. row-space) strategy. --- ## 3. `ListReader` To read as little as possible, `projection_evaluation` first **classifies the expression** (`get_necessary_list_children`) into the minimal set of children it needs, then routes to a matching read path: | class (`ListChildrenNeeded`) | example expr | reads | path | | ---------------------------- | ------------------------------- | --------------------- | ------------------------- | | `Validity` | `is_null(x)` / `is_not_null(x)` | validity only | `project_validity` | | `OffsetsAndValidity` | `list_length(x)` | offsets + validity | `project_offsets_validity`| | `All` | `x`, or anything over elements | elements + the rest | `project_all` | `project_all` materializes the list, and itself picks between two sub-paths: - **Whole-chunk / concurrent** (`project_all_concurrent`) — used for a full-column range, or when `elements` is a single flat segment (nothing to skip). Fires the `elements`, `offsets`, and `validity` reads concurrently, assembles the list, slices to the requested range, and filters. No offsets→elements ordering dependency. - **Bounded** (`project_all_elements_bounded`) — used for a strict sub-range over **chunked** elements. Reads `offsets[range]`, decodes the first/last offset to bound the elements read to `[first … last)`, fetches only the element chunks that range overlaps, and rebases the offsets to index into the sliced buffer. Costs one offsets→elements round-trip but avoids reading the whole elements column for a partial scan split. ## TODOs: * Add list-related stats for better pruning. * Pruning for element zones. * Correctly handle validity. Right now, validity is written as a flat layout. We also probably do not need a reader. * Better handling of arbitrarily nested lists. * Perf improvement. --------- Signed-off-by: "Matthew Katz" Signed-off-by: "Matt Katz" Signed-off-by: Matt Katz Co-authored-by: Matthew Katz --- vortex-file/src/strategy.rs | 54 +- vortex-file/src/tests.rs | 70 ++ vortex-layout/src/layouts/chunked/mod.rs | 2 +- vortex-layout/src/layouts/list/expr.rs | 150 +++ vortex-layout/src/layouts/list/mod.rs | 284 ++++++ vortex-layout/src/layouts/list/reader.rs | 1180 ++++++++++++++++++++++ vortex-layout/src/layouts/list/writer.rs | 578 +++++++++++ vortex-layout/src/layouts/mod.rs | 1 + vortex-layout/src/layouts/table.rs | 239 ++++- vortex-layout/src/session.rs | 2 + 10 files changed, 2552 insertions(+), 8 deletions(-) create mode 100644 vortex-layout/src/layouts/list/expr.rs create mode 100644 vortex-layout/src/layouts/list/mod.rs create mode 100644 vortex-layout/src/layouts/list/reader.rs create mode 100644 vortex-layout/src/layouts/list/writer.rs diff --git a/vortex-file/src/strategy.rs b/vortex-file/src/strategy.rs index 7db2b436a38..3cedbc1d68c 100644 --- a/vortex-file/src/strategy.rs +++ b/vortex-file/src/strategy.rs @@ -50,9 +50,11 @@ use vortex_layout::layouts::compressed::CompressingStrategy; use vortex_layout::layouts::compressed::CompressorPlugin; use vortex_layout::layouts::dict::writer::DictStrategy; use vortex_layout::layouts::flat::writer::FlatLayoutStrategy; +use vortex_layout::layouts::list::writer::ListLayoutStrategy; use vortex_layout::layouts::repartition::RepartitionStrategy; use vortex_layout::layouts::repartition::RepartitionWriterOptions; use vortex_layout::layouts::table::TableStrategy; +use vortex_layout::layouts::table::use_experimental_list_layout; use vortex_layout::layouts::zoned::writer::ZonedLayoutOptions; use vortex_layout::layouts::zoned::writer::ZonedStrategy; #[cfg(feature = "unstable_encodings")] @@ -157,6 +159,10 @@ pub struct WriteStrategyBuilder { allow_encodings: Option>, flat_strategy: Option>, probe_compressor: Option>, + /// Whether to write list fields using [`ListLayoutStrategy`]. + /// + /// [`ListLayoutStrategy`]: vortex_layout::layouts::list::writer::ListLayoutStrategy + use_list_layout: bool, } impl Default for WriteStrategyBuilder { @@ -170,6 +176,7 @@ impl Default for WriteStrategyBuilder { allow_encodings: Some(ALLOWED_ENCODINGS.clone()), flat_strategy: None, probe_compressor: None, + use_list_layout: use_experimental_list_layout(), } } } @@ -184,6 +191,17 @@ impl WriteStrategyBuilder { self } + /// Enable writing list fields with [`ListLayoutStrategy`]. + /// + /// **Note**: this is an unstable and experimental layout that is expected to change. + /// Using it may lead to unreadable files in the future. + /// + /// [`ListLayoutStrategy`]: vortex_layout::layouts::list::writer::ListLayoutStrategy + pub fn with_list_layout(mut self) -> Self { + self.use_list_layout = true; + self + } + /// Override the write layout for a specific field somewhere in the nested schema tree. /// /// The field path is matched after the root struct is split into columns. This is useful when a @@ -308,12 +326,14 @@ impl WriteStrategyBuilder { probe_compressor, ); + let row_block_size = NonZeroUsize::new(self.row_block_size).vortex_expect("must be non 0"); + // 2. calculate stats for each row group let stats = ZonedStrategy::new( dict, compress_then_flat.clone(), ZonedLayoutOptions { - block_size: NonZeroUsize::new(self.row_block_size).vortex_expect("must be non 0"), + block_size: row_block_size, ..Default::default() }, ); @@ -332,11 +352,37 @@ impl WriteStrategyBuilder { ); // 0. start with splitting columns - let validity_strategy = CollectStrategy::new(compress_then_flat); + let validity_strategy = CollectStrategy::new(compress_then_flat.clone()); // Take any field overrides from the builder and apply them to the final strategy. - let table_strategy = TableStrategy::new(Arc::new(validity_strategy), Arc::new(repartition)) - .with_field_writers(self.field_writers); + let mut table_strategy = + TableStrategy::new(Arc::new(validity_strategy), Arc::new(repartition)) + .with_field_writers(self.field_writers); + + if self.use_list_layout { + // We need a closure here to enable recursive application of list layout. + table_strategy = table_strategy.with_list_layout_factory( + move |list_layout: ListLayoutStrategy| -> Arc { + let zoned = ZonedStrategy::new( + list_layout, + compress_then_flat.clone(), + ZonedLayoutOptions { + block_size: row_block_size, + ..Default::default() + }, + ); + Arc::new(RepartitionStrategy::new( + zoned, + RepartitionWriterOptions { + block_size_minimum: 0, + block_len_multiple: row_block_size.get(), + block_size_target: None, + canonicalize: false, + }, + )) + }, + ); + } Arc::new(table_strategy) } diff --git a/vortex-file/src/tests.rs b/vortex-file/src/tests.rs index ea7f8cbeda5..e665ab18d50 100644 --- a/vortex-file/src/tests.rs +++ b/vortex-file/src/tests.rs @@ -15,6 +15,7 @@ use vortex_array::ArrayRef; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; use vortex_array::array_session; +use vortex_array::arrays::BoolArray; use vortex_array::arrays::ChunkedArray; use vortex_array::arrays::ConstantArray; use vortex_array::arrays::DecimalArray; @@ -1671,6 +1672,75 @@ async fn test_writer_with_complex_types() -> VortexResult<()> { Ok(()) } +/// Write `array` with list decomposition forced on (through the full compress/zone pipeline) and +/// read the whole thing back. +async fn write_read_roundtrip(array: ArrayRef) -> VortexResult { + let strategy = crate::strategy::WriteStrategyBuilder::default() + .with_list_layout() + .build(); + let mut buf = ByteBufferMut::empty(); + SESSION + .write_options() + .with_strategy(strategy) + .write(&mut buf, array.to_array_stream()) + .await?; + SESSION + .open_options() + .open_buffer(buf)? + .scan()? + .into_array_stream()? + .read_all() + .await +} + +/// A `list>` column round-trips through the `TableStrategy` dispatcher, exercising list +/// decomposition recursing into itself (the outer list's `elements` are themselves lists). +#[tokio::test] +#[cfg_attr(miri, ignore)] +async fn nested_list_of_list_roundtrip() -> VortexResult<()> { + let inner = ListArray::try_new( + buffer![1i32, 2, 3, 4, 5, 6].into_array(), + buffer![0u32, 2, 5, 5, 6].into_array(), + Validity::NonNullable, + )? + .into_array(); + let outer = ListArray::try_new( + inner, + buffer![0u32, 2, 4].into_array(), + Validity::NonNullable, + )? + .into_array(); + let st = StructArray::from_fields(&[("nested", outer)])?.into_array(); + + let result = write_read_roundtrip(st.clone()).await?; + assert_arrays_eq!(result, st, &mut SESSION.create_execution_ctx()); + Ok(()) +} + +/// A `struct<{ items: list>? }>` column round-trips, exercising list decomposition +/// recursing into struct decomposition (list `elements` are structs) plus a nullable list validity +/// child. +#[tokio::test] +#[cfg_attr(miri, ignore)] +async fn nested_struct_list_struct_roundtrip() -> VortexResult<()> { + let inner_struct = StructArray::from_fields(&[ + ("a", buffer![1i32, 2, 3, 4, 5].into_array()), + ("b", buffer![10i32, 20, 30, 40, 50].into_array()), + ])? + .into_array(); + let items = ListArray::try_new( + inner_struct, + buffer![0u32, 2, 5, 5].into_array(), + Validity::Array(BoolArray::from_iter([true, false, true]).into_array()), + )? + .into_array(); + let st = StructArray::from_fields(&[("items", items)])?.into_array(); + + let result = write_read_roundtrip(st.clone()).await?; + assert_arrays_eq!(result, st, &mut SESSION.create_execution_ctx()); + Ok(()) +} + #[tokio::test] async fn test_writer_with_statistics() -> VortexResult<()> { let array = StructArray::from_fields(&[("numbers", buffer![1u32, 2, 3, 4, 5].into_array())])? diff --git a/vortex-layout/src/layouts/chunked/mod.rs b/vortex-layout/src/layouts/chunked/mod.rs index a21b5605b31..336de3235b1 100644 --- a/vortex-layout/src/layouts/chunked/mod.rs +++ b/vortex-layout/src/layouts/chunked/mod.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -mod reader; +pub(crate) mod reader; pub mod writer; use std::sync::Arc; diff --git a/vortex-layout/src/layouts/list/expr.rs b/vortex-layout/src/layouts/list/expr.rs new file mode 100644 index 00000000000..152b7ea5dce --- /dev/null +++ b/vortex-layout/src/layouts/list/expr.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use vortex_array::expr::Expression; +use vortex_array::expr::is_root; +use vortex_array::expr::not; +use vortex_array::expr::root; +use vortex_array::scalar_fn::fns::is_not_null::IsNotNull; +use vortex_array::scalar_fn::fns::is_null::IsNull; +use vortex_array::scalar_fn::fns::list_length::ListLength; +use vortex_error::VortexResult; + +/// The minimal set of list children an expression needs for evaluation. +/// +/// For example: +/// - `is_null(root())` only needs the validity child. +/// - `list_length(root())` only needs the offsets and validity children. +/// - `root()` needs elements, offsets, and validity children. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum ListChildrenNeeded { + /// Only the validity child is needed (`is_null` / `is_not_null`). + Validity, + /// Only the offsets and validity children are needed (`list_length`). + OffsetsAndValidity, + /// All children are needed. + All, +} + +/// The minimal set of list children needed to evaluate `expr`, where `root()` is a field with list dtype. +pub(super) fn get_necessary_list_children(expr: &Expression) -> ListChildrenNeeded { + if is_null_root(expr) { + return ListChildrenNeeded::Validity; + } + + if is_list_length_root(expr) { + return ListChildrenNeeded::OffsetsAndValidity; + } + + if is_root(expr) { + return ListChildrenNeeded::All; + } + + // Otherwise the requirement is the max over the operands. Childless expressions that never + // touch the list, such as literals, fall back to the cheapest usable child. + expr.children() + .iter() + .map(get_necessary_list_children) + .max() + .unwrap_or(ListChildrenNeeded::Validity) +} + +fn is_null_root(expr: &Expression) -> bool { + (expr.is::() || expr.is::()) + && expr.children().len() == 1 + && is_root(expr.child(0)) +} + +fn is_list_length_root(expr: &Expression) -> bool { + expr.is::() && expr.children().len() == 1 && is_root(expr.child(0)) +} + +/// Rewrite a validity-class expression so it can be evaluated against the list's validity bool +/// array (`true` == valid row): `is_not_null(root())` becomes `root()` and `is_null(root())` +/// becomes `not(root())`. All other nodes are rebuilt with rewritten children. +pub(super) fn rewrite_validity_expr(expr: &Expression) -> VortexResult { + if expr.is::() && expr.children().len() == 1 && is_root(expr.child(0)) { + return Ok(root()); + } + if expr.is::() && expr.children().len() == 1 && is_root(expr.child(0)) { + return Ok(not(root())); + } + let children = expr + .children() + .iter() + .map(rewrite_validity_expr) + .collect::>>()?; + expr.clone().with_children(children) +} + +/// Rewrite an offsets-class expression so it can be evaluated against an array of list lengths. +/// `list_length(root())` becomes `root()`. Other references to `root()` are left intact: for +/// offsets-class expressions they can only be validity checks, and the lengths array carries the +/// same validity as the original list. +pub(super) fn rewrite_offsets_expr(expr: &Expression) -> VortexResult { + if is_list_length_root(expr) { + return Ok(root()); + } + + let children = expr + .children() + .iter() + .map(rewrite_offsets_expr) + .collect::>>()?; + expr.clone().with_children(children) +} + +#[cfg(test)] +mod tests { + use rstest::rstest; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::expr::cast; + use vortex_array::expr::eq; + use vortex_array::expr::gt; + use vortex_array::expr::is_not_null; + use vortex_array::expr::is_null; + use vortex_array::expr::list_length; + use vortex_array::expr::lit; + use vortex_array::expr::not; + use vortex_array::expr::root; + + use super::*; + + /// `get_necessary_list_children` keys off the deepest list child an expression touches; `All` + /// is the always-correct default for anything not specifically recognized. + #[rstest] + // `is_null` / `is_not_null` of the list itself need only validity. + #[case::is_null(is_null(root()), ListChildrenNeeded::Validity)] + #[case::is_not_null(is_not_null(root()), ListChildrenNeeded::Validity)] + // Compound over validity-only operands stays validity. + #[case::not_is_null(not(is_null(root())), ListChildrenNeeded::Validity)] + // A list-independent (constant) expression falls to the cheapest usable child. + #[case::constant(lit(5), ListChildrenNeeded::Validity)] + // `list_length(root())` needs offsets and validity, but not elements. + #[case::list_length(list_length(root()), ListChildrenNeeded::OffsetsAndValidity)] + // Compound over offsets-only operands stays offsets. + #[case::list_length_filter( + gt(list_length(root()), lit(1u64)), + ListChildrenNeeded::OffsetsAndValidity + )] + #[case::cast_list_length( + cast( + list_length(root()), + DType::Primitive(PType::I64, Nullability::Nullable), + ), + ListChildrenNeeded::OffsetsAndValidity + )] + // A bare list reference needs the elements. + #[case::bare_root(root(), ListChildrenNeeded::All)] + // Any other fn over the list needs the elements. + #[case::not_root(not(root()), ListChildrenNeeded::All)] + // `is_null` only short-circuits to validity when its argument is the list itself. + #[case::is_null_of_derived(is_null(not(root())), ListChildrenNeeded::All)] + // Max over operands: validity + elements => elements. + #[case::validity_and_elements(eq(is_null(root()), root()), ListChildrenNeeded::All)] + fn classify_expr_class(#[case] expr: Expression, #[case] expected: ListChildrenNeeded) { + assert_eq!(get_necessary_list_children(&expr), expected); + } +} diff --git a/vortex-layout/src/layouts/list/mod.rs b/vortex-layout/src/layouts/list/mod.rs new file mode 100644 index 00000000000..0d47ad5266d --- /dev/null +++ b/vortex-layout/src/layouts/list/mod.rs @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! An experimental structural layout for list-typed columns. Note that this is expected to change. +//! +//! [`ListLayout`] decomposes a list column into independently configurable child layouts: +//! `elements`, `offsets`, and, for nullable lists, `validity`. Keeping the children independent allows +//! each child to use its own configurable layout and lets nested list elements (e.g. with `List`) be decomposed recursively. +//! +//! This provides benefits such as: +//! * Reading only the children needed to evaluate an expression. For example, `ListLength` does +//! not need to read list elements. +//! * Restricting element reads to the range covered by the selected outer rows, avoiding elements +//! belonging exclusively to unselected leading or trailing lists. +//! * Allowing each child to use its own compression, chunking, and pruning strategy. + +mod expr; +mod reader; +pub mod writer; + +use std::sync::Arc; + +use reader::ListReader; +use vortex_array::DeserializeMetadata; +use vortex_array::ProstMetadata; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::PType; +use vortex_error::VortexExpect; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; +use vortex_error::vortex_ensure_eq; +use vortex_error::vortex_err; +use vortex_error::vortex_panic; +use vortex_session::VortexSession; +use vortex_session::registry::CachedId; + +use crate::LayoutBuildContext; +use crate::LayoutChildType; +use crate::LayoutEncodingRef; +use crate::LayoutId; +use crate::LayoutReaderContext; +use crate::LayoutReaderRef; +use crate::LayoutRef; +use crate::VTable; +use crate::children::LayoutChildren; +use crate::segments::SegmentId; +use crate::segments::SegmentSource; +use crate::vtable; + +/// Child index of the `elements` layout. +pub const ELEMENTS_CHILD_INDEX: usize = 0; +/// Child index of the `offsets` layout. +pub const OFFSETS_CHILD_INDEX: usize = 1; +/// Child index of the `validity` layout (only present when the list dtype is nullable). +pub const VALIDITY_CHILD_INDEX: usize = 2; + +/// Number of children when the list dtype is non-nullable. +pub const NUM_CHILDREN_NON_NULLABLE: usize = 2; + +vtable!(List); + +impl VTable for List { + type Layout = ListLayout; + type Encoding = ListLayoutEncoding; + type Metadata = ProstMetadata; + + fn id(_encoding: &Self::Encoding) -> LayoutId { + static ID: CachedId = CachedId::new("vortex.list"); + *ID + } + + fn encoding(_layout: &Self::Layout) -> LayoutEncodingRef { + LayoutEncodingRef::new_ref(ListLayoutEncoding.as_ref()) + } + + fn row_count(layout: &Self::Layout) -> u64 { + layout.row_count() + } + + fn dtype(layout: &Self::Layout) -> &DType { + &layout.dtype + } + + fn metadata(layout: &Self::Layout) -> Self::Metadata { + ProstMetadata(ListLayoutMetadata::new(layout.offsets_ptype())) + } + + fn segment_ids(_layout: &Self::Layout) -> Vec { + vec![] + } + + fn nchildren(layout: &Self::Layout) -> usize { + if layout.dtype.is_nullable() { + NUM_CHILDREN_NON_NULLABLE + 1 + } else { + NUM_CHILDREN_NON_NULLABLE + } + } + + fn child(layout: &Self::Layout, idx: usize) -> VortexResult { + match (idx, layout.validity.as_ref()) { + (ELEMENTS_CHILD_INDEX, _) => Ok(Arc::clone(&layout.elements)), + (OFFSETS_CHILD_INDEX, _) => Ok(Arc::clone(&layout.offsets)), + (VALIDITY_CHILD_INDEX, Some(validity)) => Ok(Arc::clone(validity)), + _ => vortex_bail!("Invalid child index {idx} for ListLayout"), + } + } + + fn child_type(layout: &Self::Layout, idx: usize) -> LayoutChildType { + match (idx, layout.validity.is_some()) { + (ELEMENTS_CHILD_INDEX, _) => LayoutChildType::Auxiliary("elements".into()), + (OFFSETS_CHILD_INDEX, _) => LayoutChildType::Auxiliary("offsets".into()), + (VALIDITY_CHILD_INDEX, true) => LayoutChildType::Auxiliary("validity".into()), + _ => vortex_panic!("Invalid child index {idx} for ListLayout"), + } + } + + fn new_reader( + layout: &Self::Layout, + name: Arc, + segment_source: Arc, + session: &VortexSession, + ctx: &LayoutReaderContext, + ) -> VortexResult { + Ok(Arc::new(ListReader::try_new( + layout.clone(), + name, + segment_source, + session.clone(), + ctx, + )?)) + } + + fn build( + _encoding: &Self::Encoding, + dtype: &DType, + _row_count: u64, + metadata: &::Output, + _segment_ids: Vec, + children: &dyn LayoutChildren, + _ctx: &LayoutBuildContext<'_>, + ) -> VortexResult { + validate_children(dtype, children.nchildren())?; + + let elements_dtype = dtype + .as_list_element_opt() + .ok_or_else(|| vortex_err!("ListLayout requires a List dtype, got {dtype}"))?; + let elements = children.child(ELEMENTS_CHILD_INDEX, elements_dtype.as_ref())?; + + let offsets_dtype = DType::Primitive(metadata.offsets_ptype(), Nullability::NonNullable); + let offsets = children.child(OFFSETS_CHILD_INDEX, &offsets_dtype)?; + + let validity = dtype + .is_nullable() + .then(|| children.child(VALIDITY_CHILD_INDEX, &DType::Bool(Nullability::NonNullable))) + .transpose()?; + + Ok(ListLayout { + dtype: dtype.clone(), + elements, + offsets, + validity, + }) + } + + fn with_children(layout: &mut Self::Layout, children: Vec) -> VortexResult<()> { + validate_children(layout.dtype(), children.len())?; + + let mut iter = children.into_iter(); + layout.elements = iter + .next() + .ok_or_else(|| vortex_err!("missing elements child"))?; + layout.offsets = iter + .next() + .ok_or_else(|| vortex_err!("missing offsets child"))?; + layout.validity = layout + .dtype + .is_nullable() + .then(|| { + iter.next() + .ok_or_else(|| vortex_err!("missing validity child")) + }) + .transpose()?; + Ok(()) + } +} + +/// Validates expected number of children based on `dtype` +fn validate_children(dtype: &DType, n_children: usize) -> VortexResult<()> { + let expected = if dtype.is_nullable() { + NUM_CHILDREN_NON_NULLABLE + 1 + } else { + NUM_CHILDREN_NON_NULLABLE + }; + + vortex_ensure_eq!(n_children, expected); + Ok(()) +} + +#[derive(Debug)] +pub struct ListLayoutEncoding; + +/// Stores a list-typed array by shredding `elements`, `offsets`, and optional `validity` children. +#[derive(Clone, Debug)] +pub struct ListLayout { + dtype: DType, + elements: LayoutRef, + offsets: LayoutRef, + validity: Option, +} + +impl ListLayout { + /// Construct a new `ListLayout` from its components. + /// + /// # Invariants + /// + /// - `dtype` must be a [`DType::List`]. + /// - `validity` must be `Some` iff `dtype.is_nullable()`. + /// - `offsets.dtype()` must be a non-nullable integer. + /// - `offsets.row_count()` is the Arrow-canonical `n+1` for `n` lists (or `0` for empty). + /// - When present, `validity.row_count() == offsets.row_count().saturating_sub(1)`. + pub fn new( + dtype: DType, + elements: LayoutRef, + offsets: LayoutRef, + validity: Option, + ) -> Self { + Self { + dtype, + elements, + offsets, + validity, + } + } + + /// Number of lists in this layout. + #[inline] + pub fn row_count(&self) -> u64 { + self.offsets.row_count().saturating_sub(1) + } + + #[inline] + pub fn elements(&self) -> &LayoutRef { + &self.elements + } + + #[inline] + pub fn offsets(&self) -> &LayoutRef { + &self.offsets + } + + #[inline] + pub fn validity(&self) -> Option<&LayoutRef> { + self.validity.as_ref() + } + + /// The integer type used for the `offsets` child layout. + #[inline] + pub fn offsets_ptype(&self) -> PType { + self.offsets.dtype().as_ptype() + } + + /// The dtype of the inner elements column. + pub fn elements_dtype(&self) -> &DType { + self.dtype + .as_list_element_opt() + .vortex_expect("ListLayout dtype must be a List") + } +} + +#[derive(prost::Message)] +pub struct ListLayoutMetadata { + #[prost(enumeration = "PType", tag = "1")] + offsets_ptype: i32, +} + +impl ListLayoutMetadata { + pub fn new(offsets_ptype: PType) -> Self { + let mut metadata = Self::default(); + metadata.set_offsets_ptype(offsets_ptype); + metadata + } +} diff --git a/vortex-layout/src/layouts/list/reader.rs b/vortex-layout/src/layouts/list/reader.rs new file mode 100644 index 00000000000..0d33f1f4ac5 --- /dev/null +++ b/vortex-layout/src/layouts/list/reader.rs @@ -0,0 +1,1180 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use std::ops::Range; +use std::sync::Arc; + +use futures::FutureExt; +use futures::future::BoxFuture; +use futures::try_join; +use vortex_array::ArrayRef; +use vortex_array::Canonical; +use vortex_array::IntoArray; +use vortex_array::MaskFuture; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::ListArray; +use vortex_array::builtins::ArrayBuiltins; +use vortex_array::dtype::DType; +use vortex_array::dtype::FieldMask; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::PType; +use vortex_array::expr::Expression; +use vortex_array::expr::root; +use vortex_array::scalar_fn::fns::operators::Operator; +use vortex_array::validity::Validity; +use vortex_error::VortexExpect; +use vortex_error::VortexResult; +use vortex_mask::Mask; +use vortex_session::VortexSession; + +use crate::ArrayFuture; +use crate::LayoutReader; +use crate::LayoutReaderContext; +use crate::LayoutReaderRef; +use crate::RowSplits; +use crate::SplitRange; +use crate::layouts::list::ListLayout; +use crate::layouts::list::expr::ListChildrenNeeded; +use crate::layouts::list::expr::get_necessary_list_children; +use crate::layouts::list::expr::rewrite_offsets_expr; +use crate::layouts::list::expr::rewrite_validity_expr; +use crate::segments::SegmentSource; + +type OptionalArrayFuture = BoxFuture<'static, VortexResult>>; + +/// The threshold of mask density below which we push the input mask into projection evaluation, +/// and above which we evaluate the expression over all rows and intersect afterward. +const EXPR_EVAL_THRESHOLD: f64 = 0.2; + +/// Maximum number of outer-row scan ranges contributed by one list layout. +const MAX_LIST_SPLIT_COUNT: u64 = 64; + +/// Reader for [`ListLayout`]. +#[derive(Clone)] +pub struct ListReader { + layout: ListLayout, + name: Arc, + session: VortexSession, + elements: LayoutReaderRef, + offsets: LayoutReaderRef, + validity: Option, +} + +impl ListReader { + pub(super) fn try_new( + layout: ListLayout, + name: Arc, + segment_source: Arc, + session: VortexSession, + ctx: &LayoutReaderContext, + ) -> VortexResult { + let elements = layout.elements().new_reader( + format!("{name}.elements").into(), + Arc::clone(&segment_source), + &session, + ctx, + )?; + let offsets = layout.offsets().new_reader( + format!("{name}.offsets").into(), + Arc::clone(&segment_source), + &session, + ctx, + )?; + let validity = layout + .validity() + .map(|v| { + v.new_reader( + format!("{name}.validity").into(), + Arc::clone(&segment_source), + &session, + ctx, + ) + }) + .transpose()?; + + Ok(Self { + layout, + name, + session, + elements, + offsets, + validity, + }) + } + + /// Projection for [`ListChildrenNeeded::Validity`] expressions. Reads only the validity child, + /// synthesizing all-valid for a non-nullable list, and never touches the offsets or elements. + fn project_validity( + &self, + row_range: &Range, + expr: &Expression, + mask: MaskFuture, + ) -> VortexResult { + let validity_reader = self.validity.clone(); + let nullability = self.layout.dtype().nullability(); + let row_range = row_range.clone(); + // Evaluate the rewritten expression against the validity bool array (true == valid row). + let rewritten = rewrite_validity_expr(expr)?; + + Ok(async move { + let mask = mask.await?; + let row_count = usize::try_from(row_range.end - row_range.start)?; + let out_len = if mask.all_true() { + row_count + } else { + mask.true_count() + }; + + let validity_array = match validity_reader.as_ref() { + Some(v) => Some( + v.projection_evaluation(&row_range, &root(), MaskFuture::ready(mask))? + .await?, + ), + None => None, + }; + + let validity = create_validity(validity_array, nullability).to_array(out_len); + + validity.apply(&rewritten) + } + .boxed()) + } + + /// Projection for [`ListChildrenNeeded::All`] expressions. + /// + /// An all-true mask over the full local range reads every child concurrently. Otherwise, the + /// read is bounded to the first and last selected list. + fn project_all( + &self, + row_range: &Range, + expr: &Expression, + mask: MaskFuture, + ) -> VortexResult { + let is_full_range = row_range.start == 0 && row_range.end == self.layout.row_count(); + let reader = self.clone(); + let row_range = row_range.clone(); + let expr = expr.clone(); + Ok(async move { + let mask = mask.await?; + if is_full_range && mask.all_true() { + reader.project_all_full(&expr)?.await + } else { + reader.project_all_bounded(&row_range, &expr, mask)?.await + } + } + .boxed()) + } + + /// Fetch the complete `elements`, `offsets`, and `validity` children concurrently. + fn project_all_full(&self, expr: &Expression) -> VortexResult { + let row_count = self.layout.row_count(); + let elements_row_count = self.elements.row_count(); + let nullability = self.layout.dtype().nullability(); + let expr = expr.clone(); + + let offsets_fut = self.fetch_raw_offsets(&(0..row_count))?; + let elements_fut = self.fetch_raw_elements(&(0..elements_row_count))?; + let validity_fut = fetch_validity( + self.validity.as_ref(), + &(0..row_count), + MaskFuture::new_true(usize::try_from(row_count)?), + )?; + + Ok(async move { + let (offsets, elements, validity) = try_join!(offsets_fut, elements_fut, validity_fut)?; + // SAFETY: ListLayout is constructed from a valid ListArray and reading its children + // without transformation preserves the list invariants. + let list = unsafe { + ListArray::new_unchecked(elements, offsets, create_validity(validity, nullability)) + } + .into_array(); + list.apply(&expr) + } + .boxed()) + } + + /// Bounded read for a sub-range or selective mask. + /// + /// Crops leading and trailing unselected lists, reads their offsets, and translates the first + /// and last offset into the element-row range to fetch. Any holes in the selection are filtered + /// after reconstructing the list array. + fn project_all_bounded( + &self, + row_range: &Range, + expr: &Expression, + mask: Mask, + ) -> VortexResult { + // Crop to the smallest contiguous row range containing every selected list. + let Some(selected_rows) = selected_row_range(&mask) else { + let empty = Canonical::empty(self.layout.dtype()).into_array(); + let expr = expr.clone(); + return Ok(async move { empty.apply(&expr) }.boxed()); + }; + + let selected_mask = mask.slice(selected_rows.clone()); + let selected_row_range = (row_range.start + u64::try_from(selected_rows.start)?) + ..(row_range.start + u64::try_from(selected_rows.end)?); + + let nullability = self.layout.dtype().nullability(); + let expr = expr.clone(); + let reader = self.clone(); + let offsets_fut = self.fetch_raw_offsets(&selected_row_range)?; + + Ok(async move { + let offsets = offsets_fut.await?; + + let elements_range = elements_range_from_offsets(&offsets, &reader.session)?; + let elements_fut = reader.fetch_raw_elements(&elements_range)?; + let validity_fut = fetch_validity( + reader.validity.as_ref(), + &selected_row_range, + MaskFuture::new_true(selected_mask.len()), + )?; + let (elements, validity) = try_join!(elements_fut, validity_fut)?; + + let offsets = rebase_offsets(offsets, elements_range.start)?; + // SAFETY: the selected offsets remain monotonically increasing, rebasing them against + // the selected element range preserves their lengths, and validity covers the same + // cropped list rows. + let list = unsafe { + ListArray::new_unchecked(elements, offsets, create_validity(validity, nullability)) + } + .into_array(); + let list = if selected_mask.all_true() { + list + } else { + list.filter(selected_mask)? + }; + list.apply(&expr) + } + .boxed()) + } + + /// Projection for [`ListChildrenNeeded::OffsetsAndValidity`] expressions. Only reads offsets and validity children. + fn project_offsets_validity( + &self, + row_range: &Range, + expr: &Expression, + mask: MaskFuture, + ) -> VortexResult { + let offsets = self.fetch_raw_offsets(row_range)?; + let reader = self.clone(); + let row_range = row_range.clone(); + let rewritten = rewrite_offsets_expr(expr)?; + + Ok(async move { + let mask = mask.await?; + let row_count = usize::try_from(row_range.end - row_range.start)?; + let nullability = reader.layout.dtype().nullability(); + + let validity_mask = if mask.all_true() { + MaskFuture::new_true(row_count) + } else { + MaskFuture::ready(mask.clone()) + }; + let validity_fut = fetch_validity(reader.validity.as_ref(), &row_range, validity_mask)?; + + let offsets = offsets.await?; + let lengths = list_lengths_from_offsets(offsets)?; + let lengths = if mask.all_true() { + lengths + } else { + lengths.filter(mask)? + }; + let validity = validity_fut.await?; + let lengths = apply_lengths_validity(lengths, validity, nullability)?; + + lengths.apply(&rewritten) + } + .boxed()) + } + + /// Fire the offsets read for `row_range` in list row space. The offsets child has an extra entry, so reading + /// `row_range` maps to offsets in `[row_range.start..row_range.end + 1)`. + /// + /// No mask or expression is applied. + fn fetch_raw_offsets(&self, row_range: &Range) -> VortexResult { + let offsets_range = row_range.start..(row_range.end + 1); + let offsets_count = usize::try_from(offsets_range.end - offsets_range.start)?; + self.offsets.projection_evaluation( + &offsets_range, + &root(), + MaskFuture::new_true(offsets_count), + ) + } + + /// Fire the elements read for `row_range` in element space. + /// + /// No mask or expression is applied. + fn fetch_raw_elements(&self, row_range: &Range) -> VortexResult { + let row_count = usize::try_from(row_range.end - row_range.start)?; + self.elements + .projection_evaluation(row_range, &root(), MaskFuture::new_true(row_count)) + } +} + +fn selected_row_range(mask: &Mask) -> Option> { + Some(mask.first()?..mask.last()? + 1) +} + +fn create_validity(validity_array: Option, nullability: Nullability) -> Validity { + match validity_array { + Some(arr) => Validity::Array(arr), + None => match nullability { + Nullability::Nullable => Validity::AllValid, + Nullability::NonNullable => Validity::NonNullable, + }, + } +} + +impl LayoutReader for ListReader { + fn name(&self) -> &Arc { + &self.name + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn dtype(&self) -> &DType { + self.layout.dtype() + } + + fn row_count(&self) -> u64 { + self.layout.row_count() + } + + fn register_splits( + &self, + _field_mask: &[FieldMask], + split_range: &SplitRange, + splits: &mut RowSplits, + ) -> VortexResult<()> { + split_range.check_bounds(self.layout.row_count())?; + + // Splits are difficult to calculate because all children live in different row coordinate spaces. + // List elements typically comprise the majority of the data in a list, and validity/offsets can be treated + // as metadata. We therefore want to parallelize the scan based on element work. + // + // Scan splits must be expressed in the list layout's outer-row space, but the elements child + // reports its natural boundaries in element-row space. So we translate the element splits using a + // heuristic to outer-row space. + + let element_row_count = self.elements.row_count(); + if element_row_count != 0 { + let mut element_splits = RowSplits::new_capacity(128); + self.elements.register_splits( + &[FieldMask::All], + &SplitRange::root(0..element_row_count)?, + &mut element_splits, + )?; + + let row_range = split_range.row_range(); + let mut last_split = None; + for element_split in element_splits.into_sorted_deduped() { + let Some(split) = map_element_split_to_outer_grid( + element_split, + element_row_count, + self.layout.row_count(), + MAX_LIST_SPLIT_COUNT, + ) else { + continue; + }; + if split <= row_range.start { + continue; + } + if split >= row_range.end { + break; + } + if last_split == Some(split) { + continue; + } + splits.push( + split_range + .row_offset() + .checked_add(split) + .vortex_expect("List layout split offset overflow"), + ); + last_split = Some(split); + } + } + splits.push(split_range.root_row_range().end); + Ok(()) + } + + // TODO(mk): handle zones for list elements + fn pruning_evaluation( + &self, + _row_range: &Range, + _expr: &Expression, + mask: Mask, + ) -> VortexResult { + Ok(MaskFuture::ready(mask)) + } + + fn filter_evaluation( + &self, + row_range: &Range, + expr: &Expression, + mask: MaskFuture, + ) -> VortexResult { + let len = mask.len(); + let reader = self.clone(); + let row_range = row_range.clone(); + let expr = expr.clone(); + let session = self.session.clone(); + + Ok(MaskFuture::new(len, async move { + let mask = mask.await?; + + if mask.all_false() { + return Ok(mask); + } + + if mask.density() < EXPR_EVAL_THRESHOLD { + let predicate = reader + .projection_evaluation(&row_range, &expr, MaskFuture::ready(mask.clone()))? + .await?; + let predicate_mask = predicate_array_to_mask(predicate, &session)?; + Ok(mask.intersect_by_rank(&predicate_mask)) + } else { + let predicate = reader + .projection_evaluation(&row_range, &expr, MaskFuture::new_true(len))? + .await?; + let predicate_mask = predicate_array_to_mask(predicate, &session)?; + Ok(mask & &predicate_mask) + } + })) + } + + /// Reads only the list children needed to evaluate `expr`. + /// + /// Validity-only expressions avoid offsets and elements, length expressions read offsets and + /// validity, and general expressions reconstruct a list from all applicable children. + fn projection_evaluation( + &self, + row_range: &Range, + expr: &Expression, + mask: MaskFuture, + ) -> VortexResult { + // Read as little as possible based on which list children the expression needs. + match get_necessary_list_children(expr) { + ListChildrenNeeded::Validity => self.project_validity(row_range, expr, mask), + ListChildrenNeeded::OffsetsAndValidity => { + self.project_offsets_validity(row_range, expr, mask) + } + ListChildrenNeeded::All => self.project_all(row_range, expr, mask), + } + } +} + +/// Converts a natural boundary from element-row space into an approximate outer-row scan split. +/// +/// Scan splits must be expressed in the list layout's outer-row space, but the elements child +/// reports its natural boundaries in element-row space. Translating a boundary exactly would +/// require consulting the list offsets, so this function instead preserves its relative position: +/// +/// ```text +/// element_split / element_row_count ≈ outer_split / outer_row_count +/// ``` +/// +/// The relative position is first rounded onto a grid containing at most `max_split_count` scan +/// ranges, then mapped into outer-row space. Multiple element boundaries may therefore map to the +/// same outer split and are deduplicated by the caller. With a grid size of 64, at most 63 interior +/// splits—and therefore 64 scan ranges—can be produced. +/// +/// The result is only a task-sizing hint. It is always between outer rows, but it is not guaranteed +/// to correspond exactly to the original physical element boundary. Endpoint boundaries are +/// omitted because they do not subdivide the scan +fn map_element_split_to_outer_grid( + element_split: u64, + element_row_count: u64, + outer_row_count: u64, + max_split_count: u64, +) -> Option { + if element_split == 0 + || element_split >= element_row_count + || outer_row_count == 0 + || max_split_count < 2 + { + return None; + } + debug_assert!(max_split_count.is_power_of_two()); + + let grid_index = (u128::from(element_split) * u128::from(max_split_count) + + u128::from(element_row_count / 2)) + / u128::from(element_row_count); + if grid_index == 0 || grid_index >= u128::from(max_split_count) { + return None; + } + + let outer_split = grid_index * u128::from(outer_row_count) / u128::from(max_split_count); + let outer_split = u64::try_from(outer_split) + .vortex_expect("Outer split is bounded by the list layout row count"); + (outer_split != 0 && outer_split < outer_row_count).then_some(outer_split) +} + +/// Fetch the validity child for `row_range` under `mask`, yielding `None` for a non-nullable list +/// (which has no validity child). +fn fetch_validity( + validity: Option<&LayoutReaderRef>, + row_range: &Range, + mask: MaskFuture, +) -> VortexResult { + let fut = validity + .map(|v| v.projection_evaluation(row_range, &root(), mask)) + .transpose()?; + Ok(async move { + match fut { + Some(f) => f.await.map(Some), + None => Ok(None), + } + } + .boxed()) +} + +/// Read `offsets[0]` and `offsets[-1]` and return the elements range they bound. +fn elements_range_from_offsets( + offsets: &ArrayRef, + session: &VortexSession, +) -> VortexResult> { + if offsets.is_empty() { + return Ok(0..0); + } + let mut exec_ctx = session.create_execution_ctx(); + let start = offsets + .execute_scalar(0, &mut exec_ctx)? + .as_primitive() + .as_::() + .vortex_expect("offset value fits in u64"); + let end = offsets + .execute_scalar(offsets.len() - 1, &mut exec_ctx)? + .as_primitive() + .as_::() + .vortex_expect("offset value fits in u64"); + Ok(start..end) +} + +/// Subtract `first` from every offset so they index into a sliced `elements[first..]` buffer that +/// starts at zero. +fn rebase_offsets(offsets: ArrayRef, first: u64) -> VortexResult { + if first == 0 { + return Ok(offsets); + } + let constant = ConstantArray::new(first, offsets.len()) + .into_array() + .cast(offsets.dtype().clone())?; + offsets.binary(constant, Operator::Sub) +} + +/// Compute `offsets[i + 1] - offsets[i]` as the unmasked list length values. +fn list_lengths_from_offsets(offsets: ArrayRef) -> VortexResult { + let len = offsets.len().saturating_sub(1); + offsets + .slice(1..offsets.len())? + .binary(offsets.slice(0..len)?, Operator::Sub) +} + +fn apply_lengths_validity( + lengths: ArrayRef, + validity: Option, + nullability: Nullability, +) -> VortexResult { + let len = lengths.len(); + let lengths = lengths.cast(DType::Primitive(PType::U64, nullability))?; + + if matches!(nullability, Nullability::Nullable) { + lengths.mask(create_validity(validity, nullability).to_array(len)) + } else { + Ok(lengths) + } +} + +fn predicate_array_to_mask(array: ArrayRef, session: &VortexSession) -> VortexResult { + let mut ctx = session.create_execution_ctx(); + array.null_as_false().execute(&mut ctx) +} + +#[cfg(test)] +mod tests { + use std::ops::Range; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + use rstest::rstest; + use vortex_array::ArrayContext; + use vortex_array::arrays::BoolArray; + use vortex_array::arrays::ListArray; + use vortex_array::arrays::PrimitiveArray; + use vortex_array::assert_arrays_eq; + use vortex_array::expr::cast; + use vortex_array::expr::gt; + use vortex_array::expr::is_not_null; + use vortex_array::expr::is_null; + use vortex_array::expr::list_length; + use vortex_array::expr::lit; + use vortex_buffer::buffer; + use vortex_io::session::RuntimeSession; + use vortex_io::session::RuntimeSessionExt; + + use super::*; + use crate::LayoutRef; + use crate::LayoutStrategy; + use crate::layouts::chunked::writer::ChunkedLayoutStrategy; + use crate::layouts::flat::writer::FlatLayoutStrategy; + use crate::layouts::list::writer::ListLayoutStrategy; + use crate::layouts::repartition::RepartitionStrategy; + use crate::layouts::repartition::RepartitionWriterOptions; + use crate::scan::split_by::SplitBy; + use crate::segments::SegmentFuture; + use crate::segments::SegmentSource; + use crate::segments::TestSegments; + use crate::sequence::SequenceId; + use crate::sequence::SequentialArrayStreamExt; + use crate::session::LayoutSession; + use crate::test::SESSION; + + /// Validity-class projections (`is_null` / `is_not_null` of the list) round-trip through the + /// validity-only read path, for both nullable and non-nullable lists. + #[rstest] + // `create_basic_list_array(true)` has validity `[true, false, true]`. + #[case::nullable(true, vec![true, false, true])] + #[case::non_nullable(false, vec![true, true, true])] + #[tokio::test] + async fn projection_validity_class( + #[case] nullable: bool, + #[case] valid: Vec, + ) -> VortexResult<()> { + let list = create_basic_list_array(nullable); + let ctx = LayoutReaderContext::new(); + let (segments, layout, session) = write_layout(&flat_list_strategy(), list).await?; + let reader = layout.new_reader("".into(), segments, &session, &ctx)?; + + let not_null = reader + .projection_evaluation(&(0..3), &is_not_null(root()), MaskFuture::new_true(3))? + .await?; + let mut exec_ctx = session.create_execution_ctx(); + assert_arrays_eq!(not_null, BoolArray::from_iter(valid.clone()), &mut exec_ctx); + + let is_null_res = reader + .projection_evaluation(&(0..3), &is_null(root()), MaskFuture::new_true(3))? + .await?; + assert_arrays_eq!( + is_null_res, + BoolArray::from_iter(valid.iter().map(|v| !v).collect::>()), + &mut exec_ctx + ); + + Ok(()) + } + + #[tokio::test] + async fn projection_list_length_reads_offsets() -> VortexResult<()> { + let list = create_basic_list_array(false); + let ctx = LayoutReaderContext::new(); + let (segments, layout, session) = write_layout(&flat_list_strategy(), list).await?; + let reader = layout.new_reader("".into(), segments, &session, &ctx)?; + + let result = reader + .projection_evaluation(&(0..3), &list_length(root()), MaskFuture::new_true(3))? + .await?; + + let mut exec_ctx = session.create_execution_ctx(); + assert_arrays_eq!(result, buffer![2u64, 2, 1].into_array(), &mut exec_ctx); + Ok(()) + } + + #[tokio::test] + async fn projection_list_length_preserves_validity() -> VortexResult<()> { + let list = create_basic_list_array(true); + let ctx = LayoutReaderContext::new(); + let (segments, layout, session) = write_layout(&flat_list_strategy(), list).await?; + let reader = layout.new_reader("".into(), segments, &session, &ctx)?; + + let result = reader + .projection_evaluation(&(0..3), &list_length(root()), MaskFuture::new_true(3))? + .await?; + + let expected = + PrimitiveArray::from_option_iter::([Some(2), None, Some(1)]).into_array(); + let mut exec_ctx = session.create_execution_ctx(); + assert_arrays_eq!(result, expected, &mut exec_ctx); + Ok(()) + } + + #[tokio::test] + async fn projection_list_length_applies_sparse_mask() -> VortexResult<()> { + let list = create_basic_list_array(true); + let ctx = LayoutReaderContext::new(); + let (segments, layout, session) = write_layout(&flat_list_strategy(), list).await?; + let reader = layout.new_reader("".into(), segments, &session, &ctx)?; + + let mask = Mask::from_iter([false, true, true]); + let result = reader + .projection_evaluation(&(0..3), &list_length(root()), MaskFuture::ready(mask))? + .await?; + + let expected = PrimitiveArray::from_option_iter::([None, Some(1)]).into_array(); + let mut exec_ctx = session.create_execution_ctx(); + assert_arrays_eq!(result, expected, &mut exec_ctx); + Ok(()) + } + + #[tokio::test] + async fn projection_cast_list_length() -> VortexResult<()> { + let list = create_basic_list_array(true); + let ctx = LayoutReaderContext::new(); + let (segments, layout, session) = write_layout(&flat_list_strategy(), list).await?; + let reader = layout.new_reader("".into(), segments, &session, &ctx)?; + + let expr = cast( + list_length(root()), + DType::Primitive(PType::I64, Nullability::Nullable), + ); + let result = reader + .projection_evaluation(&(0..3), &expr, MaskFuture::new_true(3))? + .await?; + + let expected = + PrimitiveArray::from_option_iter::([Some(2), None, Some(1)]).into_array(); + let mut exec_ctx = session.create_execution_ctx(); + assert_arrays_eq!(result, expected, &mut exec_ctx); + Ok(()) + } + + #[tokio::test] + async fn filter_evaluation_list_length() -> VortexResult<()> { + let list = create_basic_list_array(true); + let ctx = LayoutReaderContext::new(); + let (segments, layout, session) = write_layout(&flat_list_strategy(), list).await?; + let reader = layout.new_reader("".into(), segments, &session, &ctx)?; + + let result = reader + .filter_evaluation( + &(0..3), + >(list_length(root()), lit(1u64)), + MaskFuture::new_true(3), + )? + .await?; + + assert_eq!(result, Mask::from_iter([true, false, false])); + Ok(()) + } + + #[rstest] + #[case::is_not_null_nullable(true, is_not_null(root()), Mask::from_iter([true, false, true]))] + #[case::is_not_null_non_nullable(false, is_not_null(root()), Mask::new_true(3))] + #[case::is_null_nullable(true, is_null(root()), Mask::from_iter([false, true, false]))] + #[case::is_null_non_nullable(false, is_null(root()), Mask::new_false(3))] + #[tokio::test] + async fn filter_evaluation_validity_class( + #[case] nullable: bool, + #[case] expr: Expression, + #[case] expected: Mask, + ) -> VortexResult<()> { + let list = create_basic_list_array(nullable); + let ctx = LayoutReaderContext::new(); + let (segments, layout, session) = write_layout(&flat_list_strategy(), list).await?; + let reader = layout.new_reader("".into(), segments, &session, &ctx)?; + + let result = reader + .filter_evaluation(&(0..3), &expr, MaskFuture::new_true(3))? + .await?; + + assert_eq!(result, expected); + Ok(()) + } + + #[tokio::test] + async fn filter_evaluation_intersects_with_input_mask() -> VortexResult<()> { + let list = create_basic_list_array(true); + let ctx = LayoutReaderContext::new(); + let (segments, layout, session) = write_layout(&flat_list_strategy(), list).await?; + let reader = layout.new_reader("".into(), segments, &session, &ctx)?; + + let input_mask = Mask::from_iter([true, true, false]); + let result = reader + .filter_evaluation(&(0..3), &is_not_null(root()), MaskFuture::ready(input_mask))? + .await?; + + assert_eq!(result, Mask::from_iter([true, false, false])); + Ok(()) + } + + #[tokio::test] + async fn filter_evaluation_sparse_mask_maps_by_rank() -> VortexResult<()> { + let list = create_six_list_array(); + let ctx = LayoutReaderContext::new(); + let (segments, layout, session) = write_layout(&flat_list_strategy(), list).await?; + let reader = layout.new_reader("".into(), segments, &session, &ctx)?; + + let input_mask = Mask::from_iter([false, false, false, false, true, false]); + let result = reader + .filter_evaluation(&(0..6), &is_not_null(root()), MaskFuture::ready(input_mask))? + .await?; + + assert_eq!( + result, + Mask::from_iter([false, false, false, false, true, false]) + ); + Ok(()) + } + + fn flat_list_strategy() -> ListLayoutStrategy { + ListLayoutStrategy::default() + } + + fn layout_test_session() -> VortexSession { + vortex_array::array_session() + .with::() + .with::() + } + + async fn write_layout( + strategy: &S, + array: ArrayRef, + ) -> VortexResult<(Arc, LayoutRef, VortexSession)> { + let session = layout_test_session().with_tokio(); + let segments = Arc::new(TestSegments::default()); + let segments_ref: Arc = Arc::::clone(&segments); + let (ptr, eof) = SequenceId::root().split(); + let stream = array.to_array_stream().sequenced(ptr); + let layout = strategy + .write_stream(ArrayContext::empty(), segments, stream, eof, &session) + .await?; + Ok((segments_ref, layout, session)) + } + + fn materialize_u64_array(array: ArrayRef) -> Vec { + let mut ctx = SESSION.create_execution_ctx(); + array + .execute::(&mut ctx) + .unwrap() + .as_slice::() + .to_vec() + } + + fn create_basic_list_array(nullable: bool) -> ArrayRef { + let validity = if nullable { + Validity::Array(BoolArray::from_iter([true, false, true]).into_array()) + } else { + Validity::NonNullable + }; + + ListArray::try_new( + buffer![1i32, 2, 3, 4, 5].into_array(), + buffer![0u32, 2, 4, 5].into_array(), + validity, + ) + .expect("array is valid") + .into_array() + } + + fn create_six_list_array() -> ArrayRef { + let validity = Validity::Array( + BoolArray::from_iter([true, false, true, false, true, true]).into_array(), + ); + + ListArray::try_new( + buffer![1i32, 2, 3, 4, 5, 6].into_array(), + buffer![0u32, 1, 2, 3, 4, 5, 6].into_array(), + validity, + ) + .expect("array is valid") + .into_array() + } + + #[tokio::test] + async fn fetch_offsets_includes_extra_endpoint() -> VortexResult<()> { + let list = create_basic_list_array(false); + + let (segments, layout, session) = write_layout(&flat_list_strategy(), list).await?; + let ctx = LayoutReaderContext::new(); + let reader = layout.new_reader("".into(), segments, &session, &ctx)?; + let reader = reader + .as_any() + .downcast_ref::() + .expect("ListReader"); + + let offsets = reader.fetch_raw_offsets(&(1..3))?.await?; + assert_eq!(materialize_u64_array(offsets), vec![2u64, 4, 5]); + + Ok(()) + } + + #[rstest] + #[case::full_range(0..3, false)] + #[case::partial_start(0..2, false)] + #[case::partial_end(1..3, false)] + #[case::middle_single(1..2, false)] + #[case::empty_range(1..1, false)] + #[case::full_range_null(0..3, true)] + #[tokio::test] + async fn projection_evaluation_round_trips( + #[case] row_range: Range, + #[case] nullable: bool, + ) -> VortexResult<()> { + let list = create_basic_list_array(nullable); + let ctx = LayoutReaderContext::new(); + + let len = usize::try_from(row_range.end - row_range.start)?; + let (segments, layout, session) = write_layout(&flat_list_strategy(), list.clone()).await?; + let reader = layout.new_reader("".into(), segments, &session, &ctx)?; + + let result = reader + .projection_evaluation(&row_range, &root(), MaskFuture::new_true(len))? + .await?; + + let expected = + list.slice(usize::try_from(row_range.start)?..usize::try_from(row_range.end)?)?; + let mut exec_ctx = session.create_execution_ctx(); + assert_arrays_eq!(result, expected, &mut exec_ctx); + Ok(()) + } + + #[tokio::test] + async fn projection_evaluation_applies_mask() -> VortexResult<()> { + let list = create_basic_list_array(false); + let ctx = LayoutReaderContext::new(); + let (segments, layout, session) = write_layout(&flat_list_strategy(), list.clone()).await?; + let reader = layout.new_reader("".into(), segments, &session, &ctx)?; + + let mask = Mask::from_iter([true, false, true]); + let result = reader + .projection_evaluation(&(0..3), &root(), MaskFuture::ready(mask.clone()))? + .await?; + + let expected = list.filter(mask)?; + let mut exec_ctx = session.create_execution_ctx(); + assert_arrays_eq!(result, expected, &mut exec_ctx); + Ok(()) + } + + /// Build a list with 5 rows and lengths [2, 3, 0, 3, 2]. + fn create_wider_list_array(nullable: bool) -> ArrayRef { + let validity = if nullable { + Validity::Array(BoolArray::from_iter([true, true, false, true, true]).into_array()) + } else { + Validity::NonNullable + }; + ListArray::try_new( + buffer![10i32, 11, 20, 21, 22, 30, 31, 32, 40, 41].into_array(), + buffer![0u32, 2, 5, 5, 8, 10].into_array(), + validity, + ) + .expect("array is valid") + .into_array() + } + + /// Sparse row masks over a whole chunk round-trip: the whole-chunk read is filtered by the + /// mask in memory. + #[rstest] + #[case::single_middle(Mask::from_iter([false, false, false, true, false]), false)] + #[case::two_far_apart(Mask::from_iter([true, false, false, true, false]), false)] + #[case::boundaries(Mask::from_iter([true, false, false, false, true]), false)] + #[case::kept_empty_row(Mask::from_iter([false, false, true, false, false]), false)] + #[case::sparse_nullable(Mask::from_iter([true, false, true, false, true]), true)] + #[case::all_false(Mask::new_false(5), false)] + #[tokio::test] + async fn projection_evaluation_sparse_mask_round_trips( + #[case] mask: Mask, + #[case] nullable: bool, + ) -> VortexResult<()> { + let list = create_wider_list_array(nullable); + let ctx = LayoutReaderContext::new(); + let (segments, layout, session) = write_layout(&flat_list_strategy(), list.clone()).await?; + let reader = layout.new_reader("".into(), segments, &session, &ctx)?; + + let result = reader + .projection_evaluation(&(0..5), &root(), MaskFuture::ready(mask.clone()))? + .await?; + + let expected = list.filter(mask)?; + let mut exec_ctx = session.create_execution_ctx(); + assert_arrays_eq!(result, expected, &mut exec_ctx); + Ok(()) + } + + /// A partial range against a single flat elements segment takes the bounded path. The flat + /// reader may still fetch its whole segment, but the list reader reconstructs only the requested + /// element range. + #[tokio::test] + async fn projection_evaluation_partial_range_bounded() -> VortexResult<()> { + let list = create_wider_list_array(false); + let ctx = LayoutReaderContext::new(); + let (segments, layout, session) = write_layout(&flat_list_strategy(), list.clone()).await?; + let reader = layout.new_reader("".into(), segments, &session, &ctx)?; + + let result = reader + .projection_evaluation(&(1..4), &root(), MaskFuture::new_true(3))? + .await?; + + let expected = list.slice(1..4)?; + let mut exec_ctx = session.create_execution_ctx(); + assert_arrays_eq!(result, expected, &mut exec_ctx); + Ok(()) + } + + #[test] + fn maps_element_splits_to_outer_grid() { + assert_eq!(map_element_split_to_outer_grid(0, 100, 100, 8), None); + assert_eq!(map_element_split_to_outer_grid(20, 100, 100, 8), Some(25)); + assert_eq!(map_element_split_to_outer_grid(25, 100, 100, 8), Some(25)); + assert_eq!(map_element_split_to_outer_grid(50, 100, 100, 8), Some(50)); + assert_eq!(map_element_split_to_outer_grid(75, 100, 100, 8), Some(75)); + assert_eq!(map_element_split_to_outer_grid(100, 100, 100, 8), None); + + let mut splits = (1..1_000) + .filter_map(|split| { + map_element_split_to_outer_grid(split, 1_000, 100_000, MAX_LIST_SPLIT_COUNT) + }) + .collect::>(); + splits.dedup(); + + let expected = (1..MAX_LIST_SPLIT_COUNT) + .map(|grid_index| grid_index * 100_000 / MAX_LIST_SPLIT_COUNT) + .collect::>(); + assert_eq!(splits, expected); + } + + #[tokio::test] + async fn nested_list_propagates_element_splits() -> VortexResult<()> { + let inner = ListArray::try_new( + PrimitiveArray::from_iter(0..128_i32).into_array(), + PrimitiveArray::from_iter((0..=8_u32).map(|idx| idx * 16)).into_array(), + Validity::NonNullable, + )? + .into_array(); + let outer = ListArray::try_new( + inner, + buffer![0u32, 2, 4, 6, 8].into_array(), + Validity::NonNullable, + )? + .into_array(); + + let inner_strategy = + ListLayoutStrategy::default().with_elements(chunked_elements_strategy()); + let strategy = ListLayoutStrategy::default().with_elements(Arc::new(inner_strategy)); + let (segments, layout, session) = write_layout(&strategy, outer).await?; + let reader = + layout.new_reader("".into(), segments, &session, &LayoutReaderContext::new())?; + + let splits = SplitBy::Layout.splits(reader.as_ref(), &(0..4), &[FieldMask::All])?; + assert_eq!(splits, vec![0, 1, 2, 3, 4]); + Ok(()) + } + + /// A list strategy whose `elements` child is repartitioned into two-element chunks, so the + /// reader takes the bounded (chunk-skipping) path for strict sub-ranges. Offsets stay flat. + fn chunked_elements_list_strategy() -> ListLayoutStrategy { + ListLayoutStrategy::default().with_elements(chunked_elements_strategy()) + } + + fn chunked_elements_strategy() -> Arc { + Arc::new(RepartitionStrategy::new( + ChunkedLayoutStrategy::new(FlatLayoutStrategy::default()), + RepartitionWriterOptions { + block_size_minimum: 0, + block_len_multiple: 2, + block_size_target: None, + canonicalize: true, + }, + )) + } + + struct CountingSegmentSource { + inner: Arc, + request_count: Arc, + } + + impl SegmentSource for CountingSegmentSource { + fn request(&self, id: crate::segments::SegmentId) -> SegmentFuture { + self.request_count.fetch_add(1, Ordering::Relaxed); + self.inner.request(id) + } + } + + /// The chunked-elements strategy must actually produce a chunked `elements` layout, otherwise + /// the reader would silently take the whole-chunk path and the bounded read would be untested. + #[tokio::test] + async fn chunked_elements_produces_chunked_layout() -> VortexResult<()> { + let list = create_wider_list_array(false); + let (_segments, layout, _session) = + write_layout(&chunked_elements_list_strategy(), list).await?; + let tree = layout.display_tree().to_string(); + assert!( + tree.contains("elements: vortex.chunked"), + "elements should be chunked:\n{tree}" + ); + Ok(()) + } + + /// A sparse mask must remain selective even when the requested row range covers the complete + /// local list layout. The selected first list touches one element chunk plus the offsets + /// segment; reading all five element chunks would make the count six. + #[tokio::test] + async fn full_range_sparse_mask_crops_element_read() -> VortexResult<()> { + let list = create_wider_list_array(false); + let ctx = LayoutReaderContext::new(); + let (segments, layout, session) = + write_layout(&chunked_elements_list_strategy(), list.clone()).await?; + let request_count = Arc::new(AtomicUsize::new(0)); + let source = Arc::new(CountingSegmentSource { + inner: segments, + request_count: Arc::clone(&request_count), + }); + let reader = layout.new_reader("".into(), source, &session, &ctx)?; + + let mask = Mask::from_iter([true, false, false, false, false]); + let result = reader + .projection_evaluation(&(0..5), &root(), MaskFuture::ready(mask.clone()))? + .await?; + + let expected = list.filter(mask)?; + let mut exec_ctx = session.create_execution_ctx(); + assert_arrays_eq!(result, expected, &mut exec_ctx); + assert_eq!(request_count.load(Ordering::Relaxed), 2); + Ok(()) + } + + /// With chunked elements, sub-range projections take the bounded read path. Every + /// range/mask/nullability combination must match the same projection over the ground-truth + /// array (`list.slice(range).filter(mask)`). + #[rstest] + #[case::full_all_true(0..5, Mask::new_true(5), false)] + #[case::subrange_all_true(1..4, Mask::new_true(3), false)] + #[case::subrange_sparse(1..4, Mask::from_iter([true, false, true]), false)] + #[case::partial_start(0..2, Mask::new_true(2), false)] + #[case::partial_end(2..5, Mask::new_true(3), false)] + #[case::single_non_empty(0..1, Mask::new_true(1), false)] + #[case::single_empty_row(2..3, Mask::from_iter([true]), false)] + #[case::empty_range(2..2, Mask::new_true(0), false)] + #[case::subrange_all_false(1..4, Mask::new_false(3), false)] + #[case::subrange_single_interior(0..4, Mask::from_iter([false, true, false, false]), false)] + #[case::subrange_sparse_nullable(1..4, Mask::from_iter([true, false, true]), true)] + #[case::partial_end_nullable(2..5, Mask::new_true(3), true)] + #[tokio::test] + async fn chunked_elements_round_trips( + #[case] row_range: Range, + #[case] mask: Mask, + #[case] nullable: bool, + ) -> VortexResult<()> { + let list = create_wider_list_array(nullable); + let ctx = LayoutReaderContext::new(); + let (segments, layout, session) = + write_layout(&chunked_elements_list_strategy(), list.clone()).await?; + let reader = layout.new_reader("".into(), segments, &session, &ctx)?; + + let result = reader + .projection_evaluation(&row_range, &root(), MaskFuture::ready(mask.clone()))? + .await?; + + let sliced = + list.slice(usize::try_from(row_range.start)?..usize::try_from(row_range.end)?)?; + let expected = sliced.filter(mask)?; + let mut exec_ctx = session.create_execution_ctx(); + assert_arrays_eq!(result, expected, &mut exec_ctx); + Ok(()) + } +} diff --git a/vortex-layout/src/layouts/list/writer.rs b/vortex-layout/src/layouts/list/writer.rs new file mode 100644 index 00000000000..9f33c1eee3e --- /dev/null +++ b/vortex-layout/src/layouts/list/writer.rs @@ -0,0 +1,578 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use std::sync::Arc; + +use async_trait::async_trait; +use futures::StreamExt; +use futures::future::try_join; +use futures::future::try_join_all; +use vortex_array::ArrayContext; +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::List; +use vortex_array::arrays::ListView; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::arrays::list::ListDataParts; +use vortex_array::arrays::listview::list_from_list_view; +use vortex_array::builtins::ArrayBuiltins; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::PType; +use vortex_array::matcher::Matcher; +use vortex_array::scalar_fn::fns::operators::Operator; +use vortex_error::VortexExpect; +use vortex_error::VortexResult; +use vortex_error::vortex_bail; +use vortex_io::kanal_ext::KanalExt; +use vortex_io::session::RuntimeSessionExt; +use vortex_session::VortexSession; + +use crate::IntoLayout; +use crate::LayoutRef; +use crate::LayoutStrategy; +use crate::layouts::flat::writer::FlatLayoutStrategy; +use crate::layouts::list::ListLayout; +use crate::segments::SegmentSinkRef; +use crate::sequence::SendableSequentialStream; +use crate::sequence::SequenceId; +use crate::sequence::SequencePointer; +use crate::sequence::SequentialStream; +use crate::sequence::SequentialStreamAdapter; +use crate::sequence::SequentialStreamExt; + +/// Item carried on each child sub-stream: a sequenced, materialized chunk. +type ChildChunk = VortexResult<(SequenceId, ArrayRef)>; + +/// Strategy for writing list-typed arrays, with a fallback for non-list dtypes. +/// +/// This is a *structural* writer that decomposes a list column into independent `elements`, +/// `offsets`, and (when nullable) `validity` sub-columns, each written through its own downstream +/// strategy, producing a single [`ListLayout`]. +/// +/// For list-typed input the strategy transposes the whole column stream into three sub-streams: +/// 1. Each chunk is canonicalized to a [`ListArray`] (rebuilding a [`ListView`] via +/// [`list_from_list_view`] when necessary). +/// 2. `offsets` are rebased to global `u64` positions (cumulative across chunks) so the single +/// `offsets` child indexes into the concatenated `elements` child. +/// 3. `elements`, `offsets`, and `validity` are streamed to their child strategies concurrently. +/// +/// For input whose dtype is not [`DType::List`], the stream is forwarded unchanged to the +/// configured `fallback` strategy. +/// +/// [`ListArray`]: vortex_array::arrays::ListArray +#[derive(Clone)] +pub struct ListLayoutStrategy { + elements: Arc, + offsets: Arc, + validity: Arc, + fallback: Arc, +} + +impl Default for ListLayoutStrategy { + /// Routes every child (elements, offsets, validity) and the non-list fallback through + /// [`FlatLayoutStrategy`]. Override individual children with the `with_*` builder methods. + fn default() -> Self { + let flat: Arc = Arc::new(FlatLayoutStrategy::default()); + Self { + elements: Arc::clone(&flat), + offsets: Arc::clone(&flat), + validity: Arc::clone(&flat), + fallback: flat, + } + } +} + +impl ListLayoutStrategy { + /// Strategy for the `elements` child. + pub fn with_elements(mut self, elements: Arc) -> Self { + self.elements = elements; + self + } + + /// Strategy for the `offsets` child. + pub fn with_offsets(mut self, offsets: Arc) -> Self { + self.offsets = offsets; + self + } + + /// Strategy for the `validity` child (written only when the list is nullable). + pub fn with_validity(mut self, validity: Arc) -> Self { + self.validity = validity; + self + } + + /// Strategy for non-list input, which is forwarded through this strategy unchanged. + pub fn with_fallback(mut self, fallback: Arc) -> Self { + self.fallback = fallback; + self + } +} + +#[async_trait] +impl LayoutStrategy for ListLayoutStrategy { + async fn write_stream( + &self, + ctx: ArrayContext, + segment_sink: SegmentSinkRef, + stream: SendableSequentialStream, + mut eof: SequencePointer, + session: &VortexSession, + ) -> VortexResult { + let dtype = stream.dtype().clone(); + if !dtype.is_list() { + return self + .fallback + .write_stream(ctx, segment_sink, stream, eof, session) + .await; + } + + let is_nullable = dtype.is_nullable(); + let element_dtype = dtype + .as_list_element_opt() + .vortex_expect("DType is List") + .as_ref() + .clone(); + // Global (whole-column) offsets are cumulative and may exceed the input offset width, + // so definsively widen. + let offsets_dtype = DType::Primitive(PType::U64, Nullability::NonNullable); + + // One bounded sub-stream per child: elements, offsets, and (when nullable) validity. + let (elements_tx, elements_rx) = kanal::bounded_async::(1); + let (offsets_tx, offsets_rx) = kanal::bounded_async::(1); + let (validity_tx, validity_rx) = if is_nullable { + let (tx, rx) = kanal::bounded_async::(1); + (Some(tx), Some(rx)) + } else { + (None, None) + }; + + // Transpose the list column into its child sub-streams and rebase offsets to global + // positions. Kept joined with the child writers below so producer errors surface rather + // than being hidden as an early channel close. + let fanout_fut = transpose_list_column( + stream, + session.clone(), + elements_tx, + offsets_tx, + validity_tx, + ); + + // Spawn a writer per child sub-stream, concurrently. + let handle = session.handle(); + let mut child_specs: Vec<( + DType, + Arc, + kanal::AsyncReceiver, + )> = vec![ + (element_dtype, Arc::clone(&self.elements), elements_rx), + (offsets_dtype, Arc::clone(&self.offsets), offsets_rx), + ]; + if let Some(validity_rx) = validity_rx { + child_specs.push(( + DType::Bool(Nullability::NonNullable), + Arc::clone(&self.validity), + validity_rx, + )); + } + + let layout_futures: Vec<_> = child_specs + .into_iter() + .map(|(child_dtype, strategy, rx)| { + let child_stream = + SequentialStreamAdapter::new(child_dtype, rx.into_stream().boxed()).sendable(); + let child_eof = eof.split_off(); + let ctx = ctx.clone(); + let segment_sink = Arc::clone(&segment_sink); + let session = session.clone(); + handle.spawn_nested(move |h| async move { + let session = session.with_handle(h); + strategy + .write_stream(ctx, segment_sink, child_stream, child_eof, &session) + .await + }) + }) + .collect(); + + let (_, layouts) = try_join(fanout_fut, try_join_all(layout_futures)).await?; + let mut layouts = layouts.into_iter(); + let elements_layout = layouts.next().vortex_expect("elements layout present"); + let offsets_layout = layouts.next().vortex_expect("offsets layout present"); + let validity_layout = + is_nullable.then(|| layouts.next().vortex_expect("validity layout present")); + + Ok(ListLayout::new(dtype, elements_layout, offsets_layout, validity_layout).into_layout()) + } + + fn buffered_bytes(&self) -> u64 { + let list_bytes = self.elements.buffered_bytes() + + self.offsets.buffered_bytes() + + self.validity.buffered_bytes(); + list_bytes.max(self.fallback.buffered_bytes()) + } +} + +/// Transpose a list column into its `elements`, `offsets`, and (when present) `validity` child +/// sub-streams, rebasing each chunk's local `offsets` to global `u64` positions so the single +/// `offsets` child indexes into the concatenated `elements` child. +/// +/// `validity_tx` is `Some` exactly when the list is nullable. Errors surface to the caller, which +/// joins this against the child writers, rather than being hidden as an early channel close. +async fn transpose_list_column( + mut stream: SendableSequentialStream, + session: VortexSession, + elements_tx: kanal::AsyncSender, + offsets_tx: kanal::AsyncSender, + validity_tx: Option>, +) -> VortexResult<()> { + let mut exec_ctx = session.create_execution_ctx(); + let mut element_base: u64 = 0; + let mut first = true; + let mut saw_chunk = false; + while let Some(chunk) = stream.next().await { + let (sequence_id, array) = chunk?; + saw_chunk = true; + let mut sp = sequence_id.descend(); + let ListDataParts { + elements, + offsets, + validity, + .. + } = canonicalize_to_list_parts(array, &mut exec_ctx)?; + let n_elements = elements.len() as u64; + let row_count = offsets.len().saturating_sub(1); + let offsets = global_offsets(offsets, element_base, first, &mut exec_ctx)?; + element_base += n_elements; + first = false; + + if elements_tx + .send(Ok((sp.advance(), elements))) + .await + .is_err() + || offsets_tx.send(Ok((sp.advance(), offsets))).await.is_err() + { + vortex_bail!("list child writer finished before all chunks were sent"); + } + if let Some(validity_tx) = &validity_tx { + let validity = validity + .execute_mask(row_count, &mut exec_ctx)? + .into_array(); + if validity_tx + .send(Ok((sp.advance(), validity))) + .await + .is_err() + { + vortex_bail!("list validity writer finished before all chunks were sent"); + } + } + } + if !saw_chunk { + vortex_bail!("ListLayoutStrategy needs at least one chunk"); + } + Ok(()) +} + +/// Canonicalize a list-dtype array into [`ListDataParts`]. +fn canonicalize_to_list_parts( + array: ArrayRef, + exec_ctx: &mut ExecutionCtx, +) -> VortexResult { + let canonical = array.execute_until::(exec_ctx)?; + if let Some(list) = canonical.as_opt::() { + Ok(list.into_owned().into_data_parts()) + } else if let Some(view) = canonical.as_opt::() { + Ok(list_from_list_view(view.into_owned(), exec_ctx)?.into_data_parts()) + } else { + unreachable!("AnyList matcher guarantees List or ListView") + } +} + +/// Rebase a chunk's local `offsets` into global `u64` positions for the whole-column `offsets` +/// child. Each chunk's offsets are shifted by `element_base` (the number of elements already +/// emitted) so they index into the concatenated `elements`. The duplicated boundary offset is +/// dropped on every chunk after the first, so the concatenation of all chunks' contributions is a +/// single monotonic `[0, .., total_elements]` array of length `row_count + 1`. +fn global_offsets( + offsets: ArrayRef, + element_base: u64, + first: bool, + exec_ctx: &mut ExecutionCtx, +) -> VortexResult { + let widened = offsets.cast(DType::Primitive(PType::U64, Nullability::NonNullable))?; + let based = if element_base == 0 { + widened + } else { + let base = ConstantArray::new(element_base, widened.len()).into_array(); + widened.binary(base, Operator::Add)? + }; + let based = if first { + based + } else { + based.slice(1..based.len())? + }; + // Materialize so the child sub-stream carries a concrete array rather than a lazy expression. + Ok(based.execute::(exec_ctx)?.into_array()) +} + +/// Matcher for `Array` or `Array`. +struct AnyList; + +impl Matcher for AnyList { + type Match<'a> = (); + + fn try_match(array: &ArrayRef) -> Option> { + (array.as_opt::().is_some() || array.as_opt::().is_some()).then_some(()) + } +} + +#[cfg(test)] +mod tests { + use futures::stream; + use vortex_array::arrays::BoolArray; + use vortex_array::arrays::ChunkedArray; + use vortex_array::arrays::ListArray; + use vortex_array::arrays::StructArray; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::validity::Validity; + use vortex_buffer::buffer; + use vortex_io::session::RuntimeSession; + + use super::*; + use crate::layouts::chunked::writer::ChunkedLayoutStrategy; + use crate::layouts::flat::writer::FlatLayoutStrategy; + use crate::layouts::table::TableStrategy; + use crate::segments::TestSegments; + use crate::sequence::SequentialArrayStreamExt; + use crate::session::LayoutSession; + + fn layout_test_session() -> VortexSession { + vortex_array::array_session() + .with::() + .with::() + .with_tokio() + } + + fn flat_list_strategy() -> ListLayoutStrategy { + ListLayoutStrategy::default() + } + + async fn write(strategy: &S, array: ArrayRef) -> VortexResult { + let session = layout_test_session(); + let segments = Arc::new(TestSegments::default()); + let (ptr, eof) = SequenceId::root().split(); + let stream = array.to_array_stream().sequenced(ptr); + strategy + .write_stream(ArrayContext::empty(), segments, stream, eof, &session) + .await + } + + fn i32_list_dtype(nullable: bool) -> DType { + DType::List( + Arc::new(DType::Primitive(PType::I32, Nullability::NonNullable)), + if nullable { + Nullability::Nullable + } else { + Nullability::NonNullable + }, + ) + } + + fn create_basic_list(validity: Validity) -> ArrayRef { + ListArray::try_new( + buffer![1i32, 2, 3, 4, 5].into_array(), + buffer![0u32, 2, 5, 5].into_array(), + validity, + ) + .unwrap() + .into_array() + } + + #[tokio::test] + async fn basic_non_nullable_input() -> VortexResult<()> { + let list = create_basic_list(Validity::NonNullable); + + let layout = write(&flat_list_strategy(), list).await?; + assert_eq!(layout.row_count(), 3); + + insta::assert_snapshot!(layout.display_tree(), @" + vortex.list, dtype: list(i32), children: 2 + ├── elements: vortex.flat, dtype: i32, segment: 0 + └── offsets: vortex.flat, dtype: u64, segment: 1 + "); + Ok(()) + } + + #[tokio::test] + async fn basic_nullable_input() -> VortexResult<()> { + let list = create_basic_list(Validity::Array( + BoolArray::from_iter([true, false, true]).into_array(), + )); + + let layout = write(&flat_list_strategy(), list).await?; + assert_eq!(layout.row_count(), 3); + + insta::assert_snapshot!(layout.display_tree(), @" + vortex.list, dtype: list(i32)?, children: 3 + ├── elements: vortex.flat, dtype: i32, segment: 0 + ├── offsets: vortex.flat, dtype: u64, segment: 1 + └── validity: vortex.flat, dtype: bool, segment: 2 + "); + Ok(()) + } + + /// Non-list input dispatches to the fallback strategy unchanged. + #[tokio::test] + async fn non_list_input_routes_to_fallback() -> VortexResult<()> { + let primitive = buffer![1i32, 2, 3].into_array(); + let layout = write(&flat_list_strategy(), primitive).await?; + insta::assert_snapshot!(layout.display_tree(), @"vortex.flat, dtype: i32, segment: 0"); + Ok(()) + } + + #[tokio::test] + async fn empty_stream_errors() { + let segments = Arc::new(TestSegments::default()); + let (_, eof) = SequenceId::root().split(); + let empty = stream::empty::>().boxed(); + let stream = SequentialStreamAdapter::new(i32_list_dtype(false), empty).sendable(); + let session = layout_test_session(); + + let res = flat_list_strategy() + .write_stream(ArrayContext::empty(), segments, stream, eof, &session) + .await; + assert!(res.is_err()) + } + + #[tokio::test] + async fn list_of_struct_tree() -> VortexResult<()> { + let struct_array = StructArray::from_fields( + [ + ("a", buffer![1i32, 2, 3, 4, 5].into_array()), + ("b", buffer![10i32, 20, 30, 40, 50].into_array()), + ] + .as_slice(), + )? + .into_array(); + let list = ListArray::try_new( + struct_array, + buffer![0u32, 2, 5, 5].into_array(), + Validity::NonNullable, + )? + .into_array(); + + let flat: Arc = Arc::new(FlatLayoutStrategy::default()); + let table_strategy: Arc = + Arc::new(TableStrategy::new(Arc::clone(&flat), Arc::clone(&flat))); + let writer = ListLayoutStrategy::default().with_elements(table_strategy); + + let layout = write(&writer, list).await?; + insta::assert_snapshot!(layout.display_tree(), @" + vortex.list, dtype: list({a=i32, b=i32}), children: 2 + ├── elements: vortex.struct, dtype: {a=i32, b=i32}, children: 2 + │ ├── a: vortex.flat, dtype: i32, segment: 1 + │ └── b: vortex.flat, dtype: i32, segment: 2 + └── offsets: vortex.flat, dtype: u64, segment: 0 + "); + Ok(()) + } + + #[tokio::test] + async fn list_of_list_tree() -> VortexResult<()> { + let inner_list = ListArray::try_new( + buffer![1i32, 2, 3, 4, 5, 6].into_array(), + buffer![0u32, 2, 5, 5, 6].into_array(), + Validity::NonNullable, + )? + .into_array(); + let list = ListArray::try_new( + inner_list, + buffer![0u32, 2, 4].into_array(), + Validity::NonNullable, + )? + .into_array(); + + let writer = + ListLayoutStrategy::default().with_elements(Arc::new(ListLayoutStrategy::default())); + let layout = write(&writer, list).await?; + insta::assert_snapshot!(layout.display_tree(), @" + vortex.list, dtype: list(list(i32)), children: 2 + ├── elements: vortex.list, dtype: list(i32), children: 2 + │ ├── elements: vortex.flat, dtype: i32, segment: 1 + │ └── offsets: vortex.flat, dtype: u64, segment: 2 + └── offsets: vortex.flat, dtype: u64, segment: 0 + "); + Ok(()) + } + + #[tokio::test] + async fn list_of_list_of_list_tree() -> VortexResult<()> { + let innermost = ListArray::try_new( + buffer![1i32, 2, 3, 4].into_array(), + buffer![0u32, 2, 4].into_array(), + Validity::NonNullable, + )? + .into_array(); + let middle = ListArray::try_new( + innermost, + buffer![0u32, 2].into_array(), + Validity::NonNullable, + )? + .into_array(); + let outer = + ListArray::try_new(middle, buffer![0u32, 1].into_array(), Validity::NonNullable)? + .into_array(); + + let writer = ListLayoutStrategy::default().with_elements(Arc::new( + ListLayoutStrategy::default().with_elements(Arc::new(ListLayoutStrategy::default())), + )); + let layout = write(&writer, outer).await?; + insta::assert_snapshot!(layout.display_tree(), @" + vortex.list, dtype: list(list(list(i32))), children: 2 + ├── elements: vortex.list, dtype: list(list(i32)), children: 2 + │ ├── elements: vortex.list, dtype: list(i32), children: 2 + │ │ ├── elements: vortex.flat, dtype: i32, segment: 2 + │ │ └── offsets: vortex.flat, dtype: u64, segment: 3 + │ └── offsets: vortex.flat, dtype: u64, segment: 1 + └── offsets: vortex.flat, dtype: u64, segment: 0 + "); + Ok(()) + } + + #[tokio::test] + async fn chunked_list_input_with_chunked_strategy_succeeds() -> VortexResult<()> { + let chunk0 = ListArray::try_new( + buffer![1i32, 2, 3].into_array(), + buffer![0u32, 2, 3].into_array(), + Validity::NonNullable, + ) + .unwrap() + .into_array(); + let chunk1 = ListArray::try_new( + buffer![4i32, 5, 6, 7].into_array(), + buffer![0u32, 1, 4].into_array(), + Validity::NonNullable, + ) + .unwrap() + .into_array(); + + let chunked = + ChunkedArray::try_new(vec![chunk0, chunk1], i32_list_dtype(false))?.into_array(); + + let layout = write(&ChunkedLayoutStrategy::new(flat_list_strategy()), chunked).await?; + + insta::assert_snapshot!(layout.display_tree(), @" + vortex.chunked, dtype: list(i32), children: 2 + ├── [0]: vortex.list, dtype: list(i32), children: 2 + │ ├── elements: vortex.flat, dtype: i32, segment: 0 + │ └── offsets: vortex.flat, dtype: u64, segment: 1 + └── [1]: vortex.list, dtype: list(i32), children: 2 + ├── elements: vortex.flat, dtype: i32, segment: 2 + └── offsets: vortex.flat, dtype: u64, segment: 3 + "); + Ok(()) + } +} diff --git a/vortex-layout/src/layouts/mod.rs b/vortex-layout/src/layouts/mod.rs index 18df5b8f347..47fa31aa3d9 100644 --- a/vortex-layout/src/layouts/mod.rs +++ b/vortex-layout/src/layouts/mod.rs @@ -16,6 +16,7 @@ pub mod dict; pub mod file_stats; pub mod flat; pub(crate) mod foreign; +pub mod list; pub(crate) mod partitioned; pub mod repartition; pub mod row_idx; diff --git a/vortex-layout/src/layouts/table.rs b/vortex-layout/src/layouts/table.rs index 8e7256d44aa..0853478c14a 100644 --- a/vortex-layout/src/layouts/table.rs +++ b/vortex-layout/src/layouts/table.rs @@ -4,14 +4,17 @@ //! A configurable writer strategy for tabular data. //! //! [`TableStrategy`] is a *dispatcher*: it inspects the dtype of the stream it is handed and -//! routes struct columns to [`StructStrategy`] and everything else to the configured leaf -//! strategy. Because it hands *itself* (suitably descended) to [`StructStrategy`] as the strategy -//! for the struct's children, arbitrarily nested struct trees are written with no manual wiring. +//! routes struct columns to [`StructStrategy`], list columns to [`ListLayoutStrategy`], and +//! everything else to the configured leaf strategy. Because it hands *itself* (suitably descended) +//! to those structural writers as the strategy for their children, arbitrarily nested struct/list +//! trees are written with no manual wiring. //! //! The dispatcher also owns field-path overrides, letting callers force a specific leaf field — //! at any depth — onto a custom strategy. +use std::env; use std::sync::Arc; +use std::sync::LazyLock; use async_trait::async_trait; use vortex_array::ArrayContext; @@ -25,17 +28,36 @@ use vortex_utils::aliases::hash_set::HashSet; use crate::LayoutRef; use crate::LayoutStrategy; +use crate::layouts::list::writer::ListLayoutStrategy; use crate::layouts::struct_::StructStrategy; use crate::segments::SegmentSinkRef; use crate::sequence::SendableSequentialStream; use crate::sequence::SequencePointer; +/// Whether [`TableStrategy`] writes list fields using a [`ListLayoutStrategy`] by +/// default. Disabled unless the environment variable `VORTEX_EXPERIMENTAL_LIST_LAYOUT` +/// is set to `1`. +/// +/// [`ListLayoutStrategy`]: crate::layouts::list::writer::ListLayoutStrategy +pub fn use_experimental_list_layout() -> bool { + static USE_EXPERIMENTAL_LIST_LAYOUT: LazyLock = + LazyLock::new(|| env::var("VORTEX_EXPERIMENTAL_LIST_LAYOUT").is_ok_and(|v| v == "1")); + *USE_EXPERIMENTAL_LIST_LAYOUT +} + +type ListLayoutFactory = Arc Arc + Send + Sync>; + /// A configurable strategy for writing nested tabular data, dispatching each (sub)stream to the /// structural writer for its dtype. /// /// Dispatch rules, applied to the dtype of the stream handed to [`write_stream`]: /// - **struct** → [`StructStrategy`], with each field written by its override (if any) or by a /// descended copy of this dispatcher. +/// - **list** → [`ListLayoutStrategy`], with `elements` written by a descended copy of this +/// dispatcher (so nested structs/lists recurse) and `offsets`/`validity` by the leaf/validity +/// strategies. Gated: only when list decomposition is enabled via +/// [`with_list_layout`][Self::with_list_layout] (off by default); otherwise a list falls through +/// to the leaf strategy. /// - **anything else** → the leaf strategy. /// /// [`write_stream`]: LayoutStrategy::write_stream @@ -47,6 +69,11 @@ pub struct TableStrategy { validity: Arc, /// The writer for leaf fields, i.e. anything that is not a struct. leaf: Arc, + /// Optional factory applied to each dynamically constructed [`ListLayoutStrategy`]. + /// Its presence also enables list decomposition. + /// + /// [`ListLayoutStrategy`]: crate::layouts::list::writer::ListLayoutStrategy + list_layout_factory: Option, } impl TableStrategy { @@ -73,6 +100,7 @@ impl TableStrategy { leaf_writers: Default::default(), validity, leaf: fallback, + list_layout_factory: None, } } @@ -139,6 +167,27 @@ impl TableStrategy { self.validity = validity; self } + + /// Enable writing list fields with [`ListLayoutStrategy`]. + /// + /// **Note**: this is an unstable and experimental layout that is expected to change. + /// Using it may lead to unreadable files in the future. + pub fn with_list_layout(self) -> Self { + self.with_list_layout_factory(|strategy| Arc::new(strategy)) + } + + /// Enable writing list fields with [`ListLayoutStrategy`] and wrap each list writer. This + /// allows repartitioning or zoning to operate in the list's outer-row space before shredding. + /// + /// **Note**: this is an unstable and experimental layout that is expected to change. + /// Using it may lead to unreadable files in the future. + pub fn with_list_layout_factory( + mut self, + factory: impl Fn(ListLayoutStrategy) -> Arc + Send + Sync + 'static, + ) -> Self { + self.list_layout_factory = Some(Arc::new(factory)); + self + } } impl TableStrategy { @@ -174,6 +223,21 @@ impl TableStrategy { .with_field_writers(field_writers) } + /// Build the [`ListLayoutStrategy`] used to write a list field stream at this level. + /// + /// The `elements` sub-column is routed back through a clean descended dispatcher so nested + /// structs/lists recurse; `offsets` go straight to the leaf (they are always a primitive + /// column); and `validity` uses the shared validity strategy. + fn list_strategy(&self) -> Option> { + let factory = self.list_layout_factory.as_ref()?; + let list_layout = ListLayoutStrategy::default() + .with_elements(Arc::new(self.descend_clean())) + .with_offsets(Arc::clone(&self.leaf)) + .with_validity(Arc::clone(&self.validity)) + .with_fallback(Arc::clone(&self.leaf)); + Some(factory(list_layout)) + } + /// Descend into a subfield, retaining only the overrides that apply beneath it (rebased to be /// relative to the child). fn descend(&self, field: &Field) -> Self { @@ -192,6 +256,7 @@ impl TableStrategy { leaf_writers: new_writers, validity: Arc::clone(&self.validity), leaf: Arc::clone(&self.leaf), + list_layout_factory: self.list_layout_factory.clone(), } } @@ -202,6 +267,7 @@ impl TableStrategy { leaf_writers: HashMap::default(), validity: Arc::clone(&self.validity), leaf: Arc::clone(&self.leaf), + list_layout_factory: self.list_layout_factory.clone(), } } @@ -244,6 +310,14 @@ impl LayoutStrategy for TableStrategy { .await; } + if dtype.is_list() + && let Some(list_strategy) = self.list_strategy() + { + return list_strategy + .write_stream(ctx, segment_sink, stream, eof, session) + .await; + } + // Leaf: hand off to the leaf strategy. self.leaf .write_stream(ctx, segment_sink, stream, eof, session) @@ -253,13 +327,16 @@ impl LayoutStrategy for TableStrategy { #[cfg(test)] mod tests { + use std::num::NonZeroUsize; use std::sync::Arc; use std::task::Poll; use vortex_array::ArrayContext; use vortex_array::ArrayRef; use vortex_array::IntoArray; + use vortex_array::arrays::BoolArray; use vortex_array::arrays::ChunkedArray; + use vortex_array::arrays::ListArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::StructArray; use vortex_array::dtype::DType; @@ -268,7 +345,9 @@ mod tests { use vortex_array::dtype::PType; use vortex_array::dtype::StructFields; use vortex_array::field_path; + use vortex_array::validity::Validity; use vortex_buffer::buffer; + use vortex_error::VortexExpect; use vortex_error::VortexResult; use vortex_io::runtime::single::block_on; use vortex_io::session::RuntimeSessionExt; @@ -277,7 +356,13 @@ mod tests { use crate::LayoutStrategy; use crate::layouts::chunked::writer::ChunkedLayoutStrategy; use crate::layouts::flat::writer::FlatLayoutStrategy; + use crate::layouts::list::List; + use crate::layouts::repartition::RepartitionStrategy; + use crate::layouts::repartition::RepartitionWriterOptions; use crate::layouts::table::TableStrategy; + use crate::layouts::zoned::Zoned; + use crate::layouts::zoned::writer::ZonedLayoutOptions; + use crate::layouts::zoned::writer::ZonedStrategy; use crate::segments::TestSegments; use crate::sequence::SequenceId; use crate::sequence::SequentialArrayStreamExt; @@ -322,6 +407,154 @@ mod tests { Ok(()) } + /// A `list>` column: the dispatcher recurses into itself so the outer list's + /// `elements` are decomposed as a nested `ListLayout`. + #[tokio::test] + async fn dispatches_nested_list() -> VortexResult<()> { + let inner = ListArray::try_new( + buffer![1i32, 2, 3, 4, 5, 6].into_array(), + buffer![0u32, 2, 5, 5, 6].into_array(), + Validity::NonNullable, + )? + .into_array(); + let outer = ListArray::try_new( + inner, + buffer![0u32, 2, 4].into_array(), + Validity::NonNullable, + )? + .into_array(); + + let layout = write(&flat_table().with_list_layout(), outer).await?; + insta::assert_snapshot!(layout.display_tree(), @r" + vortex.list, dtype: list(list(i32)), children: 2 + ├── elements: vortex.list, dtype: list(i32), children: 2 + │ ├── elements: vortex.flat, dtype: i32, segment: 1 + │ └── offsets: vortex.flat, dtype: u64, segment: 2 + └── offsets: vortex.flat, dtype: u64, segment: 0 + "); + Ok(()) + } + + /// A `struct<{ items: list>? }>` column: list decomposition recurses into struct + /// decomposition for the elements, and a nullable list writes a validity child. + #[tokio::test] + async fn dispatches_struct_list_struct() -> VortexResult<()> { + let inner_struct = StructArray::from_fields( + [ + ("a", buffer![1i32, 2, 3, 4, 5].into_array()), + ("b", buffer![10i32, 20, 30, 40, 50].into_array()), + ] + .as_slice(), + )? + .into_array(); + let items = ListArray::try_new( + inner_struct, + buffer![0u32, 2, 5, 5].into_array(), + Validity::Array(BoolArray::from_iter([true, false, true]).into_array()), + )? + .into_array(); + let st = StructArray::from_fields([("items", items)].as_slice())?.into_array(); + + let layout = write(&flat_table().with_list_layout(), st).await?; + insta::assert_snapshot!(layout.display_tree(), @r" + vortex.struct, dtype: {items=list({a=i32, b=i32})?}, children: 1 + └── items: vortex.list, dtype: list({a=i32, b=i32})?, children: 3 + ├── elements: vortex.struct, dtype: {a=i32, b=i32}, children: 2 + │ ├── a: vortex.flat, dtype: i32, segment: 2 + │ └── b: vortex.flat, dtype: i32, segment: 3 + ├── offsets: vortex.flat, dtype: u64, segment: 0 + └── validity: vortex.flat, dtype: bool, segment: 1 + "); + Ok(()) + } + + /// A multi-chunk `list` written with a chunked leaf: each sub-column (`elements`, + /// `offsets`) becomes its own `ChunkedLayout`, so elements are chunked independently of rows. + /// This is the "list-of-chunkeds" topology top-level decomposition unlocks. + #[tokio::test] + async fn dispatches_chunked_list() -> VortexResult<()> { + let chunk0 = ListArray::try_new( + buffer![1i32, 2, 3].into_array(), + buffer![0u32, 2, 3].into_array(), + Validity::NonNullable, + )? + .into_array(); + let chunk1 = ListArray::try_new( + buffer![4i32, 5, 6, 7].into_array(), + buffer![0u32, 1, 4].into_array(), + Validity::NonNullable, + )? + .into_array(); + let dtype = chunk0.dtype().clone(); + let chunked = ChunkedArray::try_new(vec![chunk0, chunk1], dtype)?.into_array(); + + let flat: Arc = Arc::new(FlatLayoutStrategy::default()); + let dispatcher = TableStrategy::new( + Arc::clone(&flat), + Arc::new(ChunkedLayoutStrategy::new(FlatLayoutStrategy::default())), + ) + .with_list_layout(); + let layout = write(&dispatcher, chunked).await?; + insta::assert_snapshot!(layout.display_tree(), @r" + vortex.list, dtype: list(i32), children: 2 + ├── elements: vortex.chunked, dtype: i32, children: 2 + │ ├── [0]: vortex.flat, dtype: i32, segment: 0 + │ └── [1]: vortex.flat, dtype: i32, segment: 1 + └── offsets: vortex.chunked, dtype: u64, children: 2 + ├── [0]: vortex.flat, dtype: u64, segment: 2 + └── [1]: vortex.flat, dtype: u64, segment: 3 + "); + Ok(()) + } + + /// A wrapper can repartition and zone lists in outer-row space before decomposition. + #[tokio::test] + async fn wraps_list_strategy_before_decomposition() -> VortexResult<()> { + let list = ListArray::try_new( + PrimitiveArray::from_iter(0..9_i32).into_array(), + PrimitiveArray::from_iter(0..=9_u32).into_array(), + Validity::NonNullable, + )? + .into_array(); + + let row_block_size = NonZeroUsize::new(4).vortex_expect("4 is non-zero"); + let flat: Arc = Arc::new(FlatLayoutStrategy::default()); + let stats = Arc::clone(&flat); + let chunked: Arc = + Arc::new(ChunkedLayoutStrategy::new(FlatLayoutStrategy::default())); + let dispatcher = TableStrategy::new(Arc::clone(&flat), chunked).with_list_layout_factory( + move |list_layout| { + let zoned = ZonedStrategy::new( + list_layout, + Arc::clone(&stats), + ZonedLayoutOptions { + block_size: row_block_size, + ..Default::default() + }, + ); + Arc::new(RepartitionStrategy::new( + zoned, + RepartitionWriterOptions { + block_size_minimum: 0, + block_len_multiple: row_block_size.get(), + block_size_target: None, + canonicalize: false, + }, + )) as Arc + }, + ); + + let layout = write(&dispatcher, list).await?; + let zoned = layout.as_::(); + assert_eq!(zoned.zone_len(), 4); + assert_eq!(zoned.nzones(), 3); + + let data = layout.child(0)?; + assert!(data.is::()); + assert_eq!(data.row_count(), 9); + Ok(()) + } + /// A non-struct stream is not shredded; it is handed straight to the leaf strategy. #[tokio::test] async fn non_struct_input_uses_leaf() -> VortexResult<()> { diff --git a/vortex-layout/src/session.rs b/vortex-layout/src/session.rs index be938bdc192..6bb52b1d1e8 100644 --- a/vortex-layout/src/session.rs +++ b/vortex-layout/src/session.rs @@ -12,6 +12,7 @@ use crate::LayoutEncodingRef; use crate::layouts::chunked::ChunkedLayoutEncoding; use crate::layouts::dict::DictLayoutEncoding; use crate::layouts::flat::FlatLayoutEncoding; +use crate::layouts::list::ListLayoutEncoding; use crate::layouts::struct_::StructLayoutEncoding; use crate::layouts::zoned::LegacyStatsLayoutEncoding; use crate::layouts::zoned::ZonedLayoutEncoding; @@ -57,6 +58,7 @@ impl Default for LayoutSession { LegacyStatsLayoutEncoding.as_ref(), ); layouts.register(DictLayoutEncoding.id(), DictLayoutEncoding.as_ref()); + layouts.register(ListLayoutEncoding.id(), ListLayoutEncoding.as_ref()); Self { registry: layouts } } From 3975895ef257e07458cfcb30cac25ac01aae6509 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Thu, 16 Jul 2026 17:36:08 -0400 Subject: [PATCH 102/104] Remove the old v2 benchmarks website (#8805) ## Rationale for this change The live benchmarks website is now located at https://github.com/vortex-data/benchmarks-website and it has been going strong for the past few weeks, so I think it is time to remove this. ## What changes are included in this PR? Deletes the old website code from here. ## What APIs are changed? Are there any user-facing changes? N/A Signed-off-by: Connor Tsui --- .../workflows/publish-benchmarks-website.yml | 38 - REUSE.toml | 5 - _typos.toml | 2 +- benchmarks-website/.dockerignore | 3 - benchmarks-website/.gitignore | 2 - benchmarks-website/Dockerfile | 16 - benchmarks-website/README.md | 119 -- benchmarks-website/docker-compose.yml | 29 - benchmarks-website/index.html | 36 - benchmarks-website/package-lock.json | 1324 ----------------- benchmarks-website/package.json | 32 - .../public/Vortex_Black_NoBG.png | Bin 145750 -> 0 bytes .../public/Vortex_White_NoBG.png | Bin 144245 -> 0 bytes .../public/android-chrome-192x192.png | Bin 8637 -> 0 bytes .../public/android-chrome-512x512.png | Bin 27388 -> 0 bytes .../public/apple-touch-icon.png | Bin 7639 -> 0 bytes benchmarks-website/public/favicon-16x16.png | Bin 362 -> 0 bytes benchmarks-website/public/favicon-32x32.png | Bin 873 -> 0 bytes benchmarks-website/public/favicon.ico | Bin 15406 -> 0 bytes benchmarks-website/public/site.webmanifest | 19 - benchmarks-website/server.js | 775 ---------- benchmarks-website/src/App.jsx | 295 ---- benchmarks-website/src/api.js | 41 - .../src/components/BenchmarkSection.jsx | 147 -- .../src/components/BenchmarkSummary.jsx | 129 -- .../src/components/ChartContainer.jsx | 664 --------- benchmarks-website/src/components/Header.jsx | 110 -- benchmarks-website/src/components/Modal.jsx | 476 ------ benchmarks-website/src/components/Sidebar.jsx | 59 - benchmarks-website/src/config.js | 285 ---- benchmarks-website/src/main.jsx | 10 - benchmarks-website/src/styles/index.css | 1319 ---------------- benchmarks-website/src/utils.js | 104 -- benchmarks-website/vite.config.js | 19 - 34 files changed, 1 insertion(+), 6057 deletions(-) delete mode 100644 .github/workflows/publish-benchmarks-website.yml delete mode 100644 benchmarks-website/.dockerignore delete mode 100644 benchmarks-website/.gitignore delete mode 100644 benchmarks-website/Dockerfile delete mode 100644 benchmarks-website/README.md delete mode 100644 benchmarks-website/docker-compose.yml delete mode 100644 benchmarks-website/index.html delete mode 100644 benchmarks-website/package-lock.json delete mode 100644 benchmarks-website/package.json delete mode 100644 benchmarks-website/public/Vortex_Black_NoBG.png delete mode 100644 benchmarks-website/public/Vortex_White_NoBG.png delete mode 100644 benchmarks-website/public/android-chrome-192x192.png delete mode 100644 benchmarks-website/public/android-chrome-512x512.png delete mode 100644 benchmarks-website/public/apple-touch-icon.png delete mode 100644 benchmarks-website/public/favicon-16x16.png delete mode 100644 benchmarks-website/public/favicon-32x32.png delete mode 100644 benchmarks-website/public/favicon.ico delete mode 100644 benchmarks-website/public/site.webmanifest delete mode 100644 benchmarks-website/server.js delete mode 100644 benchmarks-website/src/App.jsx delete mode 100644 benchmarks-website/src/api.js delete mode 100644 benchmarks-website/src/components/BenchmarkSection.jsx delete mode 100644 benchmarks-website/src/components/BenchmarkSummary.jsx delete mode 100644 benchmarks-website/src/components/ChartContainer.jsx delete mode 100644 benchmarks-website/src/components/Header.jsx delete mode 100644 benchmarks-website/src/components/Modal.jsx delete mode 100644 benchmarks-website/src/components/Sidebar.jsx delete mode 100644 benchmarks-website/src/config.js delete mode 100644 benchmarks-website/src/main.jsx delete mode 100644 benchmarks-website/src/styles/index.css delete mode 100644 benchmarks-website/src/utils.js delete mode 100644 benchmarks-website/vite.config.js diff --git a/.github/workflows/publish-benchmarks-website.yml b/.github/workflows/publish-benchmarks-website.yml deleted file mode 100644 index 5555dcf841a..00000000000 --- a/.github/workflows/publish-benchmarks-website.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Publish Benchmarks Website - -on: - push: - branches: [develop] - paths: - - "benchmarks-website/**" - -jobs: - publish: - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - - name: Log in to GHCR - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up QEMU - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - - - name: Build and push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 - with: - context: ./benchmarks-website - platforms: linux/arm64 - push: true - tags: ghcr.io/${{ github.repository }}/benchmarks-website:latest diff --git a/REUSE.toml b/REUSE.toml index fb3079a046d..6ce32411711 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -12,11 +12,6 @@ path = "vortex-btrblocks/decision-tree.*" SPDX-FileCopyrightText = "Copyright the Vortex contributors" SPDX-License-Identifier = "CC-BY-4.0" -[[annotations]] -path = "benchmarks-website/**" -SPDX-FileCopyrightText = "Copyright the Vortex contributors" -SPDX-License-Identifier = "CC-BY-4.0" - # Golden files are licensed under CC-BY-4.0. [[annotations]] path = "**/goldenfiles/**" diff --git a/_typos.toml b/_typos.toml index d5d2a8e4db3..359b89c80fb 100644 --- a/_typos.toml +++ b/_typos.toml @@ -11,7 +11,7 @@ extend-ignore-re = [ # scripts/measurement_id_golden.json is a frozen hash-pin artifact whose vectors include # deliberate Unicode edge cases (JSON-escaped, so "café" reads as the token "caf"); its bytes # must never change, so it is excluded rather than "fixed". -extend-exclude = ["/vortex-bench/**", "/docs/references.bib", "benchmarks/**", "vortex-sqllogictest/slt/**", "encodings/fsst/src/dfa/tests.rs", "encodings/fsst/src/dfa/flat_contains.rs", "benchmarks-website/server/static/**", "benchmarks-website/server/tests/snapshots/**", "scripts/measurement_id_golden.json"] +extend-exclude = ["/vortex-bench/**", "/docs/references.bib", "benchmarks/**", "vortex-sqllogictest/slt/**", "encodings/fsst/src/dfa/tests.rs", "encodings/fsst/src/dfa/flat_contains.rs", "scripts/measurement_id_golden.json"] [type.py] extend-ignore-identifiers-re = [ diff --git a/benchmarks-website/.dockerignore b/benchmarks-website/.dockerignore deleted file mode 100644 index a21f178d3ab..00000000000 --- a/benchmarks-website/.dockerignore +++ /dev/null @@ -1,3 +0,0 @@ -node_modules -dist -.git diff --git a/benchmarks-website/.gitignore b/benchmarks-website/.gitignore deleted file mode 100644 index 76add878f8d..00000000000 --- a/benchmarks-website/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist \ No newline at end of file diff --git a/benchmarks-website/Dockerfile b/benchmarks-website/Dockerfile deleted file mode 100644 index 1f87a7148b5..00000000000 --- a/benchmarks-website/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -FROM node:24-alpine AS build -WORKDIR /app -COPY package.json package-lock.json ./ -RUN npm ci -COPY . . -RUN npm run build - -FROM node:24-alpine -WORKDIR /app -COPY package.json package-lock.json ./ -RUN npm ci --omit=dev -COPY --from=build /app/dist ./dist -COPY server.js . -COPY src/config.js ./src/config.js -EXPOSE 3000 -CMD ["node", "server.js"] diff --git a/benchmarks-website/README.md b/benchmarks-website/README.md deleted file mode 100644 index 01bbcc265fd..00000000000 --- a/benchmarks-website/README.md +++ /dev/null @@ -1,119 +0,0 @@ - - -# bench.vortex.dev - -The website behind `bench.vortex.dev`. The directory currently houses **two -implementations side by side**, run together until the v3 cutover lands: - -- **v2** (top-level files: `server.js`, `src/`, `index.html`, `vite.config.js`, - `package.json`, `Dockerfile`, `docker-compose.yml`, `public/`). The Node + - React stack that has shipped to production for the life of the site. Built - and published by - [`.github/workflows/publish-benchmarks-website.yml`](../.github/workflows/publish-benchmarks-website.yml). -- **v3** (`server/` + `migrate/` + `ops/`). A single Rust binary - - [`vortex-bench-server`](server/) - that owns a DuckDB file on local disk, - serves the API, and renders the HTML. Compiles all static assets - (`chart.umd.js`, `chart-init.js`, `style.css`) into the binary so deploys - are one file plus a database. Built directly on the EC2 host by - [`ops/deploy.sh`](ops/deploy.sh) - see [`ops/README.md`](ops/README.md). - [`migrate/`](migrate/) is a one-shot tool that loads v2's S3 dataset into a - v3 DuckDB; it is throwaway and goes away after cutover. - -Live results are produced by -[`.github/workflows/bench.yml`](../.github/workflows/bench.yml) and -[`.github/workflows/sql-benchmarks.yml`](../.github/workflows/sql-benchmarks.yml), -which CI runs after every push to `develop`. Until cutover the same payload is -emitted to both stacks (v2 via the legacy `--gh-json` path appended to a public -S3 bucket; v3 via `--gh-json-v3` POSTed to `/api/ingest`). - -## v3 architecture in one paragraph - -`axum` (HTTP) + `maud` (compile-time HTML) + embedded `duckdb-rs` over a single -local DB file. Five fact tables (`query_measurements`, `compression_times`, -`compression_sizes`, `random_access_times`, `vector_search_runs`) plus a -`commits` dim table - see [`server/src/schema.rs`](server/src/schema.rs) for -the column contracts. Three HTML routes (`/`, `/chart/{slug}`, -`/group/{slug}`) and four stable JSON routes (`GET /api/groups`, -`GET /api/chart/{slug}`, `GET /api/group/{slug}`, `GET /health`), plus -versioned group shard artifacts and bearer-gated `POST /api/ingest`. The hot -website path serves precomputed, precompressed latest-100 artifacts from an -in-memory read model; pages render chart shells and hydrate groups via shard -artifacts, while full history warms in the background. See -[`server/ARCHITECTURE.md`](server/ARCHITECTURE.md). - -For the per-module crate map and the request-flow walkthrough, see the -`//!` doc on [`server/src/lib.rs`](server/src/lib.rs). The producer side of -the ingest contract lives in -[`vortex-bench/src/v3.rs`](../vortex-bench/src/v3.rs); the historical-data -side in [`migrate/src/classifier.rs`](migrate/src/classifier.rs). - -## Local dev - -```bash -# v3 server (DuckDB lives at ./bench.duckdb by default). -INGEST_BEARER_TOKEN=dev cargo run -p vortex-bench-server -# server logs: "bench server listening addr=127.0.0.1:3000 db=bench.duckdb" - -# v3 historical migrator (writes a fully populated DuckDB the server can open). -cargo run -p vortex-bench-migrate -- run --output ./bench.duckdb -``` - -Ingest fixture data via the snapshot tests' envelopes (see -[`server/tests/common/mod.rs`](server/tests/common/mod.rs)) or by hand-rolling -a JSONL file and POSTing through `scripts/post-ingest.py`. - -```bash -cargo nextest run -p vortex-bench-server -p vortex-bench-migrate -INSTA_UPDATE=auto cargo nextest run -p vortex-bench-server # update snapshots -``` - -For the v2 stack: - -```bash -cd benchmarks-website -npm install -npm run dev -``` - -## Deployment - -v3 runs as a systemd service on a single EC2 host. The full operator -runbook (first-time install, day-to-day, failure modes) is in -[`ops/README.md`](ops/README.md). Summary: - -- A `vortex-bench-deploy.timer` polls `origin/develop` every 60s. If commits - in the range touch `benchmarks-website/server/`, `benchmarks-website/migrate/`, - `Cargo.toml`, or `Cargo.lock`, it builds and atomically swaps the binary, - then verifies `/health`. Otherwise it fast-forwards the working tree and - exits silently. -- A `vortex-bench-backup.timer` fires hourly: it asks the server to write a - per-table Vortex snapshot (`schema.sql` plus one `

.vortex` file per - table) via the bearer-gated `/api/admin/snapshot` endpoint, `tar czf`s the - snapshot directory into `.tar.gz`, uploads it to - `s3://vortex-benchmark-results-database/v3-backups/`, and deletes the local - copies. -- For ad-hoc reads against the live DB, `ops/inspect.sh` calls a - bearer-gated `/api/admin/sql` endpoint - no server stop required. - -The v3 server is throwaway-friendly: every request runs against the local -DuckDB file, and a fresh boot reapplies the schema DDL idempotently. The -migrator deletes the target file (and its `.wal`) before populating it, so -re-running `vortex-bench-migrate run --output ...` is safe. - -## Cutover plan (in flight) - -The work to flip `bench.vortex.dev` from v2 to v3 is tracked outside this -repo. The relevant code-side bits: - -- v3 runs alongside v2 on the same EC2 host today and is fed by CI's - dual-write `--gh-json-v3` path. -- v2 keeps shipping unchanged until DNS flips. **Do not touch the top-level - v2 files unless you are doing the cleanup PR opened post-flip.** -- The v2 cleanup PR removes everything top-level under `benchmarks-website/` - that belongs to v2 (`server.js`, `src/`, `index.html`, `vite.config.js`, - `package.json`, `package-lock.json`, `public/`, the top-level `Dockerfile`, - `docker-compose.yml`, and the `publish-benchmarks-website.yml` workflow). - The v3 tree under `server/`, `migrate/`, and `ops/` is untouched. diff --git a/benchmarks-website/docker-compose.yml b/benchmarks-website/docker-compose.yml deleted file mode 100644 index b97482a230a..00000000000 --- a/benchmarks-website/docker-compose.yml +++ /dev/null @@ -1,29 +0,0 @@ -services: - benchmarks-website: - image: ghcr.io/vortex-data/vortex/benchmarks-website:latest - ports: - - "80:3000" - restart: unless-stopped - - vortex-bench-server: - image: ghcr.io/vortex-data/vortex/vortex-bench-server:latest - ports: - - "3001:3000" - environment: - VORTEX_BENCH_DB: "/app/data/bench.duckdb" - VORTEX_BENCH_BIND: "0.0.0.0:3000" - VORTEX_BENCH_LOG: "info,vortex_bench_server=debug" - env_file: - - /etc/vortex-bench/secrets.env - volumes: - - /opt/benchmarks-website/data:/app/data - restart: unless-stopped - - watchtower: - image: containrrr/watchtower - volumes: - - /var/run/docker.sock:/var/run/docker.sock - environment: - - WATCHTOWER_POLL_INTERVAL=60 - - WATCHTOWER_CLEANUP=true - restart: unless-stopped diff --git a/benchmarks-website/index.html b/benchmarks-website/index.html deleted file mode 100644 index e475f3ad254..00000000000 --- a/benchmarks-website/index.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - Vortex Benchmarks - - - - - - - - - - - - - - - - - -
- - - diff --git a/benchmarks-website/package-lock.json b/benchmarks-website/package-lock.json deleted file mode 100644 index 7eb09dbfa84..00000000000 --- a/benchmarks-website/package-lock.json +++ /dev/null @@ -1,1324 +0,0 @@ -{ - "name": "vortex-benchmarks-website", - "version": "2.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "vortex-benchmarks-website", - "version": "2.0.0", - "dependencies": { - "chart.js": "^4.5.1", - "chartjs-plugin-zoom": "^2.2.0", - "downsample": "^1.4.0", - "hammerjs": "^2.0.8", - "lucide-react": "^1.11.0", - "react": "^19.2.5", - "react-chartjs-2": "^5.3.1", - "react-dom": "^19.2.5" - }, - "devDependencies": { - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "concurrently": "^10.0.0", - "vite": "^8.0.10" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@kurkle/color": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", - "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", - "license": "MIT" - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.138.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", - "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", - "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", - "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", - "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", - "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", - "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", - "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", - "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", - "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", - "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", - "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", - "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", - "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", - "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", - "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", - "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/hammerjs": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", - "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", - "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chart.js": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", - "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", - "license": "MIT", - "dependencies": { - "@kurkle/color": "^0.3.0" - }, - "engines": { - "pnpm": ">=8" - } - }, - "node_modules/chartjs-plugin-zoom": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/chartjs-plugin-zoom/-/chartjs-plugin-zoom-2.2.0.tgz", - "integrity": "sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==", - "license": "MIT", - "dependencies": { - "@types/hammerjs": "^2.0.45", - "hammerjs": "^2.0.8" - }, - "peerDependencies": { - "chart.js": ">=3.2.0" - } - }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/concurrently": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", - "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "5.6.2", - "rxjs": "7.8.2", - "shell-quote": "1.8.4", - "supports-color": "10.2.2", - "tree-kill": "1.2.2", - "yargs": "18.0.0" - }, - "bin": { - "conc": "dist/bin/index.js", - "concurrently": "dist/bin/index.js" - }, - "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/downsample": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/downsample/-/downsample-1.4.0.tgz", - "integrity": "sha512-teYPhUPxqwtyICt47t1mP/LjhbRV/ghuKb/LmFDbcZ0CjqFD31tn6rVLZoeCEa1xr8+f2skW8UjRiLiGIKQE4w==", - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/hammerjs": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", - "integrity": "sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lucide-react": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", - "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-chartjs-2": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", - "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", - "license": "MIT", - "peerDependencies": { - "chart.js": "^4.1.1", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.7" - } - }, - "node_modules/rolldown": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", - "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.138.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.4", - "@rolldown/binding-darwin-arm64": "1.1.4", - "@rolldown/binding-darwin-x64": "1.1.4", - "@rolldown/binding-freebsd-x64": "1.1.4", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", - "@rolldown/binding-linux-arm64-gnu": "1.1.4", - "@rolldown/binding-linux-arm64-musl": "1.1.4", - "@rolldown/binding-linux-ppc64-gnu": "1.1.4", - "@rolldown/binding-linux-s390x-gnu": "1.1.4", - "@rolldown/binding-linux-x64-gnu": "1.1.4", - "@rolldown/binding-linux-x64-musl": "1.1.4", - "@rolldown/binding-openharmony-arm64": "1.1.4", - "@rolldown/binding-wasm32-wasi": "1.1.4", - "@rolldown/binding-win32-arm64-msvc": "1.1.4", - "@rolldown/binding-win32-x64-msvc": "1.1.4" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/vite": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", - "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.16", - "rolldown": "~1.1.3", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - } - } -} diff --git a/benchmarks-website/package.json b/benchmarks-website/package.json deleted file mode 100644 index a795e0fb22c..00000000000 --- a/benchmarks-website/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "vortex-benchmarks-website", - "version": "2.0.0", - "type": "module", - "scripts": { - "dev": "concurrently \"npm run server\" \"npm run vite\"", - "vite": "vite", - "server": "node server.js", - "build": "vite build", - "preview": "vite preview" - }, - "engines": { - "node": ">=18.0.0" - }, - "dependencies": { - "chart.js": "^4.5.1", - "chartjs-plugin-zoom": "^2.2.0", - "downsample": "^1.4.0", - "hammerjs": "^2.0.8", - "lucide-react": "^1.11.0", - "react": "^19.2.5", - "react-chartjs-2": "^5.3.1", - "react-dom": "^19.2.5" - }, - "devDependencies": { - "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.1", - "concurrently": "^10.0.0", - "vite": "^8.0.10" - } -} diff --git a/benchmarks-website/public/Vortex_Black_NoBG.png b/benchmarks-website/public/Vortex_Black_NoBG.png deleted file mode 100644 index b4caa0e0287d14601e5e52aab461b46512ad3dc8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 145750 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

}jRyNDHa!Kp34 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 z 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 diff --git a/benchmarks-website/public/Vortex_White_NoBG.png b/benchmarks-website/public/Vortex_White_NoBG.png deleted file mode 100644 index 702d378306b70c5db39443d6fdf04f825bbb7331..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 144245 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

pY_<5^vko5IgB91+-0 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 I73*vN0W&Frc>n+a diff --git a/benchmarks-website/public/apple-touch-icon.png b/benchmarks-website/public/apple-touch-icon.png deleted file mode 100644 index 2b34da9ee0c8da782b1c891c315987054dfde580..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7639 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 diff --git a/benchmarks-website/public/favicon.ico b/benchmarks-website/public/favicon.ico deleted file mode 100644 index 3d4e2ff9fe0b846a37a498f1324d0e7fe9777920..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15406 zcmeI2Rjd?C5QgXG-UoPqaFL)75bQvZgS$N74#9#GNN@@6?j8ccf`kNjcMI+W4J0@T zJmGTCy8G2(mOXQ3cV}mIL&)7uHapW&^>$UtMD2&-Ma_aVCplB>4LHhWJJ#*Zcl#i->&p@cZf;>!rZkO9?yv{Q2{t z=-ZCpyLYc#xNt$oO`SSbE?>SJjE3#_Q>RWzr%s(@{P^)QWy%y;uwa3lK7CrCYbmM5 zU%GTj`;QwpPPT2^CQFwt6`rAIVhW&b@WT zDOmB%1z+*IexDr3GL~Ju`bn?Y;{3fz(A#*=^8fPXi+ug+nvCm#=Cs?kZ7a8K-7;Sz z{ddg&&6_v2w7Ylju4~E1j~{K@!&ExvU$$&n*|~G4EMB}==Fgw+Cp&%mbQv{jlvJ-? z-Cq~MgJb@WA3v4~6)MPp0Rse?w{G29-oAY+8#Zi^p+kpCp+bfHY@a@TD&@+`^xRxw{?z<8a0yQ#f$4ZdWY`c zxpPM%^uvz$bLOa2sgl|m^h=d0r5IMMSfM&Gd-iOpQKN=p3?b`)Y5O0;@_+sMwUjAS zM)0X-LB4|q4N^U$9iPD22KgZOXV0FQUBmr{D92PyIRG5Ju6qqDcz5|H&ne|Mz~qxcuYt_acF~ z{)CYpXP|H2zJ=-Kx;f_ZCzr^XiWpZ6N=%3b;v`1IIr!D9SAo^hdiI3hiA>k5StFdQ zIlD*UTxXrPC;Z5koa6WJ-!;BM491F)JScIAef##w=+UDii9uMg+P!0-DRtk|m3*UcEX6R_ucKAn{oz`j}#H^1k0v71=BNi6bIo z;?ql(EYX-5d5gn`56koC&oypO%#!$;8C!x2@tiJQx(MIx?Af!CF;4F7N&Y57;<((G zAl_KCXi?2E^ytw;a^}n_W51c{;Ub)Ytp4lr*T_s$VG`7eqv|b*8qRk ztXWmh$)#k=mQB*8O)CQj4wNxt#%Mf|zQ`N7PntAI^IV@kxk_2P!cWW-n?;w=2V_c| zvu@qGs_Xgk<&)#bkE=M*A=>dnYuB#TKHRk+Z`Q6|JH78he`3TgO2qhy#}g;Vmtc?R zbgNdaR8Q8eTc>vV;K2jGEn>rr;UC-u3l`M)cHzQ>HLjW?M-IX6>Eog}!VhnJ1GdUK z#JwQo&;1ze5Hd{oBRuD)8U!HHGX7??O}u1A~uOVFsERJF(*%+lz#pCso%t=ko%!Shx9w* zm)VoB7SPv0afKf_PM9!3^@iLMzL10_#*U)g3dEHz7KuC&V38~!Ajpf2mJAq zCr^Sv3|Z|8Klxqc$hyNk@cZxHy;EG&p&$5D_M+@@z?V65X3YU}o}{n!oDdRa@J3ho z@jK+E**Aj^TLTX=MYi1IY}l}&egk|H^I^W&D)(2=d588H9C>n!EBxl(t83S;DRP?V z1NhiS;9KyIoV$4DZVdNZxjVsrXW_zyPVV7E9Y4N-JI3|u)k}dLM(8r@7-vdkjc?~$ zu^-|tO|D$IROh*4%eU*+ty?M@;Q(9OUCG}iU3>|9PWBh9OJ-pnoCUBK?ih0~ zhCa;8Ly0PWuTK1C01W>;Rh$RahTfo?A?^ zQO6Hq)=>5b>}6tHVuBx;;)AYSxe{aensBoS_1z8cZIO; diff --git a/benchmarks-website/public/site.webmanifest b/benchmarks-website/public/site.webmanifest deleted file mode 100644 index ba9480d90de..00000000000 --- a/benchmarks-website/public/site.webmanifest +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "Vortex Benchmarks", - "short_name": "Benchmarks", - "icons": [ - { - "src": "/android-chrome-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/android-chrome-512x512.png", - "sizes": "512x512", - "type": "image/png" - } - ], - "theme_color": "#ffffff", - "background_color": "#ffffff", - "display": "standalone" -} diff --git a/benchmarks-website/server.js b/benchmarks-website/server.js deleted file mode 100644 index d6d3f98eef9..00000000000 --- a/benchmarks-website/server.js +++ /dev/null @@ -1,775 +0,0 @@ -import http from "http"; -import fs from "fs"; -import path from "path"; -import { fileURLToPath } from "url"; -import zlib from "zlib"; -import readline from "readline"; -import { Readable } from "stream"; -import { LTTB } from "downsample"; -import { QUERY_SUITES, FAN_OUT_GROUPS, ENGINE_RENAMES } from "./src/config.js"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// Configuration -const PORT = process.env.PORT || 3000; -const DATA_URL = - process.env.DATA_URL || - "https://vortex-ci-benchmark-results.s3.amazonaws.com/data.json.gz"; -const COMMITS_URL = - process.env.COMMITS_URL || - "https://vortex-ci-benchmark-results.s3.amazonaws.com/commits.json"; -const REFRESH_INTERVAL = process.env.REFRESH_INTERVAL || 5 * 60 * 1000; -const MAX_POINTS = 200; -const USE_LOCAL_DATA = process.env.USE_LOCAL_DATA === "true"; - -// Benchmark groups: non-query groups + simple suites + fan-out suites -const GROUPS = [ - "Compression", - "Compression Size", - ...QUERY_SUITES.filter((s) => !s.skip && !s.fanOut).map((s) => s.displayName), - "Random Access", - ...FAN_OUT_GROUPS, -]; - -const MIME = { - ".html": "text/html", - ".js": "application/javascript", - ".css": "text/css", - ".json": "application/json", - ".png": "image/png", - ".jpg": "image/jpeg", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".woff": "font/woff", - ".woff2": "font/woff2", - ".webmanifest": "application/manifest+json", -}; - -let store = { - commits: [], - groups: {}, - metadata: null, - downsampled: {}, - lastUpdated: null, -}; - -// Utilities -const rename = (s) => ENGINE_RENAMES[s.toLowerCase()] || ENGINE_RENAMES[s] || s; -const geoMean = (arr) => - arr.length - ? Math.pow( - arr.reduce((a, v) => a * v, 1), - 1 / arr.length, - ) - : null; - -// Categorize benchmarks based on name patterns and metadata -function getGroup(benchmark) { - const name = benchmark.name; - const lower = name.toLowerCase(); - - // Random Access: "random-access/..." or "random access/..." - if ( - lower.startsWith("random-access/") || - lower.startsWith("random access/") - ) { - return "Random Access"; - } - - // Compression Size: size measurements - if ( - lower.startsWith("vortex size/") || - lower.startsWith("vortex-file-compressed size/") || - lower.startsWith("parquet size/") || - lower.startsWith("lance size/") || - lower.includes(":raw size/") || - lower.includes(":parquet-zstd size/") || - lower.includes(":lance size/") - ) { - return "Compression Size"; - } - - // Compression: compress/decompress time and ratio measurements - if ( - lower.startsWith("compress time/") || - lower.startsWith("decompress time/") || - lower.startsWith("parquet_rs-zstd compress") || - lower.startsWith("parquet_rs-zstd decompress") || - lower.startsWith("lance compress") || - lower.startsWith("lance decompress") || - lower.startsWith("vortex:lance ratio") || - lower.startsWith("vortex:parquet-zstd ratio") || - lower.startsWith("vortex:raw ratio") - ) { - return "Compression"; - } - - // SQL query suites: match "{prefix}_q..." or "{prefix}/..." - for (const suite of QUERY_SUITES) { - if ( - !lower.startsWith(suite.prefix + "_q") && - !lower.startsWith(suite.prefix + "/") - ) - continue; - if (suite.skip) return null; - if (!suite.fanOut) return suite.displayName; - // Fan-out suites: expand by storage and scale factor - const storage = benchmark.storage?.toUpperCase() === "S3" ? "S3" : "NVMe"; - const rawSf = benchmark.dataset?.[suite.datasetKey]?.scale_factor; - const sf = rawSf ? Math.round(parseFloat(rawSf)) : 1; - return `${suite.displayName} (${storage}) (SF=${sf})`; - } - - return null; -} - -// Format query name for display: "{prefix}_q00" -> "{QUERY_PREFIX} Q0" -function formatQuery(q) { - const lower = q.toLowerCase(); - for (const suite of QUERY_SUITES) { - if (suite.skip) continue; - const m = lower.match(new RegExp(`^${suite.prefix}[_ ]?q(\\d+)`, "i")); - if (m) return `${suite.queryPrefix} Q${parseInt(m[1], 10)}`; - } - return q.toUpperCase().replace(/[_-]/g, " "); -} - -function normalizeChartName(group, chartName) { - if (group === "Compression Size" && chartName === "VORTEX FILE COMPRESSED SIZE") { - return "VORTEX SIZE"; - } - return chartName; -} - -// LTTB downsampling -function lttbIndices(seriesMap, target) { - const keys = [...seriesMap.keys()]; - if (!keys.length) return []; - const len = seriesMap.get(keys[0])?.length || 0; - if (len <= target) return [...Array(len).keys()]; - - const avg = Array(len); - for (let i = 0; i < len; i++) { - let sum = 0, - n = 0; - for (const arr of seriesMap.values()) { - const v = arr[i]?.value ?? arr[i]; - if (v != null && !isNaN(v)) { - sum += v; - n++; - } - } - avg[i] = [i, n ? sum / n : 0]; - } - - const idx = LTTB(avg, target).map((p) => Math.round(p[0])); - if (!idx.includes(0)) idx.unshift(0); - if (!idx.includes(len - 1)) idx.push(len - 1); - return idx.sort((a, b) => a - b); -} - -function downsample(data, factor) { - const target = Math.ceil(data.commits.length / factor); - if (target >= data.commits.length) return data; - - const idx = lttbIndices(data.series, target); - const series = new Map(); - for (const [k, v] of data.series) - series.set( - k, - idx.map((i) => v[i]), - ); - - return { - ...data, - commits: idx.map((i) => data.commits[i]), - series, - originalLength: data.commits.length, - }; -} - -// Data fetching — streams response body directly instead of buffering -async function fetchJsonl(url) { - const res = await fetch(url); - if (!res.ok) throw new Error(`Fetch failed: ${url} ${res.status}`); - return new Promise((resolve, reject) => { - const results = []; - const rl = readline.createInterface({ - input: Readable.fromWeb(res.body), - crlfDelay: Infinity, - }); - rl.on("line", (l) => { - if (l.trim()) - try { - results.push(JSON.parse(l)); - } catch {} - }); - rl.on("close", () => resolve(results)); - rl.on("error", reject); - }); -} - -function readLocalJsonl(fp) { - return new Promise((resolve, reject) => { - const results = []; - const rl = readline.createInterface({ - input: fs.createReadStream(fp), - crlfDelay: Infinity, - }); - rl.on("line", (l) => { - if (l.trim()) - try { - results.push(JSON.parse(l)); - } catch {} - }); - rl.on("close", () => resolve(results)); - rl.on("error", reject); - }); -} - -// Stream benchmark data record-by-record without buffering the entire dataset -async function forEachBenchmark(callback) { - let stream; - if (USE_LOCAL_DATA) { - stream = fs.createReadStream(path.join(__dirname, "sample/data.json")); - } else { - const res = await fetch(DATA_URL); - if (!res.ok) throw new Error(`Fetch failed: ${DATA_URL} ${res.status}`); - stream = Readable.fromWeb(res.body).pipe(zlib.createGunzip()); - } - return new Promise((resolve, reject) => { - const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - rl.on("line", (l) => { - if (l.trim()) - try { - callback(JSON.parse(l)); - } catch {} - }); - rl.on("close", resolve); - rl.on("error", reject); - }); -} - -// Main data processing -async function refresh() { - console.log("Refreshing data..."); - const t0 = Date.now(); - - try { - // Load commits first (small dataset, must be fully in memory for indexing) - const commitsArr = USE_LOCAL_DATA - ? await readLocalJsonl(path.join(__dirname, "sample/commits.json")) - : await fetchJsonl(COMMITS_URL); - - // Build commit index (O(1) lookup) - const commitMap = new Map(commitsArr.map((c) => [c.id, c])); - const commits = commitsArr.sort( - (a, b) => new Date(a.timestamp) - new Date(b.timestamp), - ); - const commitIdx = new Map(commits.map((c, i) => [c.id, i])); - - const groups = Object.fromEntries(GROUPS.map((g) => [g, new Map()])); - let missing = 0; - let benchmarkCount = 0; - const uncategorized = new Set(); - - // Stream benchmarks one record at a time to avoid loading all into memory - await forEachBenchmark((b) => { - benchmarkCount++; - const commit = b.commit || commitMap.get(b.commit_id); - if (!commit) { - missing++; - return; - } - - const group = getGroup(b); - if (!group) { - uncategorized.add(b.name.split("/")[0]); - return; - } - if (!groups[group]) return; - - // Random access names have the form: random-access/{dataset}/{pattern}/{format} - // Historical random access names: random-access/{format} - // Other benchmarks use: {query}/{series} - let seriesName, chartName; - const parts = b.name.split("/"); - if (group === "Random Access" && parts.length === 4) { - chartName = `${parts[1]}/${parts[2]}`.toUpperCase().replace(/[_-]/g, " "); - seriesName = rename(parts[3] || "default"); - } else if (group === "Random Access" && parts.length === 2) { - chartName = "RANDOM ACCESS"; - seriesName = rename(parts[1] || "default"); - } else { - seriesName = rename(parts[1] || "default"); - chartName = formatQuery(parts[0]); - } - chartName = normalizeChartName(group, chartName); - if (chartName.includes("PARQUET-UNC")) return; - - // Skip throughput metrics (keep only time/size) - if (b.name.includes(" throughput")) return; - - let unit = b.unit; - if (!unit) { - if (b.name.toLowerCase().includes(" size/")) unit = "bytes"; - else if (b.name.toLowerCase().includes(" ratio ")) unit = "ratio"; - else unit = "ns"; - } - - const sortPos = parts[0].match(/q(\d+)$/i)?.[1] - ? parseInt(RegExp.$1, 10) - : 0; - const idx = commitIdx.get(commit.id); - if (idx === undefined) return; - - let chart = groups[group].get(chartName); - if (!chart) { - let displayUnit = unit; - if (unit === "ns") displayUnit = "ms/iter"; - else if (unit === "bytes") displayUnit = "MiB"; - chart = { - sort_position: sortPos, - commits, - unit: displayUnit, - series: new Map(), - }; - groups[group].set(chartName, chart); - } - - if (!chart.series.has(seriesName)) { - chart.series.set(seriesName, Array(commits.length).fill(null)); - } - - // Convert values: ns -> ms, bytes -> MiB - let val = b.value; - if (unit === "ns" && typeof val === "number") { - val = val / 1e6; // ns to ms - } else if (unit === "bytes" && typeof val === "number") { - val = val / (1024 * 1024); // bytes to MiB - } - - chart.series.get(seriesName)[idx] = { value: val }; - }); - - console.log( - `Processed ${benchmarkCount} benchmarks, ${commitsArr.length} commits`, - ); - - // Log uncategorized benchmarks for debugging - if (uncategorized.size > 0) { - console.log( - `Uncategorized benchmark prefixes (${uncategorized.size}):`, - [...uncategorized].slice(0, 20).join(", "), - ); - } - - // Trim leading empty commits - let firstIdx = commits.length; - for (const gc of Object.values(groups)) { - for (const cd of gc.values()) { - for (const sd of cd.series.values()) { - const i = sd.findIndex((d) => d !== null); - if (i !== -1 && i < firstIdx) firstIdx = i; - } - } - } - - if (firstIdx > 0 && firstIdx < commits.length) { - console.log(`Trimming ${firstIdx} empty commits`); - commits.splice(0, firstIdx); - for (const gc of Object.values(groups)) { - for (const cd of gc.values()) { - cd.commits = commits; - for (const [k, v] of cd.series) cd.series.set(k, v.slice(firstIdx)); - } - } - } - - // Sort charts within groups - for (const gc of Object.values(groups)) { - const sorted = [...gc.entries()].sort( - (a, b) => - a[1].sort_position - b[1].sort_position || a[0].localeCompare(b[0]), - ); - gc.clear(); - for (const [k, v] of sorted) gc.set(k, v); - } - - // Precompute downsampled versions - const downsampled = {}; - for (const [gn, gc] of Object.entries(groups)) { - downsampled[gn] = {}; - for (const [cn, cd] of gc) { - downsampled[gn][cn] = { - "1x": cd, - "2x": downsample(cd, 2), - "4x": downsample(cd, 4), - "8x": downsample(cd, 8), - }; - } - } - - // Count charts per group for logging - const groupCounts = Object.entries(groups) - .map(([n, g]) => `${n}: ${g.size}`) - .filter((s) => !s.endsWith(": 0")); - console.log("Charts per group:", groupCounts.join(", ")); - - store = { - commits, - groups, - metadata: buildMeta(groups, commits), - downsampled, - lastUpdated: new Date().toISOString(), - }; - console.log( - `Refresh done in ${Date.now() - t0}ms (${missing} missing commits)`, - ); - } catch (e) { - console.error("Refresh error:", e); - } -} - -// Summary calculations -function latestIdx(chart) { - for (let i = chart.commits.length - 1; i >= 0; i--) { - for (const s of chart.series.values()) if (s[i]?.value != null) return i; - } - return -1; -} - -function calcSummary(name, charts) { - if (name === "Random Access") { - for (const q of charts.values()) { - const i = latestIdx(q); - if (i === -1) continue; - const vals = new Map(); - for (const [n, d] of q.series) - if (d[i]?.value != null) vals.set(n, d[i].value); - if (!vals.size) continue; - const min = Math.min(...vals.values()); - return { - type: "randomAccess", - title: "Random Access Performance", - rankings: [...vals] - .map(([n, t]) => ({ name: n, time: t, ratio: t / min })) - .sort((a, b) => a.time - b.time), - explanation: "Random access time | Ratio to fastest (lower is better)", - }; - } - return null; - } - - if (name === "Compression") { - const cc = charts.get("VORTEX:PARQUET ZSTD RATIO COMPRESS TIME"); - const dc = charts.get("VORTEX:PARQUET ZSTD RATIO DECOMPRESS TIME"); - if (!cc && !dc) return null; - const i = latestIdx(cc || dc); - if (i === -1) return null; - const collect = (c) => - c - ? [...c.series] - .filter(([n]) => !n.toLowerCase().includes("wide table")) - .map(([, d]) => d[i]?.value) - .filter((v) => v > 0) - .map((v) => 1 / v) - : []; - return { - type: "compression", - title: "Compression Throughput vs Parquet", - compressRatio: geoMean(collect(cc)), - decompressRatio: geoMean(collect(dc)), - datasetCount: collect(cc).length, - explanation: - "Inverse geomean of Vortex/Parquet ratios (higher is better)", - }; - } - - if (name === "Compression Size") { - const c = charts.get("VORTEX:PARQUET ZSTD SIZE"); - if (!c) return null; - const i = latestIdx(c); - if (i === -1) return null; - const ratios = [...c.series] - .filter(([n]) => !n.toLowerCase().includes("wide table")) - .map(([, d]) => d[i]?.value) - .filter((v) => v > 0); - return ratios.length - ? { - type: "compressionSize", - title: "Compression Size Summary", - minRatio: Math.min(...ratios), - meanRatio: geoMean(ratios), - maxRatio: Math.max(...ratios), - datasetCount: ratios.length, - explanation: - "Geomean of Vortex/Parquet size ratios (lower is better)", - } - : null; - } - - if ( - QUERY_SUITES.some( - (s) => - !s.skip && - (name === s.displayName || name.startsWith(s.displayName + " ")), - ) - ) { - const all = new Map(); - for (const q of charts.values()) - for (const n of q.series.keys()) if (!all.has(n)) all.set(n, new Map()); - for (const [qn, qd] of charts) { - for (const [sn, sd] of qd.series) { - for (let i = sd.length - 1; i >= 0; i--) { - if (sd[i]?.value != null) { - all.get(sn).set(qn, sd[i].value); - break; - } - } - } - } - if (!all.size) return null; - - const scores = new Map(); - for (const [sn, qr] of all) { - let total = 0, - max = 0; - for (const v of qr.values()) { - total += v; - max = Math.max(max, v); - } - const penalty = Math.max(300000, max) * 2; - const ratios = []; - for (const qn of charts.keys()) { - let base = Infinity; - for (const m of all.values()) - if (m.has(qn)) base = Math.min(base, m.get(qn)); - if (base < Infinity) - ratios.push((10 + (qr.get(qn) ?? penalty)) / (10 + base)); - } - if (ratios.length) - scores.set(sn, { score: geoMean(ratios), totalRuntime: total }); - } - - return scores.size - ? { - type: "queryBenchmark", - title: "Performance Summary", - rankings: [...scores] - .map(([n, d]) => ({ name: n, ...d })) - .sort((a, b) => a.score - b.score), - explanation: - "Geomean of query time ratio to fastest (lower is better)", - } - : null; - } - return null; -} - -function buildMeta(groups, commits) { - const meta = {}; - for (const [gn, gc] of Object.entries(groups)) { - const charts = [...gc].map(([cn, cd]) => { - const latest = {}; - for (const [sn, sd] of cd.series) { - for (let i = sd.length - 1; i >= 0; i--) - if (sd[i]?.value != null) { - latest[sn] = sd[i].value; - break; - } - } - return { - name: cn, - unit: cd.unit, - series: [...cd.series.keys()], - sortPosition: cd.sort_position, - totalPoints: cd.commits.length, - latestValues: latest, - }; - }); - meta[gn] = { - charts, - totalCharts: charts.length, - hasData: charts.length > 0, - summary: calcSummary(gn, gc), - }; - } - return { - groups: meta, - totalCommits: commits.length, - commits: commits.map((c) => ({ - id: c.id, - message: c.message?.split("\n")[0] || "", - timestamp: c.timestamp, - author: c.author?.name || "Unknown", - })), - lastUpdated: new Date().toISOString(), - }; -} - -// HTTP handlers -const json = (res, code, data) => { - res.writeHead(code, { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - }); - res.end(JSON.stringify(data)); -}; - -function serveFile(res, fp) { - fs.readFile(fp, (err, data) => { - if (err) { - res.writeHead(err.code === "ENOENT" ? 404 : 500); - return res.end(err.code === "ENOENT" ? "Not Found" : "Error"); - } - const ext = path.extname(fp).toLowerCase(); - const hdrs = { "Content-Type": MIME[ext] || "application/octet-stream" }; - if (ext === ".js") { - hdrs["Cache-Control"] = "no-cache"; - hdrs["Pragma"] = "no-cache"; - } - res.writeHead(200, hdrs); - res.end(data); - }); -} - -function handleData(res, group, chart, start, end, last, startIdx, endIdx) { - if (!store.downsampled) return json(res, 503, { error: "Loading" }); - const gd = store.downsampled[group]; - if (!gd) return json(res, 404, { error: "Group not found" }); - const cv = gd[chart]; - if (!cv) return json(res, 404, { error: "Chart not found" }); - - const full = cv["1x"]; - const ts = (c) => - typeof c?.timestamp === "number" - ? c.timestamp - : new Date(c?.timestamp).getTime(); - - let si = 0, - ei = full.commits.length - 1; - - // Support "last=N" parameter to get the last N commits - if (last && !start && !end && startIdx === null && endIdx === null) { - const n = parseInt(last, 10); - if (n > 0 && n < full.commits.length) { - si = full.commits.length - n; - } - } else if (startIdx !== null || endIdx !== null) { - // Support index-based range (startIdx, endIdx) - if (startIdx !== null) si = Math.max(0, parseInt(startIdx, 10)); - if (endIdx !== null) - ei = Math.min(full.commits.length - 1, parseInt(endIdx, 10)); - } else { - // Timestamp-based range - if (start) { - const t = +start, - i = full.commits.findIndex((c) => ts(c) >= t); - if (i !== -1) si = i; - } - if (end) { - const t = +end; - for (let i = ei; i >= 0; i--) - if (ts(full.commits[i]) <= t) { - ei = i; - break; - } - } - } - - const len = ei - si + 1; - const ver = - len <= MAX_POINTS - ? "1x" - : len <= MAX_POINTS * 2 - ? "2x" - : len <= MAX_POINTS * 4 - ? "4x" - : "8x"; - const cd = cv[ver]; - const val = (d) => d?.value ?? (typeof d === "number" ? d : null); - - let commits, series; - if (ver === "1x") { - commits = full.commits.slice(si, ei + 1); - series = Object.fromEntries( - [...full.series].map(([n, d]) => [n, d.slice(si, ei + 1).map(val)]), - ); - } else { - const s = +ver[0], - dsi = Math.floor(si / s), - dei = Math.min(Math.ceil(ei / s), cd.commits.length - 1); - commits = cd.commits.slice(dsi, dei + 1); - series = Object.fromEntries( - [...cd.series].map(([n, d]) => [n, d.slice(dsi, dei + 1).map(val)]), - ); - } - - json(res, 200, { - group, - chart, - unit: cd.unit, - downsampleLevel: ver, - originalLength: full.commits.length, - requestedRange: { startIndex: si, endIndex: ei, length: len }, - commits: commits.map((c) => ({ - id: c.id, - message: c.message?.split("\n")[0] || "", - timestamp: c.timestamp, - author: c.author?.name || "Unknown", - url: c.url, - })), - series, - }); -} - -const server = http.createServer((req, res) => { - const [path_, qs] = req.url.split("?"); - const params = new URLSearchParams(qs || ""); - - if (req.method === "OPTIONS") { - res.writeHead(204, { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET", - "Access-Control-Allow-Headers": "Content-Type", - }); - return res.end(); - } - - if (path_ === "/api/metadata") - return store.metadata - ? json(res, 200, store.metadata) - : json(res, 503, { error: "Loading" }); - - if (path_.startsWith("/api/data/")) { - const p = path_.slice(10).split("/"); - return handleData( - res, - decodeURIComponent(p[0] || ""), - decodeURIComponent(p.slice(1).join("/") || ""), - params.get("start"), - params.get("end"), - params.get("last"), - params.has("startIdx") ? params.get("startIdx") : null, - params.has("endIdx") ? params.get("endIdx") : null, - ); - } - - const fp = path.join(__dirname, "dist", path_ === "/" ? "index.html" : path_); - if (!fp.startsWith(__dirname) || fp.includes("/sample/")) { - res.writeHead(403); - return res.end("Forbidden"); - } - serveFile(res, fp); -}); - -async function start() { - console.log("Starting server..."); - await refresh(); - setInterval(refresh, REFRESH_INTERVAL); - server.listen(PORT, () => console.log(`Server at http://localhost:${PORT}`)); -} - -start().catch(console.error); diff --git a/benchmarks-website/src/App.jsx b/benchmarks-website/src/App.jsx deleted file mode 100644 index 0df05bebf01..00000000000 --- a/benchmarks-website/src/App.jsx +++ /dev/null @@ -1,295 +0,0 @@ -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import Header from './components/Header'; -import Sidebar from './components/Sidebar'; -import BenchmarkSection from './components/BenchmarkSection'; -import Modal from './components/Modal'; -import { fetchMetadata } from './api'; -import { BENCHMARK_CONFIGS, CATEGORY_TAGS } from './config'; - -export default function App() { - const [metadata, setMetadata] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [expandedGroups, setExpandedGroups] = useState(new Set()); - const [sidebarOpen, setSidebarOpen] = useState(false); - const [categoryFilter, setCategoryFilter] = useState('all'); - const [searchFilter, setSearchFilter] = useState(''); - const [viewMode, setViewMode] = useState('grid'); - const [modalChart, setModalChart] = useState(null); - const [showBackToTop, setShowBackToTop] = useState(false); - const metadataFetched = useRef(false); - - useEffect(() => { - if (metadataFetched.current) return; - metadataFetched.current = true; - - async function loadMetadata() { - try { - const data = await fetchMetadata(); - setMetadata(data); - const params = new URLSearchParams(window.location.search); - if (params.get('expanded') === 'true' && data?.groups) { - setExpandedGroups(new Set(Object.keys(data.groups))); - } - } catch (err) { - setError(err.message); - } finally { - setLoading(false); - } - } - loadMetadata(); - }, []); - - useEffect(() => { - const handleScroll = () => { - setShowBackToTop(window.scrollY > 400); - }; - window.addEventListener('scroll', handleScroll, { passive: true }); - return () => window.removeEventListener('scroll', handleScroll); - }, []); - - // Handle hash-based navigation on page load - useEffect(() => { - if (!metadata || loading) return; - - const hash = window.location.hash; - if (hash && hash.startsWith('#group-')) { - const groupId = hash.slice(1); // Remove the '#' - const groupName = groupId.replace('group-', '').replace(/-/g, ' '); - - // Find the matching group (case-insensitive, handle hyphenated names) - const matchingGroup = Object.keys(metadata.groups).find(name => - name.replace(/\s+/g, '-') === groupId.replace('group-', '') - ); - - if (matchingGroup) { - // Expand the group - setExpandedGroups(prev => new Set([...prev, matchingGroup])); - - // Scroll to the element after a short delay to allow rendering - setTimeout(() => { - const element = document.getElementById(groupId); - if (element) { - const headerHeight = 72; - const y = element.getBoundingClientRect().top + window.scrollY - headerHeight - 16; - window.scrollTo({ top: y, behavior: 'smooth' }); - } - }, 100); - } - } - }, [metadata, loading]); - - // Get benchmark config by group name - const getBenchmarkConfig = useCallback((groupName) => { - return BENCHMARK_CONFIGS.find(c => c.name === groupName) || {}; - }, []); - - // Filter groups based on category and search - const filteredGroups = useMemo(() => { - if (!metadata?.groups) return []; - - return Object.keys(metadata.groups).filter(groupName => { - // Category filter - if (categoryFilter !== 'all') { - const tags = CATEGORY_TAGS[groupName] || []; - if (!tags.includes(categoryFilter)) return false; - } - - // Search filter - if (searchFilter) { - const searchLower = searchFilter.toLowerCase(); - const matchesGroup = groupName.toLowerCase().includes(searchLower); - const groupData = metadata.groups[groupName]; - const charts = groupData?.charts || []; - const matchesChart = charts.some(c => - c.name.toLowerCase().includes(searchLower) - ); - if (!matchesGroup && !matchesChart) return false; - } - - return true; - }); - }, [metadata, categoryFilter, searchFilter]); - - // Toggle group expansion - const toggleGroup = useCallback((groupName) => { - setExpandedGroups(prev => { - const next = new Set(prev); - if (next.has(groupName)) { - next.delete(groupName); - } else { - next.add(groupName); - } - return next; - }); - }, []); - - // Expand all groups - const expandAll = useCallback(() => { - if (metadata?.groups) { - setExpandedGroups(new Set(Object.keys(metadata.groups))); - const url = new URL(window.location); - url.searchParams.set('expanded', 'true'); - window.history.replaceState(null, '', url); - } - }, [metadata]); - - // Collapse all groups - const collapseAll = useCallback(() => { - setExpandedGroups(new Set()); - const url = new URL(window.location); - url.searchParams.delete('expanded'); - window.history.replaceState(null, '', url); - }, []); - - // Scroll to group - const scrollToGroup = useCallback((groupName) => { - const element = document.getElementById(`group-${groupName.replace(/\s+/g, '-')}`); - if (element) { - const headerHeight = 72; - const y = element.getBoundingClientRect().top + window.scrollY - headerHeight - 16; - window.scrollTo({ top: y, behavior: 'smooth' }); - } - setSidebarOpen(false); - }, []); - - // Back to top - const scrollToTop = useCallback(() => { - window.scrollTo({ top: 0, behavior: 'smooth' }); - }, []); - - // Clear search - const clearFilter = useCallback(() => { - setSearchFilter(''); - setCategoryFilter('all'); - }, []); - - if (loading) { - return ( -
-
setSidebarOpen(!sidebarOpen)} - categoryFilter={categoryFilter} - onCategoryChange={setCategoryFilter} - searchFilter={searchFilter} - onSearchChange={setSearchFilter} - viewMode={viewMode} - onViewModeChange={setViewMode} - onExpandAll={expandAll} - onCollapseAll={collapseAll} - /> -
-
-
-
-

Loading benchmarks...

-
-
-
-
- ); - } - - if (error) { - return ( -
-
setSidebarOpen(!sidebarOpen)} - categoryFilter={categoryFilter} - onCategoryChange={setCategoryFilter} - searchFilter={searchFilter} - onSearchChange={setSearchFilter} - viewMode={viewMode} - onViewModeChange={setViewMode} - onExpandAll={expandAll} - onCollapseAll={collapseAll} - /> -
-
-
-

Error loading benchmarks: {error}

-
-
-
-
- ); - } - - return ( -
-
setSidebarOpen(!sidebarOpen)} - categoryFilter={categoryFilter} - onCategoryChange={setCategoryFilter} - searchFilter={searchFilter} - onSearchChange={setSearchFilter} - viewMode={viewMode} - onViewModeChange={setViewMode} - onExpandAll={expandAll} - onCollapseAll={collapseAll} - /> - -
- setSidebarOpen(false)} - onGroupClick={scrollToGroup} - onClearFilter={clearFilter} - showClearFilter={categoryFilter !== 'all' || searchFilter !== ''} - /> - -
setSidebarOpen(false)} - /> - -
- {filteredGroups.map(groupName => { - const groupData = metadata.groups[groupName] || {}; - const charts = groupData.charts || []; - const config = getBenchmarkConfig(groupName); - const isExpanded = expandedGroups.has(groupName); - - if (config.hidden) return null; - - return ( - toggleGroup(groupName)} - viewMode={viewMode} - onFullscreen={(chartData) => setModalChart(chartData)} - commitRange={metadata.totalCommits} - summary={groupData.summary} - /> - ); - })} -
-
- - {showBackToTop && ( - - )} - - {modalChart && ( - setModalChart(null)} - /> - )} -
- ); -} diff --git a/benchmarks-website/src/api.js b/benchmarks-website/src/api.js deleted file mode 100644 index 042ea7a6f0b..00000000000 --- a/benchmarks-website/src/api.js +++ /dev/null @@ -1,41 +0,0 @@ -const API_BASE = ''; - -export async function fetchMetadata() { - const response = await fetch(`${API_BASE}/api/metadata`); - if (!response.ok) throw new Error(`Failed to fetch metadata: ${response.status}`); - return response.json(); -} - -export async function fetchChartData(groupName, chartName, options = {}) { - const { startTimestamp, endTimestamp, last, startIdx, endIdx } = options; - let url = `${API_BASE}/api/data/${encodeURIComponent(groupName)}/${encodeURIComponent(chartName)}`; - const params = new URLSearchParams(); - - if (last) { - params.set('last', last); - } else if (startIdx !== undefined || endIdx !== undefined) { - // Index-based range - if (startIdx !== undefined) params.set('startIdx', startIdx); - if (endIdx !== undefined) params.set('endIdx', endIdx); - } else { - // Timestamp-based range - if (startTimestamp) { - const ts = typeof startTimestamp === 'number' - ? startTimestamp - : new Date(startTimestamp).getTime(); - params.set('start', ts); - } - if (endTimestamp) { - const ts = typeof endTimestamp === 'number' - ? endTimestamp - : new Date(endTimestamp).getTime(); - params.set('end', ts); - } - } - - if (params.toString()) url += '?' + params.toString(); - - const response = await fetch(url); - if (!response.ok) throw new Error(`Failed to fetch chart data: ${response.status}`); - return response.json(); -} diff --git a/benchmarks-website/src/components/BenchmarkSection.jsx b/benchmarks-website/src/components/BenchmarkSection.jsx deleted file mode 100644 index 706a9c27fe6..00000000000 --- a/benchmarks-website/src/components/BenchmarkSection.jsx +++ /dev/null @@ -1,147 +0,0 @@ -import React, { useState, useCallback, useMemo } from 'react'; -import { Info, Link2 } from 'lucide-react'; -import ChartContainer from './ChartContainer'; -import BenchmarkSummary from './BenchmarkSummary'; -import { getBenchmarkDescription, remapChartName } from '../utils'; - -export default function BenchmarkSection({ - groupName, - charts, - config, - isExpanded, - onToggle, - viewMode, - onFullscreen, - commitRange, - summary, -}) { - const [engineFilter, setEngineFilter] = useState('all'); - const [copiedLink, setCopiedLink] = useState(false); - - // Get unique engines from chart series - const engines = useMemo(() => { - const engineSet = new Set(); - charts.forEach(chart => { - chart.series?.forEach(seriesName => { - if (seriesName.includes(':')) { - const engine = seriesName.split(':')[0].toLowerCase(); - engineSet.add(engine); - } - }); - }); - return Array.from(engineSet).sort(); - }, [charts]); - - // Filter and sort charts based on config - const filteredCharts = useMemo(() => { - if (!charts) return []; - - let result = charts.filter(chart => { - // Apply keptCharts filter - if (config.keptCharts) { - const upperName = chart.name.toUpperCase(); - return config.keptCharts.some(kept => upperName === kept.toUpperCase()); - } - return true; - }); - - // Sort by keptCharts order if specified - if (config.keptCharts) { - const orderMap = new Map(config.keptCharts.map((name, idx) => [name.toUpperCase(), idx])); - result.sort((a, b) => { - const aIdx = orderMap.get(a.name.toUpperCase()) ?? 999; - const bIdx = orderMap.get(b.name.toUpperCase()) ?? 999; - return aIdx - bIdx; - }); - } - - return result; - }, [charts, config]); - - // Copy link to clipboard - const handleCopyLink = useCallback((e) => { - e.stopPropagation(); - const url = `${window.location.origin}${window.location.pathname}#group-${groupName.replace(/\s+/g, '-')}`; - navigator.clipboard.writeText(url); - setCopiedLink(true); - setTimeout(() => setCopiedLink(false), 2000); - }, [groupName]); - - const description = getBenchmarkDescription(groupName); - const hasData = filteredCharts.length > 0; - const chartCount = filteredCharts.length; - - return ( -
-
-
- {isExpanded ? '▼' : '▶'} -
-

- {groupName} - -

- {description && ( - - - - )} -
- {chartCount} {chartCount === 1 ? 'CHART' : 'CHARTS'} -
-
-
- - {isExpanded && engines.length > 0 && ( -
- Filter by engine: - - {engines.map(engine => ( - - ))} -
- )} -
- - - - {isExpanded && ( -
- {filteredCharts.map(chart => ( - - ))} -
- )} -
- ); -} diff --git a/benchmarks-website/src/components/BenchmarkSummary.jsx b/benchmarks-website/src/components/BenchmarkSummary.jsx deleted file mode 100644 index 6bc2d4f1790..00000000000 --- a/benchmarks-website/src/components/BenchmarkSummary.jsx +++ /dev/null @@ -1,129 +0,0 @@ -import React from 'react'; -import { formatTime } from '../utils'; - -// BenchmarkSummary now uses pre-computed summary from metadata (passed via props) -// instead of fetching all chart data -export default function BenchmarkSummary({ groupName, charts, summary }) { - // Use pre-computed summary from metadata - const summaryData = summary; - - if (!summaryData) return null; - - // Query benchmarks (Clickbench, TPC-H, TPC-DS, etc.) - if (summaryData.type === 'queryBenchmark' && summaryData.rankings?.length > 0) { - return ( -
-

{summaryData.title || 'Performance Summary'}

-
- {summaryData.rankings.map((item, idx) => ( -
- #{idx + 1} - {item.name} - - {item.score.toFixed(2)}x - {formatTime(item.totalRuntime)} - -
- ))} -
-
- {summaryData.explanation || 'Score: geometric mean of query time ratio to fastest (lower is better)'} -
-
- ); - } - - if (summaryData.type === 'randomAccess' && summaryData.rankings?.length > 0) { - return ( -
-

{summaryData.title || 'Random Access Performance'}

-
- {summaryData.rankings.map((item, idx) => ( -
- #{idx + 1} - {item.name} - - {formatTime(item.time)} - {item.ratio.toFixed(2)}x - -
- ))} -
-
- {summaryData.explanation || 'Random access time | Ratio to fastest (lower is better)'} -
-
- ); - } - - if (summaryData.type === 'compression') { - return ( -
-

{summaryData.title || 'Compression Throughput vs Parquet'}

-
- {summaryData.compressRatio && ( -
- - Write Speed (Compression) - - {summaryData.compressRatio.toFixed(2)}x - -
- )} - {summaryData.decompressRatio && ( -
- 📤 - Scan Speed (Decompression) - - {summaryData.decompressRatio.toFixed(2)}x - -
- )} -
-
- {summaryData.explanation || `Inverse geometric mean of Vortex/Parquet ratios across ${summaryData.datasetCount || 'multiple'} datasets (higher is better)`} -
-
- ); - } - - if (summaryData.type === 'compressionSize' && summaryData.meanRatio) { - return ( -
-

{summaryData.title || 'Compression Size Summary'}

-
- {summaryData.minRatio && ( -
- ⬇️ - Min Size Ratio - - {summaryData.minRatio.toFixed(2)}x - -
- )} -
- 📊 - Mean Size Ratio - - {summaryData.meanRatio.toFixed(2)}x - -
- {summaryData.maxRatio && ( -
- ⬆️ - Max Size Ratio - - {summaryData.maxRatio.toFixed(2)}x - -
- )} -
-
- {summaryData.explanation || `Geometric mean of Vortex/Parquet size ratios across ${summaryData.datasetCount || 'multiple'} datasets (lower is better)`} -
-
- ); - } - - return null; -} diff --git a/benchmarks-website/src/components/ChartContainer.jsx b/benchmarks-website/src/components/ChartContainer.jsx deleted file mode 100644 index 1dfb193e836..00000000000 --- a/benchmarks-website/src/components/ChartContainer.jsx +++ /dev/null @@ -1,664 +0,0 @@ -import { - CategoryScale, - Chart as ChartJS, - Legend, - LinearScale, - LineElement, - PointElement, - Title, - Tooltip, -} from 'chart.js'; -import zoomPlugin from 'chartjs-plugin-zoom'; -import { - ChevronLeft, - ChevronRight, - Expand, - MoveHorizontal, - SkipBack, - SkipForward, - ZoomIn, - ZoomOut, -} from 'lucide-react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Line } from 'react-chartjs-2'; -import { fetchChartData } from '../api'; -import { formatDate, stringToColor } from '../utils'; - -ChartJS.register( - CategoryScale, - LinearScale, - PointElement, - LineElement, - Title, - Tooltip, - Legend, - zoomPlugin -); - -// Custom tooltip positioner - 50px from cursor, 50px above nearest point -Tooltip.positioners.topCorner = function(elements, eventPosition) { - const chart = this.chart; - const chartCenter = (chart.chartArea.left + chart.chartArea.right) / 2; - const chartVerticalCenter = (chart.chartArea.top + chart.chartArea.bottom) / 2; - const isOnRightSide = eventPosition.x > chartCenter; - const isOnTopSide = eventPosition.y < chartVerticalCenter; - - let x = isOnRightSide ? eventPosition.x - 150 : eventPosition.x + 150; - let y = isOnTopSide ? chart.chartArea.top + 100 : chart.chartArea.bottom - 100; - return { - x, - y, - xAlign: isOnRightSide ? 'right' : 'left', - yAlign: isOnTopSide ? 'top' : 'bottom', - }; -}; - -const DEFAULT_RANGE_SIZE = 100; - -export default function ChartContainer({ - groupName, - chartName, - displayName, - unit, - config, - engineFilter, - onFullscreen, -}) { - const [totalCommits, setTotalCommits] = useState(null); - const [chartData, setChartData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - // viewRange stores the requested range: either { last: N } or { startIdx, endIdx } - const [viewRange, setViewRange] = useState({ last: DEFAULT_RANGE_SIZE }); - const chartRef = useRef(null); - const isResettingZoom = useRef(false); - - // Fetch data for the current view range - useEffect(() => { - let cancelled = false; - - async function loadData() { - setLoading(true); - setError(null); - - try { - let options = {}; - if (viewRange.last) { - // Initial load: get last N commits - options = { last: viewRange.last }; - } else if (viewRange.startIdx !== undefined && viewRange.endIdx !== undefined) { - // Navigation: use index-based range - options = { startIdx: viewRange.startIdx, endIdx: viewRange.endIdx }; - } - - const data = await fetchChartData(groupName, chartName, options); - if (!cancelled && data) { - setChartData(data); - if (data.originalLength) { - setTotalCommits(data.originalLength); - } else if (data.commits) { - setTotalCommits(data.commits.length); - } - } - } catch (err) { - if (!cancelled) { - setError(err.message); - } - } finally { - if (!cancelled) { - setLoading(false); - } - } - } - - loadData(); - - return () => { - cancelled = true; - }; - }, [groupName, chartName, viewRange]); - - // Compute display range info from chartData (for rendering only) - const displayRangeInfo = useMemo(() => { - if (!chartData) return { startIdx: 0, endIdx: 0, total: 0, rangeSize: 0 }; - const total = chartData.originalLength || chartData.commits?.length || 0; - const rangeSize = chartData.commits?.length || 0; - const req = chartData.requestedRange || {}; - const startIdx = req.startIndex ?? (total - rangeSize); - const endIdx = req.endIndex ?? (total - 1); - return { startIdx, endIdx, total, rangeSize }; - }, [chartData]); - - // Compute current range from viewRange state (for navigation calculations) - const getCurrentRange = useCallback(() => { - const total = totalCommits || 0; - if (viewRange.last) { - const rangeSize = Math.min(viewRange.last, total); - return { - startIdx: Math.max(0, total - rangeSize), - endIdx: total - 1, - total, - rangeSize, - }; - } - const startIdx = viewRange.startIdx ?? 0; - const endIdx = viewRange.endIdx ?? (total - 1); - return { - startIdx, - endIdx, - total, - rangeSize: endIdx - startIdx + 1, - }; - }, [viewRange, totalCommits]); - - const isAtStart = displayRangeInfo.startIdx === 0; - const isAtEnd = displayRangeInfo.endIdx >= displayRangeInfo.total - 1; - const currentRangeSize = displayRangeInfo.rangeSize; - - // Navigation handlers - use functional updates to get latest state - const handleGoToStart = useCallback(() => { - const range = getCurrentRange(); - if (range.startIdx === 0 || !range.total) return; - setViewRange({ - startIdx: 0, - endIdx: Math.min(range.rangeSize - 1, range.total - 1), - }); - }, [getCurrentRange]); - - const handleGoToEnd = useCallback(() => { - const range = getCurrentRange(); - if (range.endIdx >= range.total - 1 || !range.total) return; - setViewRange({ last: range.rangeSize }); - }, [getCurrentRange]); - - const handleMoveBackward = useCallback(() => { - const range = getCurrentRange(); - if (range.startIdx === 0 || !range.total) return; - const moveAmount = Math.max(1, Math.floor(range.rangeSize / 2)); - const newStartIdx = Math.max(0, range.startIdx - moveAmount); - const newEndIdx = newStartIdx + range.rangeSize - 1; - setViewRange({ - startIdx: newStartIdx, - endIdx: Math.min(newEndIdx, range.total - 1), - }); - }, [getCurrentRange]); - - const handleMoveForward = useCallback(() => { - const range = getCurrentRange(); - if (range.endIdx >= range.total - 1 || !range.total) return; - const moveAmount = Math.max(1, Math.floor(range.rangeSize / 2)); - const newEndIdx = Math.min(range.total - 1, range.endIdx + moveAmount); - const newStartIdx = Math.max(0, newEndIdx - range.rangeSize + 1); - setViewRange({ startIdx: newStartIdx, endIdx: newEndIdx }); - }, [getCurrentRange]); - - const handleZoomIn = useCallback(() => { - const range = getCurrentRange(); - if (!range.total || range.rangeSize <= 10) return; - const center = Math.floor((range.startIdx + range.endIdx) / 2); - const newRangeSize = Math.max(10, Math.floor(range.rangeSize / 2)); - const halfRange = Math.floor(newRangeSize / 2); - let newStartIdx = center - halfRange; - let newEndIdx = newStartIdx + newRangeSize - 1; - - // Clamp to bounds - if (newStartIdx < 0) { - newStartIdx = 0; - newEndIdx = newRangeSize - 1; - } - if (newEndIdx >= range.total) { - newEndIdx = range.total - 1; - newStartIdx = Math.max(0, newEndIdx - newRangeSize + 1); - } - - setViewRange({ startIdx: newStartIdx, endIdx: newEndIdx }); - }, [getCurrentRange]); - - const handleZoomOut = useCallback(() => { - const range = getCurrentRange(); - if (!range.total) return; - const center = Math.floor((range.startIdx + range.endIdx) / 2); - const newRangeSize = Math.min(range.total, range.rangeSize * 2); - const halfRange = Math.floor(newRangeSize / 2); - let newStartIdx = center - halfRange; - let newEndIdx = newStartIdx + newRangeSize - 1; - - // Clamp to bounds - if (newStartIdx < 0) { - newStartIdx = 0; - newEndIdx = Math.min(newRangeSize - 1, range.total - 1); - } - if (newEndIdx >= range.total) { - newEndIdx = range.total - 1; - newStartIdx = Math.max(0, newEndIdx - newRangeSize + 1); - } - - setViewRange({ startIdx: newStartIdx, endIdx: newEndIdx }); - }, [getCurrentRange]); - - const handleShowFullRange = useCallback(() => { - const range = getCurrentRange(); - if (!range.total) return; - setViewRange({ startIdx: 0, endIdx: range.total - 1 }); - }, [getCurrentRange]); - - const isFullRange = isAtStart && isAtEnd; - - // Handle drag selection zoom - const handleDragZoom = useCallback((startDataIdx, endDataIdx) => { - if (!chartData?.commits || !chartData.requestedRange) return; - - const numCommits = chartData.commits.length; - if (numCommits < 2) return; - - const rangeStart = chartData.requestedRange.startIndex; - const rangeEnd = chartData.requestedRange.endIndex; - const total = chartData.originalLength || rangeEnd + 1; - - // Map chart indices to original dataset indices using linear interpolation - // This correctly handles downsampled data where numCommits < (rangeEnd - rangeStart + 1) - const minIdx = Math.min(startDataIdx, endDataIdx); - const maxIdx = Math.max(startDataIdx, endDataIdx); - const globalStartIdx = rangeStart + Math.round(minIdx / (numCommits - 1) * (rangeEnd - rangeStart)); - const globalEndIdx = rangeStart + Math.round(maxIdx / (numCommits - 1) * (rangeEnd - rangeStart)); - - // Ensure minimum range - if (globalEndIdx - globalStartIdx < 5) return; - - setViewRange({ - startIdx: Math.max(0, globalStartIdx), - endIdx: Math.min(total - 1, globalEndIdx), - }); - }, [chartData]); - - // Process series data with filters and renaming - const processedData = useMemo(() => { - if (!chartData?.series || !chartData?.commits) return null; - - const { series, commits } = chartData; - const datasets = []; - const labels = commits.map(c => formatDate(c.timestamp)); - - Object.entries(series).forEach(([seriesName, points]) => { - // Apply removed datasets filter - if (config.removedDatasets?.has(seriesName)) return; - - // Apply engine filter - if (engineFilter !== 'all') { - const engine = seriesName.split(':')[0].toLowerCase(); - if (engine !== engineFilter && !seriesName.toLowerCase().includes(engineFilter)) { - return; - } - } - - // Rename series if needed - let displaySeriesName = seriesName; - if (config.renamedDatasets) { - const caseInsensitive = {}; - Object.entries(config.renamedDatasets).forEach(([k, v]) => { - caseInsensitive[k.toLowerCase()] = v; - }); - displaySeriesName = caseInsensitive[seriesName.toLowerCase()] || seriesName; - } - - // Check if hidden by default - const hidden = config.hiddenDatasets?.has(seriesName) || - config.hiddenDatasets?.has(displaySeriesName); - - datasets.push({ - label: displaySeriesName, - data: points, - borderColor: stringToColor(displaySeriesName), - backgroundColor: stringToColor(displaySeriesName) + '20', - pointRadius: 2, - pointHoverRadius: 5, - pointStyle: 'cross', - borderWidth: 1.5, - tension: 0, - spanGaps: true, - hidden, - }); - }); - - return { labels, datasets, commits }; - }, [chartData, config, engineFilter]); - - // Handle click on chart point to open commit on GitHub - const handleChartClick = useCallback((event, elements) => { - if (!elements.length || !processedData?.commits) return; - const dataIndex = elements[0].index; - const commit = processedData.commits[dataIndex]; - if (commit?.id) { - window.open(`https://github.com/vortex-data/vortex/commit/${commit.id}`, '_blank'); - } - }, [processedData]); - - // Chart.js options with drag zoom - const options = useMemo(() => ({ - responsive: true, - maintainAspectRatio: false, - animation: false, - onClick: handleChartClick, - onHover: (event, elements) => { - event.native.target.style.cursor = elements.length ? 'pointer' : 'default'; - }, - interaction: { - mode: 'index', - intersect: true, - }, - plugins: { - legend: { - position: 'top', - align: 'start', - labels: { - boxWidth: 12, - padding: 8, - font: { - size: 12, - family: 'Geist, sans-serif', - }, - usePointStyle: true, - pointStyle: 'rectRounded', - }, - }, - tooltip: { - backgroundColor: 'rgba(16, 16, 16, 0.9)', - titleFont: { family: 'Geist, sans-serif', size: 13 }, - bodyFont: { family: 'Geist Mono, monospace', size: 12 }, - padding: 12, - cornerRadius: 4, - position: 'topCorner', - caretSize: 0, - itemSort: (a, b) => b.parsed.y - a.parsed.y, - // Limit to top 10 items by value to prevent tooltip from getting too large - filter: (item, _index, items) => { - if (items.length <= 10) return item.parsed.y != null; - const validItems = items.filter(i => i.parsed.y != null); - if (validItems.length <= 10) return item.parsed.y != null; - const sorted = [...validItems].sort((a, b) => (b.parsed.y ?? 0) - (a.parsed.y ?? 0)); - const top10 = sorted.slice(0, 10); - return top10.some(i => i.datasetIndex === item.datasetIndex); - }, - callbacks: { - title: (items) => { - if (!items.length || !processedData?.commits) return ''; - const commit = processedData.commits[items[0].dataIndex]; - if (!commit) return items[0].label; - const author = commit.author || 'Unknown'; - return `${formatDate(commit.timestamp)} — ${author}\n(${commit.id?.slice(0, 7) || ''}) ${commit.message || ''}`; - }, - label: (item) => { - const value = item.parsed.y; - if (value == null) return null; - const formattedValue = value < 1 ? value.toFixed(4) : value.toFixed(2); - return `${item.dataset.label}: ${formattedValue} ${unit || ''}`; - }, - labelTextColor: (tooltipItem) => { - const chart = tooltipItem.chart; - const activeElements = chart._active || []; - if (activeElements.length === 0) return '#ffffff'; - const lastEvent = chart._lastEvent; - if (!lastEvent) return '#ffffff'; - // Find which dataset point is closest to the cursor - let hoveredDatasetIndex = activeElements[0].datasetIndex; - let closestDist = Infinity; - for (const el of activeElements) { - const point = chart.getDatasetMeta(el.datasetIndex).data[el.index]; - if (point) { - const dist = Math.hypot(point.x - lastEvent.x, point.y - lastEvent.y); - if (dist < closestDist) { - closestDist = dist; - hoveredDatasetIndex = el.datasetIndex; - } - } - } - if (tooltipItem.datasetIndex === hoveredDatasetIndex) { - return '#ffffff'; - } - return 'rgba(255, 255, 255, 0.45)'; - }, - }, - }, - zoom: { - zoom: { - drag: { - enabled: true, - backgroundColor: 'rgba(99, 102, 241, 0.2)', - borderColor: 'rgba(99, 102, 241, 0.8)', - borderWidth: 1, - }, - mode: 'x', - onZoomComplete: ({ chart }) => { - // Prevent infinite loop from resetZoom triggering onZoomComplete - if (isResettingZoom.current) { - isResettingZoom.current = false; - return; - } - - const { min, max } = chart.scales.x; - const startIdx = Math.floor(min); - const endIdx = Math.ceil(max); - if (startIdx >= 0 && endIdx > startIdx) { - handleDragZoom(startIdx, endIdx); - } - // Reset chart zoom state - isResettingZoom.current = true; - chart.resetZoom(); - }, - }, - }, - }, - scales: { - x: { - display: true, - grid: { - display: true, - color: 'rgba(166, 166, 166, 0.12)', - }, - ticks: { - maxRotation: 45, - minRotation: 45, - font: { - size: 11, - family: 'Geist, sans-serif', - }, - maxTicksLimit: 10, - callback: function(value, index, ticks) { - // Always show first and last tick - if (index === 0 || index === ticks.length - 1) { - return this.getLabelForValue(value); - } - // Show intermediate ticks based on maxTicksLimit - const step = Math.ceil(ticks.length / 10); - if (index % step === 0) { - return this.getLabelForValue(value); - } - return null; - }, - }, - }, - y: { - display: true, - beginAtZero: true, - grid: { - color: 'rgba(166, 166, 166, 0.12)', - }, - ticks: { - font: { - size: 12, - family: 'Geist Mono, monospace', - }, - }, - title: { - display: !!unit, - text: unit || '', - font: { - size: 12, - family: 'Geist, sans-serif', - }, - }, - }, - }, - }), [unit, processedData, handleDragZoom, handleChartClick]); - - // Fullscreen handler - const handleFullscreen = useCallback(() => { - if (processedData) { - onFullscreen({ - title: displayName, - groupName, - chartName, - unit, - config, - initialData: processedData, - totalCommits, - currentRange: getCurrentRange(), - }); - } - }, [processedData, displayName, groupName, chartName, unit, config, totalCommits, getCurrentRange, onFullscreen]); - - // Show placeholder only on initial load (no data yet) - const showPlaceholder = !processedData && (loading || error); - const showOverlay = loading && processedData; - - if (showPlaceholder) { - return ( -
-
- {displayName} -
-
- {error ? ( -

Error loading chart

- ) : ( -
- )} -
-
- ); - } - - if (!loading && error) { - return ( -
-
- {displayName} -
-
-

Error loading chart

-
-
- ); - } - - if (!processedData || processedData.datasets.length === 0) { - return ( -
-
- {displayName} -
-
-

No data available

-
-
- ); - } - - return ( -
-
- - {displayName} - {chartData?.downsampleLevel && chartData.downsampleLevel !== '1x' && ( - - {chartData.downsampleLevel} downsampled - - )} - -
-
- - - - - - - -
- -
-
-
- - {showOverlay && ( -
-
-
- )} -
-
- ); -} diff --git a/benchmarks-website/src/components/Header.jsx b/benchmarks-website/src/components/Header.jsx deleted file mode 100644 index b9624794119..00000000000 --- a/benchmarks-website/src/components/Header.jsx +++ /dev/null @@ -1,110 +0,0 @@ -import React from 'react'; - -export default function Header({ - sidebarOpen, - onMenuToggle, - categoryFilter, - onCategoryChange, - searchFilter, - onSearchChange, - viewMode, - onViewModeChange, - onExpandAll, - onCollapseAll, -}) { - return ( -
-
- - -

Vortex Benchmarks

- -
-
- - - - onSearchChange(e.target.value)} - /> -
-
- -
-
- - -
- - - - - GitHub - -
-
-
- ); -} diff --git a/benchmarks-website/src/components/Modal.jsx b/benchmarks-website/src/components/Modal.jsx deleted file mode 100644 index b9bb7d419b1..00000000000 --- a/benchmarks-website/src/components/Modal.jsx +++ /dev/null @@ -1,476 +0,0 @@ -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; -import { Line } from 'react-chartjs-2'; -import { - SkipBack, - ChevronLeft, - ChevronRight, - SkipForward, - ZoomIn, - ZoomOut, - MoveHorizontal, - X, -} from 'lucide-react'; -import { fetchChartData } from '../api'; -import { stringToColor, formatDate } from '../utils'; - -export default function Modal({ chartData, onClose }) { - const [loading, setLoading] = useState(false); - const [viewRange, setViewRange] = useState(null); - const [currentData, setCurrentData] = useState(null); - const [totalCommits, setTotalCommits] = useState(chartData?.totalCommits || 0); - const chartRef = useRef(null); - const isResettingZoom = useRef(false); - - // Initialize with the data passed from parent - useEffect(() => { - if (chartData?.initialData) { - setCurrentData(chartData.initialData); - setTotalCommits(chartData.totalCommits || chartData.initialData.commits?.length || 0); - if (chartData.currentRange) { - setViewRange({ - startIdx: chartData.currentRange.startIdx, - endIdx: chartData.currentRange.endIdx, - }); - } - } - }, [chartData]); - - // Close on escape key - useEffect(() => { - const handleKeyDown = (e) => { - if (e.key === 'Escape') { - onClose(); - } - }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [onClose]); - - // Prevent body scroll when modal is open - useEffect(() => { - document.body.style.overflow = 'hidden'; - return () => { - document.body.style.overflow = ''; - }; - }, []); - - const handleBackdropClick = useCallback((e) => { - if (e.target === e.currentTarget) { - onClose(); - } - }, [onClose]); - - // Fetch data for a new range - const fetchRange = useCallback(async (range) => { - if (!chartData?.groupName || !chartData?.chartName) return; - - setLoading(true); - try { - let options = {}; - if (range.last) { - options = { last: range.last }; - } else if (range.startIdx !== undefined && range.endIdx !== undefined) { - options = { startIdx: range.startIdx, endIdx: range.endIdx }; - } - - const data = await fetchChartData(chartData.groupName, chartData.chartName, options); - if (data) { - // Process the data similar to ChartContainer - const processedData = processChartData(data, chartData.config); - setCurrentData(processedData); - if (data.originalLength) { - setTotalCommits(data.originalLength); - } - } - } catch (err) { - console.error('Error fetching range:', err); - } finally { - setLoading(false); - } - }, [chartData]); - - // Process chart data (similar to ChartContainer) - const processChartData = useCallback((data, config) => { - if (!data?.series || !data?.commits) return null; - - const { series, commits } = data; - const datasets = []; - const labels = commits.map(c => formatDate(c.timestamp)); - - Object.entries(series).forEach(([seriesName, points]) => { - if (config?.removedDatasets?.has(seriesName)) return; - - let displaySeriesName = seriesName; - if (config?.renamedDatasets) { - const caseInsensitive = {}; - Object.entries(config.renamedDatasets).forEach(([k, v]) => { - caseInsensitive[k.toLowerCase()] = v; - }); - displaySeriesName = caseInsensitive[seriesName.toLowerCase()] || seriesName; - } - - const dataPoints = points.map(p => p); - const hidden = config?.hiddenDatasets?.has(seriesName) || - config?.hiddenDatasets?.has(displaySeriesName); - - datasets.push({ - label: displaySeriesName, - data: dataPoints, - borderColor: stringToColor(displaySeriesName), - backgroundColor: stringToColor(displaySeriesName) + '20', - pointRadius: 2, - pointHoverRadius: 5, - pointStyle: 'cross', - borderWidth: 1.5, - tension: 0, - spanGaps: true, - hidden, - }); - }); - - return { labels, datasets, commits }; - }, []); - - // Get current range info - const getCurrentRange = useCallback(() => { - if (!viewRange) { - return { startIdx: 0, endIdx: totalCommits - 1, total: totalCommits, rangeSize: totalCommits }; - } - const startIdx = viewRange.startIdx ?? 0; - const endIdx = viewRange.endIdx ?? (totalCommits - 1); - return { - startIdx, - endIdx, - total: totalCommits, - rangeSize: endIdx - startIdx + 1, - }; - }, [viewRange, totalCommits]); - - const range = getCurrentRange(); - const isAtStart = range.startIdx === 0; - const isAtEnd = range.endIdx >= range.total - 1; - const currentRangeSize = range.rangeSize; - const isFullRange = isAtStart && isAtEnd; - - // Navigation handlers - const handleGoToStart = useCallback(() => { - if (isAtStart || !range.total) return; - const newRange = { - startIdx: 0, - endIdx: Math.min(currentRangeSize - 1, range.total - 1), - }; - setViewRange(newRange); - fetchRange(newRange); - }, [isAtStart, currentRangeSize, range.total, fetchRange]); - - const handleGoToEnd = useCallback(() => { - if (isAtEnd || !range.total) return; - const newRange = { last: currentRangeSize }; - setViewRange({ - startIdx: range.total - currentRangeSize, - endIdx: range.total - 1, - }); - fetchRange(newRange); - }, [isAtEnd, currentRangeSize, range.total, fetchRange]); - - const handleMoveBackward = useCallback(() => { - if (isAtStart || !range.total) return; - const moveAmount = Math.max(1, Math.floor(currentRangeSize / 2)); - const newStartIdx = Math.max(0, range.startIdx - moveAmount); - const newEndIdx = newStartIdx + currentRangeSize - 1; - const newRange = { - startIdx: newStartIdx, - endIdx: Math.min(newEndIdx, range.total - 1), - }; - setViewRange(newRange); - fetchRange(newRange); - }, [isAtStart, range, currentRangeSize, fetchRange]); - - const handleMoveForward = useCallback(() => { - if (isAtEnd || !range.total) return; - const moveAmount = Math.max(1, Math.floor(currentRangeSize / 2)); - const newEndIdx = Math.min(range.total - 1, range.endIdx + moveAmount); - const newStartIdx = Math.max(0, newEndIdx - currentRangeSize + 1); - const newRange = { startIdx: newStartIdx, endIdx: newEndIdx }; - setViewRange(newRange); - fetchRange(newRange); - }, [isAtEnd, range, currentRangeSize, fetchRange]); - - const handleZoomIn = useCallback(() => { - if (!range.total || currentRangeSize <= 10) return; - const center = Math.floor((range.startIdx + range.endIdx) / 2); - const newRangeSize = Math.max(10, Math.floor(currentRangeSize / 2)); - const halfRange = Math.floor(newRangeSize / 2); - let newStartIdx = center - halfRange; - let newEndIdx = newStartIdx + newRangeSize - 1; - - if (newStartIdx < 0) { - newStartIdx = 0; - newEndIdx = newRangeSize - 1; - } - if (newEndIdx >= range.total) { - newEndIdx = range.total - 1; - newStartIdx = Math.max(0, newEndIdx - newRangeSize + 1); - } - - const newRange = { startIdx: newStartIdx, endIdx: newEndIdx }; - setViewRange(newRange); - fetchRange(newRange); - }, [range, currentRangeSize, fetchRange]); - - const handleZoomOut = useCallback(() => { - if (!range.total) return; - const center = Math.floor((range.startIdx + range.endIdx) / 2); - const newRangeSize = Math.min(range.total, currentRangeSize * 2); - const halfRange = Math.floor(newRangeSize / 2); - let newStartIdx = center - halfRange; - let newEndIdx = newStartIdx + newRangeSize - 1; - - if (newStartIdx < 0) { - newStartIdx = 0; - newEndIdx = Math.min(newRangeSize - 1, range.total - 1); - } - if (newEndIdx >= range.total) { - newEndIdx = range.total - 1; - newStartIdx = Math.max(0, newEndIdx - newRangeSize + 1); - } - - const newRange = { startIdx: newStartIdx, endIdx: newEndIdx }; - setViewRange(newRange); - fetchRange(newRange); - }, [range, currentRangeSize, fetchRange]); - - const handleShowFullRange = useCallback(() => { - if (!range.total) return; - const newRange = { startIdx: 0, endIdx: range.total - 1 }; - setViewRange(newRange); - fetchRange(newRange); - }, [range.total, fetchRange]); - - // Handle drag selection zoom - const handleDragZoom = useCallback((startDataIdx, endDataIdx) => { - if (!currentData?.commits) return; - - const numCommits = currentData.commits.length; - if (numCommits < 2) return; - - const rangeStart = range.startIdx; - const rangeEnd = range.endIdx; - const total = range.total; - - const minIdx = Math.min(startDataIdx, endDataIdx); - const maxIdx = Math.max(startDataIdx, endDataIdx); - const globalStartIdx = rangeStart + Math.round(minIdx / (numCommits - 1) * (rangeEnd - rangeStart)); - const globalEndIdx = rangeStart + Math.round(maxIdx / (numCommits - 1) * (rangeEnd - rangeStart)); - - if (globalEndIdx - globalStartIdx < 5) return; - - const newRange = { - startIdx: Math.max(0, globalStartIdx), - endIdx: Math.min(total - 1, globalEndIdx), - }; - setViewRange(newRange); - fetchRange(newRange); - }, [currentData, range, fetchRange]); - - // Chart options - const options = useMemo(() => ({ - responsive: true, - maintainAspectRatio: false, - animation: false, - interaction: { - mode: 'index', - intersect: false, - }, - plugins: { - legend: { - position: 'top', - align: 'start', - labels: { - boxWidth: 12, - padding: 8, - font: { size: 11, family: 'Geist, sans-serif' }, - usePointStyle: true, - pointStyle: 'rectRounded', - }, - }, - tooltip: { - backgroundColor: 'rgba(16, 16, 16, 0.9)', - titleFont: { family: 'Geist, sans-serif', size: 12 }, - bodyFont: { family: 'Geist Mono, monospace', size: 11 }, - padding: 12, - cornerRadius: 4, - caretSize: 0, - position: 'topCorner', - itemSort: (a, b) => b.parsed.y - a.parsed.y, - callbacks: { - title: (items) => { - if (!items.length || !currentData?.commits) return ''; - const commit = currentData.commits[items[0].dataIndex]; - if (!commit) return items[0].label; - const author = commit.author || 'Unknown'; - return `${formatDate(commit.timestamp)} — ${author}\n(${commit.id?.slice(0, 7) || ''}) ${commit.message || ''}`; - }, - label: (item) => { - const value = item.parsed.y; - if (value == null) return null; - const formattedValue = value < 1 ? value.toFixed(4) : value.toFixed(2); - return `${item.dataset.label}: ${formattedValue} ${chartData?.unit || ''}`; - }, - }, - }, - zoom: { - zoom: { - drag: { - enabled: true, - backgroundColor: 'rgba(99, 102, 241, 0.2)', - borderColor: 'rgba(99, 102, 241, 0.8)', - borderWidth: 1, - }, - mode: 'x', - onZoomComplete: ({ chart }) => { - if (isResettingZoom.current) { - isResettingZoom.current = false; - return; - } - const { min, max } = chart.scales.x; - const startIdx = Math.floor(min); - const endIdx = Math.ceil(max); - if (startIdx >= 0 && endIdx > startIdx) { - handleDragZoom(startIdx, endIdx); - } - isResettingZoom.current = true; - chart.resetZoom(); - }, - }, - }, - }, - scales: { - x: { - display: true, - grid: { display: true, color: 'rgba(0, 0, 0, 0.12)' }, - ticks: { - maxRotation: 45, - minRotation: 45, - font: { size: 10, family: 'Geist, sans-serif' }, - maxTicksLimit: 15, - callback: function(value, index, ticks) { - // Always show first and last tick - if (index === 0 || index === ticks.length - 1) { - return this.getLabelForValue(value); - } - // Show intermediate ticks based on maxTicksLimit - const step = Math.ceil(ticks.length / 15); - if (index % step === 0) { - return this.getLabelForValue(value); - } - return null; - }, - }, - }, - y: { - display: true, - beginAtZero: true, - grid: { color: 'rgba(0, 0, 0, 0.12)' }, - ticks: { font: { size: 11, family: 'Geist Mono, monospace' } }, - title: { - display: !!chartData?.unit, - text: chartData?.unit || '', - font: { size: 11, family: 'Geist, sans-serif' }, - }, - }, - }, - }), [currentData, chartData, handleDragZoom]); - - if (!chartData) return null; - - return ( -
-
-
-

{chartData.title}

-
-
- - - - - - - -
- -
-
-
- {currentData && ( - - )} - {loading && ( -
-
-
- )} -
-
-
- ); -} diff --git a/benchmarks-website/src/components/Sidebar.jsx b/benchmarks-website/src/components/Sidebar.jsx deleted file mode 100644 index 3a53b801f8e..00000000000 --- a/benchmarks-website/src/components/Sidebar.jsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; - -export default function Sidebar({ - isOpen, - groups, - onClose, - onGroupClick, - onClearFilter, - showClearFilter, -}) { - return ( - - ); -} diff --git a/benchmarks-website/src/config.js b/benchmarks-website/src/config.js deleted file mode 100644 index dfe5555019d..00000000000 --- a/benchmarks-website/src/config.js +++ /dev/null @@ -1,285 +0,0 @@ -// ============================================================================= -// SQL query benchmark suites — single source of truth. -// To add a new SQL query benchmark, add one entry to QUERY_SUITES. -// The server (routing/formatting) and frontend (UI config) both derive from this. -// ============================================================================= - -export const QUERY_SUITES = [ - { - prefix: "clickbench", - displayName: "Clickbench", - queryPrefix: "CLICKBENCH", - description: - "ClickHouse's analytical benchmark suite testing real-world query patterns on web analytics data", - tags: ["Queries (NVMe)"], - hiddenDatasets: ["datafusion:lance"], - }, - { - prefix: "clickbench-sorted", - displayName: "Clickbench Sorted", - queryPrefix: "CLICKBENCH SORTED", - description: - "ClickBench queries over data globally sorted by event date and event time", - tags: ["Queries (NVMe)"], - hiddenDatasets: ["datafusion:lance"], - }, - { - prefix: "statpopgen", - displayName: "Statistical and Population Genetics", - queryPrefix: "STATPOPGEN", - description: - "A suite of Statistical and Population genetics queries using the gnomAD dataset", - tags: ["Queries (NVMe)", "StatPopGen"], - }, - { - prefix: "polarsignals", - displayName: "PolarSignals Profiling", - queryPrefix: "POLARSIGNALS", - description: - "Profiling data benchmark modeled on PolarSignals/Parca, exercising scan-layer performance with projection and filter pushdown on deeply nested schemas", - tags: ["Queries (NVMe)", "PolarSignals"], - }, - { - prefix: "tpch", - displayName: "TPC-H", - queryPrefix: "TPC-H", - datasetKey: "tpch", - fanOut: true, - hiddenDatasets: ["datafusion:lance"], - }, - { - prefix: "tpcds", - displayName: "TPC-DS", - queryPrefix: "TPC-DS", - datasetKey: "tpcds", - fanOut: true, - }, - { prefix: "fineweb", skip: true }, -]; - -// Pre-registered fan-out groups (storage x scale factor). -export const FAN_OUT_GROUPS = [ - "TPC-H (NVMe) (SF=1)", - "TPC-H (S3) (SF=1)", - "TPC-H (NVMe) (SF=10)", - "TPC-H (S3) (SF=10)", - "TPC-H (NVMe) (SF=100)", - "TPC-H (S3) (SF=100)", - "TPC-H (NVMe) (SF=1000)", - "TPC-H (S3) (SF=1000)", - "TPC-DS (NVMe) (SF=1)", - "TPC-DS (NVMe) (SF=10)", -]; - -// Canonical engine:format renaming used by all query suites. -export const ENGINE_RENAMES = { - "datafusion:vortex-file-compressed": "datafusion:vortex", - "datafusion:parquet": "datafusion:parquet", - "datafusion:arrow": "datafusion:in-memory-arrow", - "datafusion:lance": "datafusion:lance", - "datafusion:vortex-compact": "datafusion:vortex-compact", - "duckdb:vortex-file-compressed": "duckdb:vortex", - "duckdb:parquet": "duckdb:parquet", - "duckdb:duckdb": "duckdb:duckdb", - "duckdb:vortex-compact": "duckdb:vortex-compact", - "vortex-tokio-local-disk": "vortex-nvme", - "vortex-compact-tokio-local-disk": "vortex-compact-nvme", - "lance-tokio-local-disk": "lance-nvme", - "parquet-tokio-local-disk": "parquet-nvme", - lance: "lance", -}; - -// ============================================================================= -// Below: frontend UI config, derived from QUERY_SUITES where possible. -// ============================================================================= - -// Build BENCHMARK_CONFIGS: bespoke non-query groups + generated query group entries. -const BESPOKE_CONFIGS = [ - { - name: "Random Access", - renamedDatasets: { - "vortex-tokio-local-disk": "vortex-nvme", - "vortex-compact-tokio-local-disk": "vortex-compact-nvme", - "lance-tokio-local-disk": "lance-nvme", - "parquet-tokio-local-disk": "parquet-nvme", - }, - }, - { - name: "Compression", - keptCharts: [ - "COMPRESS TIME", - "DECOMPRESS TIME", - "PARQUET RS ZSTD COMPRESS TIME", - "PARQUET RS ZSTD DECOMPRESS TIME", - "LANCE COMPRESS TIME", - "LANCE DECOMPRESS TIME", - "VORTEX:PARQUET ZSTD RATIO COMPRESS TIME", - "VORTEX:PARQUET ZSTD RATIO DECOMPRESS TIME", - "VORTEX:LANCE RATIO COMPRESS TIME", - "VORTEX:LANCE RATIO DECOMPRESS TIME", - ], - hiddenDatasets: new Set([ - "wide table cols=1000 chunks=1 rows=1000", - "wide table cols=1000 chunks=50 rows=1000", - ]), - removedDatasets: new Set([ - "TPC-H l_comment canonical", - "TPC-H l_comment chunked without fsst", - "wide table cols=10 chunks=1 rows=1000", - "wide table cols=100 chunks=1 rows=1000", - "wide table cols=10 chunks=50 rows=1000", - "wide table cols=100 chunks=50 rows=1000", - ]), - renamedDatasets: { lance: "lance", Lance: "lance", LANCE: "lance" }, - }, - { - name: "Compression Size", - keptCharts: [ - "VORTEX SIZE", - "PARQUET SIZE", - "LANCE SIZE", - "VORTEX:PARQUET ZSTD SIZE", - "VORTEX:LANCE SIZE", - ], - hiddenDatasets: new Set(["wide table cols=1000"]), - removedDatasets: new Set([ - "wide table cols=10 chunks=1 rows=1000", - "wide table cols=100 chunks=1 rows=1000", - "wide table cols=10 chunks=50 rows=1000", - "wide table cols=100 chunks=50 rows=1000", - ]), - renamedDatasets: { lance: "lance", Lance: "lance", LANCE: "lance" }, - }, -]; - -function querySuiteConfig(name, suite) { - const cfg = { name, renamedDatasets: { ...ENGINE_RENAMES } }; - if (suite?.hiddenDatasets?.length) - cfg.hiddenDatasets = new Set(suite.hiddenDatasets); - return cfg; -} - -function buildQueryConfigs() { - const configs = []; - for (const s of QUERY_SUITES) { - if (s.skip) continue; - if (!s.fanOut) { - configs.push(querySuiteConfig(s.displayName, s)); - } - } - for (const g of FAN_OUT_GROUPS) { - const suite = QUERY_SUITES.find( - (s) => s.fanOut && g.startsWith(s.displayName), - ); - const cfg = querySuiteConfig(g, suite); - if (g.includes("SF=1000") || (g.includes("TPC-DS") && g.includes("SF=10)"))) - cfg.hidden = true; - configs.push(cfg); - } - return configs; -} - -export const BENCHMARK_CONFIGS = [...BESPOKE_CONFIGS, ...buildQueryConfigs()]; - -// Chart name remapping (compression benchmarks only) -export const CHART_NAME_MAP = { - "COMPRESS TIME": "VORTEX WRITE TIME (COMPRESSION)", - "DECOMPRESS TIME": "VORTEX SCAN TIME (DECOMPRESSION)", - "PARQUET RS ZSTD COMPRESS TIME": "PARQUET WRITE TIME (COMPRESSION)", - "PARQUET RS ZSTD DECOMPRESS TIME": "PARQUET SCAN TIME (DECOMPRESSION)", - "LANCE COMPRESS TIME": "LANCE WRITE TIME (COMPRESSION)", - "LANCE DECOMPRESS TIME": "LANCE SCAN TIME (DECOMPRESSION)", - "VORTEX SIZE": "VORTEX SIZE", - "PARQUET ZSTD SIZE": "PARQUET SIZE", - "LANCE SIZE": "LANCE SIZE", - "VORTEX:RAW SIZE": "VORTEX vs RAW SIZE RATIO", - "VORTEX:PARQUET ZSTD SIZE": "VORTEX vs PARQUET SIZE RATIO", - "VORTEX:LANCE SIZE": "VORTEX vs LANCE SIZE RATIO", - "VORTEX:PARQUET ZSTD RATIO COMPRESS TIME": - "VORTEX vs PARQUET WRITE TIME RATIO", - "VORTEX:PARQUET ZSTD RATIO DECOMPRESS TIME": - "VORTEX vs PARQUET SCAN TIME RATIO", - "VORTEX:LANCE RATIO COMPRESS TIME": "VORTEX vs LANCE WRITE TIME RATIO", - "VORTEX:LANCE RATIO DECOMPRESS TIME": "VORTEX vs LANCE SCAN TIME RATIO", -}; - -// Category tags for sidebar filtering -export const CATEGORY_TAGS = { - "Random Access": ["Read/Write"], - Compression: ["Read/Write"], - "Compression Size": ["Read/Write"], -}; -for (const s of QUERY_SUITES) { - if (!s.skip && !s.fanOut && s.tags) CATEGORY_TAGS[s.displayName] = s.tags; -} -for (const g of FAN_OUT_GROUPS) { - const m = g.match(/^(.+?) \((NVMe|S3)\) \((SF=\d+)\)$/); - CATEGORY_TAGS[g] = [ - m[2] === "S3" ? "Queries (S3)" : "Queries (NVMe)", - `${m[1]} (${m[3]})`, - ]; -} - -// Benchmark descriptions -export const BENCHMARK_DESCRIPTIONS = { - "Random Access": - "Tests performance of selecting arbitrary row indices from a file on NVMe storage", - Compression: - "Measures encoding and decoding throughput (MB/s) for Vortex files and Parquet files (with zstd page compression)", - "Compression Size": - "Compares compressed file sizes and compression ratios across different encoding strategies", -}; -for (const s of QUERY_SUITES) { - if (s.description) BENCHMARK_DESCRIPTIONS[s.displayName] = s.description; -} - -// Scale factor descriptions -export const SCALE_FACTOR_DESCRIPTIONS = { - 1: "SF=1 (~1GB of data)", - 10: "SF=10 (~10GB of data)", - 100: "SF=100 (~100GB of data)", - 1000: "SF=1000 (~1TB of data)", -}; - -// Engine filter labels -export const ENGINE_LABELS = { - all: "All", - duckdb: "DuckDB", - datafusion: "DataFusion", - vortex: "Vortex", - parquet: "Parquet", -}; - -// Series color map -export const SERIES_COLOR_MAP = { - "vortex-nvme": "#19a508", - "vortex-compact-nvme": "#15850a", - "parquet-nvme": "#ef7f1d", - "lance-nvme": "#3B82F6", - "datafusion:arrow": "#7a27b1", - "datafusion:in-memory-arrow": "#7a27b1", - "datafusion:parquet": "#ef7f1d", - "datafusion:vortex": "#19a508", - "datafusion:vortex-compact": "#15850a", - "datafusion:lance": "#2D936C", - "duckdb:parquet": "#985113", - "duckdb:vortex": "#0e5e04", - "duckdb:vortex-compact": "#0b4a03", - "duckdb:duckdb": "#87752e", - "vortex:lance": "#FF8787", -}; - -// Fallback color palette -export const FALLBACK_PALETTE = [ - "#5971FD", - "#CEE562", - "#EEB3E1", - "#FF8C42", - "#B8336A", - "#726DA8", - "#2D936C", - "#E9B44C", -]; - -// Default visible commits -export const DEFAULT_COMMIT_RANGE = 100; diff --git a/benchmarks-website/src/main.jsx b/benchmarks-website/src/main.jsx deleted file mode 100644 index 32fcc3f979f..00000000000 --- a/benchmarks-website/src/main.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; -import './styles/index.css'; - -ReactDOM.createRoot(document.getElementById('root')).render( - - - -); diff --git a/benchmarks-website/src/styles/index.css b/benchmarks-website/src/styles/index.css deleted file mode 100644 index 910670ec28f..00000000000 --- a/benchmarks-website/src/styles/index.css +++ /dev/null @@ -1,1319 +0,0 @@ -/* CSS Variables for consistent theming */ -:root { - /* Vortex Brand Colors */ - --vortex-black: #101010; - --vortex-gray: #ECECEC; - --vortex-green: #CEE562; - --vortex-blue: #5971FD; - --vortex-pink: #EEB3E1; - - /* Theme Colors */ - --primary-color: var(--vortex-blue); - --primary-hover: #4A5FE5; - --accent-color: var(--vortex-green); - --bg-color: #ffffff; - --bg-secondary: #FAFAFA; - --text-color: var(--vortex-black); - --text-secondary: #666666; - --border-color: var(--vortex-gray); - - /* Layout */ - --header-height: 72px; - --sidebar-width: 280px; - --chart-spacing: 24px; - --mobile-breakpoint: 768px; - --tablet-breakpoint: 1024px; - - /* Shadows */ - --shadow-sm: 0 1px 3px rgba(16,16,16,0.08); - --shadow-md: 0 4px 8px rgba(16,16,16,0.08); - --shadow-lg: 0 12px 24px rgba(16,16,16,0.12); - - /* Border Radius */ - --radius-sm: 4px; - --radius-md: 8px; - --radius-lg: 12px; -} - -@media (prefers-color-scheme: dark) { :root { - --primary-color: var(--vortex-blue); - --primary-hover: #8a9cff; - --accent-color: var(--vortex-green); - --bg-color: #050507; - --bg-secondary: #121219; - --text-color: #F5F5F7; - --text-secondary: #A0A0B0; - --border-color: #30303a; - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.6); - --shadow-md: 0 4px 8px rgba(0, 0, 0, 0.7); - --shadow-lg: 0 12px 24px rgba(0, 0, 0, 0.8); -}} - -/* Reset and base styles */ -* { - box-sizing: border-box; -} - -html { - font-family: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", "SF Pro Display", Roboto, sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - background-color: var(--bg-color); - font-size: 16px; - scroll-behavior: smooth; - overflow-x: hidden; -} - -body { - color: var(--text-color); - margin: 0; - padding: 0; - font-size: 1em; - font-weight: 400; - padding-top: var(--header-height); - line-height: 1.6; - letter-spacing: -0.01em; - overflow-x: hidden; - position: relative; -} - -/* Typography */ -h1, h2, h3, h4, h5, h6 { - font-family: "Funnel Display", sans-serif; - font-weight: 600; - letter-spacing: -0.02em; -} - -code, pre { - font-family: "Geist Mono", monospace; - font-size: 0.9em; -} - -/* Sticky Header */ -.sticky-header { - position: fixed; - top: 0; - left: 0; - right: 0; - height: var(--header-height); - background: rgba(255, 255, 255, 0.95); - -webkit-backdrop-filter: blur(10px); - backdrop-filter: blur(10px); - border-bottom: 1px solid var(--border-color); - box-shadow: var(--shadow-sm); - z-index: 1000; - display: flex; - align-items: center; -} - -.header-content { - display: flex; - align-items: center; - justify-content: space-between; - height: 100%; - padding: 0 32px; - width: 100%; - gap: 24px; - background: var(--bg-secondary); -} - -.header-left { - display: flex; - align-items: center; - gap: 12px; -} - -.menu-toggle { - display: block; - background: none; - border: none; - font-size: 20px; - cursor: pointer; - padding: 8px; - border-radius: var(--radius-sm); - transition: all 0.2s; - color: var(--text-color); -} - -.menu-toggle:hover { - background-color: var(--bg-secondary); -} - -.logo-link { - display: flex; - align-items: center; - text-decoration: none; - transition: opacity 0.2s; -} - -.logo-link:hover { - opacity: 0.8; -} - -.site-logo { - height: 24px; - width: auto; - display: block; -} - -.site-title { - font-family: "Funnel Display", sans-serif; - font-size: 1.5rem; - font-weight: 600; - margin: 0; - margin-left: calc(var(--sidebar-width) - 156px); - color: var(--text-color); - display: none; - white-space: nowrap; -} - -@media (min-width: 1400px) { - .site-title { - display: block; - } -} - -.header-center { - flex: 1; - display: flex; - justify-content: center; - padding: 0 20px; -} - -.filter-controls { - display: flex; - align-items: center; - gap: 16px; - max-width: 600px; -} - -.control-btn, .view-btn { - font-family: "Geist", sans-serif; - padding: 8px 20px; - border: 1px solid var(--border-color); - background: var(--bg-color); - color: var(--text-color); - border-radius: var(--radius-md); - cursor: pointer; - font-size: 14px; - font-weight: 500; - transition: all 0.2s; - white-space: nowrap; -} - -.control-btn:hover, .view-btn:hover { - background-color: var(--bg-secondary); - border-color: var(--primary-color); - transform: translateY(-1px); - box-shadow: var(--shadow-sm); -} - -.view-btn.active { - background-color: var(--primary-color); - color: white; - border-color: var(--primary-color); -} - -.category-filter, .search-filter { - font-family: "Geist", sans-serif; - padding: 8px 12px; - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - font-size: 14px; - font-weight: 500; - background: var(--bg-color); - color: var(--text-color); - transition: border-color 0.2s; -} - -.category-filter:focus, .search-filter:focus { - outline: none; - border-color: var(--primary-color); -} - -.search-filter { - width: 200px; -} - -.header-right { - display: flex; - align-items: center; - gap: 16px; -} - -.view-controls { - display: flex; - gap: 4px; -} - -.repo-link { - display: flex; - align-items: center; - gap: 8px; - color: var(--text-color); - text-decoration: none; - font-weight: 600; - font-size: 14px; - padding: 8px 16px; - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - transition: all 0.2s; -} - -.github-logo { - flex-shrink: 0; -} - -.repo-link:hover { - background-color: var(--bg-secondary); - border-color: var(--primary-color); - color: var(--primary-color); -} - -/* Main Container */ -.main-container { - display: flex; - min-height: calc(100vh - var(--header-height)); - overflow-x: hidden; - width: 100%; -} - -/* Sidebar Navigation */ -.sidebar { - width: var(--sidebar-width); - background: var(--bg-secondary); - -webkit-backdrop-filter: blur(10px); - backdrop-filter: blur(10px); - border-right: 1px solid var(--border-color); - position: fixed; - top: var(--header-height); - left: 0; - height: calc(100vh - var(--header-height)); - overflow-y: auto; - transition: transform 0.3s ease; - z-index: 998; - transform: translateX(-100%); - box-shadow: 2px 0 12px rgba(0, 0, 0, 0.15); -} - -.sidebar.active, -.sidebar.open { - transform: translateX(0); -} - -.sidebar-nav { - display: flex; - flex-direction: column; - height: 100%; -} - -.sidebar-header { - padding: 20px; - border-bottom: 1px solid var(--border-color); - display: flex; - justify-content: space-between; - align-items: center; -} - -.sidebar-header h2 { - font-family: "Funnel Display", sans-serif; - margin: 0; - font-size: 1.2rem; - color: var(--text-color); - font-weight: 600; -} - -.sidebar-close { - background: none; - border: none; - font-size: 24px; - cursor: pointer; - padding: 4px; - line-height: 1; -} - -.clear-filter-btn { - font-family: "Geist", sans-serif; - width: calc(100% - 40px); - margin: 16px 20px; - padding: 10px 16px; - background-color: var(--primary-color); - color: white; - border: 1px solid var(--primary-color); - border-radius: var(--radius-md); - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; -} - -.clear-filter-btn:hover { - background-color: var(--primary-hover); - border-color: var(--primary-hover); -} - -.toc-list { - list-style: none; - padding: 0; - margin: 0; - flex: 1; - overflow-y: auto; -} - -.toc-list li { - border-bottom: 1px solid rgba(0,0,0,0.05); -} - -.toc-list a { - display: block; - padding: 12px 20px; - color: var(--text-color); - text-decoration: none; - transition: all 0.2s; - position: relative; -} - -.toc-list a:hover { - background-color: rgba(89, 113, 253, 0.08); - color: var(--primary-color); - padding-left: 24px; -} - -.sidebar-footer { - padding: 20px; - border-top: 1px solid var(--border-color); -} - -.download-btn { - display: block; - width: 100%; - padding: 10px 16px; - background-color: transparent; - color: var(--text-secondary); - text-align: center; - text-decoration: none; - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - font-size: 13px; - font-weight: 500; - transition: all 0.2s; -} - -.download-btn:hover { - background-color: var(--bg-secondary); - color: var(--text-color); - border-color: var(--text-secondary); -} - -/* Sidebar overlay */ -.sidebar-overlay { - display: none; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.3); - z-index: 997; -} - -.sidebar-overlay.active { - display: block; -} - -/* Main Content */ -.main-content { - flex: 1; - padding: 32px; - width: 100%; - position: relative; -} - -@media (min-width: 1600px) { - .main-content { - padding: 40px 60px; - } - .header-content { - padding: 0 60px; - } -} - -@media (min-width: 1920px) { - .main-content { - padding: 48px 80px; - } - .header-content { - padding: 0 80px; - } -} - -/* Loading Indicator */ -.loading-indicator, -.error-indicator { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: 400px; - gap: 16px; -} - -.loading-indicator p, -.error-indicator p { - font-family: "Geist", sans-serif; - color: var(--text-secondary); - font-size: 14px; - font-weight: 500; -} - -.spinner { - width: 48px; - height: 48px; - border: 3px solid var(--border-color); - border-top: 3px solid var(--primary-color); - border-radius: 50%; - animation: spin 1s linear infinite; -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -/* Benchmark Sections */ -.benchmark-set { - margin-bottom: 48px; - background: var(--bg-color); - border-radius: var(--radius-lg); - border: 1px solid var(--border-color); - overflow: visible; - transition: opacity 0.3s ease, border-color 0.3s ease; - position: relative; - z-index: 1; -} - -.benchmark-set:first-child { - margin-top: 0; -} - -.benchmark-set.no-data { - opacity: 0.5; -} - -.benchmark-set.no-data .benchmark-header { - cursor: not-allowed; -} - -.benchmark-set.no-data .collapse-icon { - visibility: hidden; -} - -.sticky-header-container { - position: relative; - z-index: 50; - background: var(--bg-secondary); - border-radius: var(--radius-lg) var(--radius-lg) 0 0; - margin: 0; - overflow: visible; - padding: 0; -} - -.benchmark-header { - display: flex; - align-items: center; - gap: 12px; - padding: 16px 32px; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-color); - border-radius: var(--radius-lg); - cursor: pointer; - -webkit-user-select: none; - user-select: none; - transition: background-color 0.2s; - overflow: visible; -} - -.benchmark-header:hover { - background: var(--bg-color); -} - -.title-wrapper { - display: flex; - align-items: center; - gap: 12px; - flex: 1; - min-width: 0; - overflow: visible; -} - -.benchmark-title { - font-family: "Funnel Display", sans-serif; - font-size: 1.5rem; - font-weight: 600; - margin: 0; - color: var(--text-color); - display: flex; - align-items: center; - gap: 12px; - letter-spacing: -0.02em; - line-height: 1.2; -} - -.group-link-btn { - font-size: 18px; - background: none; - border: none; - cursor: pointer; - opacity: 0.5; - padding: 4px 8px; - border-radius: var(--radius-sm); - transition: opacity 0.2s, background-color 0.2s, color 0.2s; - color: var(--text-secondary); -} - -.benchmark-header:hover .group-link-btn { - opacity: 1; -} - -.group-link-btn:hover { - background-color: var(--bg-secondary); - color: var(--primary-color); -} - -.group-link-btn.copied { - color: var(--accent-color); - opacity: 1; -} - -.collapse-icon { - font-size: 1rem; - flex-shrink: 0; - width: 1rem; - text-align: center; - display: inline-block; -} - -.benchmark-secondary-info { - display: flex; - align-items: center; - gap: 12px; - flex-shrink: 0; -} - -.benchmark-meta { - font-family: "Geist Mono", monospace; - display: flex; - gap: 16px; - font-size: 11px; - font-weight: 500; - color: var(--text-secondary); - letter-spacing: 0.02em; - text-transform: uppercase; - flex-shrink: 0; - line-height: 1; -} - -.info-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 50%; - background: var(--bg-secondary); - color: var(--text-secondary); - font-size: 16px; - cursor: help; - position: relative; - transition: background-color 0.2s, color 0.2s; - flex-shrink: 0; -} - -.info-icon:hover { - background: var(--primary-color); - color: white; -} - -.info-icon::after { - content: attr(data-tooltip); - position: absolute; - bottom: calc(100% + 8px); - left: 50%; - transform: translateX(-50%); - padding: 8px 12px; - background: rgba(16, 16, 16, 0.95); - color: white; - font-size: 13px; - line-height: 1.4; - border-radius: var(--radius-sm); - white-space: nowrap; - opacity: 0; - visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; - pointer-events: none; - z-index: 10000; - font-weight: normal; -} - -.info-icon:hover::after { - opacity: 1; - visibility: visible; -} - -/* Generic Tooltip */ -[data-tooltip] { - position: relative; -} - -[data-tooltip]::after { - content: attr(data-tooltip); - position: absolute; - bottom: 100%; - left: 50%; - transform: translateX(-50%); - margin-bottom: 6px; - padding: 6px 10px; - background: rgba(16, 16, 16, 0.95); - color: white; - font-family: "Geist", sans-serif; - font-size: 12px; - font-weight: 500; - line-height: 1.3; - border-radius: var(--radius-sm); - white-space: nowrap; - opacity: 0; - visibility: hidden; - transition: all 0.15s; - pointer-events: none; - z-index: 1000; -} - -[data-tooltip]:hover::after { - opacity: 1; - visibility: visible; -} - -[data-tooltip]:disabled::after, -[data-tooltip][disabled]::after { - display: none; -} - -/* Engine Filter Controls */ -.engine-filter-container { - padding: 12px 32px; - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-color); - display: flex; - align-items: center; - gap: 12px; - flex-wrap: wrap; -} - -.engine-filter-label { - font-family: "Geist", sans-serif; - font-size: 14px; - font-weight: 500; - color: var(--text-secondary); -} - -.engine-filter-btn { - font-family: "Geist", sans-serif; - padding: 6px 14px; - border: 1px solid var(--border-color); - background: var(--bg-color); - border-radius: var(--radius-sm); - font-size: 13px; - font-weight: 500; - color: var(--text-color); - cursor: pointer; - transition: all 0.2s; -} - -.engine-filter-btn:hover { - background-color: var(--bg-secondary); - border-color: var(--primary-color); -} - -.engine-filter-btn.active { - background-color: var(--primary-color); - color: white; - border-color: var(--primary-color); -} - -/* Chart Grid */ -.benchmark-graphs { - display: grid; - grid-template-columns: 1fr; - gap: 20px; - padding: 20px; - border-radius: 0 0 var(--radius-lg) var(--radius-lg); - background: var(--bg-color); -} - -@media (min-width: 1200px) { - .benchmark-graphs { - grid-template-columns: repeat(2, 1fr); - gap: 24px; - padding: 24px; - } - - .benchmark-graphs.single-chart { - grid-template-columns: 1fr; - max-width: 1400px; - margin: 0 auto; - } -} - -@media (min-width: 1600px) { - .benchmark-graphs { - padding: 28px 32px; - } -} - -.benchmark-graphs.list-view { - grid-template-columns: 1fr; - max-width: 1200px; - margin: 0 auto; -} - -.chart-container { - background: var(--bg-color); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - padding: 20px; - position: relative; - transition: all 0.3s ease; - display: flex; - flex-direction: column; -} - -.chart-container:hover { - box-shadow: var(--shadow-md); -} - -.chart-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 8px; -} - -.chart-title { - font-family: "Funnel Display", sans-serif; - font-size: 16px; - font-weight: 500; - color: var(--text-color); - letter-spacing: -0.01em; - display: flex; - align-items: center; - gap: 8px; -} - -.downsample-indicator { - font-family: "Geist Mono", monospace; - font-size: 10px; - font-weight: 600; - min-width: 100px; - padding: 2px 6px; - background-color: var(--vortex-pink); - color: var(--vortex-black); - border-radius: var(--radius-sm); - text-transform: uppercase; - letter-spacing: 0.02em; -} - -.chart-actions { - display: flex; - gap: 8px; -} - -.chart-action-btn { - background: var(--bg-color); - border: 1px solid var(--border-color); - padding: 6px 12px; - border-radius: var(--radius-sm); - cursor: pointer; - font-size: 12px; - font-weight: 500; - transition: all 0.2s; - display: flex; - align-items: center; - gap: 6px; -} - -.chart-action-btn:hover { - background-color: #f5f5f5; - border-color: var(--primary-color); -} - -.chart-zoom-controls { - display: flex; - gap: 2px; - margin-right: 8px; -} - -.chart-zoom-btn { - background: var(--bg-color); - border: 1px solid var(--border-color); - padding: 4px 8px; - border-radius: var(--radius-sm); - cursor: pointer; - font-size: 12px; - font-weight: 600; - min-width: 28px; - transition: all 0.15s; - color: var(--text-secondary); - display: flex; - align-items: center; - justify-content: center; -} - -.chart-zoom-btn:hover:not(:disabled) { - background-color: var(--primary-color); - border-color: var(--primary-color); - color: white; -} - -.chart-zoom-btn:disabled { - opacity: 0.4; - cursor: not-allowed; - background: var(--bg-secondary); -} - -/* Chart Canvas */ -.chart-container canvas { - max-height: 450px; - min-height: 320px; - width: 100% !important; - height: auto !important; - display: block; -} - -.benchmark-graphs.list-view .chart-container canvas { - max-height: 600px; - min-height: 400px; -} - -.chart-canvas-wrapper { - position: relative; - height: 100%; - min-height: 320px; -} - -.chart-canvas-wrapper.loading canvas { - filter: blur(2px); - pointer-events: none; - transition: filter 0.2s; -} - -.chart-canvas-placeholder { - display: flex; - align-items: center; - justify-content: center; - min-height: 320px; - max-height: 450px; - background: var(--bg-secondary); - border-radius: var(--radius-sm); - margin-top: 8px; - position: relative; - overflow: hidden; -} - -.chart-loading-overlay { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: center; - z-index: 10; -} - -.chart-loading-spinner { - width: 32px; - height: 32px; - border: 3px solid var(--border-color); - border-top-color: var(--primary-color); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -/* Back to Top Button */ -.back-to-top { - position: fixed; - bottom: 32px; - right: 32px; - width: 48px; - height: 48px; - background-color: var(--primary-color); - color: white; - border: none; - border-radius: 50%; - font-size: 20px; - cursor: pointer; - box-shadow: 0 4px 12px rgba(89, 113, 253, 0.3); - opacity: 0; - visibility: hidden; - transition: all 0.3s ease; - z-index: 999; -} - -.back-to-top.visible { - opacity: 1; - visibility: visible; -} - -.back-to-top:hover { - background-color: var(--primary-hover); - transform: translateY(-4px); - box-shadow: 0 8px 20px rgba(89, 113, 253, 0.4); -} - -/* Chart Modal */ -.chart-modal { - display: none; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.8); - z-index: 2000; -} - -.chart-modal.active { - display: flex; - align-items: center; - justify-content: center; -} - -.modal-content { - background: var(--bg-color); - padding: 32px; - border-radius: var(--radius-lg); - width: 95%; - max-width: 1800px; - height: 90vh; - position: relative; - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2); -} - -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 16px; -} - -.modal-header h2 { - font-family: 'Funnel Display', sans-serif; - font-size: 20px; - font-weight: 600; - margin: 0; -} - -.modal-controls { - display: flex; - align-items: center; - gap: 12px; -} - -.modal-close-btn { - background: var(--bg-color); - border: 1px solid var(--border-color); - padding: 6px; - border-radius: var(--radius-sm); - cursor: pointer; - color: var(--text-secondary); - display: flex; - align-items: center; - justify-content: center; - transition: all 0.15s; -} - -.modal-close-btn:hover { - background-color: var(--primary-color); - border-color: var(--primary-color); - color: white; -} - -.modal-chart-container { - width: 100%; - height: calc(100% - 60px); - position: relative; -} - -.modal-chart-container.loading canvas { - filter: blur(2px); - pointer-events: none; - transition: filter 0.2s; -} - -/* Benchmark Scores Summary */ -.benchmark-scores-summary { - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-color); - padding: 12px 32px; - margin: 0; - margin-top: 0 !important; -} - -.scores-title { - font-size: 12px; - font-weight: 600; - margin: 0 0 8px 0; - color: var(--text-secondary); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.scores-list { - column-count: 2; - column-gap: 20px; -} - -.scores-list:has(.score-item:only-child) { - column-count: 1; -} - -@media (max-width: 780px) { - .scores-list { - column-count: 1; - } -} - -.score-item { - display: flex; - align-items: center; - background: transparent; - padding: 6px 0; - margin-bottom: 4px; - border-radius: 0; - border: none; - transition: none; - font-size: 14px; - break-inside: avoid; -} - -.score-rank { - font-weight: 500; - color: var(--primary-color); - min-width: 24px; - font-size: 14px; -} - -.score-series { - flex: 1; - font-weight: 500; - color: var(--text-color); - margin: 0 8px; - font-size: 14px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.score-metrics { - display: flex; - gap: 6px; - align-items: center; -} - -.score-value { - font-family: 'Geist Mono', monospace; - font-weight: 600; - color: var(--primary-color); - font-size: 13px; -} - -.score-runtime { - font-family: 'Geist Mono', monospace; - font-weight: 600; - color: var(--text-secondary); - background: transparent; - padding: 0 4px; - border-radius: 0; - font-size: 13px; -} - -.scores-explanation { - margin-top: 8px; - font-size: 11px; - color: var(--text-secondary); - font-style: italic; - text-align: center; -} - -/* Utility Classes */ -.hidden { - display: none !important; -} - -/* Mobile Styles */ -@media (max-width: 768px) { - :root { - --header-height: 56px; - } - - .header-content { - padding: 0 8px; - gap: 8px; - } - - .header-left { - gap: 8px; - } - - .site-logo { - height: 16px; - } - - .site-title { - display: none; - } - - .header-center { - display: none; - } - - .view-controls { - display: none; - } - - .repo-link span { - display: none; - } - - .main-content { - padding: 16px; - max-width: 100vw; - } - - .benchmark-set { - margin-bottom: 24px; - border-radius: var(--radius-md); - overflow: hidden; - } - - .benchmark-header { - padding: 12px 16px; - flex-wrap: wrap; - gap: 8px; - overflow: hidden; - } - - .title-wrapper { - flex-wrap: wrap; - gap: 8px; - overflow: hidden; - } - - .benchmark-title { - font-size: 1.1rem; - flex: 1 1 auto; - min-width: 0; - word-break: break-word; - } - - .benchmark-meta { - flex-shrink: 1; - font-size: 10px; - } - - .benchmark-secondary-info { - flex-shrink: 1; - min-width: 0; - } - - .benchmark-graphs { - grid-template-columns: 1fr; - gap: 16px; - padding: 12px; - } - - .chart-container { - padding: 12px; - overflow: hidden; - } - - .chart-header { - flex-wrap: wrap; - gap: 8px; - } - - .chart-title { - flex: 1 1 100%; - min-width: 0; - font-size: 14px; - } - - .chart-actions { - width: 100%; - justify-content: flex-start; - } - - .chart-zoom-controls { - flex-wrap: wrap; - gap: 4px; - } - - .chart-zoom-btn { - padding: 6px 8px; - min-width: 32px; - } - - .chart-container canvas { - max-height: 350px; - min-height: 250px; - } - - .chart-container:hover { - transform: none; - box-shadow: none; - } - - .chart-action-btn { - display: none; - } - - .engine-filter-container { - padding: 12px 16px; - flex-wrap: wrap; - } - - .back-to-top { - bottom: 16px; - right: 16px; - width: 40px; - height: 40px; - font-size: 16px; - } - - .modal-content { - padding: 16px; - height: 90vh; - } - - .modal-header { - flex-wrap: wrap; - gap: 8px; - } - - .modal-header h2 { - font-size: 16px; - min-width: 0; - word-break: break-word; - } - - .benchmark-scores-summary { - padding: 8px 16px; - } -} diff --git a/benchmarks-website/src/utils.js b/benchmarks-website/src/utils.js deleted file mode 100644 index 140ebebeb6c..00000000000 --- a/benchmarks-website/src/utils.js +++ /dev/null @@ -1,104 +0,0 @@ -import { SERIES_COLOR_MAP, FALLBACK_PALETTE, CHART_NAME_MAP } from './config'; - -// Simple hash function for color selection -function simpleHash(str) { - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; - } - return Math.abs(hash); -} - -export function stringToColor(str) { - if (SERIES_COLOR_MAP[str]) { - return SERIES_COLOR_MAP[str]; - } - - const lowerStr = str - .replace(/^DataFusion:/i, 'datafusion:') - .replace(/^DuckDB:/i, 'duckdb:') - .replace(/^Vortex:/i, 'vortex:') - .replace(/^Arrow:/i, 'arrow:'); - - if (lowerStr !== str && SERIES_COLOR_MAP[lowerStr]) { - return SERIES_COLOR_MAP[lowerStr]; - } - - const index = simpleHash(str) % FALLBACK_PALETTE.length; - return FALLBACK_PALETTE[index]; -} - -export function remapChartName(name) { - if (CHART_NAME_MAP[name]) { - return CHART_NAME_MAP[name]; - } - // Convert dashes to spaces for readability - return name.replace(/-/g, ' '); -} - -export function formatDate(timestamp) { - if (!timestamp) return ''; - const date = new Date(timestamp); - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - return `${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`; -} - -export function formatTime(ms) { - if (ms < 1) return `${(ms * 1000).toFixed(0)}μs`; - if (ms < 1000) return `${ms.toFixed(1)}ms`; - if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; - return `${(ms / 60000).toFixed(1)}m`; -} - -export function debounce(func, wait) { - let timeout; - return function (...args) { - clearTimeout(timeout); - timeout = setTimeout(() => func.apply(this, args), wait); - }; -} - -export function throttle(func, limit) { - let inThrottle; - return function (...args) { - if (!inThrottle) { - func.apply(this, args); - inThrottle = true; - setTimeout(() => (inThrottle = false), limit); - } - }; -} - -export function isMobile() { - return window.innerWidth <= 768; -} - -export function getBenchmarkDescription(categoryName) { - if (categoryName.startsWith('TPC-H')) { - const match = categoryName.match(/SF=(\d+)/); - const sf = match ? match[1] : null; - const sfDesc = sf ? `at SF=${sf} (~${sf === '1' ? '1GB' : sf === '10' ? '10GB' : sf === '100' ? '100GB' : '1TB'} of data)` : ''; - if (categoryName.includes('NVMe')) { - return `TPC-H benchmark queries on local NVMe storage ${sfDesc}`; - } else if (categoryName.includes('S3')) { - return `TPC-H benchmark queries against S3 storage ${sfDesc}`; - } - } - if (categoryName.startsWith('TPC-DS')) { - const match = categoryName.match(/SF=(\d+)/); - const sf = match ? match[1] : null; - const sfDesc = sf ? `at SF=${sf}` : ''; - return `TPC-DS benchmark queries on local NVMe storage ${sfDesc}`; - } - const descriptions = { - 'Random Access': 'Tests performance of selecting arbitrary row indices from a file on NVMe storage', - 'Compression': 'Measures encoding and decoding throughput (MB/s) for Vortex and Parquet files', - 'Compression Size': 'Compares compressed file sizes across different encoding strategies', - 'Clickbench': "ClickHouse's analytical benchmark suite on web analytics data", - 'Clickbench Sorted': 'ClickBench queries over data globally sorted by event date and event time', - 'Statistical and Population Genetics': 'Statistical and population genetics queries on gnomAD dataset', - }; - return descriptions[categoryName] || ''; -} diff --git a/benchmarks-website/vite.config.js b/benchmarks-website/vite.config.js deleted file mode 100644 index ad0cc7446f6..00000000000 --- a/benchmarks-website/vite.config.js +++ /dev/null @@ -1,19 +0,0 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; - -export default defineConfig({ - plugins: [react()], - publicDir: 'public', - server: { - port: 5173, - proxy: { - '/api': { - target: 'http://localhost:3000', - changeOrigin: true, - }, - }, - }, - build: { - outDir: 'dist', - }, -}); From c981534284110173ec237ad25a4294c99ee87d67 Mon Sep 17 00:00:00 2001 From: Onur Satici Date: Fri, 17 Jul 2026 10:55:24 +0100 Subject: [PATCH 103/104] apply alp patches in parallel in cuda (#8793) We used to walk through the patches after decoding ALP then apply them one by one. Now we scatter them among workers and apply these in parallel --------- Signed-off-by: Onur Satici --- vortex-cuda/benches/dynamic_dispatch_cuda.rs | 82 ++++++++++------- vortex-cuda/kernels/src/dynamic_dispatch.cu | 97 +++++++++++++------- 2 files changed, 111 insertions(+), 68 deletions(-) diff --git a/vortex-cuda/benches/dynamic_dispatch_cuda.rs b/vortex-cuda/benches/dynamic_dispatch_cuda.rs index 81ee3a45167..e4726d7c46d 100644 --- a/vortex-cuda/benches/dynamic_dispatch_cuda.rs +++ b/vortex-cuda/benches/dynamic_dispatch_cuda.rs @@ -7,6 +7,7 @@ mod bench_config; +use std::f64::consts::PI; use std::marker::PhantomData; use std::mem::size_of; use std::os::raw::c_void; @@ -39,6 +40,7 @@ use vortex::dtype::NativePType; use vortex::dtype::PType; use vortex::encodings::alp::ALP; use vortex::encodings::alp::ALPArrayExt; +use vortex::encodings::alp::ALPArrayOwnedExt; use vortex::encodings::alp::ALPArraySlotsExt; use vortex::encodings::alp::ALPFloat; use vortex::encodings::alp::Exponents; @@ -745,40 +747,50 @@ fn bench_alp_for_bitpacked_f64(c: &mut Criterion) { for (len, len_str) in BENCH_SIZES { group.throughput(Throughput::Bytes((len * size_of::()) as u64)); - // Generate f64 values that ALP-encode without patches. - let floats: Vec = (0..*len) - .map(|i| ::decode_single(10 + (i as i64 % 64), exponents)) - .collect(); - let float_prim = PrimitiveArray::new(Buffer::from(floats), NonNullable); - - // Encode: ALP → FoR → BitPacked - let alp = - alp_encode(float_prim.as_view(), Some(exponents), &mut ctx).vortex_expect("alp_encode"); - assert!(alp.patches().is_none()); - let for_arr = FoRData::encode( - alp.encoded() - .clone() - .execute::(&mut ctx) - .vortex_expect("to primitive"), - &mut ctx, - ) - .vortex_expect("for encode"); - let bp = BitPackedData::encode(for_arr.encoded(), bit_width, &mut ctx) - .vortex_expect("bitpack encode"); - - let tree = ALP::new( - FoR::try_new(bp.into_array(), for_arr.reference_scalar().clone()) - .vortex_expect("for_new") - .into_array(), - exponents, - None, - ); - let array = tree.into_array(); + for (patch_interval, benchmark_name) in [ + (None, "cuda/alp_for_bp_6bw_f64/dispatch_f64"), + ( + Some(100), + "cuda/alp_for_bp_6bw_f64_1pct_patches/dispatch_f64", + ), + ] { + let floats: Vec = (0..*len) + .map(|i| { + if patch_interval.is_some_and(|interval| i % interval == 0) { + PI + } else { + ::decode_single(10 + (i as i64 % 64), exponents) + } + }) + .collect(); + let float_prim = PrimitiveArray::new(Buffer::from(floats), NonNullable); + + // Encode: ALP → FoR → BitPacked, preserving ALP's exception patches. + let alp = alp_encode(float_prim.as_view(), Some(exponents), &mut ctx) + .vortex_expect("alp_encode"); + assert_eq!(alp.patches().is_some(), patch_interval.is_some()); + let (encoded, alp_exponents, patches) = alp.into_parts(); + let for_arr = FoRData::encode( + encoded + .execute::(&mut ctx) + .vortex_expect("to primitive"), + &mut ctx, + ) + .vortex_expect("for encode"); + let bp = BitPackedData::encode(for_arr.encoded(), bit_width, &mut ctx) + .vortex_expect("bitpack encode"); + assert!(bp.patches().is_none(), "expected only ALP patches"); + + let tree = ALP::new( + FoR::try_new(bp.into_array(), for_arr.reference_scalar().clone()) + .vortex_expect("for_new") + .into_array(), + alp_exponents, + patches, + ); + let array = tree.into_array(); - group.bench_with_input( - BenchmarkId::new("cuda/alp_for_bp_6bw_f64/dispatch_f64", len_str), - len, - |b, &n| { + group.bench_with_input(BenchmarkId::new(benchmark_name, len_str), len, |b, &n| { let mut cuda_ctx = CudaSession::create_execution_ctx(&cuda_session()).vortex_expect("ctx"); @@ -791,8 +803,8 @@ fn bench_alp_for_bitpacked_f64(c: &mut Criterion) { } total_time }); - }, - ); + }); + } } group.finish(); diff --git a/vortex-cuda/kernels/src/dynamic_dispatch.cu b/vortex-cuda/kernels/src/dynamic_dispatch.cu index fa19b47fe1a..3a7f28e1576 100644 --- a/vortex-cuda/kernels/src/dynamic_dispatch.cu +++ b/vortex-cuda/kernels/src/dynamic_dispatch.cu @@ -117,12 +117,8 @@ __shared__ uint64_t runend_cursors[BLOCK_SIZE]; // ═══════════════════════════════════════════════════════════════════════════ /// Apply one scalar operation to N values in registers. -/// -/// `abs_pos` is the absolute output position of the first value to process. -/// It is used by scalar operations that apply patches, e.g. ALP. template -__device__ inline void -scalar_op(T *values, const struct ScalarOp &op, char *__restrict smem, uint64_t abs_pos = 0) { +__device__ inline void scalar_op(T *values, const struct ScalarOp &op, char *__restrict smem) { switch (op.op_code) { case ScalarOp::FOR: { const T ref = static_cast(op.params.frame_of_ref.reference); @@ -165,30 +161,9 @@ scalar_op(T *values, const struct ScalarOp &op, char *__restrict smem, uint64_t values[i] = static_cast(__double_as_longlong(r)); } } - // Apply ALP patches: override positions whose float value couldn't - // be reconstructed through the ALP encode/decode cycle. - // Per-value cursor — with a slice offset, a tile's N values can - // straddle two FL chunks, so each value needs its own lookup. - if (op.params.alp.patches_ptr != 0) { - const auto &patches = *reinterpret_cast(op.params.alp.patches_ptr); - const uint32_t chunk_start = patches.offset / FL_CHUNK; -#pragma unroll - for (uint32_t i = 0; i < N; ++i) { - uint64_t my_pos = (N > 1) ? abs_pos + i * blockDim.x + threadIdx.x : abs_pos; - uint64_t orig = my_pos + patches.offset; - uint32_t chunk = static_cast(orig / FL_CHUNK) - chunk_start; - uint32_t within = static_cast(orig % FL_CHUNK); - PatchesCursor cursor(patches, chunk, 0, 1); - auto patch = cursor.next(); - while (patch.index != FL_CHUNK) { - if (patch.index == within) { - values[i] = patch.value; - break; - } - patch = cursor.next(); - } - } - } + // ALP patches are scattered cooperatively after the stage has decoded. + // Looking up patches here would restart a chunk cursor for every value, + // turning sparse exception handling into O(values * patches). break; } case ScalarOp::DICT: { @@ -225,6 +200,58 @@ scatter_patches_chunk(const GPUPatches &patches, T *__restrict out, uint32_t chu } } +/// Scatter patches overlapping a logical output range. +/// +/// `logical_start` and `range_len` are relative to the sliced array represented by +/// `patches`. Patch indices are stored in the coordinate space of the original array, +/// so add `patches.offset` while locating chunks and subtract it when addressing `out`. +/// Every thread cooperates on each overlapping FastLanes chunk. +template +__device__ inline void scatter_patches_range(const GPUPatches &patches, + T *__restrict out, + uint64_t logical_start, + uint32_t range_len) { + if (range_len == 0) { + return; + } + + const uint64_t original_start = logical_start + patches.offset; + const uint64_t original_end = original_start + range_len; + const uint32_t patch_chunk_start = patches.offset / FL_CHUNK; + const uint32_t first_chunk = static_cast(original_start / FL_CHUNK); + const uint32_t last_chunk = static_cast((original_end - 1) / FL_CHUNK); + + for (uint32_t original_chunk = first_chunk; original_chunk <= last_chunk; ++original_chunk) { + const uint32_t chunk = original_chunk - patch_chunk_start; + PatchesCursor cursor(patches, chunk, threadIdx.x, blockDim.x); + auto patch = cursor.next(); + while (patch.index != FL_CHUNK) { + const uint64_t original_pos = static_cast(original_chunk) * FL_CHUNK + patch.index; + if (original_pos >= original_start && original_pos < original_end) { + out[original_pos - patches.offset] = patch.value; + } + patch = cursor.next(); + } + } +} + +/// Apply patch payloads attached to scalar operations after their stage has decoded. +template +__device__ inline void +scatter_scalar_patches(const Stage &stage, T *__restrict out, uint64_t logical_start, uint32_t range_len) { + for (uint8_t op_idx = 0; op_idx < stage.num_scalar_ops; ++op_idx) { + const auto &op = stage.scalar_ops[op_idx]; + if (op.op_code == ScalarOp::ALP && op.params.alp.patches_ptr != 0) { + const auto &patches = *reinterpret_cast(op.params.alp.patches_ptr); + // All ordinary writes must complete before exception values overwrite them. + __syncthreads(); + scatter_patches_range(patches, out, logical_start, range_len); + // A later stage may consume this patched shared-memory output. + __syncthreads(); + } + } +} + // ═══════════════════════════════════════════════════════════════════════════ // Source ops // ═══════════════════════════════════════════════════════════════════════════ @@ -491,7 +518,7 @@ __device__ void execute_output_stage(T *__restrict output, smem); for (uint8_t op = 0; op < stage.num_scalar_ops; ++op) { - scalar_op(values, stage.scalar_ops[op], smem, tile_start); + scalar_op(values, stage.scalar_ops[op], smem); } #pragma unroll @@ -513,7 +540,7 @@ __device__ void execute_output_stage(T *__restrict output, source_op(&val, src, raw_input, ptype, smem_src, i, gpos, smem); for (uint8_t op = 0; op < stage.num_scalar_ops; ++op) { - scalar_op(&val, stage.scalar_ops[op], smem, gpos); + scalar_op(&val, stage.scalar_ops[op], smem); } __stcs(&output[gpos], val); } @@ -525,6 +552,8 @@ __device__ void execute_output_stage(T *__restrict output, } elem_idx += chunk_len; } + + scatter_scalar_patches(stage, output, block_start, block_len); } // ═══════════════════════════════════════════════════════════════════════════ @@ -559,7 +588,7 @@ __device__ void execute_input_stage(const Stage &stage, char *__restrict smem) { for (uint32_t elem_idx = threadIdx.x; elem_idx < stage.len; elem_idx += blockDim.x) { T val = smem_out[elem_idx]; for (uint8_t op = 0; op < stage.num_scalar_ops; ++op) { - scalar_op(&val, stage.scalar_ops[op], smem, elem_idx); + scalar_op(&val, stage.scalar_ops[op], smem); } smem_out[elem_idx] = val; } @@ -583,7 +612,7 @@ __device__ void execute_input_stage(const Stage &stage, char *__restrict smem) { T val; source_op(&val, src, raw_input, stage.source_ptype, nullptr, 0, elem_idx, smem); for (uint8_t op = 0; op < stage.num_scalar_ops; ++op) { - scalar_op(&val, stage.scalar_ops[op], smem, elem_idx); + scalar_op(&val, stage.scalar_ops[op], smem); } smem_out[elem_idx] = val; } @@ -591,6 +620,8 @@ __device__ void execute_input_stage(const Stage &stage, char *__restrict smem) { // stages to read. __syncthreads(); } + + scatter_scalar_patches(stage, smem_out, 0, stage.len); } // ═══════════════════════════════════════════════════════════════════════════ From 68badd6899c82156a3a6d4c4cf9906051a674ece Mon Sep 17 00:00:00 2001 From: Adam Gutglick Date: Tue, 7 Jul 2026 17:29:51 +0100 Subject: [PATCH 104/104] fix: TableStrategy currently hides panic in spawned column writer tasks. (#8672) ## Rationale for this change `TableStrategy` currently hides panics that happen in the individual column streams. ## What changes are included in this PR? Instead of detaching the fanout work, we await it and make sure everything finishes successfully. ## What APIs are changed? Are there any user-facing changes? No API change, added test to verify panic propagation. Signed-off-by: Adam Gutglick (cherry picked from commit 4abe3d04f097b1fbbfcae23795f347b7af5b0919) --- vortex-layout/src/layouts/table.rs | 95 ++++++++++++++++++++++++------ 1 file changed, 76 insertions(+), 19 deletions(-) diff --git a/vortex-layout/src/layouts/table.rs b/vortex-layout/src/layouts/table.rs index 28b3af52c87..04c47a9f732 100644 --- a/vortex-layout/src/layouts/table.rs +++ b/vortex-layout/src/layouts/table.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use async_trait::async_trait; use futures::StreamExt; use futures::TryStreamExt; +use futures::future::try_join; use futures::future::try_join_all; use futures::pin_mut; use itertools::Itertools; @@ -266,30 +267,33 @@ impl LayoutStrategy for TableStrategy { let (column_streams_tx, column_streams_rx): (Vec<_>, Vec<_>) = (0..stream_count).map(|_| kanal::bounded_async(1)).unzip(); - // Spawn a task to fan out column chunks to their respective transposed streams + // Fan out column chunks to their respective transposed streams. Keep this future joined + // with the column writers so producer panics/errors cannot be hidden as channel EOF. let handle = session.handle(); - handle - .spawn(async move { - pin_mut!(columns_vec_stream); - while let Some(result) = columns_vec_stream.next().await { - match result { - Ok(columns) => { - for (tx, column) in column_streams_tx.iter().zip_eq(columns.into_iter()) - { - let _ = tx.send(Ok(column)).await; + let fanout_fut = async move { + pin_mut!(columns_vec_stream); + while let Some(result) = columns_vec_stream.next().await { + match result { + Ok(columns) => { + for (tx, column) in column_streams_tx.iter().zip_eq(columns.into_iter()) { + if tx.send(Ok(column)).await.is_err() { + vortex_bail!( + "table column writer finished before all chunks were sent" + ); } } - Err(e) => { - let e: Arc = Arc::new(e); - for tx in column_streams_tx.iter() { - let _ = tx.send(Err(VortexError::from(Arc::clone(&e)))).await; - } - break; + } + Err(e) => { + let e: Arc = Arc::new(e); + for tx in column_streams_tx.iter() { + let _ = tx.send(Err(VortexError::from(Arc::clone(&e)))).await; } + return Err(VortexError::from(e)); } } - }) - .detach(); + } + Ok(()) + }; // First child column is the validity, subsequence children are the individual struct fields let column_dtypes: Vec = if is_nullable { @@ -359,7 +363,7 @@ impl LayoutStrategy for TableStrategy { }) .collect(); - let column_layouts = try_join_all(layout_futures).await?; + let (_success, column_layouts) = try_join(fanout_fut, try_join_all(layout_futures)).await?; // TODO(os): transposed stream could count row counts as well, // This must hold though, all columns must have the same row count of the struct layout let row_count = column_layouts.first().map(|l| l.row_count()).unwrap_or(0); @@ -370,12 +374,28 @@ impl LayoutStrategy for TableStrategy { #[cfg(test)] mod tests { use std::sync::Arc; + use std::task::Poll; + use vortex_array::ArrayContext; + use vortex_array::ArrayRef; + use vortex_array::dtype::DType; use vortex_array::dtype::FieldPath; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::dtype::StructFields; use vortex_array::field_path; + use vortex_error::VortexResult; + use vortex_io::runtime::single::block_on; + use vortex_io::session::RuntimeSessionExt; + use crate::LayoutStrategy; use crate::layouts::flat::writer::FlatLayoutStrategy; use crate::layouts::table::TableStrategy; + use crate::segments::TestSegments; + use crate::sequence::SequenceId; + use crate::sequence::SequentialStreamAdapter; + use crate::sequence::SequentialStreamExt; + use crate::test::SESSION; #[test] #[should_panic( @@ -407,4 +427,41 @@ mod tests { ) .with_field_writer(FieldPath::root(), flat); } + + #[test] + #[should_panic(expected = "panic while transposing table stream")] + fn table_fanout_panic_propagates() { + let ctx = ArrayContext::empty(); + let segments = Arc::new(TestSegments::default()); + let (_, eof) = SequenceId::root().split(); + let dtype = DType::Struct( + StructFields::from_iter([( + "a", + DType::Primitive(PType::I32, Nullability::NonNullable), + )]), + Nullability::NonNullable, + ); + let stream = + futures::stream::poll_fn(|_| -> Poll>> { + panic!("panic while transposing table stream"); + }); + let strategy = TableStrategy::new( + Arc::new(FlatLayoutStrategy::default()), + Arc::new(FlatLayoutStrategy::default()), + ); + + block_on(|handle| async move { + let session = SESSION.clone().with_handle(handle); + strategy + .write_stream( + ctx, + segments, + SequentialStreamAdapter::new(dtype, stream).sendable(), + eof, + &session, + ) + .await + .unwrap(); + }); + } }