Skip to main content

vortex_compressor/builtins/dict/
integer.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Integer-specific dictionary encoding implementation.
5//!
6//! Vortex encoders must always produce unsigned integer codes; signed codes are only accepted
7//! for external compatibility.
8
9use vortex_array::ArrayId;
10use vortex_array::ArrayRef;
11use vortex_array::ArrayView;
12use vortex_array::Canonical;
13use vortex_array::ExecutionCtx;
14use vortex_array::IntoArray;
15use vortex_array::VTable;
16use vortex_array::arrays::Dict;
17use vortex_array::arrays::DictArray;
18use vortex_array::arrays::Primitive;
19use vortex_array::arrays::PrimitiveArray;
20use vortex_array::arrays::dict::DictArrayExt;
21use vortex_array::arrays::dict::DictArraySlotsExt;
22use vortex_array::arrays::primitive::PrimitiveArrayExt;
23use vortex_array::validity::Validity;
24use vortex_buffer::Buffer;
25use vortex_error::VortexExpect;
26use vortex_error::VortexResult;
27
28use crate::CascadingCompressor;
29use crate::scheme::CompressionEstimate;
30use crate::scheme::CompressorContext;
31use crate::scheme::EstimateVerdict;
32use crate::scheme::Scheme;
33use crate::scheme::SchemeExt;
34use crate::stats::ArrayAndStats;
35use crate::stats::GenerateStatsOptions;
36use crate::stats::IntegerErasedStats;
37use crate::stats::IntegerStats;
38
39/// Dictionary encoding for low-cardinality integer values.
40#[derive(Debug, Copy, Clone, PartialEq, Eq)]
41pub struct IntDictScheme;
42
43impl Scheme for IntDictScheme {
44    fn scheme_name(&self) -> &'static str {
45        "vortex.int.dict"
46    }
47
48    fn matches(&self, canonical: &Canonical) -> bool {
49        canonical.dtype().is_int()
50    }
51
52    fn produced_encodings(&self) -> Vec<ArrayId> {
53        vec![Dict.id()]
54    }
55
56    fn stats_options(&self) -> GenerateStatsOptions {
57        GenerateStatsOptions {
58            count_distinct_values: true,
59        }
60    }
61
62    /// Children: values=0, codes=1.
63    fn num_children(&self) -> usize {
64        2
65    }
66
67    fn expected_compression_ratio(
68        &self,
69        data: &ArrayAndStats,
70        _compress_ctx: CompressorContext,
71        exec_ctx: &mut ExecutionCtx,
72    ) -> CompressionEstimate {
73        let bit_width = data.array_as_primitive().ptype().bit_width();
74        let stats = data.integer_stats(exec_ctx);
75
76        if stats.value_count() == 0 {
77            return CompressionEstimate::Verdict(EstimateVerdict::Skip);
78        }
79
80        let distinct_values_count = stats.distinct_count().vortex_expect(
81            "this must be present since `DictScheme` declared that we need distinct values",
82        );
83
84        // If > 50% of the values are distinct, skip dictionary scheme.
85        if distinct_values_count > stats.value_count() / 2 {
86            return CompressionEstimate::Verdict(EstimateVerdict::Skip);
87        }
88
89        // Ignore nulls encoding for the estimate. We only focus on values.
90
91        let values_size = bit_width * distinct_values_count as usize;
92
93        // TODO(connor): Should we just hardcode this instead of let the compressor choose?
94        // Assume codes are compressed RLE + BitPacking.
95        let codes_bw = u32::BITS - distinct_values_count.leading_zeros();
96
97        let n_runs = (stats.value_count() / stats.average_run_length()) as usize;
98
99        // Assume that codes will either be BitPack or RLE-BitPack.
100        let codes_size_bp = codes_bw as usize * stats.value_count() as usize;
101        let codes_size_rle_bp = usize::checked_mul(codes_bw as usize + 32, n_runs);
102
103        let codes_size = usize::min(codes_size_bp, codes_size_rle_bp.unwrap_or(usize::MAX));
104
105        let before = stats.value_count() as usize * bit_width;
106
107        CompressionEstimate::Verdict(EstimateVerdict::Ratio(
108            before as f64 / (values_size + codes_size) as f64,
109        ))
110    }
111
112    fn compress(
113        &self,
114        compressor: &CascadingCompressor,
115        data: &ArrayAndStats,
116        compress_ctx: CompressorContext,
117        exec_ctx: &mut ExecutionCtx,
118    ) -> VortexResult<ArrayRef> {
119        let stats = data.integer_stats(exec_ctx);
120        let dict = dictionary_encode(data.array_as_primitive(), &stats)?;
121
122        // Values = child 0.
123        let compressed_values =
124            compressor.compress_child(dict.values(), &compress_ctx, self.id(), 0, exec_ctx)?;
125
126        // Codes = child 1.
127        let narrowed_codes = dict
128            .codes()
129            .clone()
130            .execute::<PrimitiveArray>(exec_ctx)?
131            .narrow(exec_ctx)?
132            .into_array();
133        let compressed_codes =
134            compressor.compress_child(&narrowed_codes, &compress_ctx, self.id(), 1, exec_ctx)?;
135
136        // SAFETY: compressing codes does not change their values.
137        unsafe {
138            Ok(
139                DictArray::new_unchecked(compressed_codes, compressed_values)
140                    .set_all_values_referenced(dict.has_all_values_referenced())
141                    .into_array(),
142            )
143        }
144    }
145}
146
147/// Encodes a typed integer array into a [`DictArray`] using the pre-computed distinct values.
148macro_rules! typed_encode {
149    ($source_array:ident, $stats:ident, $typed:ident, $typ:ty) => {{
150        let distinct = $typed.distinct().vortex_expect(
151            "this must be present since `DictScheme` declared that we need distinct values",
152        );
153
154        let values_validity = match $source_array.validity()? {
155            Validity::NonNullable => Validity::NonNullable,
156            _ => Validity::AllValid,
157        };
158        let codes_validity = $source_array.validity()?;
159
160        let values: Buffer<$typ> = distinct.distinct_values().keys().map(|x| x.0).collect();
161
162        let max_code = values.len();
163        let codes = if max_code <= u8::MAX as usize {
164            let buf = <DictEncoder as Encode<$typ, u8>>::encode(
165                &values,
166                $source_array.as_slice::<$typ>(),
167            );
168            PrimitiveArray::new(buf, codes_validity).into_array()
169        } else if max_code <= u16::MAX as usize {
170            let buf = <DictEncoder as Encode<$typ, u16>>::encode(
171                &values,
172                $source_array.as_slice::<$typ>(),
173            );
174            PrimitiveArray::new(buf, codes_validity).into_array()
175        } else {
176            let buf = <DictEncoder as Encode<$typ, u32>>::encode(
177                &values,
178                $source_array.as_slice::<$typ>(),
179            );
180            PrimitiveArray::new(buf, codes_validity).into_array()
181        };
182
183        let values = PrimitiveArray::new(values, values_validity).into_array();
184        // SAFETY: invariants enforced in DictEncoder.
185        Ok(unsafe { DictArray::new_unchecked(codes, values).set_all_values_referenced(true) })
186    }};
187}
188
189/// Compresses an integer array into a dictionary array according to attached stats.
190///
191/// # Errors
192///
193/// Returns an error if unable to compute validity.
194#[expect(
195    clippy::cognitive_complexity,
196    reason = "complexity from match on all integer types"
197)]
198pub fn dictionary_encode(
199    array: ArrayView<'_, Primitive>,
200    stats: &IntegerStats,
201) -> VortexResult<DictArray> {
202    match stats.erased() {
203        IntegerErasedStats::U8(typed) => typed_encode!(array, stats, typed, u8),
204        IntegerErasedStats::U16(typed) => typed_encode!(array, stats, typed, u16),
205        IntegerErasedStats::U32(typed) => typed_encode!(array, stats, typed, u32),
206        IntegerErasedStats::U64(typed) => typed_encode!(array, stats, typed, u64),
207        IntegerErasedStats::I8(typed) => typed_encode!(array, stats, typed, i8),
208        IntegerErasedStats::I16(typed) => typed_encode!(array, stats, typed, i16),
209        IntegerErasedStats::I32(typed) => typed_encode!(array, stats, typed, i32),
210        IntegerErasedStats::I64(typed) => typed_encode!(array, stats, typed, i64),
211    }
212}
213
214/// Stateless encoder that maps values to dictionary codes via a `HashMap`.
215struct DictEncoder;
216
217/// Trait for encoding values of type `T` into codes of type `I`.
218trait Encode<T, I> {
219    /// Using the distinct value set, turn the values into a set of codes.
220    fn encode(distinct: &[T], values: &[T]) -> Buffer<I>;
221}
222
223/// Implements [`Encode`] for an integer type with all code width variants (u8, u16, u32).
224macro_rules! impl_encode {
225    ($typ:ty) => { impl_encode!($typ, u8, u16, u32); };
226    ($typ:ty, $($ityp:ty),+) => {
227        $(
228        impl Encode<$typ, $ityp> for DictEncoder {
229            #[expect(clippy::cast_possible_truncation)]
230            fn encode(distinct: &[$typ], values: &[$typ]) -> Buffer<$ityp> {
231                let mut codes =
232                    vortex_utils::aliases::hash_map::HashMap::<$typ, $ityp>::with_capacity(
233                        distinct.len(),
234                    );
235                for (code, &value) in distinct.iter().enumerate() {
236                    codes.insert(value, code as $ityp);
237                }
238
239                let mut output = vortex_buffer::BufferMut::with_capacity(values.len());
240                for value in values {
241                    // Any code lookups which fail are for nulls, so their value does not matter.
242                    // SAFETY: we have exactly sized output to be as large as values.
243                    unsafe { output.push_unchecked(codes.get(value).copied().unwrap_or_default()) };
244                }
245
246                output.freeze()
247            }
248        }
249        )*
250    };
251}
252
253impl_encode!(u8);
254impl_encode!(u16);
255impl_encode!(u32);
256impl_encode!(u64);
257impl_encode!(i8);
258impl_encode!(i16);
259impl_encode!(i32);
260impl_encode!(i64);
261
262#[cfg(test)]
263mod tests {
264    use vortex_array::IntoArray;
265    use vortex_array::VortexSessionExecute;
266    use vortex_array::arrays::BoolArray;
267    use vortex_array::arrays::PrimitiveArray;
268    use vortex_array::arrays::dict::DictArraySlotsExt;
269    use vortex_array::assert_arrays_eq;
270    use vortex_array::validity::Validity;
271    use vortex_buffer::buffer;
272    use vortex_error::VortexResult;
273
274    use super::dictionary_encode;
275    use crate::stats::IntegerStats;
276
277    #[test]
278    fn test_dict_encode_integer_stats() -> VortexResult<()> {
279        let mut ctx = vortex_array::array_session().create_execution_ctx();
280        let data = buffer![100i32, 200, 100, 0, 100];
281        let validity =
282            Validity::Array(BoolArray::from_iter([true, true, true, false, true]).into_array());
283        let array = PrimitiveArray::new(data, validity);
284
285        let stats = IntegerStats::generate_opts(
286            &array,
287            crate::stats::GenerateStatsOptions {
288                count_distinct_values: true,
289            },
290            &mut ctx,
291        );
292        let dict_array = dictionary_encode(array.as_view(), &stats)?;
293        assert_eq!(dict_array.values().len(), 2);
294        assert_eq!(dict_array.codes().len(), 5);
295
296        let expected = PrimitiveArray::new(
297            buffer![100i32, 200, 100, 100, 100],
298            Validity::Array(BoolArray::from_iter([true, true, true, false, true]).into_array()),
299        )
300        .into_array();
301        let undict = dict_array
302            .as_array()
303            .clone()
304            .execute::<PrimitiveArray>(&mut ctx)?
305            .into_array();
306        assert_arrays_eq!(undict, expected, &mut ctx);
307        Ok(())
308    }
309}