Skip to main content

lance_encoding/encodings/physical/
general.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Lance Authors
3
4use log::trace;
5
6use crate::{
7    Result,
8    buffer::LanceBuffer,
9    compression::MiniBlockDecompressor,
10    data::DataBlock,
11    encodings::{
12        logical::primitive::miniblock::{
13            MiniBlockCompressed, MiniBlockCompressionContext, MiniBlockCompressor,
14        },
15        physical::block::{CompressionConfig, GeneralBufferCompressor},
16    },
17    format::{ProtobufUtils21, pb21::CompressiveEncoding},
18};
19
20/// A miniblock compressor that wraps another miniblock compressor and applies
21/// general-purpose compression (LZ4, Zstd) to the resulting buffers.
22#[derive(Debug)]
23pub struct GeneralMiniBlockCompressor {
24    inner: Box<dyn MiniBlockCompressor>,
25    compression: CompressionConfig,
26}
27
28impl GeneralMiniBlockCompressor {
29    pub fn new(inner: Box<dyn MiniBlockCompressor>, compression: CompressionConfig) -> Self {
30        Self { inner, compression }
31    }
32}
33
34/// Minimum buffer size to consider for compression
35const MIN_BUFFER_SIZE_FOR_COMPRESSION: usize = 4 * 1024;
36
37use super::super::logical::primitive::miniblock::MiniBlockChunk;
38
39impl MiniBlockCompressor for GeneralMiniBlockCompressor {
40    fn compress(
41        &self,
42        context: MiniBlockCompressionContext,
43        page: DataBlock,
44    ) -> Result<(MiniBlockCompressed, CompressiveEncoding)> {
45        // First, compress with the inner compressor
46        let (inner_compressed, inner_encoding) = self.inner.compress(context, page)?;
47
48        // Return the original encoding without compression if there's no data or
49        // the first buffer is not large enough
50        if inner_compressed.data.is_empty()
51            || inner_compressed.data[0].len() < MIN_BUFFER_SIZE_FOR_COMPRESSION
52        {
53            return Ok((inner_compressed, inner_encoding));
54        }
55
56        // We'll compress each chunk's portion of the first buffer independently
57        let first_buffer = &inner_compressed.data[0];
58        let mut compressed_first_buffer = Vec::new();
59        let mut new_chunks = Vec::with_capacity(inner_compressed.chunks.iter().len());
60        let mut offset = 0usize;
61        let mut total_original_size = 0usize;
62
63        let compressor = GeneralBufferCompressor::get_compressor(self.compression)?;
64
65        for chunk in &inner_compressed.chunks {
66            let chunk_first_buffer_size = chunk.buffer_sizes[0] as usize;
67
68            let chunk_data = &first_buffer.as_ref()[offset..offset + chunk_first_buffer_size];
69            total_original_size += chunk_first_buffer_size;
70
71            let compressed_start = compressed_first_buffer.len();
72            compressor.compress(chunk_data, &mut compressed_first_buffer)?;
73            let compressed_size = compressed_first_buffer.len() - compressed_start;
74
75            // Create new chunk with updated first buffer size
76            let mut new_buffer_sizes = chunk.buffer_sizes.clone();
77            new_buffer_sizes[0] = compressed_size as u32;
78
79            new_chunks.push(MiniBlockChunk {
80                buffer_sizes: new_buffer_sizes,
81                log_num_values: chunk.log_num_values,
82            });
83
84            offset += chunk_first_buffer_size;
85        }
86
87        // Check if compression was effective
88        let compressed_total_size = compressed_first_buffer.len();
89        if compressed_total_size >= total_original_size {
90            // Compression didn't help, return original
91            return Ok((inner_compressed, inner_encoding));
92        }
93
94        trace!(
95            "First buffer compressed from {} to {} bytes (ratio: {:.2})",
96            total_original_size,
97            compressed_total_size,
98            compressed_total_size as f32 / total_original_size as f32
99        );
100
101        // Build final buffers: compressed first buffer + remaining original buffers
102        let mut final_buffers = vec![LanceBuffer::from(compressed_first_buffer)];
103        final_buffers.extend(inner_compressed.data.into_iter().skip(1));
104
105        let compressed_result = MiniBlockCompressed {
106            data: final_buffers,
107            chunks: new_chunks,
108            num_values: inner_compressed.num_values,
109        };
110
111        // Return compressed encoding
112        let encoding = ProtobufUtils21::wrapped(self.compression, inner_encoding)?;
113        Ok((compressed_result, encoding))
114    }
115}
116
117/// A miniblock decompressor that first decompresses buffers using general-purpose
118/// compression (LZ4, Zstd) and then delegates to an inner miniblock decompressor.
119#[derive(Debug)]
120pub struct GeneralMiniBlockDecompressor {
121    inner: Box<dyn MiniBlockDecompressor>,
122    compression: CompressionConfig,
123}
124
125impl GeneralMiniBlockDecompressor {
126    pub fn new(inner: Box<dyn MiniBlockDecompressor>, compression: CompressionConfig) -> Self {
127        Self { inner, compression }
128    }
129}
130
131impl MiniBlockDecompressor for GeneralMiniBlockDecompressor {
132    fn decompress(&self, mut data: Vec<LanceBuffer>, num_values: u64) -> Result<DataBlock> {
133        let mut decompressed_buffer = Vec::new();
134
135        let decompressor = GeneralBufferCompressor::get_compressor(self.compression)?;
136        decompressor.decompress(&data[0], &mut decompressed_buffer)?;
137        data[0] = LanceBuffer::from(decompressed_buffer);
138
139        self.inner.decompress(data, num_values)
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::compression::{DecompressionStrategy, DefaultDecompressionStrategy};
147    use crate::data::{BlockInfo, FixedWidthDataBlock};
148    use crate::encodings::physical::block::CompressionScheme;
149    use crate::encodings::physical::rle::RleEncoder;
150    use crate::encodings::physical::value::ValueEncoder;
151    use crate::format::pb21;
152    use crate::format::pb21::compressive_encoding::Compression;
153    use arrow_array::{Float64Array, Int32Array};
154
155    fn miniblock_context() -> MiniBlockCompressionContext {
156        MiniBlockCompressionContext::new(0, true, true)
157    }
158
159    #[derive(Debug)]
160    struct TestCase {
161        name: &'static str,
162        inner_encoder: Box<dyn MiniBlockCompressor>,
163        compression: CompressionConfig,
164        data: DataBlock,
165        expected_compressed: bool, // Whether we expect compression to be applied
166        min_compression_ratio: f32, // Minimum compression ratio if compressed
167    }
168
169    fn create_test_cases() -> Vec<TestCase> {
170        vec![
171            // Small data with RLE - should not compress due to size threshold
172            TestCase {
173                name: "small_rle_data",
174                inner_encoder: Box::new(RleEncoder::new()),
175                compression: CompressionConfig {
176                    scheme: CompressionScheme::Lz4,
177                    level: None,
178                },
179                data: create_repeated_i32_block(vec![1, 1, 1, 1, 2, 2, 2, 2]),
180                expected_compressed: false,
181                min_compression_ratio: 1.0,
182            },
183            // Large repeated data with RLE + LZ4
184            TestCase {
185                name: "large_rle_lz4",
186                inner_encoder: Box::new(RleEncoder::new()),
187                compression: CompressionConfig {
188                    scheme: CompressionScheme::Lz4,
189                    level: None,
190                },
191                data: create_pattern_i32_block(2048, |i| (i / 8) as i32),
192                expected_compressed: false, // RLE already compresses well, additional LZ4 may not help
193                min_compression_ratio: 1.0,
194            },
195            // Large repeated data with RLE + Zstd
196            TestCase {
197                name: "large_rle_zstd",
198                inner_encoder: Box::new(RleEncoder::new()),
199                compression: CompressionConfig {
200                    scheme: CompressionScheme::Zstd,
201                    level: Some(3),
202                },
203                data: create_pattern_i32_block(8192, |i| (i / 16) as i32),
204                expected_compressed: true, // Zstd might provide additional compression
205                min_compression_ratio: 0.9, // But not as much since RLE already compressed
206            },
207            // Sequential data with ValueEncoder + LZ4
208            TestCase {
209                name: "sequential_value_lz4",
210                inner_encoder: Box::new(ValueEncoder {}),
211                compression: CompressionConfig {
212                    scheme: CompressionScheme::Lz4,
213                    level: None,
214                },
215                data: create_pattern_i32_block(1024, |i| i as i32),
216                expected_compressed: false, // Sequential data doesn't compress well
217                min_compression_ratio: 1.0,
218            },
219            // Float data with ValueEncoder + Zstd
220            TestCase {
221                name: "float_value_zstd",
222                inner_encoder: Box::new(ValueEncoder {}),
223                compression: CompressionConfig {
224                    scheme: CompressionScheme::Zstd,
225                    level: Some(3),
226                },
227                data: create_pattern_f64_block(1024, |i| i as f64 * 0.1),
228                expected_compressed: true,
229                min_compression_ratio: 0.9,
230            },
231        ]
232    }
233
234    fn create_repeated_i32_block(values: Vec<i32>) -> DataBlock {
235        let array = Int32Array::from(values);
236        DataBlock::from_array(array)
237    }
238
239    fn create_pattern_i32_block<F>(size: usize, pattern: F) -> DataBlock
240    where
241        F: Fn(usize) -> i32,
242    {
243        let values: Vec<i32> = (0..size).map(pattern).collect();
244        let array = Int32Array::from(values);
245        DataBlock::from_array(array)
246    }
247
248    fn create_pattern_f64_block<F>(size: usize, pattern: F) -> DataBlock
249    where
250        F: Fn(usize) -> f64,
251    {
252        let values: Vec<f64> = (0..size).map(pattern).collect();
253        let array = Float64Array::from(values);
254        DataBlock::from_array(array)
255    }
256
257    fn run_round_trip_test(test_case: TestCase) {
258        let compressor =
259            GeneralMiniBlockCompressor::new(test_case.inner_encoder, test_case.compression);
260
261        // Compress the data
262        let (compressed, encoding) = compressor
263            .compress(miniblock_context(), test_case.data)
264            .unwrap();
265
266        // Check if compression was applied as expected
267        match &encoding.compression {
268            Some(Compression::General(cm)) => {
269                assert!(
270                    test_case.expected_compressed,
271                    "{}: Expected compression to be applied",
272                    test_case.name
273                );
274                assert_eq!(
275                    CompressionScheme::try_from(cm.compression.as_ref().unwrap().scheme()).unwrap(),
276                    test_case.compression.scheme
277                );
278            }
279            _ => {
280                // Could be RLE or other encoding if compression didn't help
281                if test_case.expected_compressed {
282                    // Check if it's RLE encoding (which means compression didn't help)
283                    match &encoding.compression {
284                        Some(Compression::Rle(_)) => {
285                            // RLE encoding returned - compression didn't help
286                        }
287                        Some(Compression::Flat(_)) => {
288                            // Flat encoding returned - compression didn't help
289                        }
290                        _ => {
291                            panic!(
292                                "{}: Expected GeneralMiniBlock but got {:?}",
293                                test_case.name, encoding.compression
294                            );
295                        }
296                    }
297                }
298            }
299        }
300
301        // Verify chunks are created correctly
302        assert!(
303            !compressed.chunks.is_empty(),
304            "{}: No chunks created",
305            test_case.name
306        );
307
308        // Test decompression by simulating the actual miniblock decoding process
309        let decompressed_data = decompress_miniblock_chunks(&compressed, &encoding);
310
311        // Verify round trip by checking data size
312        // We expect the decompressed data to match the original number of values
313        // The bytes per value depends on the test case
314        let bytes_per_value = if test_case.name.contains("float") {
315            8 // f64
316        } else {
317            4 // i32
318        };
319        let expected_bytes = compressed.num_values as usize * bytes_per_value;
320        assert_eq!(
321            expected_bytes,
322            decompressed_data.len(),
323            "{}: Data size mismatch",
324            test_case.name
325        );
326
327        // Check compression ratio if applicable
328        if test_case.expected_compressed {
329            let compression_ratio = compressed.data[0].len() as f32 / expected_bytes as f32;
330            assert!(
331                compression_ratio <= test_case.min_compression_ratio,
332                "{}: Compression ratio {} > expected {}",
333                test_case.name,
334                compression_ratio,
335                test_case.min_compression_ratio
336            );
337        }
338    }
339
340    fn decompress_miniblock_chunks(
341        compressed: &MiniBlockCompressed,
342        encoding: &CompressiveEncoding,
343    ) -> Vec<u8> {
344        let mut decompressed_data = Vec::new();
345        let mut offsets = vec![0usize; compressed.data.len()]; // Track offset for each buffer
346        let decompression_strategy = DefaultDecompressionStrategy::default();
347
348        for chunk in &compressed.chunks {
349            let chunk_values = if chunk.log_num_values > 0 {
350                1u64 << chunk.log_num_values
351            } else {
352                // Last chunk - calculate remaining values
353                let decompressed_values =
354                    decompressed_data.len() as u64 / get_bytes_per_value(compressed) as u64;
355                compressed.num_values.saturating_sub(decompressed_values)
356            };
357
358            // Extract buffers for this chunk
359            let mut chunk_buffers = Vec::new();
360            for (i, &size) in chunk.buffer_sizes.iter().enumerate() {
361                if i < compressed.data.len() {
362                    let buffer_data =
363                        compressed.data[i].slice_with_length(offsets[i], size as usize);
364                    chunk_buffers.push(buffer_data);
365                    offsets[i] += size as usize;
366                }
367            }
368
369            // Create a decompressor for this chunk
370            let decompressor = decompression_strategy
371                .create_miniblock_decompressor(encoding, &decompression_strategy)
372                .unwrap();
373
374            // Decompress the chunk
375            let chunk_decompressed = decompressor
376                .decompress(chunk_buffers, chunk_values)
377                .unwrap();
378
379            match chunk_decompressed {
380                DataBlock::FixedWidth(ref block) => {
381                    decompressed_data.extend_from_slice(block.data.as_ref());
382                }
383                _ => panic!("Expected FixedWidth block"),
384            }
385        }
386
387        decompressed_data
388    }
389
390    fn get_bytes_per_value(compressed: &MiniBlockCompressed) -> usize {
391        // This is a simplification - in reality we'd need to know the data type
392        // For our tests, we mostly use i32 (4 bytes) or f64 (8 bytes)
393        // We can try to guess based on the data size
394        if compressed.num_values == 0 {
395            return 4; // Default to i32
396        }
397
398        // For float tests, the number is usually 1024 and we use f64
399        if compressed.num_values == 1024 {
400            return 8; // Likely f64
401        }
402
403        4 // Default to i32
404    }
405
406    #[test]
407    fn test_compressed_mini_block_table_driven() {
408        for test_case in create_test_cases() {
409            run_round_trip_test(test_case);
410        }
411    }
412
413    #[test]
414    fn test_compressed_mini_block_threshold() {
415        // Test that small buffers don't get compressed
416        let small_test = TestCase {
417            name: "small_buffer_no_compression",
418            inner_encoder: Box::new(RleEncoder::new()),
419            compression: CompressionConfig {
420                scheme: CompressionScheme::Lz4,
421                level: None,
422            },
423            data: create_repeated_i32_block(vec![1, 1, 2, 2]),
424            expected_compressed: false,
425            min_compression_ratio: 1.0,
426        };
427        run_round_trip_test(small_test);
428    }
429
430    #[test]
431    fn test_compressed_mini_block_with_doubles() {
432        // Test with large sequential doubles that should compress well with Zstd
433        // The test focuses on verifying that GeneralMiniBlock works correctly
434        // when wrapping a simple ValueEncoder
435        let test_case = TestCase {
436            name: "float_values_with_zstd",
437            inner_encoder: Box::new(ValueEncoder {}),
438            compression: CompressionConfig {
439                scheme: CompressionScheme::Zstd,
440                level: Some(3),
441            },
442            // Create enough data to ensure compression is applied
443            data: create_pattern_f64_block(1024, |i| (i / 10) as f64),
444            expected_compressed: true,
445            min_compression_ratio: 0.5, // Zstd should achieve good compression on repetitive data
446        };
447
448        run_round_trip_test(test_case);
449    }
450
451    #[test]
452    fn test_compressed_mini_block_large_buffers() {
453        // Use value encoding which doesn't compress data, ensuring large buffers
454        // Create 1024 i32 values (4KB of data)
455        let values: Vec<i32> = (0..1024).collect();
456        let data = LanceBuffer::from_bytes(
457            bytemuck::cast_slice(&values).to_vec().into(),
458            std::mem::align_of::<i32>() as u64,
459        );
460        let block = DataBlock::FixedWidth(FixedWidthDataBlock {
461            bits_per_value: 32,
462            data,
463            num_values: 1024,
464            block_info: BlockInfo::new(),
465        });
466
467        // Create compressor with ValueEncoder (no compression) and Zstd wrapper
468        let inner = Box::new(ValueEncoder {});
469        let compression = CompressionConfig {
470            scheme: CompressionScheme::Zstd,
471            level: Some(3),
472        };
473        let compressor = GeneralMiniBlockCompressor::new(inner, compression);
474
475        // Compress the data
476        let (compressed, encoding) = compressor.compress(miniblock_context(), block).unwrap();
477
478        // Should get GeneralMiniBlock encoding since buffer is 4KB
479        match &encoding.compression {
480            Some(Compression::General(cm)) => {
481                assert!(cm.values.is_some());
482                assert_eq!(
483                    cm.compression.as_ref().unwrap().scheme(),
484                    pb21::CompressionScheme::CompressionAlgorithmZstd
485                );
486                assert_eq!(cm.compression.as_ref().unwrap().level, Some(3));
487
488                // Verify inner encoding is Flat (from ValueEncoder)
489                match &cm.values.as_ref().unwrap().compression {
490                    Some(Compression::Flat(flat)) => {
491                        assert_eq!(flat.bits_per_value, 32);
492                    }
493                    _ => panic!("Expected Flat inner encoding"),
494                }
495            }
496            _ => panic!("Expected GeneralMiniBlock encoding"),
497        }
498
499        assert_eq!(compressed.num_values, 1024);
500        // ValueEncoder produces 1 buffer, so compressed result also has 1 buffer
501        assert_eq!(compressed.data.len(), 1);
502    }
503
504    // Special test cases that don't fit the table-driven pattern
505
506    #[test]
507    fn test_compressed_mini_block_rle_multiple_buffers() {
508        // RLE produces 2 buffers (values and lengths), test that both are handled correctly
509        let data = create_repeated_i32_block(vec![1; 100]);
510        let compressor = GeneralMiniBlockCompressor::new(
511            Box::new(RleEncoder::new()),
512            CompressionConfig {
513                scheme: CompressionScheme::Lz4,
514                level: None,
515            },
516        );
517
518        let (compressed, _) = compressor.compress(miniblock_context(), data).unwrap();
519        // RLE produces 2 buffers, but only the first one is compressed
520        assert_eq!(compressed.data.len(), 2);
521    }
522
523    #[test]
524    fn test_rle_with_general_miniblock_wrapper() {
525        // Test that RLE encoding with bits_per_value >= 32 is automatically wrapped
526        // in GeneralMiniBlock with LZ4 compression
527
528        // This test directly tests the RLE encoder behavior
529        // When bits_per_value >= 32, RLE should be wrapped in GeneralMiniBlock with LZ4
530
531        // Test case 1: 32-bit RLE data
532        let test_32 = TestCase {
533            name: "rle_32bit_with_general_wrapper",
534            inner_encoder: Box::new(RleEncoder::new()),
535            compression: CompressionConfig {
536                scheme: CompressionScheme::Lz4,
537                level: None,
538            },
539            data: create_repeated_i32_block(vec![1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3]),
540            expected_compressed: false, // RLE already compresses well, LZ4 might not help much
541            min_compression_ratio: 1.0,
542        };
543
544        // For 32-bit RLE, the compression strategy should automatically wrap it
545        // Let's directly test the compressor
546        let compressor = GeneralMiniBlockCompressor::new(
547            Box::new(RleEncoder::new()),
548            CompressionConfig {
549                scheme: CompressionScheme::Lz4,
550                level: None,
551            },
552        );
553
554        let (_compressed, encoding) = compressor
555            .compress(miniblock_context(), test_32.data)
556            .unwrap();
557
558        // Verify the encoding structure
559        match &encoding.compression {
560            Some(Compression::General(cm)) => {
561                // Check inner encoding is RLE
562                match &cm.values.as_ref().unwrap().compression {
563                    Some(Compression::Rle(rle)) => {
564                        let Compression::Flat(values) =
565                            rle.values.as_ref().unwrap().compression.as_ref().unwrap()
566                        else {
567                            panic!("Expected flat for RLE values")
568                        };
569                        let Compression::Flat(run_lengths) = rle
570                            .run_lengths
571                            .as_ref()
572                            .unwrap()
573                            .compression
574                            .as_ref()
575                            .unwrap()
576                        else {
577                            panic!("Expected flat for RLE run lengths")
578                        };
579                        assert_eq!(values.bits_per_value, 32);
580                        assert_eq!(run_lengths.bits_per_value, 8);
581                    }
582                    _ => panic!("Expected RLE as inner encoding"),
583                }
584                // Check compression is LZ4
585                assert_eq!(
586                    cm.compression.as_ref().unwrap().scheme(),
587                    pb21::CompressionScheme::CompressionAlgorithmLz4
588                );
589            }
590            Some(Compression::Rle(_)) => {
591                // Also acceptable if compression didn't help
592            }
593            _ => panic!("Expected GeneralMiniBlock or Rle encoding"),
594        }
595
596        // Test case 2: 64-bit RLE data
597        let values_64: Vec<i64> = vec![100i64; 50]
598            .into_iter()
599            .chain(vec![200i64; 50])
600            .chain(vec![300i64; 50])
601            .collect();
602        let array_64 = arrow_array::Int64Array::from(values_64);
603        let block_64 = DataBlock::from_array(array_64);
604
605        let compressor_64 = GeneralMiniBlockCompressor::new(
606            Box::new(RleEncoder::new()),
607            CompressionConfig {
608                scheme: CompressionScheme::Lz4,
609                level: None,
610            },
611        );
612
613        let (_compressed_64, encoding_64) = compressor_64
614            .compress(miniblock_context(), block_64)
615            .unwrap();
616
617        // Verify the encoding structure for 64-bit
618        match &encoding_64.compression {
619            Some(Compression::General(cm)) => {
620                // Check inner encoding is RLE
621                match &cm.values.as_ref().unwrap().compression {
622                    Some(Compression::Rle(rle)) => {
623                        let Compression::Flat(values) =
624                            rle.values.as_ref().unwrap().compression.as_ref().unwrap()
625                        else {
626                            panic!("Expected flat for RLE values")
627                        };
628                        let Compression::Flat(run_lengths) = rle
629                            .run_lengths
630                            .as_ref()
631                            .unwrap()
632                            .compression
633                            .as_ref()
634                            .unwrap()
635                        else {
636                            panic!("Expected flat for RLE run lengths")
637                        };
638                        assert_eq!(values.bits_per_value, 64);
639                        assert_eq!(run_lengths.bits_per_value, 8);
640                    }
641                    _ => panic!("Expected RLE as inner encoding for 64-bit"),
642                }
643                // Check compression is LZ4
644                assert_eq!(
645                    cm.compression.as_ref().unwrap().scheme(),
646                    pb21::CompressionScheme::CompressionAlgorithmLz4
647                );
648            }
649            Some(Compression::Rle(_)) => {
650                // Also acceptable if compression didn't help
651            }
652            _ => panic!("Expected GeneralMiniBlock or Rle encoding for 64-bit"),
653        }
654    }
655
656    #[test]
657    fn test_compressed_mini_block_empty_data() {
658        let empty_array = Int32Array::from(vec![] as Vec<i32>);
659        let empty_block = DataBlock::from_array(empty_array);
660
661        let compressor = GeneralMiniBlockCompressor::new(
662            Box::new(ValueEncoder {}),
663            CompressionConfig {
664                scheme: CompressionScheme::Lz4,
665                level: None,
666            },
667        );
668
669        let result = compressor.compress(miniblock_context(), empty_block);
670        match result {
671            Ok((compressed, _)) => {
672                assert_eq!(compressed.num_values, 0);
673            }
674            Err(_) => {
675                // Empty data might not be supported by ValueEncoder
676            }
677        }
678    }
679}