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