Skip to main content

vortex_file/
strategy.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! This module defines the default layout strategy for a Vortex file.
5
6use std::num::NonZeroUsize;
7use std::sync::Arc;
8use std::sync::LazyLock;
9
10use vortex_alp::ALP;
11use vortex_alp::ALPRD;
12use vortex_array::ArrayId;
13use vortex_array::VTable;
14use vortex_array::arrays::Bool;
15use vortex_array::arrays::Chunked;
16use vortex_array::arrays::Constant;
17use vortex_array::arrays::Decimal;
18use vortex_array::arrays::Dict;
19use vortex_array::arrays::Extension;
20use vortex_array::arrays::FixedSizeList;
21use vortex_array::arrays::List;
22use vortex_array::arrays::ListView;
23use vortex_array::arrays::Masked;
24use vortex_array::arrays::Null;
25use vortex_array::arrays::Patched;
26use vortex_array::arrays::Primitive;
27use vortex_array::arrays::Struct;
28use vortex_array::arrays::VarBin;
29use vortex_array::arrays::VarBinView;
30use vortex_array::arrays::Variant;
31use vortex_array::arrays::patched::use_experimental_patches;
32use vortex_array::dtype::FieldPath;
33use vortex_btrblocks::BtrBlocksCompressorBuilder;
34use vortex_btrblocks::SchemeExt;
35use vortex_btrblocks::schemes::integer::IntDictScheme;
36use vortex_bytebool::ByteBool;
37use vortex_datetime_parts::DateTimeParts;
38use vortex_decimal_byte_parts::DecimalByteParts;
39use vortex_error::VortexExpect;
40use vortex_fastlanes::BitPacked;
41use vortex_fastlanes::Delta;
42use vortex_fastlanes::FoR;
43use vortex_fastlanes::RLE;
44use vortex_fsst::FSST;
45use vortex_layout::LayoutStrategy;
46use vortex_layout::LayoutStrategyEncodingValidator;
47use vortex_layout::layouts::buffered::BufferedStrategy;
48use vortex_layout::layouts::chunked::writer::ChunkedLayoutStrategy;
49use vortex_layout::layouts::collect::CollectStrategy;
50use vortex_layout::layouts::compressed::CompressingStrategy;
51use vortex_layout::layouts::compressed::CompressorPlugin;
52use vortex_layout::layouts::dict::writer::DictStrategy;
53use vortex_layout::layouts::flat::writer::FlatLayoutStrategy;
54use vortex_layout::layouts::list::writer::ListLayoutStrategy;
55use vortex_layout::layouts::repartition::RepartitionStrategy;
56use vortex_layout::layouts::repartition::RepartitionWriterOptions;
57use vortex_layout::layouts::table::TableStrategy;
58use vortex_layout::layouts::table::use_experimental_list_layout;
59use vortex_layout::layouts::zoned::writer::ZonedLayoutOptions;
60use vortex_layout::layouts::zoned::writer::ZonedStrategy;
61#[cfg(feature = "unstable_encodings")]
62use vortex_onpair::OnPair;
63use vortex_pco::Pco;
64use vortex_runend::RunEnd;
65use vortex_sequence::Sequence;
66use vortex_sparse::Sparse;
67use vortex_utils::aliases::hash_map::HashMap;
68use vortex_utils::aliases::hash_set::HashSet;
69use vortex_zigzag::ZigZag;
70#[cfg(feature = "zstd")]
71use vortex_zstd::Zstd;
72#[cfg(all(feature = "zstd", feature = "unstable_encodings"))]
73use vortex_zstd::ZstdBuffers;
74
75const ONE_MEG: u64 = 1 << 20;
76
77/// Static registry of all allowed array encodings for file writing.
78///
79/// This includes all canonical encodings from vortex-array plus all compressed
80/// encodings from the various encoding crates.
81pub static ALLOWED_ENCODINGS: LazyLock<HashSet<ArrayId>> = LazyLock::new(|| {
82    let mut allowed = HashSet::new();
83
84    // Canonical encodings from vortex-array
85    allowed.insert(Null.id());
86    allowed.insert(Bool.id());
87    allowed.insert(Primitive.id());
88    allowed.insert(Decimal.id());
89    allowed.insert(VarBin.id());
90    allowed.insert(VarBinView.id());
91    allowed.insert(List.id());
92    allowed.insert(ListView.id());
93    allowed.insert(FixedSizeList.id());
94    allowed.insert(Struct.id());
95    allowed.insert(Extension.id());
96    allowed.insert(Chunked.id());
97    allowed.insert(Constant.id());
98    allowed.insert(Masked.id());
99    allowed.insert(Dict.id());
100    allowed.insert(Variant.id());
101
102    // Compressed encodings from encoding crates
103    allowed.insert(ALP.id());
104    allowed.insert(ALPRD.id());
105    allowed.insert(BitPacked.id());
106    allowed.insert(ByteBool.id());
107    allowed.insert(DateTimeParts.id());
108    allowed.insert(DecimalByteParts.id());
109    allowed.insert(Delta.id());
110    allowed.insert(FoR.id());
111    allowed.insert(FSST.id());
112    #[cfg(feature = "unstable_encodings")]
113    allowed.insert(OnPair.id());
114    allowed.insert(Pco.id());
115    allowed.insert(RLE.id());
116    allowed.insert(RunEnd.id());
117    allowed.insert(Sequence.id());
118    allowed.insert(Sparse.id());
119    allowed.insert(ZigZag.id());
120
121    // Experimental encodings
122
123    if use_experimental_patches() {
124        allowed.insert(Patched.id());
125    }
126
127    #[cfg(feature = "zstd")]
128    allowed.insert(Zstd.id());
129    #[cfg(all(feature = "zstd", feature = "unstable_encodings"))]
130    allowed.insert(ZstdBuffers.id());
131
132    allowed
133});
134
135/// How the compressor was configured on [`WriteStrategyBuilder`].
136enum CompressorConfig {
137    /// A [`BtrBlocksCompressorBuilder`] that [`WriteStrategyBuilder::build`] will finalize.
138    /// `IntDictScheme` is automatically excluded from the data compressor to prevent recursive
139    /// dictionary encoding.
140    BtrBlocks(BtrBlocksCompressorBuilder),
141    /// An opaque compressor used as-is for both data and stats compression.
142    Opaque(Arc<dyn CompressorPlugin>),
143}
144
145/// Build a new [writer strategy](LayoutStrategy) to compress and reorganize chunks of a Vortex
146/// file.
147///
148/// Vortex provides an out-of-the-box file writer that optimizes the layout of chunks on-disk,
149/// repartitioning and compressing them to strike a balance between size on-disk,
150/// bulk decoding performance, and IOPS required to perform an indexed read.
151///
152/// The default pipeline first splits struct columns, repartitions rows into fixed-size row blocks,
153/// computes zoned statistics, applies dictionary encoding where useful, coalesces chunks toward
154/// segment-sized blocks, compresses arrays, buffers nearby chunks, and finally writes flat leaf
155/// layouts.
156pub struct WriteStrategyBuilder {
157    compressor: CompressorConfig,
158    row_block_size: usize,
159    field_writers: HashMap<FieldPath, Arc<dyn LayoutStrategy>>,
160    allow_encodings: Option<HashSet<ArrayId>>,
161    flat_strategy: Option<Arc<dyn LayoutStrategy>>,
162    probe_compressor: Option<Arc<dyn CompressorPlugin>>,
163    /// Whether to write list fields using [`ListLayoutStrategy`].
164    ///
165    /// [`ListLayoutStrategy`]: vortex_layout::layouts::list::writer::ListLayoutStrategy
166    use_list_layout: bool,
167}
168
169impl Default for WriteStrategyBuilder {
170    /// Create a new empty builder. It can be further configured,
171    /// and then finally built yielding the [`LayoutStrategy`].
172    fn default() -> Self {
173        Self {
174            compressor: CompressorConfig::BtrBlocks(BtrBlocksCompressorBuilder::default()),
175            row_block_size: 8192,
176            field_writers: HashMap::new(),
177            allow_encodings: Some(ALLOWED_ENCODINGS.clone()),
178            flat_strategy: None,
179            probe_compressor: None,
180            use_list_layout: use_experimental_list_layout(),
181        }
182    }
183}
184
185impl WriteStrategyBuilder {
186    /// Override the row block size used for row repartitioning and zoned statistics.
187    ///
188    /// Larger blocks reduce footer/statistics overhead. Smaller blocks can improve pruning and
189    /// random-access locality.
190    pub fn with_row_block_size(mut self, row_block_size: usize) -> Self {
191        self.row_block_size = row_block_size;
192        self
193    }
194
195    /// Enable writing list fields with [`ListLayoutStrategy`].
196    ///
197    /// **Note**: this is an unstable and experimental layout that is expected to change.
198    /// Using it may lead to unreadable files in the future.
199    ///
200    /// [`ListLayoutStrategy`]: vortex_layout::layouts::list::writer::ListLayoutStrategy
201    pub fn with_list_layout(mut self) -> Self {
202        self.use_list_layout = true;
203        self
204    }
205
206    /// Override the write layout for a specific field somewhere in the nested schema tree.
207    ///
208    /// The field path is matched after the root struct is split into columns. This is useful when a
209    /// column needs a custom compression/layout policy while the rest of the file uses defaults.
210    pub fn with_field_writer(
211        mut self,
212        field: impl Into<FieldPath>,
213        writer: Arc<dyn LayoutStrategy>,
214    ) -> Self {
215        self.field_writers.insert(field.into(), writer);
216        self
217    }
218
219    /// Override the allowed array encodings for normalization.
220    ///
221    /// The configured flat leaf strategy is wrapped in a [`LayoutStrategyEncodingValidator`]
222    /// that recursively checks every chunk before passing it to the leaf writer.
223    pub fn with_allow_encodings(mut self, allow_encodings: HashSet<ArrayId>) -> Self {
224        self.allow_encodings = Some(allow_encodings);
225        self
226    }
227
228    /// Override the flat layout strategy used for leaf chunks.
229    ///
230    /// By default, this uses [`FlatLayoutStrategy`]. This can be used to substitute a custom
231    /// layout strategy, e.g. one that inlines constant array buffers for GPU reads.
232    pub fn with_flat_strategy(mut self, flat: Arc<dyn LayoutStrategy>) -> Self {
233        self.flat_strategy = Some(flat);
234        self
235    }
236
237    /// Override the default [`BtrBlocksCompressorBuilder`] used for compression.
238    ///
239    /// The builder is finalized during [`build`](Self::build), producing two compressors: one for
240    /// data (with `IntDictScheme` excluded) and one for stats.
241    pub fn with_btrblocks_builder(mut self, builder: BtrBlocksCompressorBuilder) -> Self {
242        self.compressor = CompressorConfig::BtrBlocks(builder);
243        self
244    }
245
246    /// Set the compressor to an opaque [`CompressorPlugin`].
247    ///
248    /// The compressor is used as-is for both data and stats compression. Use this when the
249    /// compressor is already fully configured and should not be modified by the builder.
250    pub fn with_compressor<C: CompressorPlugin>(mut self, compressor: C) -> Self {
251        self.compressor = CompressorConfig::Opaque(Arc::new(compressor));
252        self
253    }
254
255    /// Override the compressor used to probe whether a column is dict-eligible.
256    pub fn with_probe_compressor<C: CompressorPlugin>(mut self, compressor: C) -> Self {
257        self.probe_compressor = Some(Arc::new(compressor));
258        self
259    }
260
261    /// Builds the canonical [`LayoutStrategy`] implementation, with the configured overrides
262    /// applied.
263    pub fn build(self) -> Arc<dyn LayoutStrategy> {
264        let flat: Arc<dyn LayoutStrategy> = if let Some(flat) = self.flat_strategy {
265            flat
266        } else {
267            Arc::new(FlatLayoutStrategy::default())
268        };
269        let flat: Arc<dyn LayoutStrategy> = if let Some(allow_encodings) = self.allow_encodings {
270            Arc::new(LayoutStrategyEncodingValidator::new(flat, allow_encodings))
271        } else {
272            flat
273        };
274
275        // 7. for each chunk create a flat layout
276        let chunked = ChunkedLayoutStrategy::new(Arc::clone(&flat));
277        // 6. buffer chunks so they end up with closer segment ids physically
278        let buffered = BufferedStrategy::new(chunked, 2 * ONE_MEG); // 2MB
279
280        // 5. compress each chunk.
281        // Exclude IntDictScheme from the data compressor because DictStrategy (step 3) already
282        // dictionary-encodes columns. Allowing IntDictScheme here would redundantly
283        // dictionary-encode the integer codes produced by that earlier step.
284        let data_compressor: Arc<dyn CompressorPlugin> = match &self.compressor {
285            CompressorConfig::BtrBlocks(builder) => Arc::new(
286                builder
287                    .clone()
288                    .exclude_schemes([IntDictScheme.id()])
289                    .build(),
290            ),
291            CompressorConfig::Opaque(compressor) => Arc::clone(compressor),
292        };
293        let compressing = CompressingStrategy::new(buffered, data_compressor);
294
295        // 4. prior to compression, coalesce up to a minimum size
296        let coalescing = RepartitionStrategy::new(
297            compressing,
298            RepartitionWriterOptions {
299                // Write stream partitions roughly become segments. Because Vortex never reads less
300                // than one segment, the size of segments and, therefore, partitions, must be small
301                // enough to both (1) allow fine-grained random access reads and (2) allow
302                // sufficient read concurrency for the desired throughput. One megabyte is small
303                // enough to achieve this for S3 (Durner et al., "Exploiting Cloud Object Storage for
304                // High-Performance Analytics", VLDB Vol 16, Iss 11).
305                block_size_minimum: ONE_MEG,
306                block_len_multiple: self.row_block_size,
307                block_size_target: Some(ONE_MEG),
308                canonicalize: true,
309            },
310        );
311
312        // 2.1. | 3.1. compress stats tables and dict values.
313        let stats_compressor: Arc<dyn CompressorPlugin> = match self.compressor {
314            CompressorConfig::BtrBlocks(builder) => Arc::new(builder.build()),
315            CompressorConfig::Opaque(compressor) => compressor,
316        };
317        let compress_then_flat = CompressingStrategy::new(flat, Arc::clone(&stats_compressor));
318
319        // 3. apply dict encoding or fallback
320        let probe_compressor = if let Some(probe_compressor) = self.probe_compressor {
321            probe_compressor
322        } else {
323            Arc::clone(&stats_compressor)
324        };
325        let dict = DictStrategy::new(
326            coalescing.clone(),
327            compress_then_flat.clone(),
328            coalescing,
329            Default::default(),
330            probe_compressor,
331        );
332
333        let row_block_size = NonZeroUsize::new(self.row_block_size).vortex_expect("must be non 0");
334
335        // 2. calculate stats for each row group
336        let stats = ZonedStrategy::new(
337            dict,
338            compress_then_flat.clone(),
339            ZonedLayoutOptions {
340                block_size: row_block_size,
341                ..Default::default()
342            },
343        );
344
345        // 1. repartition each column to fixed row counts
346        let repartition = RepartitionStrategy::new(
347            stats,
348            RepartitionWriterOptions {
349                // No minimum block size in bytes
350                block_size_minimum: 0,
351                // Always repartition into 8K row blocks
352                block_len_multiple: self.row_block_size,
353                block_size_target: None,
354                canonicalize: false,
355            },
356        );
357
358        // 0. start with splitting columns
359        let validity_strategy = CollectStrategy::new(compress_then_flat.clone());
360
361        // Take any field overrides from the builder and apply them to the final strategy.
362        let mut table_strategy =
363            TableStrategy::new(Arc::new(validity_strategy), Arc::new(repartition))
364                .with_field_writers(self.field_writers);
365
366        if self.use_list_layout {
367            // We need a closure here to enable recursive application of list layout.
368            table_strategy = table_strategy.with_list_layout_factory(
369                move |list_layout: ListLayoutStrategy| -> Arc<dyn LayoutStrategy> {
370                    let zoned = ZonedStrategy::new(
371                        list_layout,
372                        compress_then_flat.clone(),
373                        ZonedLayoutOptions {
374                            block_size: row_block_size,
375                            ..Default::default()
376                        },
377                    );
378                    Arc::new(RepartitionStrategy::new(
379                        zoned,
380                        RepartitionWriterOptions {
381                            block_size_minimum: 0,
382                            block_len_multiple: row_block_size.get(),
383                            block_size_target: None,
384                            canonicalize: false,
385                        },
386                    ))
387                },
388            );
389        }
390
391        Arc::new(table_strategy)
392    }
393}