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