Skip to main content

vortex_btrblocks/schemes/integer/
for_.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Frame of Reference integer encoding.
5
6use vortex_array::ArrayId;
7use vortex_array::ArrayRef;
8use vortex_array::Canonical;
9use vortex_array::ExecutionCtx;
10use vortex_array::IntoArray;
11use vortex_array::arrays::PrimitiveArray;
12use vortex_compressor::builtins::BinaryDictScheme;
13use vortex_compressor::builtins::FloatDictScheme;
14use vortex_compressor::builtins::IntDictScheme;
15use vortex_compressor::builtins::StringDictScheme;
16use vortex_compressor::scheme::AncestorExclusion;
17use vortex_compressor::scheme::ChildSelection;
18use vortex_compressor::scheme::CompressionEstimate;
19use vortex_compressor::scheme::EstimateVerdict;
20use vortex_error::VortexExpect;
21use vortex_error::VortexResult;
22use vortex_fastlanes::FoR;
23use vortex_fastlanes::FoRArrayExt;
24use vortex_fastlanes::FoRArraySlotsExt;
25use vortex_fastlanes::for_v1_id;
26use vortex_fastlanes::for_v2_id;
27
28use super::BitPackingScheme;
29use crate::ArrayAndStats;
30use crate::CascadingCompressor;
31use crate::CompressorContext;
32use crate::Scheme;
33use crate::SchemeExt;
34
35#[derive(Debug, Copy, Clone, PartialEq, Eq)]
36enum FoRSchemeMode {
37    V1,
38    V2,
39}
40
41/// The FoR scheme that only produces single-reference arrays.
42pub(crate) static FOR_V1: FoRScheme = FoRScheme::v1();
43
44/// The FoR scheme that may produce one reference per 1024-element chunk.
45pub(crate) static FOR_V2: FoRScheme = FoRScheme::v2();
46
47/// Frame of Reference encoding.
48///
49/// The v1 mode subtracts one reference, the array's minimum. The v2 mode subtracts one reference
50/// per 1024-element chunk, the chunk's minimum. Arrays with a single reference, including v2
51/// arrays whose references compress to a constant, serialize as `fastlanes.for`, while per-chunk
52/// references serialize as `fastlanes.for.v2`.
53///
54/// The default uses v1. [`refine`](Scheme::refine) picks v2 when the v2 ID is allowed and v1
55/// otherwise.
56#[derive(Debug, Copy, Clone, PartialEq, Eq)]
57pub struct FoRScheme {
58    mode: FoRSchemeMode,
59}
60
61impl FoRScheme {
62    /// Creates a FoR scheme configured for v1, which uses a single reference.
63    pub const fn v1() -> Self {
64        Self {
65            mode: FoRSchemeMode::V1,
66        }
67    }
68
69    /// Creates a FoR scheme configured for v2, which may use one reference per chunk.
70    pub const fn v2() -> Self {
71        Self {
72            mode: FoRSchemeMode::V2,
73        }
74    }
75}
76
77impl Default for FoRScheme {
78    fn default() -> Self {
79        Self::v1()
80    }
81}
82
83impl Scheme for FoRScheme {
84    fn scheme_name(&self) -> &'static str {
85        "vortex.int.for"
86    }
87
88    fn matches(&self, canonical: &Canonical) -> bool {
89        canonical.dtype().is_int()
90    }
91
92    fn produced_encodings(&self) -> Vec<ArrayId> {
93        match self.mode {
94            FoRSchemeMode::V1 => vec![for_v1_id()],
95            FoRSchemeMode::V2 => vec![for_v1_id(), for_v2_id()],
96        }
97    }
98
99    fn refine(&self, allowed: &dyn Fn(&ArrayId) -> bool) -> &dyn Scheme {
100        if allowed(&for_v2_id()) {
101            &FOR_V2
102        } else {
103            &FOR_V1
104        }
105    }
106
107    /// Children: references=0 in v2 mode. The encoded child is always bit-packed.
108    fn num_children(&self) -> usize {
109        match self.mode {
110            FoRSchemeMode::V1 => 0,
111            FoRSchemeMode::V2 => 1,
112        }
113    }
114
115    /// Dict codes always start at 0, so FoR (which subtracts the min) is a no-op.
116    fn ancestor_exclusions(&self) -> Vec<AncestorExclusion> {
117        vec![
118            AncestorExclusion {
119                ancestor: IntDictScheme.id(),
120                children: ChildSelection::One(1),
121            },
122            AncestorExclusion {
123                ancestor: FloatDictScheme.id(),
124                children: ChildSelection::One(1),
125            },
126            AncestorExclusion {
127                ancestor: StringDictScheme.id(),
128                children: ChildSelection::One(1),
129            },
130            AncestorExclusion {
131                ancestor: BinaryDictScheme.id(),
132                children: ChildSelection::One(1),
133            },
134        ]
135    }
136
137    fn expected_compression_ratio(
138        &self,
139        data: &ArrayAndStats,
140        compress_ctx: CompressorContext,
141        exec_ctx: &mut ExecutionCtx,
142    ) -> CompressionEstimate {
143        // FoR only subtracts the min. Without further compression (e.g. BitPacking), the output is
144        // the same size.
145        if compress_ctx.finished_cascading() {
146            return CompressionEstimate::Verdict(EstimateVerdict::Skip);
147        }
148
149        // Both modes estimate single-reference FoR. This is conservative for v2: per-chunk
150        // references never pack wider than one reference, since each chunk's range lies within the
151        // array's, so the ratio can understate v2. It also skips arrays where only per-chunk
152        // references would help, such as those whose minimum is zero, or whose single-reference
153        // width is no narrower than plain BitPacking.
154        let stats = data.integer_stats(exec_ctx);
155
156        // Only apply when the min is not already zero.
157        if stats.erased().min_is_zero() {
158            return CompressionEstimate::Verdict(EstimateVerdict::Skip);
159        }
160
161        // Difference between max and min.
162        let for_bitwidth = match stats.erased().max_minus_min().checked_ilog2() {
163            Some(l) => l + 1,
164            // If max-min == 0, the we should be compressing this as a constant array.
165            None => return CompressionEstimate::Verdict(EstimateVerdict::Skip),
166        };
167
168        // If BitPacking can be applied (only non-negative values) and FoR doesn't reduce bit width
169        // compared to BitPacking, don't use FoR since it has a small amount of overhead (storing
170        // the reference) for effectively no benefits.
171        if let Some(max_log) = stats
172            .erased()
173            .max_ilog2()
174            // Only skip FoR when min >= 0, otherwise BitPacking can't be applied without ZigZag.
175            .filter(|_| !stats.erased().min_is_negative())
176        {
177            let bitpack_bitwidth = max_log + 1;
178            if for_bitwidth >= bitpack_bitwidth {
179                return CompressionEstimate::Verdict(EstimateVerdict::Skip);
180            }
181        }
182
183        let full_width: u32 = data
184            .array_as_primitive()
185            .ptype()
186            .bit_width()
187            .try_into()
188            .vortex_expect("bit width must fit in u32");
189
190        CompressionEstimate::Verdict(EstimateVerdict::Ratio(
191            full_width as f64 / for_bitwidth as f64,
192        ))
193    }
194
195    fn compress(
196        &self,
197        compressor: &CascadingCompressor,
198        data: &ArrayAndStats,
199        compress_ctx: CompressorContext,
200        exec_ctx: &mut ExecutionCtx,
201    ) -> VortexResult<ArrayRef> {
202        let primitive = data.array().clone().execute::<PrimitiveArray>(exec_ctx)?;
203        let for_array = match self.mode {
204            FoRSchemeMode::V1 => FoR::encode(primitive, exec_ctx)?,
205            FoRSchemeMode::V2 => FoR::encode_chunked(primitive, exec_ctx)?,
206        };
207        let biased = for_array
208            .encoded()
209            .clone()
210            .execute::<PrimitiveArray>(exec_ctx)?;
211
212        // Immediately bitpack. If any other scheme was preferable, it would be chosen instead
213        // of bitpacking.
214        // NOTE: we could delegate in the future if we had another downstream codec that performs
215        //  as well.
216        let leaf_ctx = compress_ctx.clone().as_leaf();
217        let biased_data =
218            ArrayAndStats::new(biased.into_array(), compress_ctx.merged_stats_options());
219        let compressed = BitPackingScheme.compress(compressor, &biased_data, leaf_ctx, exec_ctx)?;
220
221        // TODO(connor): This should really be `new_unchecked`.
222        let for_compressed = match for_array.constant_reference() {
223            Some(reference) => FoR::try_new(compressed, reference)?,
224            None => {
225                let references = compressor.compress_child(
226                    for_array.references(),
227                    &compress_ctx,
228                    self.id(),
229                    0,
230                    exec_ctx,
231                )?;
232                FoR::try_new_chunked(compressed, references, for_array.offset())?
233            }
234        };
235        for_compressed
236            .as_ref()
237            .statistics()
238            .inherit_from(for_array.as_ref().statistics());
239
240        Ok(for_compressed.into_array())
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use rstest::rstest;
247    use vortex_array::ArrayId;
248    use vortex_fastlanes::for_v1_id;
249    use vortex_fastlanes::for_v2_id;
250
251    use super::FOR_V1;
252    use super::FOR_V2;
253    use crate::Scheme;
254    use crate::SchemeExt;
255
256    /// Both variants refine to v2 exactly when the v2 ID is allowed.
257    #[rstest]
258    #[case::neither(false, false, false)]
259    #[case::v1(true, false, false)]
260    #[case::v2_only(false, true, true)]
261    #[case::both(true, true, true)]
262    fn refine_picks_v2_when_v2_id_is_allowed(
263        #[case] allow_v1: bool,
264        #[case] allow_v2: bool,
265        #[case] expect_v2: bool,
266        #[values(&FOR_V1, &FOR_V2)] scheme: &'static dyn Scheme,
267    ) {
268        let allowed =
269            |id: &ArrayId| (allow_v1 && *id == for_v1_id()) || (allow_v2 && *id == for_v2_id());
270        let refined = scheme.refine(&allowed);
271        assert_eq!(refined.id(), scheme.id());
272        let expected: &dyn Scheme = if expect_v2 { &FOR_V2 } else { &FOR_V1 };
273        assert_eq!(refined.produced_encodings(), expected.produced_encodings());
274    }
275}