Skip to main content

lance_encoding/encodings/physical/
fsst.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4//! FSST encoding
5//!
6//! FSST is a lightweight encoding for variable width data.  This module includes
7//! adapters for both miniblock and per-value encoding.
8//!
9//! FSST encoding creates a small symbol table that is needed for decoding.  Currently
10//! we create one symbol table per disk page and store it in the description.
11//!
12//! TODO: This seems to be potentially limiting.  Perhaps we should create one symbol
13//! table per mini-block chunk?  In the per-value compression it may even make sense to
14//! create multiple symbol tables for a single value!
15//!
16//! FSST encoding is transparent.
17
18use lance_core::{Error, Result};
19
20use crate::{
21    buffer::LanceBuffer,
22    compression::{MiniBlockDecompressor, VariablePerValueDecompressor},
23    data::{BlockInfo, DataBlock, VariableWidthBlock},
24    encodings::logical::primitive::{
25        fullzip::{PerValueCompressor, PerValueDataBlock},
26        miniblock::{MiniBlockCompressed, MiniBlockCompressionContext, MiniBlockCompressor},
27    },
28    format::{
29        EncodingUtils,
30        metadata::{self, CompressiveEncoding},
31    },
32};
33
34use super::binary::BinaryMiniBlockEncoder;
35
36pub(crate) fn map_fsst_error(err: std::io::Error) -> Error {
37    match err.kind() {
38        std::io::ErrorKind::InvalidData => Error::corrupt_file_named("fsst", err.to_string()),
39        _ => err.into(),
40    }
41}
42
43struct FsstCompressed {
44    data: VariableWidthBlock,
45    symbol_table: Vec<u8>,
46}
47
48impl FsstCompressed {
49    fn fsst_compress(data: DataBlock) -> Result<Self> {
50        match data {
51            DataBlock::VariableWidth(variable_width) => {
52                match variable_width.bits_per_offset {
53                    32 => {
54                        let offsets = variable_width.offsets.borrow_to_typed_slice::<i32>();
55                        let offsets_slice = offsets.as_ref();
56                        let bytes_data = variable_width.data.into_buffer();
57
58                        // prepare compression output buffer
59                        let mut dest_offsets = vec![0_i32; offsets_slice.len() * 2];
60                        let mut dest_values = vec![0_u8; bytes_data.len() * 2];
61                        let mut symbol_table = vec![0_u8; fsst::fsst::FSST_SYMBOL_TABLE_SIZE];
62
63                        // fsst compression
64                        fsst::fsst::compress(
65                            &mut symbol_table,
66                            bytes_data.as_slice(),
67                            offsets_slice,
68                            &mut dest_values,
69                            &mut dest_offsets,
70                        )?;
71
72                        // construct `DataBlock` for BinaryMiniBlockEncoder, we may want some `DataBlock` construct methods later
73                        let compressed = VariableWidthBlock {
74                            data: LanceBuffer::reinterpret_vec(dest_values),
75                            bits_per_offset: 32,
76                            offsets: LanceBuffer::reinterpret_vec(dest_offsets),
77                            num_values: variable_width.num_values,
78                            block_info: BlockInfo::new(),
79                        };
80
81                        Ok(Self {
82                            data: compressed,
83                            symbol_table,
84                        })
85                    }
86                    64 => {
87                        let offsets = variable_width.offsets.borrow_to_typed_slice::<i64>();
88                        let offsets_slice = offsets.as_ref();
89                        let bytes_data = variable_width.data.into_buffer();
90
91                        // prepare compression output buffer
92                        let mut dest_offsets = vec![0_i64; offsets_slice.len() * 2];
93                        let mut dest_values = vec![0_u8; bytes_data.len() * 2];
94                        let mut symbol_table = vec![0_u8; fsst::fsst::FSST_SYMBOL_TABLE_SIZE];
95
96                        // fsst compression
97                        fsst::fsst::compress(
98                            &mut symbol_table,
99                            bytes_data.as_slice(),
100                            offsets_slice,
101                            &mut dest_values,
102                            &mut dest_offsets,
103                        )?;
104
105                        // construct `DataBlock` for BinaryMiniBlockEncoder, we may want some `DataBlock` construct methods later
106                        let compressed = VariableWidthBlock {
107                            data: LanceBuffer::reinterpret_vec(dest_values),
108                            bits_per_offset: 64,
109                            offsets: LanceBuffer::reinterpret_vec(dest_offsets),
110                            num_values: variable_width.num_values,
111                            block_info: BlockInfo::new(),
112                        };
113
114                        Ok(Self {
115                            data: compressed,
116                            symbol_table,
117                        })
118                    }
119                    _ => panic!(
120                        "Unsupported offsets type {}",
121                        variable_width.bits_per_offset
122                    ),
123                }
124            }
125            _ => Err(Error::invalid_input_source(
126                format!(
127                    "Cannot compress a data block of type {} with FsstEncoder",
128                    data.name()
129                )
130                .into(),
131            )),
132        }
133    }
134}
135
136#[derive(Debug, Default)]
137pub struct FsstMiniBlockEncoder {
138    minichunk_size: Option<i64>,
139}
140
141impl FsstMiniBlockEncoder {
142    pub fn new(minichunk_size: Option<i64>) -> Self {
143        Self { minichunk_size }
144    }
145}
146
147impl MiniBlockCompressor for FsstMiniBlockEncoder {
148    fn compress(
149        &self,
150        context: MiniBlockCompressionContext,
151        data: DataBlock,
152    ) -> Result<(MiniBlockCompressed, CompressiveEncoding)> {
153        let compressed = FsstCompressed::fsst_compress(data)?;
154
155        let data_block = DataBlock::VariableWidth(compressed.data);
156
157        // compress the fsst compressed data using `BinaryMiniBlockEncoder`
158        let binary_compressor = Box::new(BinaryMiniBlockEncoder::new(self.minichunk_size))
159            as Box<dyn MiniBlockCompressor>;
160
161        let (binary_miniblock_compressed, binary_array_encoding) =
162            binary_compressor.compress(context, data_block)?;
163
164        Ok((
165            binary_miniblock_compressed,
166            EncodingUtils::fsst(binary_array_encoding, compressed.symbol_table),
167        ))
168    }
169}
170
171#[derive(Debug)]
172pub struct FsstPerValueEncoder {
173    inner: Box<dyn PerValueCompressor>,
174}
175
176impl FsstPerValueEncoder {
177    pub fn new(inner: Box<dyn PerValueCompressor>) -> Self {
178        Self { inner }
179    }
180}
181
182impl PerValueCompressor for FsstPerValueEncoder {
183    fn compress(&self, data: DataBlock) -> Result<(PerValueDataBlock, CompressiveEncoding)> {
184        let compressed = FsstCompressed::fsst_compress(data)?;
185
186        let data_block = DataBlock::VariableWidth(compressed.data);
187
188        let (binary_compressed, binary_array_encoding) = self.inner.compress(data_block)?;
189
190        Ok((
191            binary_compressed,
192            EncodingUtils::fsst(binary_array_encoding, compressed.symbol_table),
193        ))
194    }
195}
196
197#[derive(Debug)]
198pub struct FsstPerValueDecompressor {
199    symbol_table: LanceBuffer,
200    inner_decompressor: Box<dyn VariablePerValueDecompressor>,
201}
202
203impl FsstPerValueDecompressor {
204    pub fn new(
205        symbol_table: LanceBuffer,
206        inner_decompressor: Box<dyn VariablePerValueDecompressor>,
207    ) -> Self {
208        Self {
209            symbol_table,
210            inner_decompressor,
211        }
212    }
213}
214
215impl VariablePerValueDecompressor for FsstPerValueDecompressor {
216    fn decompress(&self, data: VariableWidthBlock) -> Result<DataBlock> {
217        // Step 1. Run inner decompressor
218        let compressed_variable_data = self
219            .inner_decompressor
220            .decompress(data)?
221            .as_variable_width()
222            .unwrap();
223
224        // Step 2. FSST decompress
225        let bytes = compressed_variable_data.data.borrow_to_typed_slice::<u8>();
226        let bytes = bytes.as_ref();
227
228        match compressed_variable_data.bits_per_offset {
229            32 => {
230                let offsets = compressed_variable_data
231                    .offsets
232                    .borrow_to_typed_slice::<i32>();
233                let offsets = offsets.as_ref();
234                let num_values = compressed_variable_data.num_values;
235
236                // The data will expand at most 8 times
237                // The offsets will be the same size because we have the same # of strings
238                let mut decompress_bytes_buf = vec![0u8; bytes.len() * 8];
239                let mut decompress_offset_buf = vec![0i32; offsets.len()];
240                fsst::fsst::decompress(
241                    &self.symbol_table,
242                    bytes,
243                    offsets,
244                    &mut decompress_bytes_buf,
245                    &mut decompress_offset_buf,
246                )
247                .map_err(map_fsst_error)?;
248
249                // Ensure the offsets array is trimmed to exactly num_values + 1 elements
250                decompress_offset_buf.truncate((num_values + 1) as usize);
251
252                Ok(DataBlock::VariableWidth(VariableWidthBlock {
253                    data: LanceBuffer::from(decompress_bytes_buf),
254                    offsets: LanceBuffer::reinterpret_vec(decompress_offset_buf),
255                    bits_per_offset: 32,
256                    num_values,
257                    block_info: BlockInfo::new(),
258                }))
259            }
260            64 => {
261                let offsets = compressed_variable_data
262                    .offsets
263                    .borrow_to_typed_slice::<i64>();
264                let offsets = offsets.as_ref();
265                let num_values = compressed_variable_data.num_values;
266
267                // The data will expand at most 8 times
268                // The offsets will be the same size because we have the same # of strings
269                let mut decompress_bytes_buf = vec![0u8; bytes.len() * 8];
270                let mut decompress_offset_buf = vec![0i64; offsets.len()];
271                fsst::fsst::decompress(
272                    &self.symbol_table,
273                    bytes,
274                    offsets,
275                    &mut decompress_bytes_buf,
276                    &mut decompress_offset_buf,
277                )
278                .map_err(map_fsst_error)?;
279
280                // Ensure the offsets array is trimmed to exactly num_values + 1 elements
281                decompress_offset_buf.truncate((num_values + 1) as usize);
282
283                Ok(DataBlock::VariableWidth(VariableWidthBlock {
284                    data: LanceBuffer::from(decompress_bytes_buf),
285                    offsets: LanceBuffer::reinterpret_vec(decompress_offset_buf),
286                    bits_per_offset: 64,
287                    num_values,
288                    block_info: BlockInfo::new(),
289                }))
290            }
291            _ => panic!(
292                "Unsupported offset type {}",
293                compressed_variable_data.bits_per_offset,
294            ),
295        }
296    }
297}
298
299#[derive(Debug)]
300pub struct FsstMiniBlockDecompressor {
301    symbol_table: LanceBuffer,
302    inner_decompressor: Box<dyn MiniBlockDecompressor>,
303}
304
305impl FsstMiniBlockDecompressor {
306    pub fn new(
307        description: &metadata::Fsst,
308        inner_decompressor: Box<dyn MiniBlockDecompressor>,
309    ) -> Self {
310        Self {
311            symbol_table: LanceBuffer::from_bytes(description.symbol_table.clone(), 1),
312            inner_decompressor,
313        }
314    }
315}
316
317impl MiniBlockDecompressor for FsstMiniBlockDecompressor {
318    fn num_buffers(&self) -> usize {
319        self.inner_decompressor.num_buffers()
320    }
321
322    fn decompress(&self, data: Vec<LanceBuffer>, num_values: u64) -> Result<DataBlock> {
323        // Step 1. decompress data use `BinaryMiniBlockDecompressor`
324        // Extract the bits_per_offset from the binary encoding
325        let compressed_data_block = self.inner_decompressor.decompress(data, num_values)?;
326        let DataBlock::VariableWidth(compressed_data_block) = compressed_data_block else {
327            panic!("BinaryMiniBlockDecompressor should output VariableWidth DataBlock")
328        };
329
330        // Step 2. FSST decompress
331        let bytes = &compressed_data_block.data;
332        let (decompress_bytes_buf, decompress_offset_buf) =
333            if compressed_data_block.bits_per_offset == 64 {
334                let offsets = compressed_data_block.offsets.borrow_to_typed_slice::<i64>();
335                let offsets = offsets.as_ref();
336
337                // The data will expand at most 8 times
338                // The offsets will be the same size because we have the same # of strings
339                let mut decompress_bytes_buf = vec![0u8; bytes.len() * 8];
340                let mut decompress_offset_buf = vec![0i64; offsets.len()];
341                fsst::fsst::decompress(
342                    &self.symbol_table,
343                    bytes.as_ref(),
344                    offsets,
345                    &mut decompress_bytes_buf,
346                    &mut decompress_offset_buf,
347                )
348                .map_err(map_fsst_error)?;
349
350                // Ensure the offsets array is trimmed to exactly num_values + 1 elements
351                decompress_offset_buf.truncate((num_values + 1) as usize);
352
353                (
354                    decompress_bytes_buf,
355                    LanceBuffer::reinterpret_vec(decompress_offset_buf),
356                )
357            } else {
358                let offsets = compressed_data_block.offsets.borrow_to_typed_slice::<i32>();
359                let offsets = offsets.as_ref();
360
361                // The data will expand at most 8 times
362                // The offsets will be the same size because we have the same # of strings
363                let mut decompress_bytes_buf = vec![0u8; bytes.len() * 8];
364                let mut decompress_offset_buf = vec![0i32; offsets.len()];
365                fsst::fsst::decompress(
366                    &self.symbol_table,
367                    bytes.as_ref(),
368                    offsets,
369                    &mut decompress_bytes_buf,
370                    &mut decompress_offset_buf,
371                )
372                .map_err(map_fsst_error)?;
373
374                // Ensure the offsets array is trimmed to exactly num_values + 1 elements
375                decompress_offset_buf.truncate((num_values + 1) as usize);
376
377                (
378                    decompress_bytes_buf,
379                    LanceBuffer::reinterpret_vec(decompress_offset_buf),
380                )
381            };
382
383        Ok(DataBlock::VariableWidth(VariableWidthBlock {
384            data: LanceBuffer::from(decompress_bytes_buf),
385            offsets: decompress_offset_buf,
386            bits_per_offset: compressed_data_block.bits_per_offset,
387            num_values,
388            block_info: BlockInfo::new(),
389        }))
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use std::collections::HashMap;
396
397    use arrow_array::StringArray;
398    use fsst::fsst::{FSST_SYMBOL_TABLE_SIZE, compress, decompress};
399    use lance_core::Error;
400    use lance_datagen::{ByteCount, RowCount};
401
402    use super::map_fsst_error;
403    use crate::testing::{TestCases, TestEncoding, check_round_trip_encoding_of_data};
404
405    #[rstest::rstest]
406    #[test_log::test(tokio::test)]
407    async fn test_fsst(
408        #[values(false, true)] explicit: bool,
409        #[values(
410            TestEncoding::StructuralU16,
411            TestEncoding::StructuralU32,
412            TestEncoding::StructuralSparse
413        )]
414        encoding: TestEncoding,
415    ) {
416        let test_cases = TestCases::default()
417            .with_expected_encoding("fsst")
418            .with_encoding(encoding);
419
420        // Generate data suitable for FSST (large strings, total size > 32KB)
421        let arr = lance_datagen::gen_batch()
422            .anon_col(lance_datagen::array::rand_utf8(ByteCount::from(100), false))
423            .into_batch_rows(RowCount::from(5000))
424            .unwrap()
425            .column(0)
426            .clone();
427
428        let metadata = if explicit {
429            HashMap::from([("lance-encoding:compression".to_string(), "fsst".to_string())])
430        } else {
431            // Automatic selection requires max_len >= 5 and total_size >= 32KB.
432            HashMap::new()
433        };
434        check_round_trip_encoding_of_data(vec![arr], &test_cases, metadata).await;
435    }
436
437    #[test]
438    fn test_corrupt_fsst_symbol_table_is_corrupt_file() {
439        let input = "the rain in spain stays mainly in the plain ".repeat(2048);
440        let array = StringArray::from(vec![input.as_str()]);
441        let mut symbol_table = [0u8; FSST_SYMBOL_TABLE_SIZE];
442        let mut compressed = vec![0u8; array.value_data().len().max(1)];
443        let mut compressed_offsets = vec![0i32; array.value_offsets().len()];
444        compress(
445            symbol_table.as_mut(),
446            array.value_data(),
447            array.value_offsets(),
448            &mut compressed,
449            &mut compressed_offsets,
450        )
451        .unwrap();
452
453        let st_info = u64::from_ne_bytes(symbol_table[..8].try_into().unwrap());
454        assert!(st_info & (1 << 24) != 0, "expected decoder_switch_on input");
455        let n_symbols = (st_info & 255) as usize;
456        assert!(n_symbols > 0);
457        symbol_table[8 + n_symbols * 8] = 9;
458
459        let mut out = vec![0u8; compressed.len() * 8];
460        let mut out_offsets = vec![0i32; compressed_offsets.len()];
461        let err = decompress(
462            &symbol_table,
463            &compressed,
464            &compressed_offsets,
465            &mut out,
466            &mut out_offsets,
467        )
468        .map_err(map_fsst_error)
469        .unwrap_err();
470        assert!(matches!(err, Error::CorruptFile { .. }), "{err}");
471        assert!(err.to_string().contains("symbol length"), "{err}");
472    }
473}