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        ProtobufUtils21,
30        pb21::{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            ProtobufUtils21::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            ProtobufUtils21::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: &pb21::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 decompress(&self, data: Vec<LanceBuffer>, num_values: u64) -> Result<DataBlock> {
319        // Step 1. decompress data use `BinaryMiniBlockDecompressor`
320        // Extract the bits_per_offset from the binary encoding
321        let compressed_data_block = self.inner_decompressor.decompress(data, num_values)?;
322        let DataBlock::VariableWidth(compressed_data_block) = compressed_data_block else {
323            panic!("BinaryMiniBlockDecompressor should output VariableWidth DataBlock")
324        };
325
326        // Step 2. FSST decompress
327        let bytes = &compressed_data_block.data;
328        let (decompress_bytes_buf, decompress_offset_buf) =
329            if compressed_data_block.bits_per_offset == 64 {
330                let offsets = compressed_data_block.offsets.borrow_to_typed_slice::<i64>();
331                let offsets = offsets.as_ref();
332
333                // The data will expand at most 8 times
334                // The offsets will be the same size because we have the same # of strings
335                let mut decompress_bytes_buf = vec![0u8; bytes.len() * 8];
336                let mut decompress_offset_buf = vec![0i64; offsets.len()];
337                fsst::fsst::decompress(
338                    &self.symbol_table,
339                    bytes.as_ref(),
340                    offsets,
341                    &mut decompress_bytes_buf,
342                    &mut decompress_offset_buf,
343                )
344                .map_err(map_fsst_error)?;
345
346                // Ensure the offsets array is trimmed to exactly num_values + 1 elements
347                decompress_offset_buf.truncate((num_values + 1) as usize);
348
349                (
350                    decompress_bytes_buf,
351                    LanceBuffer::reinterpret_vec(decompress_offset_buf),
352                )
353            } else {
354                let offsets = compressed_data_block.offsets.borrow_to_typed_slice::<i32>();
355                let offsets = offsets.as_ref();
356
357                // The data will expand at most 8 times
358                // The offsets will be the same size because we have the same # of strings
359                let mut decompress_bytes_buf = vec![0u8; bytes.len() * 8];
360                let mut decompress_offset_buf = vec![0i32; offsets.len()];
361                fsst::fsst::decompress(
362                    &self.symbol_table,
363                    bytes.as_ref(),
364                    offsets,
365                    &mut decompress_bytes_buf,
366                    &mut decompress_offset_buf,
367                )
368                .map_err(map_fsst_error)?;
369
370                // Ensure the offsets array is trimmed to exactly num_values + 1 elements
371                decompress_offset_buf.truncate((num_values + 1) as usize);
372
373                (
374                    decompress_bytes_buf,
375                    LanceBuffer::reinterpret_vec(decompress_offset_buf),
376                )
377            };
378
379        Ok(DataBlock::VariableWidth(VariableWidthBlock {
380            data: LanceBuffer::from(decompress_bytes_buf),
381            offsets: decompress_offset_buf,
382            bits_per_offset: compressed_data_block.bits_per_offset,
383            num_values,
384            block_info: BlockInfo::new(),
385        }))
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use std::collections::HashMap;
392
393    use arrow_array::StringArray;
394    use fsst::fsst::{FSST_SYMBOL_TABLE_SIZE, compress, decompress};
395    use lance_core::Error;
396    use lance_datagen::{ByteCount, RowCount};
397
398    use super::map_fsst_error;
399    use crate::testing::{TestCases, check_round_trip_encoding_of_data};
400
401    #[test_log::test(tokio::test)]
402    async fn test_fsst() {
403        let test_cases = TestCases::default()
404            .with_expected_encoding("fsst")
405            .with_structural_encodings();
406
407        // Generate data suitable for FSST (large strings, total size > 32KB)
408        let arr = lance_datagen::gen_batch()
409            .anon_col(lance_datagen::array::rand_utf8(ByteCount::from(100), false))
410            .into_batch_rows(RowCount::from(5000))
411            .unwrap()
412            .column(0)
413            .clone();
414
415        // Test both explicit metadata and automatic selection
416        // 1. Test with explicit FSST metadata
417        let metadata_explicit =
418            HashMap::from([("lance-encoding:compression".to_string(), "fsst".to_string())]);
419        check_round_trip_encoding_of_data(vec![arr.clone()], &test_cases, metadata_explicit).await;
420
421        // 2. Test automatic FSST selection based on data characteristics
422        // FSST should be chosen automatically: max_len >= 5 and total_size >= 32KB
423        check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await;
424    }
425
426    #[test]
427    fn test_corrupt_fsst_symbol_table_is_corrupt_file() {
428        let input = "the rain in spain stays mainly in the plain ".repeat(2048);
429        let array = StringArray::from(vec![input.as_str()]);
430        let mut symbol_table = [0u8; FSST_SYMBOL_TABLE_SIZE];
431        let mut compressed = vec![0u8; array.value_data().len().max(1)];
432        let mut compressed_offsets = vec![0i32; array.value_offsets().len()];
433        compress(
434            symbol_table.as_mut(),
435            array.value_data(),
436            array.value_offsets(),
437            &mut compressed,
438            &mut compressed_offsets,
439        )
440        .unwrap();
441
442        let st_info = u64::from_ne_bytes(symbol_table[..8].try_into().unwrap());
443        assert!(st_info & (1 << 24) != 0, "expected decoder_switch_on input");
444        let n_symbols = (st_info & 255) as usize;
445        assert!(n_symbols > 0);
446        symbol_table[8 + n_symbols * 8] = 9;
447
448        let mut out = vec![0u8; compressed.len() * 8];
449        let mut out_offsets = vec![0i32; compressed_offsets.len()];
450        let err = decompress(
451            &symbol_table,
452            &compressed,
453            &compressed_offsets,
454            &mut out,
455            &mut out_offsets,
456        )
457        .map_err(map_fsst_error)
458        .unwrap_err();
459        assert!(matches!(err, Error::CorruptFile { .. }), "{err}");
460        assert!(err.to_string().contains("symbol length"), "{err}");
461    }
462}