Skip to content

Commit 369ee4d

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> Signed-off-by: Robert Kruszewski <github@robertk.io>
1 parent f9d2abb commit 369ee4d

2 files changed

Lines changed: 101 additions & 45 deletions

File tree

‎encodings/fsst/src/array.rs‎

Lines changed: 28 additions & 20 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

@@ -21,11 +22,9 @@ use vortex_array::ArrayParts;
2122
use vortex_array::ArrayRef;
2223
use vortex_array::ArraySlots;
2324
use vortex_array::ArrayView;
24-
use vortex_array::Canonical;
2525
use vortex_array::EqMode;
2626
use vortex_array::ExecutionCtx;
2727
use vortex_array::ExecutionResult;
28-
use vortex_array::IntoArray;
2928
use vortex_array::TypedArrayRef;
3029
use vortex_array::VortexSessionExecute;
3130
use vortex_array::array_slots;
@@ -60,8 +59,9 @@ use vortex_error::vortex_panic;
6059
use vortex_session::VortexSession;
6160
use vortex_session::registry::CachedId;
6261

62+
use crate::canonical::FSST_DECODE_SLACK;
63+
use crate::canonical::FsstDecodePlan;
6364
use crate::canonical::canonicalize_fsst;
64-
use crate::canonical::fsst_decode_bytes;
6565
use crate::canonical::fsst_decode_views;
6666
use crate::rules::RULES;
6767

@@ -315,13 +315,12 @@ impl VTable for FSST {
315315
return result;
316316
}
317317

318+
// The two arms here are every builder a `Utf8`/`Binary` dtype has: all four
319+
// `VarBinBuilder` widths above, and `VarBinViewBuilder` below. There is deliberately no
320+
// canonicalize-then-append fallback — it would decode to a `VarBinView` only for
321+
// `VarBinView::append_to_builder` to reject the same remainder.
318322
let Some(builder) = builder.as_any_mut().downcast_mut::<VarBinViewBuilder>() else {
319-
return array
320-
.array()
321-
.clone()
322-
.execute::<Canonical>(ctx)?
323-
.into_array()
324-
.append_to_builder(builder, ctx);
323+
vortex_bail!("append_to_builder for FSST requires a variable-binary builder")
325324
};
326325

327326
// Decompress the whole block of data into a new buffer, and create some views
@@ -350,7 +349,10 @@ impl VTable for FSST {
350349
}
351350
}
352351

353-
/// Decompresses the values and appends them to `builder`.
352+
/// Decompresses the code stream straight into `builder`'s byte storage.
353+
///
354+
/// The offsets are the running sum of the uncompressed lengths the array already stores, so the
355+
/// only work beyond the bulk `decompress_into` is one prefix sum over them.
354356
fn append_to_varbin<O: OffsetBuilderPType>(
355357
array: ArrayView<'_, FSST>,
356358
builder: &mut VarBinBuilder<O>,
@@ -359,20 +361,26 @@ fn append_to_varbin<O: OffsetBuilderPType>(
359361
where
360362
usize: AsPrimitive<O>,
361363
{
362-
let (bytes, lengths) = fsst_decode_bytes(array, ctx)?;
364+
let plan = FsstDecodePlan::new(array, ctx)?;
363365
let validity = array
364366
.array()
365367
.validity()?
366368
.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-
)
369+
let decompressor = array.decompressor();
370+
// Built once, outside the ptype match: the decoder is `#[inline(always)]`, so creating the
371+
// closure inside each arm would stamp out a copy of the whole decode loop per length type.
372+
let mut decode = |out: &mut [MaybeUninit<u8>]| plan.decode_into(&decompressor, out);
373+
match_each_integer_ptype!(plan.lengths.ptype(), |P| {
374+
// SAFETY: `decode_into` initializes exactly the prefix whose length it returns.
375+
unsafe {
376+
builder.append_decoded(
377+
plan.total_size,
378+
FSST_DECODE_SLACK,
379+
plan.lengths.as_slice::<P>(),
380+
&validity,
381+
&mut decode,
382+
)
383+
}
376384
})
377385
}
378386

‎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)