From c88c3412ce507785e80b1b5b2fb5671a56673537 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 13:29:00 +0000 Subject: [PATCH 1/6] Add PatchesV2 with chunk-local patch indices Add a patch container addressed by chunk-local u16 indices with required, rebased u32 chunk-offset prefix counts, plus a grid offset for unaligned slices. Compared to Patches, the index child stays two bytes per patch at any array length, chunk lookup is constant time without saturating offset adjustments, and slicing rebases the chunk offsets so every slice is self-contained. Includes conversions to and from Patches, search and point lookup, slicing, and validation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019BTTvqxz7t96Ebkef7vamk Signed-off-by: Claude --- vortex-array/src/lib.rs | 1 + vortex-array/src/patches_v2.rs | 488 +++++++++++++++++++++++++++++++++ 2 files changed, 489 insertions(+) create mode 100644 vortex-array/src/patches_v2.rs diff --git a/vortex-array/src/lib.rs b/vortex-array/src/lib.rs index 9439dc7dd1b..299f4176199 100644 --- a/vortex-array/src/lib.rs +++ b/vortex-array/src/lib.rs @@ -133,6 +133,7 @@ pub mod normalize; pub mod optimizer; mod partial_ord; pub mod patches; +pub mod patches_v2; pub mod scalar; pub mod scalar_fn; pub mod search_sorted; diff --git a/vortex-array/src/patches_v2.rs b/vortex-array/src/patches_v2.rs new file mode 100644 index 00000000000..91e5c3730e7 --- /dev/null +++ b/vortex-array/src/patches_v2.rs @@ -0,0 +1,488 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! A patch set addressed by chunk-local indices. +//! +//! [`PatchesV2`] stores the same information as [`Patches`]: sparse exception values for an +//! array. It differs in how patch positions are addressed: +//! +//! - `indices` holds `u16` positions **local to each 1024-value chunk** instead of global row +//! indices, so the index child stays two bytes per patch at any array length. +//! - `chunk_offsets` is required, holds `u32` prefix patch counts with a leading zero, and is +//! rebased on every slice, so chunk lookups never need the saturating-adjustment bookkeeping +//! that global offsets force onto [`Patches`]. +//! +//! An `offset` in `0..PATCH_CHUNK_SIZE` places logical element zero inside the first chunk, so +//! slices at unaligned positions keep constant-time chunk addressing: logical index `i` lives at +//! grid position `offset + i`, in chunk `(offset + i) / 1024` at local position +//! `(offset + i) % 1024`. +//! +//! [`Patches`]: crate::patches::Patches + +use std::ops::Range; + +use num_traits::AsPrimitive; +use vortex_buffer::Buffer; +use vortex_error::VortexExpect; +use vortex_error::VortexResult; +use vortex_error::vortex_ensure; + +use crate::ArrayRef; +use crate::ExecutionCtx; +use crate::IntoArray; +use crate::arrays::PrimitiveArray; +use crate::dtype::DType; +use crate::dtype::Nullability::NonNullable; +use crate::dtype::PType; +use crate::patches::PATCH_CHUNK_SIZE; +use crate::patches::Patches; +use crate::scalar::Scalar; +use crate::search_sorted::SearchResult; +use crate::validity::Validity; + +/// Sparse patch values addressed by chunk-local `u16` indices. +#[derive(Debug, Clone)] +pub struct PatchesV2 { + array_len: usize, + /// Grid position of logical element zero, in `0..PATCH_CHUNK_SIZE`. + offset: usize, + /// Chunk-local `u16` patch positions, sorted within each chunk. + indices: ArrayRef, + /// One patch value per index. + values: ArrayRef, + /// `u32` prefix patch counts per chunk, with a leading zero. + chunk_offsets: ArrayRef, +} + +impl PatchesV2 { + /// Construct and validate a new patch set. + /// + /// Validation canonicalizes the index and chunk-offset children, so callers on a hot path + /// with already-validated components should prefer [`Self::new_unchecked`]. + pub fn try_new( + array_len: usize, + offset: usize, + indices: ArrayRef, + values: ArrayRef, + chunk_offsets: ArrayRef, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + vortex_ensure!( + offset < PATCH_CHUNK_SIZE, + "PatchesV2 offset must be within the first chunk" + ); + vortex_ensure!( + indices.len() == values.len(), + "PatchesV2 indices and values must have the same length" + ); + vortex_ensure!(!indices.is_empty(), "PatchesV2 must not be empty"); + vortex_ensure!( + indices.len() <= array_len, + "PatchesV2 cannot have more patches than rows" + ); + vortex_ensure!( + indices.dtype() == &DType::Primitive(PType::U16, NonNullable), + "PatchesV2 indices must be non-nullable u16, got {}", + indices.dtype() + ); + vortex_ensure!( + chunk_offsets.dtype() == &DType::Primitive(PType::U32, NonNullable), + "PatchesV2 chunk offsets must be non-nullable u32, got {}", + chunk_offsets.dtype() + ); + let chunk_count = (offset + array_len).div_ceil(PATCH_CHUNK_SIZE); + vortex_ensure!( + chunk_offsets.len() == chunk_count + 1, + "PatchesV2 expects {} chunk offsets, got {}", + chunk_count + 1, + chunk_offsets.len() + ); + + let local_indices = indices.clone().execute::(ctx)?; + let local_indices = local_indices.as_slice::(); + let offsets = chunk_offsets.clone().execute::(ctx)?; + let offsets = offsets.as_slice::(); + vortex_ensure!( + offsets.first() == Some(&0), + "PatchesV2 chunk offsets must start at zero" + ); + vortex_ensure!( + usize::try_from(offsets[chunk_count])? == indices.len(), + "PatchesV2 chunk offsets must end at the patch count" + ); + for chunk_idx in 0..chunk_count { + let chunk = + usize::try_from(offsets[chunk_idx])?..usize::try_from(offsets[chunk_idx + 1])?; + vortex_ensure!( + chunk.start <= chunk.end, + "PatchesV2 chunk offsets must not decrease" + ); + let chunk_grid_len = grid_range(offset, array_len, chunk_idx, chunk_count); + let locals = &local_indices[chunk]; + vortex_ensure!( + locals.windows(2).all(|pair| pair[0] < pair[1]), + "PatchesV2 indices must be strictly sorted within each chunk" + ); + vortex_ensure!( + locals + .iter() + .all(|&local| chunk_grid_len.contains(&usize::from(local))), + "PatchesV2 chunk {chunk_idx} contains out-of-range indices" + ); + } + + Ok(unsafe { Self::new_unchecked(array_len, offset, indices, values, chunk_offsets) }) + } + + /// Construct a patch set without validating the components. + /// + /// # Safety + /// + /// Callers must uphold every invariant checked by [`Self::try_new`]: matching child lengths, + /// non-nullable `u16` indices strictly sorted within each chunk and inside the sliced grid + /// range, and non-decreasing `u32` chunk offsets starting at zero and ending at the patch + /// count, with one entry per chunk plus one. + pub unsafe fn new_unchecked( + array_len: usize, + offset: usize, + indices: ArrayRef, + values: ArrayRef, + chunk_offsets: ArrayRef, + ) -> Self { + Self { + array_len, + offset, + indices, + values, + chunk_offsets, + } + } + + /// Convert a global-index [`Patches`] into chunk-local form. + pub fn from_patches(patches: &Patches, ctx: &mut ExecutionCtx) -> VortexResult { + let array_len = patches.array_len(); + let offset = patches.offset() % PATCH_CHUNK_SIZE; + let chunk_count = (offset + array_len).div_ceil(PATCH_CHUNK_SIZE); + let global = patches.indices().clone().execute::(ctx)?; + let mut locals = Vec::with_capacity(global.len()); + let mut chunk_offsets = vec![0u32; chunk_count + 1]; + let patches_offset = patches.offset(); + crate::match_each_unsigned_integer_ptype!(global.ptype(), |P| { + for &index in global.as_slice::

