Skip to content

Commit 01b289f

Browse files
committed
feat(fastlanes): put per-chunk bit widths behind a preview edition
Declare preview2026.09.0 with fastlanes.bitpacked_v2 and make it the default preview edition. Add BitPackingV2Scheme: it produces BitPacked arrays with a width per chunk and declares that its output needs the v2 format, so a writer whose editions do not permit v2 drops it and falls back to BitPackingScheme. While it is present BitPackingScheme defers to it, so FoR residuals and sampled estimates use per-chunk widths too. The scheme re-encodes the width table child through the cascade. The CUDA-compatible preset excludes it. The scheme returns the original array when half or more of the values would be patches. A few wide values otherwise pack at width 0 with nearly every value patched, which beats raw storage by a couple of buffer bytes and loses them back in footer metadata. Signed-off-by: Matt Katz <mhkatz97@gmail.com>
1 parent 09013df commit 01b289f

13 files changed

Lines changed: 314 additions & 62 deletions

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎vortex-btrblocks/src/builder.rs‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ pub const ALL_SCHEMES: &[&dyn Scheme] = &[
3434
// Per-chunk bit widths need the fastlanes.bitpacked_v2 format, so the writer keeps this
3535
// scheme only when an enabled edition permits it. Its presence switches BitPackingScheme
3636
// to per-chunk widths as well.
37-
#[cfg(feature = "unstable_encodings")]
3837
&integer::BitPackingV2Scheme,
3938
&integer::SparseScheme,
4039
&integer::IntDictScheme,
@@ -170,10 +169,6 @@ impl BtrBlocksCompressorBuilder {
170169
pub fn only_cuda_compatible(self) -> Self {
171170
// Keep FSST, which has a CUDA decoder and direct Arrow offset-based export. Other
172171
// string fragmentation and dictionary schemes still require unsupported decode paths.
173-
#[cfg_attr(
174-
not(any(feature = "pco", feature = "unstable_encodings")),
175-
allow(unused_mut)
176-
)]
177172
let mut excluded: Vec<SchemeId> = vec![
178173
integer::SparseScheme.id(),
179174
integer::IntRLEScheme.id(),
@@ -191,7 +186,6 @@ impl BtrBlocksCompressorBuilder {
191186
#[cfg(feature = "unstable_encodings")]
192187
excluded.push(integer::DeltaScheme::default().id());
193188
// CUDA bit-unpacking has no per-chunk width kernel yet.
194-
#[cfg(feature = "unstable_encodings")]
195189
excluded.push(integer::BitPackingV2Scheme.id());
196190
#[cfg(feature = "pco")]
197191
excluded.extend([integer::PcoScheme.id(), float::PcoScheme.id()]);
@@ -300,6 +294,25 @@ mod tests {
300294
}
301295
}
302296

297+
/// Per-chunk bit widths have no CUDA decode kernel yet, so the preset keeps only the
298+
/// single-width bit-packing scheme.
299+
#[test]
300+
fn cuda_compatible_excludes_per_chunk_bitpacking() {
301+
let builder = BtrBlocksCompressorBuilder::default().only_cuda_compatible();
302+
assert!(
303+
!builder
304+
.schemes
305+
.iter()
306+
.any(|s| s.id() == integer::BitPackingV2Scheme.id())
307+
);
308+
assert!(
309+
builder
310+
.schemes
311+
.iter()
312+
.any(|s| s.id() == integer::BitPackingScheme.id())
313+
);
314+
}
315+
303316
#[test]
304317
fn cuda_compatible_uses_fsst_for_strings() {
305318
let builder = BtrBlocksCompressorBuilder::default().only_cuda_compatible();

‎vortex-btrblocks/src/schemes/integer/bitpacking.rs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use crate::ArrayAndStats;
2525
use crate::CascadingCompressor;
2626
use crate::CompressorContext;
2727
use crate::Scheme;
28+
use crate::SchemeExt;
2829
use crate::compress_patches;
2930

3031
/// BitPacking encoding for non-negative integers.
@@ -66,11 +67,17 @@ impl Scheme for BitPackingScheme {
6667

6768
fn compress(
6869
&self,
69-
_compressor: &CascadingCompressor,
70+
compressor: &CascadingCompressor,
7071
data: &ArrayAndStats,
71-
_compress_ctx: CompressorContext,
72+
compress_ctx: CompressorContext,
7273
exec_ctx: &mut ExecutionCtx,
7374
) -> VortexResult<ArrayRef> {
75+
// Per-chunk widths are strictly better when the writer may serialize them; the v2 scheme
76+
// is present exactly then.
77+
if compressor.has_scheme(super::BitPackingV2Scheme.id()) {
78+
return super::BitPackingV2Scheme.compress(compressor, data, compress_ctx, exec_ctx);
79+
}
80+
7481
let primitive_array = data.array_as_primitive();
7582

7683
let histogram = bit_width_histogram(primitive_array, exec_ctx)?;
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! BitPacking with a bit width per FastLanes chunk.
5+
6+
use vortex_array::ArrayId;
7+
use vortex_array::ArrayRef;
8+
use vortex_array::Canonical;
9+
use vortex_array::ExecutionCtx;
10+
use vortex_array::IntoArray;
11+
use vortex_array::VTable;
12+
use vortex_compressor::scheme::CompressionEstimate;
13+
use vortex_compressor::scheme::DeferredEstimate;
14+
use vortex_compressor::scheme::EstimateVerdict;
15+
use vortex_error::VortexResult;
16+
use vortex_fastlanes::BitPacked;
17+
use vortex_fastlanes::BitPackedArray;
18+
use vortex_fastlanes::BitPackedArrayExt;
19+
use vortex_fastlanes::BitPackedArraySlotsExt;
20+
use vortex_fastlanes::BitPackedSlots;
21+
use vortex_fastlanes::bitpack_compress::bitpack_to_best_chunk_widths;
22+
use vortex_fastlanes::bitpacked_v2_id;
23+
24+
use crate::ArrayAndStats;
25+
use crate::CascadingCompressor;
26+
use crate::CompressorContext;
27+
use crate::Scheme;
28+
use crate::SchemeExt;
29+
use crate::compress_patches;
30+
31+
/// BitPacking for non-negative integers with a width chosen per 1024-element chunk.
32+
///
33+
/// The output is an ordinary [`BitPacked`] array, but one whose chunks may differ in width, which
34+
/// serializes as `fastlanes.bitpacked_v2`. The writer therefore keeps this scheme only when an
35+
/// enabled edition permits that format; while it is present, [`super::BitPackingScheme`] defers
36+
/// to it so bit-packed residuals get per-chunk widths too.
37+
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
38+
pub struct BitPackingV2Scheme;
39+
40+
impl Scheme for BitPackingV2Scheme {
41+
fn scheme_name(&self) -> &'static str {
42+
"vortex.int.bitpacking_v2"
43+
}
44+
45+
fn matches(&self, canonical: &Canonical) -> bool {
46+
canonical.dtype().is_int()
47+
}
48+
49+
fn produced_encodings(&self) -> Vec<ArrayId> {
50+
vec![BitPacked.id()]
51+
}
52+
53+
fn produced_serialized_ids(&self) -> Vec<ArrayId> {
54+
vec![bitpacked_v2_id()]
55+
}
56+
57+
fn expected_compression_ratio(
58+
&self,
59+
data: &ArrayAndStats,
60+
_compress_ctx: CompressorContext,
61+
exec_ctx: &mut ExecutionCtx,
62+
) -> CompressionEstimate {
63+
let stats = data.integer_stats(exec_ctx);
64+
65+
// BitPacking only works for non-negative values.
66+
if stats.erased().min_is_negative() {
67+
return CompressionEstimate::Verdict(EstimateVerdict::Skip);
68+
}
69+
70+
CompressionEstimate::Deferred(DeferredEstimate::Sample)
71+
}
72+
73+
fn compress(
74+
&self,
75+
compressor: &CascadingCompressor,
76+
data: &ArrayAndStats,
77+
compress_ctx: CompressorContext,
78+
exec_ctx: &mut ExecutionCtx,
79+
) -> VortexResult<ArrayRef> {
80+
let primitive_array = data.array_as_primitive().into_owned();
81+
let packed = bitpack_to_best_chunk_widths(&primitive_array, exec_ctx)?;
82+
83+
// If every chunk needs the full bit-width, return the original array.
84+
if packed.chunk_widths().uniform_width().map(usize::from)
85+
== Some(primitive_array.ptype().bit_width())
86+
{
87+
return Ok(primitive_array.into_array());
88+
}
89+
90+
let Some(patches) = packed.patches() else {
91+
return Ok(
92+
compress_width_table(compressor, packed, &compress_ctx, exec_ctx)?.into_array(),
93+
);
94+
};
95+
96+
// Mostly patches means the values were never really packed: the array is sparse in all
97+
// but name, and beats raw storage only by the bytes of the few values that did fit.
98+
if patches.num_patches() * 2 >= primitive_array.len() {
99+
return Ok(primitive_array.into_array());
100+
}
101+
102+
// Compress the patches and place them back into the array.
103+
let packed_stats = packed.statistics().to_owned();
104+
let ptype = packed.dtype().as_ptype();
105+
let mut parts = BitPacked::into_parts(packed);
106+
parts.patches = Some(compress_patches(patches, exec_ctx)?);
107+
let packed = BitPacked::try_new(
108+
parts.packed,
109+
ptype,
110+
parts.validity,
111+
parts.patches,
112+
parts.widths,
113+
parts.len,
114+
parts.offset,
115+
)?
116+
.with_stats_set(packed_stats);
117+
Ok(compress_width_table(compressor, packed, &compress_ctx, exec_ctx)?.into_array())
118+
}
119+
}
120+
121+
/// Re-encode the width table child through the compressor. Arrays whose chunks share one width
122+
/// have no table.
123+
fn compress_width_table(
124+
compressor: &CascadingCompressor,
125+
packed: BitPackedArray,
126+
compress_ctx: &CompressorContext,
127+
exec_ctx: &mut ExecutionCtx,
128+
) -> VortexResult<BitPackedArray> {
129+
let Some(table) = packed.width_table().cloned() else {
130+
return Ok(packed);
131+
};
132+
let compressed = compressor.compress_child(
133+
&table,
134+
compress_ctx,
135+
BitPackingV2Scheme.id(),
136+
BitPackedSlots::WIDTH_TABLE,
137+
exec_ctx,
138+
)?;
139+
BitPacked::with_width_table(packed, compressed)
140+
}

‎vortex-btrblocks/src/schemes/integer/mod.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! Integer compression schemes.
55
66
mod bitpacking;
7+
mod bitpacking_v2;
78
#[cfg(feature = "unstable_encodings")]
89
mod delta;
910
mod for_;
@@ -17,6 +18,7 @@ mod zigzag;
1718
mod pco;
1819

1920
pub use bitpacking::BitPackingScheme;
21+
pub use bitpacking_v2::BitPackingV2Scheme;
2022
#[cfg(feature = "unstable_encodings")]
2123
pub use delta::DeltaScheme;
2224
pub use for_::FoRScheme;

‎vortex-btrblocks/src/schemes/integer/scheme_selection_tests.rs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use vortex_array::IntoArray;
1313
use vortex_array::VortexSessionExecute;
1414
use vortex_array::arrays::Constant;
1515
use vortex_array::arrays::Dict;
16+
use vortex_array::arrays::Primitive;
1617
use vortex_array::arrays::PrimitiveArray;
1718
use vortex_array::expr::stats::Precision;
1819
use vortex_array::expr::stats::Stat;
@@ -273,3 +274,19 @@ fn has_nested_delta(array: &vortex_array::ArrayRef, under_delta: bool) -> bool {
273274
.iter()
274275
.any(|child| has_nested_delta(child, under_delta || is_delta))
275276
}
277+
278+
/// A few wide values pack at width 0 with nearly every value patched, which beats raw storage
279+
/// only by the bytes of the values that did fit. Such an array stays primitive.
280+
#[test]
281+
fn test_mostly_patched_stays_primitive() -> VortexResult<()> {
282+
let values: Vec<i64> = vec![0, 1 << 40, 1 << 41, 1 << 42, 1 << 43, 1 << 44];
283+
let array = PrimitiveArray::new(Buffer::copy_from(&values), Validity::NonNullable);
284+
let btr = BtrBlocksCompressor::default();
285+
let compressed = btr.compress(&array.into_array(), &mut SESSION.create_execution_ctx())?;
286+
assert!(
287+
compressed.is::<Primitive>(),
288+
"got {}",
289+
compressed.encoding_id()
290+
);
291+
Ok(())
292+
}

‎vortex-btrblocks/tests/snapshots/golden__default__int_monotone_jitter.snap‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ source: vortex-btrblocks/tests/golden.rs
33
expression: rendered
44
---
55
input: u64, len=16384, nbytes=131072
6-
root: fastlanes.for(u64, len=16384) nbytes=49152
6+
root: fastlanes.for(u64, len=16384) nbytes=47248
77
metadata: reference: 1700000001036u64
8-
encoded: fastlanes.bitpacked(u64, len=16384) nbytes=49152
9-
metadata: bit_width: 24, offset: 0
8+
encoded: fastlanes.bitpacked(u64, len=16384) nbytes=47248
9+
metadata: bit_widths: 16 chunks, max 24, offset: 0
10+
width_table: vortex.primitive(u8, len=16) nbytes=16
11+
metadata: ptype: u8

‎vortex-btrblocks/tests/snapshots/golden__default__list_of_int_runs.snap‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ source: vortex-btrblocks/tests/golden.rs
33
expression: rendered
44
---
55
input: list(i32), len=4066, nbytes=81804
6-
root: vortex.list(list(i32), len=4066) nbytes=11146
6+
root: vortex.list(list(i32), len=4066) nbytes=10944
77
metadata:
88
elements: vortex.runend(i32, len=16384) nbytes=3968
99
metadata: offset: 0
@@ -15,11 +15,13 @@ root: vortex.list(list(i32), len=4066) nbytes=11146
1515
metadata: reference: -49931i32
1616
encoded: fastlanes.bitpacked(i32, len=1020) nbytes=2176
1717
metadata: bit_width: 17, offset: 0
18-
offsets: fastlanes.bitpacked(u16, len=4067) nbytes=7178
19-
metadata: bit_width: 14, offset: 0
20-
patch_indices: vortex.primitive(u16, len=1) nbytes=2
18+
offsets: fastlanes.bitpacked(u16, len=4067) nbytes=6976
19+
metadata: bit_widths: 4 chunks, max 14, offset: 0
20+
patch_indices: vortex.primitive(u16, len=14) nbytes=28
21+
metadata: ptype: u16
22+
patch_values: vortex.primitive(u16, len=14) nbytes=28
2123
metadata: ptype: u16
22-
patch_values: vortex.constant(u16, len=1) nbytes=4
23-
metadata: scalar: 16384u16
2424
patch_chunk_offsets: vortex.primitive(u8, len=4) nbytes=4
2525
metadata: ptype: u8
26+
width_table: vortex.primitive(u8, len=4) nbytes=4
27+
metadata: ptype: u8

‎vortex-btrblocks/tests/snapshots/golden__default__string_fsst_structured.snap‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,21 @@ source: vortex-btrblocks/tests/golden.rs
33
expression: rendered
44
---
55
input: utf8, len=16384, nbytes=653785
6-
root: vortex.fsst(utf8, len=16384) nbytes=151382
6+
root: vortex.fsst(utf8, len=16384) nbytes=147198
77
metadata: len: 16384, nsymbols: 223
88
uncompressed_lengths: vortex.sparse(u8, len=16384) nbytes=3154
99
metadata: fill_value: 24u8
1010
patch_indices: vortex.primitive(u16, len=1575) nbytes=3150
1111
metadata: ptype: u16
1212
patch_values: vortex.constant(u8, len=1575) nbytes=2
1313
metadata: scalar: 23u8
14-
codes_offsets: fastlanes.bitpacked(u32, len=16385) nbytes=36992
15-
metadata: bit_width: 17, offset: 0
14+
codes_offsets: fastlanes.bitpacked(u32, len=16385) nbytes=32808
15+
metadata: bit_widths: 17 chunks, max 17, offset: 0
16+
patch_indices: vortex.primitive(u16, len=1) nbytes=2
17+
metadata: ptype: u16
18+
patch_values: vortex.constant(u32, len=1) nbytes=4
19+
metadata: scalar: 109229u32
20+
patch_chunk_offsets: vortex.primitive(u8, len=17) nbytes=17
21+
metadata: ptype: u8
22+
width_table: vortex.primitive(u8, len=17) nbytes=17
23+
metadata: ptype: u8

‎vortex-btrblocks/tests/snapshots/golden__default__temporal_timestamp_micros.snap‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ source: vortex-btrblocks/tests/golden.rs
33
expression: rendered
44
---
55
input: vortex.timestamp[µs, tz=UTC](i64), len=16384, nbytes=131072
6-
root: vortex.ext(vortex.timestamp[µs, tz=UTC](i64), len=16384) nbytes=67584
6+
root: vortex.ext(vortex.timestamp[µs, tz=UTC](i64), len=16384) nbytes=65680
77
metadata:
8-
storage: fastlanes.for(i64, len=16384) nbytes=67584
8+
storage: fastlanes.for(i64, len=16384) nbytes=65680
99
metadata: reference: 1700000000891673i64
10-
encoded: fastlanes.bitpacked(i64, len=16384) nbytes=67584
11-
metadata: bit_width: 33, offset: 0
10+
encoded: fastlanes.bitpacked(i64, len=16384) nbytes=65680
11+
metadata: bit_widths: 16 chunks, max 33, offset: 0
12+
width_table: vortex.primitive(u8, len=16) nbytes=16
13+
metadata: ptype: u8

0 commit comments

Comments
 (0)