Skip to content

Commit 2de8a74

Browse files
committed
FSST: decompress straight into the builder's byte storage
`append_to_builder` staged the decompressed heap in a temporary `ByteBufferMut` and then copied it into the builder. `FsstDecodePlan` hoists the parts of `fsst_decode_bytes` that describe the decode — the code stream, the per-row lengths and the total decoded size — so the decompression can target the builder's spare capacity directly via `append_decoded`, leaving one prefix sum over the lengths as the only work beyond the bulk `decompress_into`. `FSST_DECODE_SLACK` names the 7 spare bytes that keep `decompress_into` on its wide-store path through the final value; it is a performance knob, not a safety requirement. Signed-off-by: Robert Kruszewski <robert@spiraldb.com>
1 parent b980c09 commit 2de8a74

2 files changed

Lines changed: 96 additions & 37 deletions

File tree

‎encodings/fsst/src/array.rs‎

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::fmt::Debug;
55
use std::fmt::Display;
66
use std::fmt::Formatter;
77
use std::hash::Hasher;
8+
use std::mem::MaybeUninit;
89
use std::sync::Arc;
910
use std::sync::OnceLock;
1011

@@ -60,8 +61,9 @@ use vortex_error::vortex_panic;
6061
use vortex_session::VortexSession;
6162
use vortex_session::registry::CachedId;
6263

64+
use crate::canonical::FSST_DECODE_SLACK;
65+
use crate::canonical::FsstDecodePlan;
6366
use crate::canonical::canonicalize_fsst;
64-
use crate::canonical::fsst_decode_bytes;
6567
use crate::canonical::fsst_decode_views;
6668
use crate::rules::RULES;
6769

@@ -350,7 +352,10 @@ impl VTable for FSST {
350352
}
351353
}
352354

