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