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