Skip to main content

vortex_compressor/compressor/
cascade.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Core cascading compression flow.
5
6use vortex_array::ArrayRef;
7use vortex_array::Canonical;
8use vortex_array::CanonicalValidity;
9use vortex_array::ExecutionCtx;
10use vortex_array::IntoArray;
11use vortex_array::arrays::Constant;
12use vortex_array::arrays::ConstantArray;
13use vortex_array::arrays::ExtensionArray;
14use vortex_array::arrays::FixedSizeListArray;
15use vortex_array::arrays::Masked;
16use vortex_array::arrays::StructArray;
17use vortex_array::arrays::UnionArray;
18use vortex_array::arrays::Variant;
19use vortex_array::arrays::VariantArray;
20use vortex_array::arrays::extension::ExtensionArrayExt;
21use vortex_array::arrays::fixed_size_list::FixedSizeListArrayExt;
22use vortex_array::arrays::fixed_size_list::FixedSizeListArraySlotsExt;
23use vortex_array::arrays::listview::ListViewArraySlotsExt;
24use vortex_array::arrays::listview::list_from_list_view;
25use vortex_array::arrays::masked::MaskedArraySlotsExt;
26use vortex_array::arrays::scalar_fn::AnyScalarFn;
27use vortex_array::arrays::struct_::StructArrayExt;
28use vortex_array::arrays::union::UnionArrayExt;
29use vortex_array::arrays::variant::VariantArraySlotsExt;
30use vortex_array::scalar::Scalar;
31use vortex_error::VortexResult;
32
33use super::CascadingCompressor;
34use super::constant;
35use crate::scheme::CompressorContext;
36use crate::scheme::Scheme;
37use crate::scheme::SchemeExt;
38use crate::scheme::SchemeId;
39use crate::stats::ArrayAndStats;
40use crate::stats::GenerateStatsOptions;
41use crate::trace;
42
43impl CascadingCompressor {
44    /// Compresses an array using cascading adaptive compression.
45    ///
46    /// First canonicalizes and compacts the array, then applies optimal compression schemes.
47    ///
48    /// # Errors
49    ///
50    /// Returns an error if canonicalization or compression fails.
51    pub fn compress(
52        &self,
53        array: &ArrayRef,
54        exec_ctx: &mut ExecutionCtx,
55    ) -> VortexResult<ArrayRef> {
56        let before_nbytes = array.nbytes();
57        let span = trace::compress_span(array.len(), array.dtype(), before_nbytes);
58        let _enter = span.enter();
59
60        let canonical = array.clone().execute::<CanonicalValidity>(exec_ctx)?.0;
61        let compact = canonical.compact(exec_ctx)?;
62        let compressed = self.compress_canonical(compact, CompressorContext::new(), exec_ctx)?;
63
64        trace::record_compress_outcome(&span, before_nbytes, compressed.nbytes());
65
66        Ok(compressed)
67    }
68
69    /// Compresses a child array produced by a cascading scheme.
70    ///
71    /// If the cascade budget is exhausted, the canonical array is returned as-is. Otherwise, the
72    /// child context is created by descending and recording the parent scheme + child index, and
73    /// compression proceeds normally.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error if compression fails.
78    pub fn compress_child(
79        &self,
80        child: &ArrayRef,
81        parent_ctx: &CompressorContext,
82        parent_id: SchemeId,
83        child_index: usize,
84        exec_ctx: &mut ExecutionCtx,
85    ) -> VortexResult<ArrayRef> {
86        if parent_ctx.finished_cascading() {
87            trace::cascade_exhausted(parent_id, child_index);
88            return Ok(child.clone());
89        }
90
91        let canonical = child.clone().execute::<CanonicalValidity>(exec_ctx)?.0;
92        let compact = canonical.compact(exec_ctx)?;
93
94        let child_ctx = parent_ctx
95            .clone()
96            .descend_with_scheme(parent_id, child_index);
97        self.compress_canonical(compact, child_ctx, exec_ctx)
98    }
99
100    /// Compresses a canonical array by dispatching to type-specific logic.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if compression of any sub-array fails.
105    pub(super) fn compress_canonical(
106        &self,
107        array: Canonical,
108        compress_ctx: CompressorContext,
109        exec_ctx: &mut ExecutionCtx,
110    ) -> VortexResult<ArrayRef> {
111        match array {
112            Canonical::Null(null_array) => Ok(null_array.into_array()),
113            Canonical::Bool(bool_array) => {
114                self.choose_and_compress(Canonical::Bool(bool_array), compress_ctx, exec_ctx)
115            }
116            Canonical::Primitive(primitive) => {
117                self.choose_and_compress(Canonical::Primitive(primitive), compress_ctx, exec_ctx)
118            }
119            Canonical::Decimal(decimal) => {
120                self.choose_and_compress(Canonical::Decimal(decimal), compress_ctx, exec_ctx)
121            }
122            Canonical::Struct(struct_array) => {
123                let fields = struct_array
124                    .iter_unmasked_fields()
125                    .map(|field| self.compress(field, exec_ctx))
126                    .collect::<Result<Vec<_>, _>>()?;
127
128                Ok(StructArray::try_new(
129                    struct_array.names().clone(),
130                    fields,
131                    struct_array.len(),
132                    struct_array.validity()?,
133                )?
134                .into_array())
135            }
136            Canonical::Union(union_array) => {
137                let type_ids = self.compress(union_array.type_ids(), exec_ctx)?;
138                let children = union_array
139                    .iter_children()
140                    .map(|child| self.compress(child, exec_ctx))
141                    .collect::<Result<Vec<_>, _>>()?;
142
143                Ok(
144                    UnionArray::try_new(type_ids, union_array.variants().clone(), children)?
145                        .into_array(),
146                )
147            }
148            Canonical::List(list_view_array) => {
149                if list_view_array.is_zero_copy_to_list() || list_view_array.elements().is_empty() {
150                    let list_array = list_from_list_view(list_view_array, exec_ctx)?;
151                    self.compress_list_array(list_array, compress_ctx, exec_ctx)
152                } else {
153                    self.compress_list_view_array(list_view_array, compress_ctx, exec_ctx)
154                }
155            }
156            Canonical::FixedSizeList(fsl_array) => {
157                let compressed_elems = self.compress(fsl_array.elements(), exec_ctx)?;
158
159                Ok(FixedSizeListArray::try_new(
160                    compressed_elems,
161                    fsl_array.list_size(),
162                    fsl_array.validity()?,
163                    fsl_array.len(),
164                )?
165                .into_array())
166            }
167            Canonical::VarBinView(varbinview) => {
168                self.choose_and_compress(Canonical::VarBinView(varbinview), compress_ctx, exec_ctx)
169            }
170            Canonical::Extension(ext_array) => {
171                // Try scheme-based compression first.
172                let scheme_compressed = self.choose_and_compress(
173                    Canonical::Extension(ext_array.clone()),
174                    compress_ctx,
175                    exec_ctx,
176                )?;
177                // TODO(connor): HACK TO SUPPORT L2 DENORMALIZATION!!!
178                if scheme_compressed.is::<AnyScalarFn>() {
179                    return Ok(scheme_compressed);
180                }
181
182                // A constant extension array (that might be masked) is already in its terminal
183                // representation, and compressing the storage separately cannot do better.
184                if scheme_compressed.is::<Constant>() {
185                    return Ok(scheme_compressed);
186                }
187                if let Some(masked) = scheme_compressed.as_opt::<Masked>()
188                    && masked.child().is::<Constant>()
189                {
190                    return Ok(scheme_compressed);
191                }
192
193                // Also compress the underlying storage array. Some extension schemes can beat the
194                // extension storage but still lose to ordinary storage compression.
195                let compressed_storage = self.compress(ext_array.storage_array(), exec_ctx)?;
196                let storage_compressed =
197                    ExtensionArray::new(ext_array.ext_dtype().clone(), compressed_storage)
198                        .into_array();
199
200                if scheme_compressed.nbytes() < storage_compressed.nbytes() {
201                    Ok(scheme_compressed)
202                } else {
203                    Ok(storage_compressed)
204                }
205            }
206            Canonical::Variant(variant_array) => {
207                let core_storage =
208                    self.compress_physical_slots(variant_array.core_storage(), exec_ctx)?;
209                let shredded = variant_array
210                    .shredded()
211                    .map(|arr| {
212                        // Avoid stack-overflow for variant shredded values
213                        if arr.is::<Variant>() {
214                            self.compress_physical_slots(arr, exec_ctx)
215                        } else {
216                            self.compress(arr, exec_ctx)
217                        }
218                    })
219                    .transpose()?;
220
221                Ok(VariantArray::try_new(core_storage, shredded)?.into_array())
222            }
223        }
224    }
225
226    /// The main scheme-selection entry point for a single leaf array.
227    ///
228    /// Filters allowed schemes by [`matches`] and exclusion rules, merges their [`stats_options`]
229    /// into a single [`GenerateStatsOptions`], and picks the winner by estimated compression
230    /// ratio.
231    ///
232    /// If a winner is found and its compressed output is actually smaller, that output is
233    /// returned. Otherwise, the original array is returned unchanged.
234    ///
235    /// Empty, all-null, and constant arrays are handled by the compressor itself before any
236    /// scheme evaluation (constant detection is skipped while compressing samples).
237    ///
238    /// [`matches`]: Scheme::matches
239    /// [`stats_options`]: Scheme::stats_options
240    fn choose_and_compress(
241        &self,
242        canonical: Canonical,
243        compress_ctx: CompressorContext,
244        exec_ctx: &mut ExecutionCtx,
245    ) -> VortexResult<ArrayRef> {
246        let eligible_schemes: Vec<&'static dyn Scheme> = self
247            .schemes
248            .iter()
249            .copied()
250            .filter(|s| s.matches(&canonical) && !self.is_excluded(*s, &compress_ctx))
251            .collect();
252
253        let array: ArrayRef = canonical.into();
254
255        if array.is_empty() {
256            return Ok(array);
257        }
258
259        if array.all_invalid(exec_ctx)? {
260            return Ok(
261                ConstantArray::new(Scalar::null(array.dtype().clone()), array.len()).into_array(),
262            );
263        }
264
265        let before_nbytes = array.nbytes();
266
267        let merged_opts = eligible_schemes
268            .iter()
269            .fold(GenerateStatsOptions::default(), |acc, s| {
270                acc.merge(s.stats_options())
271            });
272        let compress_ctx = compress_ctx.with_merged_stats_options(merged_opts);
273
274        let data = ArrayAndStats::new(array, merged_opts);
275
276        // Constant detection is built into the compressor: a constant leaf always short-circuits
277        // scheme selection. Samples are exempt because a constant sample does not imply that the
278        // full array is constant.
279        if !compress_ctx.is_sample() && constant::is_constant_for_compression(&data, exec_ctx)? {
280            let _winner_span =
281                trace::winner_compress_span(constant::CONSTANT_SCHEME_ID, before_nbytes).entered();
282            let compressed = constant::compress_constant(data.array(), exec_ctx)?;
283
284            let after_nbytes = compressed.nbytes();
285            let actual_ratio =
286                (after_nbytes != 0).then(|| before_nbytes as f64 / after_nbytes as f64);
287            let accepted = after_nbytes < before_nbytes;
288            trace::record_winner_compress_result(after_nbytes, None, actual_ratio, accepted);
289
290            return if accepted {
291                Ok(compressed)
292            } else {
293                Ok(data.into_array())
294            };
295        }
296
297        if eligible_schemes.is_empty() {
298            return Ok(data.into_array());
299        }
300
301        let Some((winner, winner_estimate)) =
302            self.choose_best_scheme(&eligible_schemes, &data, compress_ctx.clone(), exec_ctx)?
303        else {
304            return Ok(data.into_array());
305        };
306
307        // Run the winning scheme's `compress`. On failure, emit an ERROR event carrying the
308        // scheme name and cascade history before propagating.
309        let error_ctx = trace::enabled_error_context(&compress_ctx);
310        let _winner_span = trace::winner_compress_span(winner.id(), before_nbytes).entered();
311        let compressed = winner
312            .compress(self, &data, compress_ctx, exec_ctx)
313            .inspect_err(|err| {
314                // NB: this is the only way we can tell which scheme panicked / bailed on their
315                // data, especially for third-party schemes where the error site may not carry any
316                // compressor context.
317                trace::scheme_compress_failed(winner.id(), before_nbytes, error_ctx.as_ref(), err);
318            })?;
319
320        let after_nbytes = compressed.nbytes();
321        let actual_ratio = (after_nbytes != 0).then(|| before_nbytes as f64 / after_nbytes as f64);
322
323        // TODO(connor): HACK TO SUPPORT L2 DENORMALIZATION!!!
324        let accepted = after_nbytes < before_nbytes || compressed.is::<AnyScalarFn>();
325
326        trace::record_winner_compress_result(
327            after_nbytes,
328            winner_estimate.trace_ratio(),
329            actual_ratio,
330            accepted,
331        );
332
333        if accepted {
334            Ok(compressed)
335        } else {
336            Ok(data.into_array())
337        }
338    }
339}