Skip to main content

vortex_fsst/
compress.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! FSST compression entry points.
5//!
6//! [`fsst_compress`] and [`fsst_train_compressor`] take an [`ArrayRef`] and dispatch
7//! on the input encoding ([`VarBinView`] or [`VarBin`]). Callers don't need to know
8//! which string encoding they hold.
9
10use std::sync::Arc;
11
12use fsst::Compressor;
13use num_traits::AsPrimitive;
14use vortex_array::ArrayRef;
15use vortex_array::ArrayView;
16use vortex_array::ExecutionCtx;
17use vortex_array::IntoArray;
18use vortex_array::arrays::PrimitiveArray;
19use vortex_array::arrays::VarBin;
20use vortex_array::arrays::VarBinView;
21use vortex_array::arrays::varbin::VarBinArrayExt;
22use vortex_array::arrays::varbin::builder::VarBinBuilder;
23use vortex_array::arrays::varbinview::BinaryView;
24use vortex_array::buffer::BufferHandle;
25use vortex_array::dtype::DType;
26use vortex_array::dtype::IntegerPType;
27use vortex_array::match_each_integer_ptype;
28use vortex_buffer::Buffer;
29use vortex_buffer::BufferMut;
30use vortex_error::VortexExpect;
31use vortex_error::VortexResult;
32use vortex_error::vortex_bail;
33use vortex_mask::AllOr;
34use vortex_mask::Mask;
35
36use crate::FSST;
37use crate::FSSTArray;
38
39/// FSST worst case: every input byte expands to an escape + literal (2x).
40const FSST_PER_BYTE_OVERHEAD: usize = 2;
41
42/// Starting capacity for the per-row `compress_into` scratch buffer; grown monotonically.
43const DEFAULT_BUFFER_LEN: usize = 1024 * 1024;
44
45/// Compress a string array using FSST.
46///
47/// Accepts any [`VarBinView`] or [`VarBin`]-encoded array; other encodings error.
48pub fn fsst_compress(
49    array: &ArrayRef,
50    compressor: &Compressor,
51    ctx: &mut ExecutionCtx,
52) -> VortexResult<FSSTArray> {
53    if let Some(view) = array.as_opt::<VarBinView>() {
54        compress_varbinview(view, compressor, ctx)
55    } else if let Some(varbin) = array.as_opt::<VarBin>() {
56        compress_varbin_array(varbin, compressor, ctx)
57    } else {
58        vortex_bail!(
59            "fsst_compress requires VarBinView or VarBin encoding, got {}",
60            array.encoding_id()
61        )
62    }
63}
64
65/// Train an FSST [`Compressor`] from a string array's non-null rows.
66///
67/// Accepts any [`VarBinView`] or [`VarBin`]-encoded array; other encodings error.
68pub fn fsst_train_compressor(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<Compressor> {
69    if let Some(view) = array.as_opt::<VarBinView>() {
70        train_varbinview(view, ctx)
71    } else if let Some(varbin) = array.as_opt::<VarBin>() {
72        train_varbin_array(varbin, ctx)
73    } else {
74        vortex_bail!(
75            "fsst_train_compressor requires VarBinView or VarBin encoding, got {}",
76            array.encoding_id()
77        )
78    }
79}
80
81fn compress_varbinview(
82    strings: ArrayView<VarBinView>,
83    compressor: &Compressor,
84    ctx: &mut ExecutionCtx,
85) -> VortexResult<FSSTArray> {
86    let mask = strings.validity()?.execute_mask(strings.len(), ctx)?;
87    let views = strings.views();
88
89    let total_input_bytes = match mask.bit_buffer() {
90        AllOr::All => views.iter().map(|v| v.len() as usize).sum(),
91        AllOr::None => 0,
92        AllOr::Some(bits) => views
93            .iter()
94            .zip(bits.iter())
95            .filter(|&(_, b)| b)
96            .map(|(v, _)| v.len() as usize)
97            .sum(),
98    };
99
100    if fsst_output_fits_in_i32_offsets(total_input_bytes) {
101        compress_views::<i32>(strings, &mask, compressor, ctx)
102    } else {
103        compress_views::<i64>(strings, &mask, compressor, ctx)
104    }
105}
106
107fn compress_varbin_array(
108    strings: ArrayView<VarBin>,
109    compressor: &Compressor,
110    ctx: &mut ExecutionCtx,
111) -> VortexResult<FSSTArray> {
112    let mask = strings.validity()?.execute_mask(strings.len(), ctx)?;
113    let offsets = strings.offsets().clone().execute::<PrimitiveArray>(ctx)?;
114    let total_input_bytes = match_each_integer_ptype!(offsets.ptype(), |O| {
115        let off = offsets.as_slice::<O>();
116        let first: usize = off[0].as_();
117        let last: usize = off[off.len() - 1].as_();
118        last - first
119    });
120
121    if fsst_output_fits_in_i32_offsets(total_input_bytes) {
122        compress_varbin::<i32>(strings, &offsets, &mask, compressor, ctx)
123    } else {
124        compress_varbin::<i64>(strings, &offsets, &mask, compressor, ctx)
125    }
126}
127
128fn train_varbinview(
129    strings: ArrayView<VarBinView>,
130    ctx: &mut ExecutionCtx,
131) -> VortexResult<Compressor> {
132    let mask = strings.validity()?.execute_mask(strings.len(), ctx)?;
133    let views = strings.views();
134    let buffers = strings.data_buffers();
135    let mut lines: Vec<&[u8]> = Vec::with_capacity(mask.true_count());
136
137    match mask.bit_buffer() {
138        AllOr::All => {
139            for view in views {
140                lines.push(view_bytes(view, buffers));
141            }
142        }
143        AllOr::None => {}
144        AllOr::Some(bits) => {
145            for (view, valid) in views.iter().zip(bits.iter()) {
146                if valid {
147                    lines.push(view_bytes(view, buffers));
148                }
149            }
150        }
151    }
152
153    Ok(Compressor::train(&lines))
154}
155
156fn train_varbin_array(
157    strings: ArrayView<VarBin>,
158    ctx: &mut ExecutionCtx,
159) -> VortexResult<Compressor> {
160    let mask = strings.validity()?.execute_mask(strings.len(), ctx)?;
161    let offsets = strings.offsets().clone().execute::<PrimitiveArray>(ctx)?;
162    let bytes = strings.bytes().as_slice();
163    let mut lines: Vec<&[u8]> = Vec::with_capacity(mask.true_count());
164
165    match_each_integer_ptype!(offsets.ptype(), |I| {
166        let off = offsets.as_slice::<I>();
167        for_each_varbin_row(off, bytes, &mask, |row| {
168            if let Some(s) = row {
169                lines.push(s);
170            }
171        });
172    });
173
174    Ok(Compressor::train(&lines))
175}
176
177#[inline]
178fn fsst_output_fits_in_i32_offsets(total_input_bytes: usize) -> bool {
179    let worst = total_input_bytes.saturating_mul(FSST_PER_BYTE_OVERHEAD);
180    worst <= i32::MAX as usize
181}
182
183#[inline]
184fn view_bytes<'a>(view: &'a BinaryView, buffers: &'a Arc<[BufferHandle]>) -> &'a [u8] {
185    if view.is_inlined() {
186        view.as_inlined().value()
187    } else {
188        let r = view.as_view();
189        &buffers[r.buffer_index as usize].as_host()[r.as_range()]
190    }
191}
192
193fn compress_views<O>(
194    strings: ArrayView<VarBinView>,
195    mask: &Mask,
196    compressor: &Compressor,
197    ctx: &mut ExecutionCtx,
198) -> VortexResult<FSSTArray>
199where
200    O: IntegerPType + 'static,
201{
202    let mut sink = FsstSink::<O>::with_capacity(strings.len(), compressor);
203    let views = strings.views();
204    let buffers = strings.data_buffers();
205    match mask.bit_buffer() {
206        AllOr::All => {
207            for view in views {
208                sink.emit(Some(view_bytes(view, buffers)));
209            }
210        }
211        AllOr::None => {
212            for _ in 0..mask.len() {
213                sink.emit(None);
214            }
215        }
216        AllOr::Some(bits) => {
217            for (view, valid) in views.iter().zip(bits.iter()) {
218                sink.emit(valid.then(|| view_bytes(view, buffers)));
219            }
220        }
221    }
222    sink.finish(strings.dtype().clone(), ctx)
223}
224
225fn compress_varbin<O>(
226    strings: ArrayView<VarBin>,
227    offsets: &PrimitiveArray,
228    mask: &Mask,
229    compressor: &Compressor,
230    ctx: &mut ExecutionCtx,
231) -> VortexResult<FSSTArray>
232where
233    O: IntegerPType + 'static,
234{
235    let mut sink = FsstSink::<O>::with_capacity(strings.len(), compressor);
236    let bytes = strings.bytes().as_slice();
237    match_each_integer_ptype!(offsets.ptype(), |I| {
238        let off = offsets.as_slice::<I>();
239        for_each_varbin_row(off, bytes, mask, |row| sink.emit(row));
240    });
241    sink.finish(strings.dtype().clone(), ctx)
242}
243
244/// Call `f` once per row of a `VarBinArray` with the row bytes or `None`.
245/// Validity dispatch is hoisted out of the per-row loop.
246#[inline]
247fn for_each_varbin_row<'a, I, F>(off: &[I], bytes: &'a [u8], mask: &Mask, mut f: F)
248where
249    I: IntegerPType + 'static,
250    F: FnMut(Option<&'a [u8]>),
251{
252    match mask.bit_buffer() {
253        AllOr::All => {
254            for w in off.windows(2) {
255                f(Some(&bytes[w[0].as_()..w[1].as_()]));
256            }
257        }
258        AllOr::None => {
259            for _ in 0..mask.len() {
260                f(None);
261            }
262        }
263        AllOr::Some(bits) => {
264            for (w, valid) in off.windows(2).zip(bits.iter()) {
265                f(valid.then(|| &bytes[w[0].as_()..w[1].as_()]));
266            }
267        }
268    }
269}
270
271/// Per-row output state for an FSST compression pass.
272struct FsstSink<'c, O: IntegerPType + 'static> {
273    buffer: Vec<u8>,
274    builder: VarBinBuilder<O>,
275    uncompressed_lengths: BufferMut<i32>,
276    compressor: &'c Compressor,
277}
278
279impl<'c, O: IntegerPType + 'static> FsstSink<'c, O> {
280    fn with_capacity(len: usize, compressor: &'c Compressor) -> Self {
281        Self {
282            buffer: Vec::with_capacity(DEFAULT_BUFFER_LEN),
283            builder: VarBinBuilder::<O>::with_capacity(len),
284            uncompressed_lengths: BufferMut::with_capacity(len),
285            compressor,
286        }
287    }
288
289    #[inline]
290    fn emit(&mut self, row: Option<&[u8]>) {
291        let Some(s) = row else {
292            self.builder.append_null();
293            self.uncompressed_lengths.push(0);
294            return;
295        };
296
297        // A single row > i32::MAX (2 GiB) is not supported.
298        self.uncompressed_lengths.push(
299            i32::try_from(s.len()).vortex_expect("per-row uncompressed length must fit in i32"),
300        );
301
302        let target = FSST_PER_BYTE_OVERHEAD * s.len();
303        if target > self.buffer.len() {
304            self.buffer.reserve(target - self.buffer.len());
305        }
306
307        // SAFETY: `self.buffer` has capacity for the FSST worst-case output of `s`.
308        unsafe { self.compressor.compress_into(s, &mut self.buffer) };
309
310        self.builder.append_value(&self.buffer);
311    }
312
313    fn finish(self, dtype: DType, ctx: &mut ExecutionCtx) -> VortexResult<FSSTArray> {
314        let codes = self.builder.finish(DType::Binary(dtype.nullability()));
315        FSST::try_new(
316            dtype,
317            Buffer::copy_from(self.compressor.symbol_table()),
318            Buffer::<u8>::copy_from(self.compressor.symbol_lengths()),
319            codes,
320            self.uncompressed_lengths.into_array(),
321            ctx,
322        )
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use vortex_array::VortexSessionExecute;
329    use vortex_array::array_session;
330    use vortex_array::arrays::VarBinViewArray;
331    use vortex_array::arrays::varbin::VarBinArrayExt;
332    use vortex_array::dtype::PType;
333    use vortex_error::VortexResult;
334
335    use super::fsst_compress;
336    use super::fsst_output_fits_in_i32_offsets;
337    use super::fsst_train_compressor;
338    use crate::array::FSSTArrayExt;
339
340    /// Regression for #7833: the i32-vs-i64 codes-offsets decision must cross at
341    /// `i32::MAX` against the worst-case bound `2 * total + 7 * non_null`.
342    #[test]
343    fn offset_width_boundary() {
344        let m = i32::MAX as usize;
345        assert!(fsst_output_fits_in_i32_offsets(m / 2 - 7));
346        assert!(fsst_output_fits_in_i32_offsets(m / 2));
347        assert!(fsst_output_fits_in_i32_offsets(0));
348        assert!(!fsst_output_fits_in_i32_offsets(usize::MAX));
349    }
350
351    /// Small inputs fit the i32 bound, so `fsst_compress` must pick i32 offsets.
352    /// The i64 branch is covered by `tests::fsst_compress_offsets_overflow_i32`.
353    #[test]
354    fn codes_offsets_dtype_small_input_is_i32() -> VortexResult<()> {
355        let array = VarBinViewArray::from_iter_str(["hello", "world", "fsst encoded"]);
356        let mut ctx = array_session().create_execution_ctx();
357        let compressor = fsst_train_compressor(array.as_array(), &mut ctx)?;
358        let fsst = fsst_compress(array.as_array(), &compressor, &mut ctx)?;
359        assert_eq!(fsst.codes().offsets().dtype().as_ptype(), PType::I32);
360        Ok(())
361    }
362}