Skip to main content

vortex_btrblocks/schemes/integer/
delta.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! FastLanes Delta 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::VTable;
12use vortex_array::arrays::PrimitiveArray;
13use vortex_compressor::builtins::BinaryDictScheme;
14use vortex_compressor::builtins::FloatDictScheme;
15use vortex_compressor::builtins::IntDictScheme;
16use vortex_compressor::builtins::StringDictScheme;
17use vortex_compressor::scheme::AncestorExclusion;
18use vortex_compressor::scheme::ChildSelection;
19use vortex_compressor::scheme::CompressionEstimate;
20use vortex_compressor::scheme::DeferredEstimate;
21use vortex_compressor::scheme::DescendantExclusion;
22use vortex_compressor::scheme::EstimateScore;
23use vortex_compressor::scheme::EstimateVerdict;
24use vortex_error::VortexResult;
25use vortex_fastlanes::Delta;
26
27use crate::ArrayAndStats;
28use crate::CascadingCompressor;
29use crate::CompressorContext;
30use crate::GenerateStatsOptions;
31use crate::Scheme;
32use crate::SchemeExt;
33
34/// FastLanes Delta encoding for smooth / near-monotone integers.
35///
36/// Delta replaces each value with its difference from an earlier value (at the FastLanes lane
37/// stride), so a later cascade layer (FoR / BitPacking) packs the smaller residuals. It only
38/// pays off when those residuals span meaningfully fewer bits than the values themselves.
39///
40/// The minimum penalized compression ratio required for Delta to be selected is configurable via
41/// [`DeltaScheme::new`]; [`DeltaScheme::default`] uses a ratio of `1.25`.
42#[derive(Debug, Copy, Clone, PartialEq)]
43pub struct DeltaScheme {
44    min_ratio: f64,
45}
46
47impl DeltaScheme {
48    /// Creates a Delta scheme requiring `min_ratio` after the delta penalty before it wins.
49    ///
50    /// Pass a higher ratio to make Delta more conservative, or a lower one to select it more
51    /// eagerly. [`DeltaScheme::default`] uses a ratio of `1.25`.
52    pub const fn new(min_ratio: f64) -> Self {
53        Self { min_ratio }
54    }
55}
56
57impl Default for DeltaScheme {
58    fn default() -> Self {
59        Self::new(1.25)
60    }
61}
62
63/// Multiplicative penalty applied to Delta's estimated compression ratio.
64///
65/// Unlike FoR/BitPacking, Delta breaks random access and adds a prefix-sum decode pass, and it
66/// carries a structural sign bit on its residuals. We therefore require Delta to be meaningfully
67/// (~5%) smaller than the best alternative before it wins, rather than picking it for a
68/// single-bit gain. This factor encodes that "delta tax".
69const DELTA_PENALTY: f64 = 0.95;
70
71/// Minimum length before Delta is worth considering (one FastLanes chunk).
72const MIN_DELTA_LEN: usize = 1024;
73
74impl Scheme for DeltaScheme {
75    fn scheme_name(&self) -> &'static str {
76        "vortex.int.delta"
77    }
78
79    fn matches(&self, canonical: &Canonical) -> bool {
80        canonical.dtype().is_int()
81    }
82
83    fn produced_encodings(&self) -> Vec<ArrayId> {
84        vec![Delta.id()]
85    }
86
87    fn num_children(&self) -> usize {
88        2
89    }
90
91    /// Delta-encode the data at most once per path: exclude Delta from the subtrees of both the
92    /// bases and the deltas children so we never delta-encode data that was already delta-encoded.
93    fn descendant_exclusions(&self) -> Vec<DescendantExclusion> {
94        vec![DescendantExclusion {
95            excluded: self.id(),
96            children: ChildSelection::All,
97        }]
98    }
99
100    /// Delta over dictionary codes just adds indirection: codes are compact integers with no
101    /// monotone structure, so (like FoR/Sequence) skip the codes child.
102    fn ancestor_exclusions(&self) -> Vec<AncestorExclusion> {
103        vec![
104            AncestorExclusion {
105                ancestor: IntDictScheme.id(),
106                children: ChildSelection::One(1),
107            },
108            AncestorExclusion {
109                ancestor: FloatDictScheme.id(),
110                children: ChildSelection::One(1),
111            },
112            AncestorExclusion {
113                ancestor: StringDictScheme.id(),
114                children: ChildSelection::One(1),
115            },
116            AncestorExclusion {
117                ancestor: BinaryDictScheme.id(),
118                children: ChildSelection::One(1),
119            },
120        ]
121    }
122
123    fn expected_compression_ratio(
124        &self,
125        data: &ArrayAndStats,
126        compress_ctx: CompressorContext,
127        _exec_ctx: &mut ExecutionCtx,
128    ) -> CompressionEstimate {
129        // Delta only pays off if a later cascade layer (FoR/BitPacking) packs the residuals.
130        if compress_ctx.finished_cascading() {
131            return CompressionEstimate::Verdict(EstimateVerdict::Skip);
132        }
133        // Too short to transpose into FastLanes chunks meaningfully.
134        if data.array_len() < MIN_DELTA_LEN {
135            return CompressionEstimate::Verdict(EstimateVerdict::Skip);
136        }
137
138        // Estimating Delta needs the real transposed-delta span, so defer to a callback that
139        // delta-encodes the array and measures the residual range.
140        let min_ratio = self.min_ratio;
141        CompressionEstimate::Deferred(DeferredEstimate::Callback(Box::new(
142            move |_compressor, data, best_so_far, _ctx, exec_ctx| {
143                let primitive = data.array().clone().execute::<PrimitiveArray>(exec_ctx)?;
144                let full_width = primitive.ptype().bit_width() as f64;
145
146                // Delta's best case is residuals collapsing to a single bit. If even that, after
147                // the penalty, can't beat the incumbent, skip before doing the encode work.
148                let threshold = best_so_far.and_then(EstimateScore::finite_ratio);
149                if threshold.is_some_and(|t| full_width * DELTA_PENALTY <= t) {
150                    return Ok(EstimateVerdict::Skip);
151                }
152
153                // Measure the actual FastLanes transposed-delta span. This is the lane-stride
154                // difference that gets bit-packed, not the lag-1 difference (which the transpose
155                // makes optimistic), so it is what truly drives the compressed size.
156                let (_bases, deltas) = vortex_fastlanes::delta_compress(&primitive, exec_ctx)?;
157                let delta_stats =
158                    ArrayAndStats::new(deltas.into_array(), GenerateStatsOptions::default());
159                let span = delta_stats.integer_stats(exec_ctx).erased().max_minus_min();
160
161                // Bits needed to FoR-pack the residuals. A zero span means constant deltas, which
162                // SequenceScheme already captures more cheaply, so defer to it.
163                let delta_bits = match span.checked_ilog2() {
164                    Some(l) => (l + 1) as f64,
165                    None => return Ok(EstimateVerdict::Skip),
166                };
167
168                let ratio = full_width / delta_bits * DELTA_PENALTY;
169                if ratio <= min_ratio {
170                    return Ok(EstimateVerdict::Skip);
171                }
172                Ok(EstimateVerdict::Ratio(ratio))
173            },
174        )))
175    }
176
177    fn compress(
178        &self,
179        compressor: &CascadingCompressor,
180        data: &ArrayAndStats,
181        compress_ctx: CompressorContext,
182        exec_ctx: &mut ExecutionCtx,
183    ) -> VortexResult<ArrayRef> {
184        let primitive = data.array().clone().execute::<PrimitiveArray>(exec_ctx)?;
185        let len = primitive.len();
186        let (bases, deltas) = vortex_fastlanes::delta_compress(&primitive, exec_ctx)?;
187
188        let compressed_bases = compressor.compress_child(
189            &bases.into_array(),
190            &compress_ctx,
191            self.id(),
192            0,
193            exec_ctx,
194        )?;
195        let compressed_deltas = compressor.compress_child(
196            &deltas.into_array(),
197            &compress_ctx,
198            self.id(),
199            1,
200            exec_ctx,
201        )?;
202
203        Delta::try_new(compressed_bases, compressed_deltas, 0, len).map(IntoArray::into_array)
204    }
205}