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