353-
/// Decompresses the values and appends them to `builder`.
355+
/// Decompresses the code stream straight into `builder`'s byte storage.
356+
///
357+
/// The offsets are the running sum of the uncompressed lengths the array already stores, so the
358+
/// only work beyond the bulk `decompress_into` is one prefix sum over them.
354359
fn append_to_varbin<O: IntegerPType>(
355360
array: ArrayView<'_, FSST>,
356361
builder: &mut VarBinBuilder<O>,
@@ -359,20 +364,26 @@ fn append_to_varbin<O: IntegerPType>(
359364
where
360365
usize: AsPrimitive<O>,
361366
{
362-
let (bytes, lengths) = fsst_decode_bytes(array, ctx)?;
367+
let plan = FsstDecodePlan::new(array, ctx)?;
363368
let validity = array
364369
.array()
365370
.validity()?
366371
.execute_mask(array.array().len(), ctx)?;
367-
match_each_integer_ptype!(lengths.ptype(), |P| {
368-
builder.append_values(
369-
bytes.as_slice(),
370-
lengths.as_slice::<P>().iter().scan(0usize, |end, length| {
371-
*end += AsPrimitive::<usize>::as_(*length);
372-
Some(*end)
373-
}),
374-
&validity,
375-
)
372+
let decompressor = array.decompressor();
373+
// Built once, outside the ptype match: the decoder is `#[inline(always)]`, so creating the
374+
// closure inside each arm would stamp out a copy of the whole decode loop per length type.
375+
let mut decode = |out: &mut [MaybeUninit<u8>]| plan.decode_into(&decompressor, out);
376+
match_each_integer_ptype!(plan.lengths.ptype(), |P| {
377+
// SAFETY: `decode_into` initializes exactly the prefix whose length it returns.
378+
unsafe {
379+
builder.append_decoded(
380+
plan.total_size,
381+
FSST_DECODE_SLACK,
382+
plan.lengths.as_slice::<P>(),
383+
&validity,
384+
&mut decode,
385+
)
386+
}
376387
})
377388
}
378389

‎encodings/fsst/src/canonical.rs‎

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
// SPDX-License-Identifier: Apache-2.0
22
// SPDX-FileCopyrightText: Copyright the Vortex contributors
33

4+
use std::mem::MaybeUninit;
45
use std::sync::Arc;
56

7+
use fsst::Decompressor;
68
use vortex_array::ArrayRef;
79
use vortex_array::ArrayView;
810
use vortex_array::ExecutionCtx;
@@ -42,36 +44,82 @@ pub(super) fn canonicalize_fsst(
4244
})
4345
}
4446

47+
/// Extra headroom that keeps [`Decompressor::decompress_into`] on its fast path.
48+
///
49+
/// It never writes past the slice it is given — every store is bounded by the end of the output —
50+
/// but it emits whole 8-byte symbols, so it can only use the wide-store loop while at least 8
51+
/// bytes remain. Handing it 7 spare bytes lets that loop run through the final value instead of
52+
/// finishing byte at a time. This is a performance knob, not a safety requirement.
53+
///
54+
/// [`Decompressor::decompress_into`]: fsst::Decompressor::decompress_into
55+
pub(crate) const FSST_DECODE_SLACK: usize = 7;
56+
57+
/// Everything needed to decode an FSST array's values in one bulk `decompress_into` call.
58+
pub(crate) struct FsstDecodePlan {
59+
codes: ByteBuffer,
60+
/// Per-row uncompressed lengths, zero for null rows.
61+
pub(crate) lengths: PrimitiveArray,
62+
/// Total decoded size, i.e. the sum of `lengths`.
63+
pub(crate) total_size: usize,
64+
}
65+
66+
impl FsstDecodePlan {
67+
pub(crate) fn new(
68+
fsst_array: ArrayView<'_, FSST>,
69+
ctx: &mut ExecutionCtx,
70+
) -> VortexResult<Self> {
71+
let codes = fsst_array.codes().sliced_bytes();
72+
let lengths = fsst_array
73+
.uncompressed_lengths()
74+
.clone()
75+
.execute::<PrimitiveArray>(ctx)?;
76+
77+
#[expect(clippy::cast_possible_truncation)]
78+
let total_size: usize = match_each_integer_ptype!(lengths.ptype(), |P| {
79+
lengths.as_slice::<P>().iter().map(|x| *x as usize).sum()
80+
});
81+
82+
Ok(Self {
83+
codes,
84+
lengths,
85+
total_size,
86+
})
87+
}
88+
89+
/// Bulk-decompresses the whole code stream into `out`, which must hold at least
90+
/// `total_size + FSST_DECODE_SLACK` bytes.
91+
///
92+
/// Kept inlinable so the decoder is not called from behind an extra frame in whichever
93+
/// codegen unit the caller lands in; see OnPair's equivalent for why that matters.
94+
#[inline]
95+
pub(crate) fn decode_into(
96+
&self,
97+
decompressor: &Decompressor<'_>,
98+
out: &mut [MaybeUninit<u8>],
99+
) -> VortexResult<usize> {
100+
let len = decompressor.decompress_into(self.codes.as_slice(), out);
101+
vortex_ensure!(
102+
len == self.total_size,
103+
"FSST decoded {len} bytes, expected {}",
104+
self.total_size
105+
);
106+
Ok(len)
107+
}
108+
}
109+
45110
pub(crate) fn fsst_decode_bytes(
46111
fsst_array: ArrayView<'_, FSST>,
47112
ctx: &mut ExecutionCtx,
48113
) -> VortexResult<(ByteBufferMut, PrimitiveArray)> {
49-
let bytes = fsst_array.codes().sliced_bytes();
50-
let uncompressed_lens_array = fsst_array
51-
.uncompressed_lengths()
52-
.clone()
53-
.execute::<PrimitiveArray>(ctx)?;
54-
55-
#[expect(clippy::cast_possible_truncation)]
56-
let total_size: usize = match_each_integer_ptype!(uncompressed_lens_array.ptype(), |P| {
57-
uncompressed_lens_array
58-
.as_slice::<P>()
59-
.iter()
60-
.map(|x| *x as usize)
61-
.sum()
62-
});
63-
64-
let decompressor = fsst_array.decompressor();
65-
let mut uncompressed_bytes = ByteBufferMut::with_capacity(total_size + 7);
66-
let len =
67-
decompressor.decompress_into(bytes.as_slice(), uncompressed_bytes.spare_capacity_mut());
68-
vortex_ensure!(
69-
len == total_size,
70-
"FSST decoded {len} bytes, expected {total_size}"
71-
);
72-
// SAFETY: `decompress_into` initialized the first `len` bytes.
114+
let plan = FsstDecodePlan::new(fsst_array, ctx)?;
115+
let mut uncompressed_bytes = ByteBufferMut::with_capacity(plan.total_size + FSST_DECODE_SLACK);
116+
let len = plan.decode_into(
117+
&fsst_array.decompressor(),
118+
uncompressed_bytes.spare_capacity_mut(),
119+
)?;
120+
// SAFETY: `decode_into` initialized the first `len` bytes.
73121
unsafe { uncompressed_bytes.set_len(len) };
74-
Ok((uncompressed_bytes, uncompressed_lens_array))
122+
Ok((uncompressed_bytes, plan.lengths))
75123
}
76124

77125
pub(crate) fn fsst_decode_views(

0 commit comments

Comments
 (0)