Skip to content

Commit 355e292

Browse files
committed
feat: add bitpacked chunk offset child
Signed-off-by: "Matt Katz" <mhkatz97@gmail.com>
1 parent bcd5b9c commit 355e292

31 files changed

Lines changed: 347 additions & 90 deletions

‎encodings/fastlanes/benches/bitpack_compare.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ fn page_aligned(array: BitPackedArray) -> BitPackedArray {
6060
parts.validity,
6161
parts.patches,
6262
parts.widths,
63+
parts.chunk_offsets,
6364
parts.len,
6465
parts.offset,
6566
)

‎encodings/fastlanes/benches/bitpack_compare_sweep.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ fn page_aligned(array: BitPackedArray) -> BitPackedArray {
8686
parts.validity,
8787
parts.patches,
8888
parts.widths,
89+
parts.chunk_offsets,
8990
parts.len,
9091
parts.offset,
9192
)

‎encodings/fastlanes/src/bitpacking/array/bitpack_compress.rs‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,15 @@ pub fn bitpack_encode(
8181
.transpose()?
8282
.flatten();
8383

84+
let widths = ChunkWidths::uniform(bit_width, array.len().div_ceil(FL_CHUNK_SIZE));
85+
let offsets = widths.offsets_array();
8486
let bitpacked = BitPacked::try_new(
8587
BufferHandle::new_host(packed),
8688
array.ptype(),
8789
array.validity()?,
8890
patches,
89-
ChunkWidths::uniform(bit_width, array.len().div_ceil(FL_CHUNK_SIZE)).into_array(),
91+
widths.into_array(),
92+
offsets,
9093
array.len(),
9194
0,
9295
)?;
@@ -110,12 +113,15 @@ pub unsafe fn bitpack_encode_unchecked(
110113
let packed = unsafe { bitpack_unchecked(&array, bit_width) };
111114

112115
let arr_ref = array.clone().into_array();
116+
let widths = ChunkWidths::uniform(bit_width, array.len().div_ceil(FL_CHUNK_SIZE));
117+
let offsets = widths.offsets_array();
113118
let bitpacked = BitPacked::try_new(
114119
BufferHandle::new_host(packed),
115120
array.ptype(),
116121
array.validity()?,
117122
None,
118-
ChunkWidths::uniform(bit_width, array.len().div_ceil(FL_CHUNK_SIZE)).into_array(),
123+
widths.into_array(),
124+
offsets,
119125
array.len(),
120126
0,
121127
)

‎encodings/fastlanes/src/bitpacking/array/mod.rs‎

Lines changed: 128 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ pub const fn chunk_packed_bytes(bit_width: u8) -> usize {
5151
}
5252

5353
/// Chunk widths and byte offsets used while encoding or executing bit-packed data.
54-
/// Operations use this view to address each packed chunk.
54+
/// Execution borrows the materialized children; only encoding computes prefix sums.
5555
#[derive(Clone, Debug)]
5656
pub struct ChunkWidths {
5757
widths: Widths,
@@ -238,6 +238,10 @@ pub struct BitPackedSlots {
238238
/// One non-nullable `u8` width per 1024-element chunk. Uniform widths use a constant array.
239239
#[slot(4)]
240240
pub width_table: ArrayRef,
241+
/// Non-nullable `u64` byte boundaries, with one trailing entry after the last chunk.
242+
/// The first offset is the origin of the packed buffer and may be nonzero after slicing.
243+
#[slot(5)]
244+
pub chunk_offsets: ArrayRef,
241245
}
242246

243247
pub(crate) const PATCH_SLOTS: PatchSlotIndices = PatchSlotIndices {
@@ -249,6 +253,9 @@ pub(crate) const PATCH_SLOTS: PatchSlotIndices = PatchSlotIndices {
249253
/// The dtype of the width table child: one byte per chunk.
250254
pub(crate) const WIDTH_TABLE_DTYPE: DType = DType::Primitive(PType::U8, Nullability::NonNullable);
251255

256+
pub(crate) const CHUNK_OFFSETS_DTYPE: DType =
257+
DType::Primitive(PType::U64, Nullability::NonNullable);
258+
252259
impl IntoArray for ChunkWidths {
253260
fn into_array(self) -> ArrayRef {
254261
if self.is_uniform() {
@@ -259,23 +266,34 @@ impl IntoArray for ChunkWidths {
259266
}
260267
}
261268

262-
/// Read the width child without executing it during reduction.
263-
pub(crate) fn materialized_widths(table: &ArrayRef) -> VortexResult<Option<ChunkWidths>> {
264-
if let Some(constant) = table.as_opt::<Constant>() {
265-
return Ok(Some(ChunkWidths::uniform(
266-
u8::try_from(constant.scalar())?,
267-
table.len(),
268-
)));
269-
}
270-
Ok(table
269+
/// Read materialized children without executing them during reduction.
270+
pub(crate) fn materialized_widths(
271+
table: &ArrayRef,
272+
offsets: &ArrayRef,
273+
) -> VortexResult<Option<ChunkWidths>> {
274+
let widths = if let Some(constant) = table.as_opt::<Constant>() {
275+
Widths::Uniform {
276+
width: u8::try_from(constant.scalar())?,
277+
len: table.len(),
278+
}
279+
} else if let Some(primitive) = table
280+
.as_opt::<Primitive>()
281+
.filter(|a| a.buffer_handle().is_on_host())
282+
{
283+
Widths::PerChunk(primitive.to_buffer::<u8>())
284+
} else {
285+
return Ok(None);
286+
};
287+
Ok(offsets
271288
.as_opt::<Primitive>()
272289
.filter(|a| a.buffer_handle().is_on_host())
273-
.map(|a| ChunkWidths::new(a.to_buffer::<u8>())))
290+
.map(|a| ChunkWidths::from_buffers(widths, a.to_buffer::<u64>())))
274291
}
275292

276293
pub struct BitPackedDataParts {
277294
pub offset: u16,
278295
pub widths: ArrayRef,
296+
pub chunk_offsets: ArrayRef,
279297
pub len: usize,
280298
pub packed: BufferHandle,
281299
pub patches: Option<Patches>,
@@ -324,9 +342,11 @@ impl BitPackedData {
324342
/// * `validity` must have `length` len
325343
/// * Any patches must have any `array_len` equal to `length`
326344
/// * The width-table child must hold one non-nullable `u8` per chunk.
345+
/// * The offsets child must hold `num_chunks + 1` non-nullable `u64` byte boundaries.
327346
///
328347
/// Once the widths are materialized, they must be no wider than `ptype`, and the packed
329-
/// buffer must be exactly the sum of the chunks' packed sizes. Compressed children are checked at execution time, before unpacking.
348+
/// buffer must be exactly the sum of the chunks' packed sizes. Offset differences must
349+
/// match the widths. Compressed children are checked at execution time, before unpacking.
330350
///
331351
/// Any violation of these preconditions will result in an error.
332352
pub fn try_new(
@@ -352,6 +372,7 @@ impl BitPackedData {
352372
validity: &Validity,
353373
patches: Option<&Patches>,
354374
table: &ArrayRef,
375+
offsets: &ArrayRef,
355376
length: usize,
356377
) -> VortexResult<()> {
357378
vortex_ensure!(ptype.is_int(), MismatchedTypes: "integer", ptype);
@@ -378,8 +399,19 @@ impl BitPackedData {
378399
"Expected {num_chunks} chunk widths, got {}",
379400
table.len()
380401
);
402+
vortex_ensure!(
403+
offsets.dtype() == &CHUNK_OFFSETS_DTYPE,
404+
"BitPacked chunk offsets must be {CHUNK_OFFSETS_DTYPE}, got {}",
405+
offsets.dtype()
406+
);
407+
vortex_ensure!(
408+
offsets.len() == num_chunks + 1,
409+
"Expected {} chunk offsets, got {}",
410+
num_chunks + 1,
411+
offsets.len()
412+
);
381413
// Compressed children are checked once materialized, before any unchecked unpacking.
382-
if let Some(widths) = materialized_widths(table)? {
414+
if let Some(widths) = materialized_widths(table, offsets)? {
383415
Self::validate_widths(&self.packed, ptype, &widths)?;
384416
}
385417
Ok(())
@@ -504,24 +536,40 @@ pub trait BitPackedArrayExt: BitPackedArraySlotsExt {
504536
BitPackedData::packed(self)
505537
}
506538

507-
/// Prepare and validate the width child once for a bulk operation.
539+
/// Prepare and validate both children once for a bulk operation, without computing prefix sums.
508540
fn chunk_widths(&self, ctx: &mut ExecutionCtx) -> VortexResult<ChunkWidths> {
509-
let widths = match materialized_widths(self.width_table())? {
541+
let widths = match materialized_widths(self.width_table(), self.chunk_offsets())? {
510542
Some(widths) => widths,
511-
None => ChunkWidths::new(
512-
self.width_table()
543+
None => {
544+
let table = self.width_table();
545+
let widths = if let Some(constant) = table.as_opt::<Constant>() {
546+
Widths::Uniform {
547+
width: u8::try_from(constant.scalar())?,
548+
len: table.len(),
549+
}
550+
} else {
551+
Widths::PerChunk(
552+
table
553+
.clone()
554+
.execute::<PrimitiveArray>(ctx)?
555+
.to_buffer::<u8>(),
556+
)
557+
};
558+
let offsets = self
559+
.chunk_offsets()
513560
.clone()
514561
.execute::<PrimitiveArray>(ctx)?
515-
.to_buffer::<u8>(),
516-
),
562+
.to_buffer::<u64>();
563+
ChunkWidths::from_buffers(widths, offsets)
564+
}
517565
};
518566
BitPackedData::validate_widths(self.packed(), self.as_ref().dtype().as_ptype(), &widths)?;
519567
Ok(widths)
520568
}
521569

522-
/// Read and validate widths only when the child is already materialized.
570+
/// Read and validate widths and offsets only when their children are already materialized.
523571
fn materialized_chunk_widths(&self) -> VortexResult<Option<ChunkWidths>> {
524-
let widths = materialized_widths(self.width_table())?;
572+
let widths = materialized_widths(self.width_table(), self.chunk_offsets())?;
525573
if let Some(widths) = &widths {
526574
BitPackedData::validate_widths(
527575
self.packed(),
@@ -532,18 +580,70 @@ pub trait BitPackedArrayExt: BitPackedArraySlotsExt {
532580
Ok(widths)
533581
}
534582

535-
/// Locate and validate one chunk using the width table.
583+
/// Read one byte boundary without executing the entire offsets child.
584+
fn chunk_byte_offset(&self, boundary: usize, ctx: &mut ExecutionCtx) -> VortexResult<u64> {
585+
vortex_ensure!(
586+
boundary < self.chunk_offsets().len(),
587+
"Chunk boundary out of bounds"
588+
);
589+
if let Some(offsets) = self
590+
.chunk_offsets()
591+
.as_opt::<Primitive>()
592+
.filter(|a| a.buffer_handle().is_on_host())
593+
{
594+
Ok(offsets.as_slice::<u64>()[boundary])
595+
} else {
596+
u64::try_from(&self.chunk_offsets().execute_scalar(boundary, ctx)?)
597+
}
598+
}
599+
600+
/// Locate and validate one chunk using scalar child access, without materializing the tables.
536601
fn chunk_range(
537602
&self,
538603
chunk: usize,
539604
ctx: &mut ExecutionCtx,
540605
) -> VortexResult<(Range<usize>, u8)> {
541-
let widths = self.chunk_widths(ctx)?;
542-
vortex_ensure!(chunk < widths.len(), "Chunk index out of bounds");
543-
Ok((
544-
widths.byte_offset(chunk)..widths.byte_offset(chunk + 1),
545-
widths.width(chunk),
546-
))
606+
vortex_ensure!(
607+
chunk < self.width_table().len(),
608+
"Chunk index out of bounds"
609+
);
610+
let width = if let Some(table) = self
611+
.width_table()
612+
.as_opt::<Primitive>()
613+
.filter(|a| a.buffer_handle().is_on_host())
614+
{
615+
table.as_slice::<u8>()[chunk]
616+
} else if let Some(table) = self.width_table().as_opt::<Constant>() {
617+
u8::try_from(table.scalar())?
618+
} else {
619+
u8::try_from(&self.width_table().execute_scalar(chunk, ctx)?)?
620+
};
621+
vortex_ensure!(
622+
width as usize <= self.as_ref().dtype().as_ptype().bit_width(),
623+
"Unsupported bit width {width}"
624+
);
625+
let base = self.chunk_byte_offset(0, ctx)?;
626+
let start = if chunk == 0 {
627+
base
628+
} else {
629+
self.chunk_byte_offset(chunk, ctx)?
630+
};
631+
let end = self.chunk_byte_offset(chunk + 1, ctx)?;
632+
vortex_ensure!(
633+
end.checked_sub(start) == Some(chunk_packed_bytes(width) as u64),
634+
"Chunk {chunk} offsets do not match its bit width"
635+
);
636+
let start = start
637+
.checked_sub(base)
638+
.ok_or_else(|| vortex_err!("Chunk offset precedes buffer origin"))?;
639+
let end = end
640+
.checked_sub(base)
641+
.ok_or_else(|| vortex_err!("Chunk offset precedes buffer origin"))?;
642+
vortex_ensure!(
643+
start % (FL_CHUNK_SIZE / 8) as u64 == 0 && end <= self.packed().len() as u64,
644+
"Chunk offsets are unaligned or exceed the packed buffer"
645+
);
646+
Ok((usize::try_from(start)?..usize::try_from(end)?, width))
547647
}
548648

549649
#[inline]

‎encodings/fastlanes/src/bitpacking/chunk_widths_tests.rs‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@
55
66
use std::sync::LazyLock;
77

8+
use rstest::rstest;
89
use vortex_array::IntoArray;
910
use vortex_array::VortexSessionExecute;
1011
use vortex_array::arrays::Constant;
1112
use vortex_array::arrays::PrimitiveArray;
13+
use vortex_array::arrays::slice::SliceKernel;
1214
use vortex_array::assert_arrays_eq;
15+
use vortex_array::scalar::Scalar;
16+
use vortex_buffer::Buffer;
17+
use vortex_buffer::buffer;
1318
use vortex_error::VortexResult;
1419
use vortex_session::VortexSession;
1520

@@ -51,6 +56,54 @@ fn encode(values: &[u32]) -> VortexResult<BitPackedArray> {
5156
bitpack_to_best_bit_width(&PrimitiveArray::from_iter(values.iter().copied()), &mut ctx)
5257
}
5358

59+
#[rstest]
60+
#[case::decreasing(buffer![0u64, 128, 256, 128])]
61+
#[case::wrong_width(buffer![0u64, 128, 256, 385])]
62+
#[case::out_of_bounds(buffer![0u64, 128, 256, u64::MAX])]
63+
fn invalid_offsets_rejected_before_unpacking(#[case] offsets: Buffer<u64>) -> VortexResult<()> {
64+
let mut ctx = SESSION.create_execution_ctx();
65+
let values = PrimitiveArray::from_iter((0..3072u32).map(|i| i % 2));
66+
let packed = bitpack_to_best_bit_width(&values, &mut ctx)?;
67+
let widths = packed.width_table().clone();
68+
let offsets = offsets.into_array();
69+
assert!(BitPacked::with_chunk_layout(packed.clone(), widths.clone(), offsets.clone()).is_err());
70+
let offsets = offsets.execute::<PrimitiveArray>(&mut ctx)?;
71+
let offsets = bitpack_to_best_bit_width(&offsets, &mut ctx)?.into_array();
72+
let packed = BitPacked::with_chunk_layout(packed, widths, offsets)?.into_array();
73+
// An isolated scalar checks only its own chunk; bulk unpacking validates the whole layout.
74+
assert_eq!(packed.execute_scalar(1, &mut ctx)?, Scalar::from(1u32));
75+
assert!(packed.execute_scalar(2048, &mut ctx).is_err());
76+
assert!(packed.execute::<PrimitiveArray>(&mut ctx).is_err());
77+
Ok(())
78+
}
79+
80+
#[test]
81+
fn slice_rejects_unaligned_offsets() -> VortexResult<()> {
82+
let mut ctx = SESSION.create_execution_ctx();
83+
let values = PrimitiveArray::from_iter((0..3072u32).map(|i| i % 2));
84+
let packed = bitpack_to_best_bit_width(&values, &mut ctx)?;
85+
let widths = packed.width_table().clone();
86+
let offsets = PrimitiveArray::from_iter([0u64, 127, 255, 383]);
87+
let offsets = bitpack_to_best_bit_width(&offsets, &mut ctx)?.into_array();
88+
let packed = BitPacked::with_chunk_layout(packed, widths, offsets)?;
89+
assert!(<BitPacked as SliceKernel>::slice(packed.as_view(), 1024..2048, &mut ctx).is_err());
90+
Ok(())
91+
}
92+
93+
#[test]
94+
fn offset_child_shape_is_validated() -> VortexResult<()> {
95+
let packed = encode(&varied(100))?;
96+
let widths = packed.width_table().clone();
97+
assert!(
98+
BitPacked::with_chunk_layout(packed.clone(), widths.clone(), buffer![0u64].into_array())
99+
.is_err()
100+
);
101+
let wrong_dtype =
102+
PrimitiveArray::from_iter(vec![0u32; packed.chunk_offsets().len()]).into_array();
103+
assert!(BitPacked::with_chunk_layout(packed, widths, wrong_dtype).is_err());
104+
Ok(())
105+
}
106+
54107
/// Every array carries a non-nullable `u8` width per chunk, including uniform arrays.
55108
#[test]
56109
fn width_table_is_validated() -> VortexResult<()> {

‎encodings/fastlanes/src/bitpacking/compute/cast.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ fn build_with_validity(
4646
.map(|patches| patches.map_values(|values| values.cast(dtype.clone())))
4747
.transpose()?,
4848
array.width_table().clone(),
49+
array.chunk_offsets().clone(),
4950
array.len(),
5051
array.offset(),
5152
)?

0 commit comments

Comments
 (0)