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