() { + // Rebase from the source offset onto this grid, which starts at `offset`. + let index: usize = index.as_(); + let grid = index - patches_offset + offset; + locals.push(u16::try_from(grid % PATCH_CHUNK_SIZE)?); + chunk_offsets[grid / PATCH_CHUNK_SIZE + 1] += 1; + } + }); + for chunk_idx in 0..chunk_count { + chunk_offsets[chunk_idx + 1] += chunk_offsets[chunk_idx]; + } + Ok(unsafe { + Self::new_unchecked( + array_len, + offset, + PrimitiveArray::new(Buffer::from(locals), Validity::NonNullable).into_array(), + patches.values().clone(), + PrimitiveArray::new(Buffer::from(chunk_offsets), Validity::NonNullable) + .into_array(), + ) + }) + } + + /// Convert back into a global-index [`Patches`]. + pub fn to_patches(&self, ctx: &mut ExecutionCtx) -> VortexResult { + let (locals, offsets) = self.canonical_parts(ctx)?; + let mut globals = Vec::with_capacity(locals.len()); + for chunk_idx in 0..offsets.len() - 1 { + let chunk = + usize::try_from(offsets[chunk_idx])?..usize::try_from(offsets[chunk_idx + 1])?; + for &local in &locals[chunk] { + globals.push(u64::try_from( + chunk_idx * PATCH_CHUNK_SIZE + usize::from(local) - self.offset, + )?); + } + } + Patches::new( + self.array_len, + 0, + PrimitiveArray::new(Buffer::from(globals), Validity::NonNullable).into_array(), + self.values.clone(), + None, + ) + } + + /// Returns the length of the patched array. + pub fn array_len(&self) -> usize { + self.array_len + } + + /// Returns the number of patches. + pub fn num_patches(&self) -> usize { + self.indices.len() + } + + /// Returns the dtype of the patch values. + pub fn dtype(&self) -> &DType { + self.values.dtype() + } + + /// Returns the chunk-local patch indices. + pub fn indices(&self) -> &ArrayRef { + &self.indices + } + + /// Returns the patch values. + pub fn values(&self) -> &ArrayRef { + &self.values + } + + /// Returns the per-chunk patch count prefix sums. + pub fn chunk_offsets(&self) -> &ArrayRef { + &self.chunk_offsets + } + + /// Returns the grid position of logical element zero. + pub fn offset(&self) -> usize { + self.offset + } + + /// Search for a patch at logical `index`. + /// + /// Returns [`SearchResult::Found`] with the patch ordinal, or [`SearchResult::NotFound`] + /// with the insertion point. + pub fn search_index(&self, index: usize, ctx: &mut ExecutionCtx) -> VortexResult { + if index >= self.array_len { + return Ok(SearchResult::NotFound(self.num_patches())); + } + let (locals, offsets) = self.canonical_parts(ctx)?; + Ok(search_local(&locals, &offsets, self.offset + index)) + } + + /// Return the patch value at logical `index`, if one exists. + pub fn get_patched( + &self, + index: usize, + ctx: &mut ExecutionCtx, + ) -> VortexResult> { + self.search_index(index, ctx)? + .to_found() + .map(|patch_idx| self.values.execute_scalar(patch_idx, ctx)) + .transpose() + } + + /// Slice the patch set to `range`, returning `None` when no patches remain. + /// + /// The chunk offsets are rebased so the result is self-contained: no saturating adjustments + /// are carried forward, unlike [`Patches::slice`]. + pub fn slice(&self, range: Range, ctx: &mut ExecutionCtx) -> VortexResult> { + vortex_ensure!( + range.end <= self.array_len, + "PatchesV2 slice is out of bounds" + ); + if range.is_empty() { + return Ok(None); + } + let (locals, offsets) = self.canonical_parts(ctx)?; + let grid_start = self.offset + range.start; + let grid_end = self.offset + range.end; + let patch_start = search_local(&locals, &offsets, grid_start).to_index(); + let patch_end = search_local(&locals, &offsets, grid_end).to_index(); + if patch_start == patch_end { + return Ok(None); + } + + let chunk_start = grid_start / PATCH_CHUNK_SIZE; + let chunk_end = grid_end.div_ceil(PATCH_CHUNK_SIZE); + let rebased: Vec = (chunk_start..=chunk_end) + .map(|chunk_idx| { + let offset = usize::try_from(offsets[chunk_idx])?.clamp(patch_start, patch_end) + - patch_start; + Ok(u32::try_from(offset)?) + }) + .collect::>()?; + Ok(Some(unsafe { + Self::new_unchecked( + range.len(), + grid_start % PATCH_CHUNK_SIZE, + self.indices.slice(patch_start..patch_end)?, + self.values.slice(patch_start..patch_end)?, + PrimitiveArray::new(Buffer::from(rebased), Validity::NonNullable).into_array(), + ) + })) + } + + /// Canonicalize the index and chunk-offset children into typed buffers. + /// + /// Canonical children are borrowed without copying; encoded children are executed once. + fn canonical_parts(&self, ctx: &mut ExecutionCtx) -> VortexResult<(Buffer, Buffer)> { + let locals = self + .indices + .clone() + .execute::(ctx)? + .into_buffer::(); + let offsets = self + .chunk_offsets + .clone() + .execute::(ctx)? + .into_buffer::(); + Ok((locals, offsets)) + } +} + +/// The grid-local index range a chunk may address, honoring first- and last-chunk trims. +fn grid_range( + offset: usize, + array_len: usize, + chunk_idx: usize, + chunk_count: usize, +) -> Range { + let start = if chunk_idx == 0 { offset } else { 0 }; + let stop = if chunk_idx == chunk_count - 1 { + (offset + array_len) - chunk_idx * PATCH_CHUNK_SIZE + } else { + PATCH_CHUNK_SIZE + }; + start..stop +} + +/// Search the flat local-index buffer for grid position `grid`. +/// +/// Chunk selection is constant time via the offsets; the in-chunk search is a binary search +/// over at most [`PATCH_CHUNK_SIZE`] `u16` values. +fn search_local(locals: &[u16], offsets: &[u32], grid: usize) -> SearchResult { + let chunk_idx = grid / PATCH_CHUNK_SIZE; + if chunk_idx >= offsets.len() - 1 { + return SearchResult::NotFound(locals.len()); + } + let chunk = offsets[chunk_idx] as usize..offsets[chunk_idx + 1] as usize; + let local = + u16::try_from(grid % PATCH_CHUNK_SIZE).vortex_expect("chunk-local index fits in u16"); + match locals[chunk.clone()].binary_search(&local) { + Ok(idx) => SearchResult::Found(chunk.start + idx), + Err(idx) => SearchResult::NotFound(chunk.start + idx), + } +} + +#[cfg(test)] +mod tests { + use vortex_buffer::Buffer; + use vortex_buffer::buffer; + use vortex_error::VortexExpect; + use vortex_error::VortexResult; + + use super::*; + use crate::VortexSessionExecute; + use crate::array_session; + use crate::patches::Patches; + + fn test_patches(ctx: &mut ExecutionCtx) -> VortexResult { + // Patches at global rows 5, 100, 1023, 1024, 2050 in a 3000-row array. + let indices = + PrimitiveArray::new(buffer![5u64, 100, 1023, 1024, 2050], Validity::NonNullable); + let values = PrimitiveArray::new(buffer![50u64, 51, 52, 53, 54], Validity::NonNullable); + let global = Patches::new(3000, 0, indices.into_array(), values.into_array(), None)?; + PatchesV2::from_patches(&global, ctx) + } + + #[test] + fn from_global_roundtrip() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let patches = test_patches(&mut ctx)?; + assert_eq!(patches.num_patches(), 5); + assert_eq!(patches.offset(), 0); + + let back = patches.to_patches(&mut ctx)?; + let globals = back.indices().clone().execute::(&mut ctx)?; + assert_eq!(globals.as_slice::(), &[5, 100, 1023, 1024, 2050]); + Ok(()) + } + + #[test] + fn validates_components() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let patches = test_patches(&mut ctx)?; + PatchesV2::try_new( + patches.array_len(), + patches.offset(), + patches.indices().clone(), + patches.values().clone(), + patches.chunk_offsets().clone(), + &mut ctx, + )?; + + // Unsorted local indices within one chunk are rejected. + let unsorted = PatchesV2::try_new( + 3000, + 0, + PrimitiveArray::new(buffer![100u16, 5], Validity::NonNullable).into_array(), + PrimitiveArray::new(buffer![1u64, 2], Validity::NonNullable).into_array(), + PrimitiveArray::new(buffer![0u32, 2, 2, 2], Validity::NonNullable).into_array(), + &mut ctx, + ); + assert!(unsorted.is_err()); + Ok(()) + } + + #[test] + fn search_across_chunks() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let patches = test_patches(&mut ctx)?; + assert_eq!( + patches.search_index(1023, &mut ctx)?, + SearchResult::Found(2) + ); + assert_eq!( + patches.search_index(1024, &mut ctx)?, + SearchResult::Found(3) + ); + assert_eq!( + patches.search_index(1500, &mut ctx)?, + SearchResult::NotFound(4) + ); + assert_eq!( + patches.search_index(2999, &mut ctx)?, + SearchResult::NotFound(5) + ); + + let value = patches.get_patched(2050, &mut ctx)?; + assert_eq!(value, Some(Scalar::primitive(54u64, NonNullable))); + assert_eq!(patches.get_patched(2051, &mut ctx)?, None); + Ok(()) + } + + #[test] + fn slice_unaligned() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let patches = test_patches(&mut ctx)?; + + // Slice 100..2050 keeps rows 100, 1023, 1024 and drops 5 and 2050. + let sliced = patches + .slice(100..2050, &mut ctx)? + .expect("patches remain in slice"); + assert_eq!(sliced.array_len(), 1950); + assert_eq!(sliced.num_patches(), 3); + assert_eq!(sliced.offset(), 100); + assert_eq!(sliced.search_index(0, &mut ctx)?, SearchResult::Found(0)); + assert_eq!(sliced.search_index(923, &mut ctx)?, SearchResult::Found(1)); + assert_eq!(sliced.search_index(924, &mut ctx)?, SearchResult::Found(2)); + assert_eq!( + sliced.get_patched(924, &mut ctx)?, + Some(Scalar::primitive(53u64, NonNullable)) + ); + assert_eq!(sliced.get_patched(925, &mut ctx)?, None); + + // Slicing a slice rebases again. + let inner = sliced + .slice(900..1000, &mut ctx)? + .expect("patches remain in inner slice"); + assert_eq!(inner.num_patches(), 2); + assert_eq!(inner.search_index(23, &mut ctx)?, SearchResult::Found(0)); + assert_eq!(inner.search_index(24, &mut ctx)?, SearchResult::Found(1)); + + // A gap with no patches slices to None. + assert!(patches.slice(1100..2000, &mut ctx)?.is_none()); + Ok(()) + } +} From f7590da2bdecb0e41aa9b37ede29b41266b1af55 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 14:00:49 +0000 Subject: [PATCH 2/6] Bench PatchesV2 lookups and read canonical children in place Extend the patches_lookup benchmark with PatchesV2 variants of each search scenario. Give PatchesV2::search_index a downcast fast path that reads canonical index and chunk-offset children in place instead of executing them per query. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019BTTvqxz7t96Ebkef7vamk Signed-off-by: Claude --- vortex-array/benches/patches_lookup.rs | 64 ++++++++++++++++++++++++++ vortex-array/src/patches_v2.rs | 19 ++++++-- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/vortex-array/benches/patches_lookup.rs b/vortex-array/benches/patches_lookup.rs index 262e3144495..9043924ddcd 100644 --- a/vortex-array/benches/patches_lookup.rs +++ b/vortex-array/benches/patches_lookup.rs @@ -9,8 +9,11 @@ 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::patches::PATCH_CHUNK_SIZE; use vortex_array::patches::Patches; +use vortex_array::patches_v2::PatchesV2; use vortex_buffer::Buffer; fn main() { @@ -165,3 +168,64 @@ fn search_index_full_range_random(bencher: Bencher) { fn search_index_full_range_random_chunked(bencher: Bencher) { bench_search_index(bencher, full_range_patches(true), queries_full_range()); } + +fn patches_v2_from(patches: &Patches) -> PatchesV2 { + let mut ctx = array_session().create_execution_ctx(); + PatchesV2::from_patches(patches, &mut ctx).unwrap() +} + +fn bench_search_index_v2(bencher: Bencher, patches: PatchesV2, queries: Vec) { + let mut ctx = array_session().create_execution_ctx(); + bencher + .with_inputs(|| (&patches, &queries)) + .bench_local_refs(|(patches, queries)| { + for &q in queries.iter() { + divan::black_box(patches.search_index(q, &mut ctx).unwrap()); + } + }); +} + +#[divan::bench] +fn search_index_below_min_v2(bencher: Bencher) { + bench_search_index_v2( + bencher, + patches_v2_from(&narrow_band_patches(false)), + queries_below_min(), + ); +} + +#[divan::bench] +fn search_index_above_max_v2(bencher: Bencher) { + bench_search_index_v2( + bencher, + patches_v2_from(&narrow_band_patches(false)), + queries_above_max(), + ); +} + +#[divan::bench] +fn search_index_mixed_out_of_range_v2(bencher: Bencher) { + bench_search_index_v2( + bencher, + patches_v2_from(&narrow_band_patches(false)), + queries_mixed_out_of_range(), + ); +} + +#[divan::bench] +fn search_index_in_range_v2(bencher: Bencher) { + bench_search_index_v2( + bencher, + patches_v2_from(&narrow_band_patches(false)), + queries_in_range(), + ); +} + +#[divan::bench] +fn search_index_full_range_random_v2(bencher: Bencher) { + bench_search_index_v2( + bencher, + patches_v2_from(&full_range_patches(false)), + queries_full_range(), + ); +} diff --git a/vortex-array/src/patches_v2.rs b/vortex-array/src/patches_v2.rs index 91e5c3730e7..2e0aceef633 100644 --- a/vortex-array/src/patches_v2.rs +++ b/vortex-array/src/patches_v2.rs @@ -30,6 +30,7 @@ use vortex_error::vortex_ensure; use crate::ArrayRef; use crate::ExecutionCtx; use crate::IntoArray; +use crate::arrays::Primitive; use crate::arrays::PrimitiveArray; use crate::dtype::DType; use crate::dtype::Nullability::NonNullable; @@ -256,8 +257,19 @@ impl PatchesV2 { if index >= self.array_len { return Ok(SearchResult::NotFound(self.num_patches())); } + let grid = self.offset + index; + if let (Some(locals), Some(offsets)) = ( + self.indices.as_opt::(), + self.chunk_offsets.as_opt::(), + ) { + return Ok(search_local( + locals.as_slice::(), + offsets.as_slice::(), + grid, + )); + } let (locals, offsets) = self.canonical_parts(ctx)?; - Ok(search_local(&locals, &offsets, self.offset + index)) + Ok(search_local(&locals, &offsets, grid)) } /// Return the patch value at logical `index`, if one exists. @@ -313,9 +325,10 @@ impl PatchesV2 { })) } - /// Canonicalize the index and chunk-offset children into typed buffers. + /// Execute the index and chunk-offset children into typed buffers. /// - /// Canonical children are borrowed without copying; encoded children are executed once. + /// This is the slow path for encoded children; canonical children are read in place by the + /// callers' downcast fast paths. fn canonical_parts(&self, ctx: &mut ExecutionCtx) -> VortexResult<(Buffer, Buffer)> { let locals = self .indices From f04a0545d9def86a6e10795c0a7c54f47d11a2a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 08:14:46 +0000 Subject: [PATCH 3/6] Wire PatchesV2 into decompression and validate on TPC-H Give PatchesV2 a resolved borrowed view for repeated lookups, an apply_each scatter primitive with a chunk cursor, and route chunked BitPacked and FoR patch application through it during decompression, with a counter instrumenting the path. A vortex-btrblocks test compresses TPC-H lineitem, orders, and partsupp, decodes every column, and asserts the chunk-local scatter ran; an ignored variant runs the same validation at scale factor one. The patches_lookup benchmark gains apply and slice comparisons. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019BTTvqxz7t96Ebkef7vamk Signed-off-by: Claude --- .../bitpacking/array/bitpack_decompress.rs | 26 +++- .../fastlanes/src/bitpacking/array/mod.rs | 5 + vortex-array/benches/patches_lookup.rs | 52 +++++++ vortex-array/src/patches_v2.rs | 145 ++++++++++++++++-- vortex-btrblocks/src/trace_tests.rs | 86 +++++++++++ 5 files changed, 300 insertions(+), 14 deletions(-) diff --git a/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs b/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs index 692e7dcdd7f..1bff3911e9d 100644 --- a/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs +++ b/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs @@ -2,6 +2,8 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors use std::mem::MaybeUninit; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; use fastlanes::BitPacking; use itertools::Itertools; @@ -16,6 +18,7 @@ use vortex_array::dtype::NativePType; use vortex_array::match_each_integer_ptype; use vortex_array::match_each_unsigned_integer_ptype; use vortex_array::patches::Patches; +use vortex_array::patches_v2::PatchesV2; use vortex_array::scalar::Scalar; use vortex_error::VortexExpect; use vortex_error::VortexResult; @@ -142,11 +145,22 @@ pub(crate) fn apply_patches_to_uninit_range VortexResult<()> { assert_eq!(patches.array_len(), dst.len()); - let indices = patches.indices().clone().execute::(ctx)?; let values = patches.values().clone().execute::(ctx)?; assert!(values.all_valid(ctx)?, "Patch values must be all valid"); let values = values.as_slice::(); + // Chunked patch sets scatter through the chunk-local PatchesV2 form to exercise it on the + // real decompression path. The conversion is one pass over the (sparse) patch indices. + if patches.chunk_offsets().is_some() { + let v2 = PatchesV2::from_patches(patches, ctx)?; + v2.apply_each(ctx, |logical, ordinal| { + dst.set_value(logical, f(values[ordinal])); + })?; + PATCHES_V2_APPLIES.fetch_add(1, Ordering::Relaxed); + return Ok(()); + } + + let indices = patches.indices().clone().execute::(ctx)?; match_each_unsigned_integer_ptype!(indices.ptype(), |P| { for (index, &value) in indices.as_slice::

().iter().zip_eq(values) { dst.set_value( @@ -158,6 +172,16 @@ pub(crate) fn apply_patches_to_uninit_range u64 { + PATCHES_V2_APPLIES.load(Ordering::Relaxed) +} + pub fn unpack_single(array: ArrayView<'_, BitPacked>, index: usize) -> Scalar { let bit_width = array.bit_width() as usize; let ptype = array.dtype().as_ptype(); diff --git a/encodings/fastlanes/src/bitpacking/array/mod.rs b/encodings/fastlanes/src/bitpacking/array/mod.rs index 03fa3ed7f4c..da0b08b62dd 100644 --- a/encodings/fastlanes/src/bitpacking/array/mod.rs +++ b/encodings/fastlanes/src/bitpacking/array/mod.rs @@ -385,11 +385,16 @@ mod test { let packed_with_patches = BitPackedData::encode(&parray, 9, &mut ctx).unwrap(); assert!(packed_with_patches.patches().is_some()); + let applies_before = crate::bitpack_decompress::patches_v2_apply_count(); let packed_primitive = packed_with_patches .as_array() .clone() .execute::(&mut ctx) .unwrap(); + assert!( + crate::bitpack_decompress::patches_v2_apply_count() > applies_before, + "expected the chunk-local patch path" + ); assert_arrays_eq!( packed_primitive, PrimitiveArray::new(values, vortex_array::validity::Validity::NonNullable), diff --git a/vortex-array/benches/patches_lookup.rs b/vortex-array/benches/patches_lookup.rs index 9043924ddcd..19a2d1aaf08 100644 --- a/vortex-array/benches/patches_lookup.rs +++ b/vortex-array/benches/patches_lookup.rs @@ -229,3 +229,55 @@ fn search_index_full_range_random_v2(bencher: Bencher) { queries_full_range(), ); } + +fn bench_apply_v1(bencher: Bencher, patches: Patches) { + let mut ctx = array_session().create_execution_ctx(); + let indices = patches + .indices() + .clone() + .execute::(&mut ctx) + .unwrap(); + bencher + .with_inputs(|| vec![0i64; ARRAY_LEN]) + .bench_local_values(|mut dst| { + for &index in indices.as_slice::() { + dst[index as usize] = 1; + } + divan::black_box(dst); + }); +} + +fn bench_apply_v2(bencher: Bencher, patches: PatchesV2) { + let mut ctx = array_session().create_execution_ctx(); + bencher + .with_inputs(|| vec![0i64; ARRAY_LEN]) + .bench_local_values(|mut dst| { + patches + .apply_each(&mut ctx, |logical, _ordinal| dst[logical] = 1) + .unwrap(); + divan::black_box(dst); + }); +} + +#[divan::bench] +fn apply_full_range(bencher: Bencher) { + bench_apply_v1(bencher, full_range_patches(false)); +} + +#[divan::bench] +fn apply_full_range_v2(bencher: Bencher) { + bench_apply_v2(bencher, patches_v2_from(&full_range_patches(false))); +} + +#[divan::bench] +fn slice_unaligned(bencher: Bencher) { + let patches = full_range_patches(true); + bencher.bench(|| divan::black_box(patches.slice(1_000..900_000).unwrap())); +} + +#[divan::bench] +fn slice_unaligned_v2(bencher: Bencher) { + let patches = patches_v2_from(&full_range_patches(true)); + let mut ctx = array_session().create_execution_ctx(); + bencher.bench_local(|| divan::black_box(patches.slice(1_000..900_000, &mut ctx).unwrap())); +} diff --git a/vortex-array/src/patches_v2.rs b/vortex-array/src/patches_v2.rs index 2e0aceef633..b8c248f4d0c 100644 --- a/vortex-array/src/patches_v2.rs +++ b/vortex-array/src/patches_v2.rs @@ -28,6 +28,7 @@ use vortex_error::VortexResult; use vortex_error::vortex_ensure; use crate::ArrayRef; +use crate::ArrayView; use crate::ExecutionCtx; use crate::IntoArray; use crate::arrays::Primitive; @@ -254,22 +255,52 @@ impl PatchesV2 { /// Returns [`SearchResult::Found`] with the patch ordinal, or [`SearchResult::NotFound`] /// with the insertion point. pub fn search_index(&self, index: usize, ctx: &mut ExecutionCtx) -> VortexResult { + if let Some(view) = self.view() { + return Ok(view.search_index(index)); + } if index >= self.array_len { return Ok(SearchResult::NotFound(self.num_patches())); } - let grid = self.offset + index; - if let (Some(locals), Some(offsets)) = ( - self.indices.as_opt::(), - self.chunk_offsets.as_opt::(), - ) { - return Ok(search_local( - locals.as_slice::(), - offsets.as_slice::(), - grid, - )); + let (locals, offsets) = self.canonical_parts(ctx)?; + Ok(search_local(&locals, &offsets, self.offset + index)) + } + + /// Borrow a resolved view over canonical index and chunk-offset children. + /// + /// Returns `None` when either child is not a canonical primitive array. Hot loops should + /// resolve the view once and query it repeatedly; each call performs the downcasts. + pub fn view(&self) -> Option> { + let locals = self.indices.as_opt::()?; + let offsets = self.chunk_offsets.as_opt::()?; + Some(PatchesV2View { + locals, + offsets, + offset: self.offset, + array_len: self.array_len, + }) + } + + /// Visit every patch as `(logical_index, patch_ordinal)`, in patch order. + /// + /// This is the decompression primitive: callers scatter the canonicalized patch values over + /// a decoded buffer without materializing global indices. + pub fn apply_each( + &self, + ctx: &mut ExecutionCtx, + mut apply: impl FnMut(usize, usize), + ) -> VortexResult<()> { + if let Some(view) = self.view() { + apply_each_parts( + view.locals.as_slice::(), + view.offsets.as_slice::(), + self.offset, + &mut apply, + ); + return Ok(()); } let (locals, offsets) = self.canonical_parts(ctx)?; - Ok(search_local(&locals, &offsets, grid)) + apply_each_parts(&locals, &offsets, self.offset, &mut apply); + Ok(()) } /// Return the patch value at logical `index`, if one exists. @@ -344,6 +375,57 @@ impl PatchesV2 { } } +/// A resolved, borrowed view over a [`PatchesV2`] with canonical children. +/// +/// Constructed via [`PatchesV2::view`]; queries are plain slice reads with no dispatch, +/// allocation, or error paths, so this is the form hot loops should hold. +#[derive(Clone, Debug)] +pub struct PatchesV2View<'a> { + locals: ArrayView<'a, Primitive>, + offsets: ArrayView<'a, Primitive>, + offset: usize, + array_len: usize, +} + +impl PatchesV2View<'_> { + /// Search for a patch at logical `index`. + pub fn search_index(&self, index: usize) -> SearchResult { + if index >= self.array_len { + return SearchResult::NotFound(self.locals.len()); + } + search_local( + self.locals.as_slice::(), + self.offsets.as_slice::(), + self.offset + index, + ) + } + + /// Returns the patch ordinal at logical `index`, if one exists. + pub fn patch_ordinal(&self, index: usize) -> Option { + self.search_index(index).to_found() + } +} + +/// Walk every patch as `(logical_index, patch_ordinal)` from resolved parts. +fn apply_each_parts( + locals: &[u16], + offsets: &[u32], + offset: usize, + apply: &mut impl FnMut(usize, usize), +) { + // Walk patches with a chunk cursor so sparse patch sets skip empty chunks cheaply. + let mut chunk_idx = 0usize; + for (ordinal, &local) in locals.iter().enumerate() { + while offsets[chunk_idx + 1] as usize <= ordinal { + chunk_idx += 1; + } + apply( + chunk_idx * PATCH_CHUNK_SIZE + usize::from(local) - offset, + ordinal, + ); + } +} + /// The grid-local index range a chunk may address, honoring first- and last-chunk trims. fn grid_range( offset: usize, @@ -380,9 +462,7 @@ fn search_local(locals: &[u16], offsets: &[u32], grid: usize) -> SearchResult { #[cfg(test)] mod tests { - use vortex_buffer::Buffer; use vortex_buffer::buffer; - use vortex_error::VortexExpect; use vortex_error::VortexResult; use super::*; @@ -465,6 +545,45 @@ mod tests { Ok(()) } + #[test] + fn apply_each_visits_all_patches() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let patches = test_patches(&mut ctx)?; + let mut visited = Vec::new(); + patches.apply_each(&mut ctx, |logical, ordinal| { + visited.push((logical, ordinal)) + })?; + assert_eq!( + visited, + vec![(5, 0), (100, 1), (1023, 2), (1024, 3), (2050, 4)] + ); + + // A sliced patch set reports logical indices relative to the slice. + let sliced = patches + .slice(100..2050, &mut ctx)? + .expect("patches remain in slice"); + let mut visited = Vec::new(); + sliced.apply_each(&mut ctx, |logical, ordinal| { + visited.push((logical, ordinal)) + })?; + assert_eq!(visited, vec![(0, 0), (923, 1), (924, 2)]); + Ok(()) + } + + #[test] + fn view_matches_generic_search() -> VortexResult<()> { + let mut ctx = array_session().create_execution_ctx(); + let patches = test_patches(&mut ctx)?; + let view = patches.view().expect("canonical children"); + for index in [0, 5, 100, 1023, 1024, 1500, 2050, 2999, 5000] { + assert_eq!( + view.search_index(index), + patches.search_index(index, &mut ctx)? + ); + } + Ok(()) + } + #[test] fn slice_unaligned() -> VortexResult<()> { let mut ctx = array_session().create_execution_ctx(); diff --git a/vortex-btrblocks/src/trace_tests.rs b/vortex-btrblocks/src/trace_tests.rs index 07069f6309a..f3d47e0f8f2 100644 --- a/vortex-btrblocks/src/trace_tests.rs +++ b/vortex-btrblocks/src/trace_tests.rs @@ -468,3 +468,89 @@ fn trace_scan_take_on_compressed_table() -> VortexResult<()> { Ok(()) } + +/// Compress `batches` lineitem batches at `scale_factor` and decompress them, returning how many +/// scatters went through the chunk-local `PatchesV2` path. +fn count_patches_v2_applies(scale_factor: f64, batch_size: usize) -> VortexResult { + use tpchgen::generators::OrderGenerator; + use tpchgen::generators::PartSuppGenerator; + use tpchgen_arrow::OrderArrow; + use tpchgen_arrow::PartSuppArrow; + use vortex_fastlanes::bitpack_decompress::patches_v2_apply_count; + + let before = patches_v2_apply_count(); + let mut ctx = execution_ctx(); + let session = trace_session(); + let mut roundtrip = |batch: RecordBatch| -> VortexResult<()> { + let schema = batch.schema(); + let array = session.arrow().from_arrow_record_batch(batch, &schema)?; + let compressed = BtrBlocksCompressor::default().compress(&array, &mut ctx)?; + // A canonical struct keeps compressed children, so decode each column explicitly. + let columns: Vec = compressed + .as_::() + .iter_unmasked_fields() + .cloned() + .collect(); + for column in columns { + let decoded = column.execute::(&mut ctx)?.into_array(); + assert_eq!(decoded.len(), array.len()); + } + Ok(()) + }; + + let lineitem = LineItemGenerator::new_with_distributions_and_text_pool( + scale_factor, + 1, + 1, + Distributions::static_default(), + &TEXT_POOL, + ); + for batch in LineItemArrow::new(lineitem).with_batch_size(batch_size) { + roundtrip(batch)?; + } + let orders = OrderGenerator::new_with_distributions_and_text_pool( + scale_factor, + 1, + 1, + Distributions::static_default(), + &TEXT_POOL, + ); + for batch in OrderArrow::new(orders).with_batch_size(batch_size) { + roundtrip(batch)?; + } + let partsupp = PartSuppGenerator::new_with_text_pool(scale_factor, 1, 1, &TEXT_POOL); + for batch in PartSuppArrow::new(partsupp).with_batch_size(batch_size) { + roundtrip(batch)?; + } + Ok(patches_v2_apply_count() - before) +} + +/// Lineitem compression produces bit-packed columns with patches, and decompressing them goes +/// through the chunk-local `PatchesV2` scatter. +#[test] +fn lineitem_decompress_uses_patches_v2() -> VortexResult<()> { + let applies = count_patches_v2_applies(0.01, 1 << 14)?; + assert!( + applies > 0, + "expected decompression to scatter patches through PatchesV2" + ); + Ok(()) +} + +/// The full TPC-H scale-factor-1 lineitem validation. Run manually: +/// `TPCH_SF=1 cargo test --release -p vortex-btrblocks lineitem_decompress -- --ignored --nocapture` +#[test] +#[ignore = "generates and compresses six million lineitem rows"] +fn lineitem_sf1_decompress_uses_patches_v2() -> VortexResult<()> { + let scale_factor = std::env::var("TPCH_SF") + .ok() + .and_then(|sf| sf.parse().ok()) + .unwrap_or(1.0); + let applies = count_patches_v2_applies(scale_factor, 1 << 16)?; + println!("PatchesV2 scatters at SF {scale_factor}: {applies}"); + assert!( + applies > 0, + "expected decompression to scatter patches through PatchesV2" + ); + Ok(()) +} From e2d0e8566bd921ee8728c5166724710db768fdfd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 08:24:12 +0000 Subject: [PATCH 4/6] Make the PatchesV2 decompression scatter opt-in Converting Patches to PatchesV2 on every decompression allocates and walks the patch set on a hot path, regressing patched decompression benchmarks. Gate the chunk-local scatter behind force_patches_v2_scatter or VORTEX_PATCHES_V2_SCATTER=1 so the default path is unchanged; the fastlanes and TPC-H validation tests enable it explicitly. Zero-cost integration needs the stored layout to be chunk-local, which stays follow-up work. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019BTTvqxz7t96Ebkef7vamk Signed-off-by: Claude --- .../bitpacking/array/bitpack_decompress.rs | 26 ++++++++++++++++--- .../fastlanes/src/bitpacking/array/mod.rs | 1 + vortex-btrblocks/src/trace_tests.rs | 2 ++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs b/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs index 1bff3911e9d..f04a82fdb41 100644 --- a/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs +++ b/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs @@ -2,6 +2,8 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors use std::mem::MaybeUninit; +use std::sync::LazyLock; +use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; @@ -149,9 +151,11 @@ pub(crate) fn apply_patches_to_uninit_range(); - // Chunked patch sets scatter through the chunk-local PatchesV2 form to exercise it on the - // real decompression path. The conversion is one pass over the (sparse) patch indices. - if patches.chunk_offsets().is_some() { + // When enabled, chunked patch sets scatter through the chunk-local PatchesV2 form to + // exercise it on the real decompression path. Converting per decompression costs a pass and + // allocations over the patch set, so this stays opt-in until the stored layout is + // chunk-local; the default path below is unchanged. + if use_patches_v2_scatter() && patches.chunk_offsets().is_some() { let v2 = PatchesV2::from_patches(patches, ctx)?; v2.apply_each(ctx, |logical, ordinal| { dst.set_value(logical, f(values[ordinal])); @@ -173,6 +177,22 @@ pub(crate) fn apply_patches_to_uninit_range bool { + static FROM_ENV: LazyLock = + LazyLock::new(|| std::env::var("VORTEX_PATCHES_V2_SCATTER").is_ok_and(|v| v == "1")); + PATCHES_V2_SCATTER.load(Ordering::Relaxed) || *FROM_ENV +} + +/// Force the chunk-local patch scatter on or off for this process. +pub fn force_patches_v2_scatter(enabled: bool) { + PATCHES_V2_SCATTER.store(enabled, Ordering::Relaxed); +} /// The number of decompressions that scattered patches through [`PatchesV2`]. /// diff --git a/encodings/fastlanes/src/bitpacking/array/mod.rs b/encodings/fastlanes/src/bitpacking/array/mod.rs index da0b08b62dd..cac4b7d82d4 100644 --- a/encodings/fastlanes/src/bitpacking/array/mod.rs +++ b/encodings/fastlanes/src/bitpacking/array/mod.rs @@ -385,6 +385,7 @@ mod test { let packed_with_patches = BitPackedData::encode(&parray, 9, &mut ctx).unwrap(); assert!(packed_with_patches.patches().is_some()); + crate::bitpack_decompress::force_patches_v2_scatter(true); let applies_before = crate::bitpack_decompress::patches_v2_apply_count(); let packed_primitive = packed_with_patches .as_array() diff --git a/vortex-btrblocks/src/trace_tests.rs b/vortex-btrblocks/src/trace_tests.rs index f3d47e0f8f2..30c2cffb9bc 100644 --- a/vortex-btrblocks/src/trace_tests.rs +++ b/vortex-btrblocks/src/trace_tests.rs @@ -476,8 +476,10 @@ fn count_patches_v2_applies(scale_factor: f64, batch_size: usize) -> VortexResul use tpchgen::generators::PartSuppGenerator; use tpchgen_arrow::OrderArrow; use tpchgen_arrow::PartSuppArrow; + use vortex_fastlanes::bitpack_decompress::force_patches_v2_scatter; use vortex_fastlanes::bitpack_decompress::patches_v2_apply_count; + force_patches_v2_scatter(true); let before = patches_v2_apply_count(); let mut ctx = execution_ctx(); let session = trace_session(); From 2992f126d3f799c0b12a15cde7952dbbdf0e5753 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 08:58:30 +0000 Subject: [PATCH 5/6] Keep the PatchesV2 scatter branch out of the hot patch loop Inlining the opt-in chunk-local scatter into apply_patches_to_uninit_range degraded the default per-patch loop codegen, regressing patched decompression by twenty percent even with the toggle off. Move it to a cold never-inlined helper; the alp_for_bp_f64 decompress benchmark returns to its develop baseline. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019BTTvqxz7t96Ebkef7vamk Signed-off-by: Claude --- .../bitpacking/array/bitpack_decompress.rs | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs b/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs index f04a82fdb41..2802c1043fa 100644 --- a/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs +++ b/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs @@ -154,14 +154,10 @@ pub(crate) fn apply_patches_to_uninit_range(ctx)?; @@ -176,6 +172,24 @@ pub(crate) fn apply_patches_to_uninit_range T>( + dst: &mut UninitRange, + patches: &Patches, + values: &[S], + ctx: &mut ExecutionCtx, + f: F, +) -> VortexResult<()> { + let v2 = PatchesV2::from_patches(patches, ctx)?; + v2.apply_each(ctx, |logical, ordinal| { + dst.set_value(logical, f(values[ordinal])); + })?; + PATCHES_V2_APPLIES.fetch_add(1, Ordering::Relaxed); + Ok(()) +} + static PATCHES_V2_APPLIES: AtomicU64 = AtomicU64::new(0); static PATCHES_V2_SCATTER: AtomicBool = AtomicBool::new(false); From 0f5a98953b9584f868116451c19e8934218448c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 18:46:47 +0000 Subject: [PATCH 6/6] Enable the PatchesV2 scatter in SQL benchmark runs Set VORTEX_PATCHES_V2_SCATTER=1 in the benchmark job environment and log a one-time marker when the scatter first runs, so benchmark job logs prove the chunk-local patch path was exercised. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019BTTvqxz7t96Ebkef7vamk Signed-off-by: Claude --- .github/workflows/sql-bench-matrix.yml | 1 + encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/sql-bench-matrix.yml b/.github/workflows/sql-bench-matrix.yml index cae3630d6b8..a31dbaebb8b 100644 --- a/.github/workflows/sql-bench-matrix.yml +++ b/.github/workflows/sql-bench-matrix.yml @@ -102,6 +102,7 @@ jobs: timeout-minutes: 120 env: VORTEX_EXPERIMENTAL_PATCHED_ARRAY: "1" + VORTEX_PATCHES_V2_SCATTER: "1" FLAT_LAYOUT_INLINE_ARRAY_NODE: "1" # Makes python output nicer COLUMNS: 120 diff --git a/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs b/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs index 2802c1043fa..7a1f498b950 100644 --- a/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs +++ b/encodings/fastlanes/src/bitpacking/array/bitpack_decompress.rs @@ -182,6 +182,8 @@ fn apply_patches_v2 T>( ctx: &mut ExecutionCtx, f: F, ) -> VortexResult<()> { + static ANNOUNCE: std::sync::Once = std::sync::Once::new(); + ANNOUNCE.call_once(|| eprintln!("vortex: PatchesV2 decompression scatter active")); let v2 = PatchesV2::from_patches(patches, ctx)?; v2.apply_each(ctx, |logical, ordinal| { dst.set_value(logical, f(values[ordinal]));