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::sync::Arc;
7use std::sync::LazyLock;
8
9use vortex_alp::ALP;
10use vortex_alp::ALPRD;
11use vortex_array::ArrayId;
12use vortex_array::VTable;
13use vortex_array::arrays::Bool;
14use vortex_array::arrays::Chunked;
15use vortex_array::arrays::Constant;
16use vortex_array::arrays::Decimal;
17use vortex_array::arrays::Dict;
18use vortex_array::arrays::Extension;
19use vortex_array::arrays::FixedSizeList;
20use vortex_array::arrays::List;
21use vortex_array::arrays::ListView;
22use vortex_array::arrays::Masked;
23use vortex_array::arrays::Null;
24use vortex_array::arrays::Patched;
25use vortex_array::arrays::Primitive;
26use vortex_array::arrays::Struct;
27use vortex_array::arrays::VarBin;
28use vortex_array::arrays::VarBinView;
29use vortex_array::arrays::Variant;
30use vortex_array::arrays::patched::use_experimental_patches;
31use vortex_array::dtype::FieldPath;
32use vortex_btrblocks::BtrBlocksCompressorBuilder;
33use vortex_btrblocks::SchemeExt;
34use vortex_btrblocks::schemes::integer::IntDictScheme;
35use vortex_bytebool::ByteBool;
36use vortex_datetime_parts::DateTimeParts;
37use vortex_decimal_byte_parts::DecimalByteParts;
38use vortex_fastlanes::BitPacked;
39use vortex_fastlanes::Delta;
40use vortex_fastlanes::FoR;
41use vortex_fastlanes::RLE;
42use vortex_fsst::FSST;
43use vortex_layout::LayoutStrategy;
44use vortex_layout::layouts::buffered::BufferedStrategy;
45use vortex_layout::layouts::chunked::writer::ChunkedLayoutStrategy;
46use vortex_layout::layouts::collect::CollectStrategy;
47use vortex_layout::layouts::compressed::CompressingStrategy;
48use vortex_layout::layouts::compressed::CompressorPlugin;
49use vortex_layout::layouts::dict::writer::DictStrategy;
50use vortex_layout::layouts::flat::writer::FlatLayoutStrategy;
51use vortex_layout::layouts::repartition::RepartitionStrategy;
52use vortex_layout::layouts::repartition::RepartitionWriterOptions;
53use vortex_layout::layouts::table::TableStrategy;
54use vortex_layout::layouts::zoned::writer::ZonedLayoutOptions;
55use vortex_layout::layouts::zoned::writer::ZonedStrategy;
56#[cfg(feature = "unstable_encodings")]
57use vortex_onpair::OnPair;
58use vortex_pco::Pco;
59use vortex_runend::RunEnd;
60use vortex_sequence::Sequence;
61use vortex_sparse::Sparse;
62use vortex_utils::aliases::hash_map::HashMap;
63use vortex_utils::aliases::hash_set::HashSet;
64use vortex_zigzag::ZigZag;
65#[cfg(feature = "zstd")]
66use vortex_zstd::Zstd;
67#[cfg(all(feature = "zstd", feature = "unstable_encodings"))]
68use vortex_zstd::ZstdBuffers;
69
70const ONE_MEG: u64 = 1 << 20;
71
72/// Static registry of all allowed array encodings for file writing.
73///
74/// This includes all canonical encodings from vortex-array plus all compressed
75/// encodings from the various encoding crates.
76pub static ALLOWED_ENCODINGS: LazyLock<HashSet<ArrayId>> = LazyLock::new(|| {
77    let mut allowed = HashSet::new();
78
79    // Canonical encodings from vortex-array
80    allowed.insert(Null.id());
81    allowed.insert(Bool.id());
82    allowed.insert(Primitive.id());
83    allowed.insert(Decimal.id());
84    allowed.insert(VarBin.id());
85    allowed.insert(VarBinView.id());
86    allowed.insert(List.id());
87    allowed.insert(ListView.id());
88    allowed.insert(FixedSizeList.id());
89    allowed.insert(Struct.id());
90    allowed.insert(Extension.id());
91    allowed.insert(Chunked.id());
92    allowed.insert(Constant.id());
93    allowed.insert(Masked.id());
94    allowed.insert(Dict.id());
95    allowed.insert(Variant.id());
96
97    // Compressed encodings from encoding crates
98    allowed.insert(ALP.id());
99    allowed.insert(ALPRD.id());
100    allowed.insert(BitPacked.id());
101    allowed.insert(ByteBool.id());
102    allowed.insert(DateTimeParts.id());
103    allowed.insert(DecimalByteParts.id());
104    allowed.insert(Delta.id());
105    allowed.insert(FoR.id());
106    allowed.insert(FSST.id());
107    #[cfg(feature = "unstable_encodings")]
108    allowed.insert(OnPair.id());
109    allowed.insert(Pco.id());
110    allowed.insert(RLE.id());
111    allowed.insert(RunEnd.id());
112    allowed.insert(Sequence.id());
113    allowed.insert(Sparse.id());
114    allowed.insert(ZigZag.id());
115
116    // Experimental encodings
117
118    if use_experimental_patches() {
119        allowed.insert(Patched.id());
120    }
121
122    #[cfg(feature = "zstd")]
123    allowed.insert(Zstd.id());
124    #[cfg(all(feature = "zstd", feature = "unstable_encodings"))]
125    allowed.insert(ZstdBuffers.id());
126
127    allowed
128});
129
130/// How the compressor was configured on [`WriteStrategyBuilder`].
131enum CompressorConfig {
132    /// A [`BtrBlocksCompressorBuilder`] that [`WriteStrategyBuilder::build`] will finalize.
133    /// `IntDictScheme` is automatically excluded from the data compressor to prevent recursive
134    /// dictionary encoding.
135    BtrBlocks(BtrBlocksCompressorBuilder),
136    /// An opaque compressor used as-is for both data and stats compression.
137    Opaque(Arc<dyn CompressorPlugin>),
138}
139
140/// Build a new [writer strategy](LayoutStrategy) to compress and reorganize chunks of a Vortex
141/// file.
142///
143/// Vortex provides an out-of-the-box file writer that optimizes the layout of chunks on-disk,
144/// repartitioning and compressing them to strike a balance between size on-disk,
145/// bulk decoding performance, and IOPS required to perform an indexed read.
146pub struct WriteStrategyBuilder {
147    compressor: CompressorConfig,
148    row_block_size: usize,
149    field_writers: HashMap<FieldPath, Arc<dyn LayoutStrategy>>,
150    allow_encodings: Option<HashSet<ArrayId>>,
151    flat_strategy: Option<Arc<dyn LayoutStrategy>>,
152}
153
154impl Default for WriteStrategyBuilder {
155    /// Create a new empty builder. It can be further configured,
156    /// and then finally built yielding the [`LayoutStrategy`].
157    fn default() -> Self {
158        Self {
159            compressor: CompressorConfig::BtrBlocks(BtrBlocksCompressorBuilder::default()),
160            row_block_size: 8192,
161            field_writers: HashMap::new(),
162            allow_encodings: Some(ALLOWED_ENCODINGS.clone()),
163            flat_strategy: None,
164        }
165    }
166}
167
168impl WriteStrategyBuilder {
169    /// Override the row block size used to determine the zone map sizes.
170    pub fn with_row_block_size(mut self, row_block_size: usize) -> Self {
171        self.row_block_size = row_block_size;
172        self
173    }
174
175    /// Override the default write layout for a specific field somewhere in the nested
176    /// schema tree.
177    pub fn with_field_writer(
178        mut self,
179        field: impl Into<FieldPath>,
180        writer: Arc<dyn LayoutStrategy>,
181    ) -> Self {
182        self.field_writers.insert(field.into(), writer);
183        self
184    }
185
186    /// Override the allowed array encodings for normalization.
187    pub fn with_allow_encodings(mut self, allow_encodings: HashSet<ArrayId>) -> Self {
188        self.allow_encodings = Some(allow_encodings);
189        self
190    }
191
192    /// Override the flat layout strategy used for leaf chunks.
193    ///
194    /// By default, this uses [`FlatLayoutStrategy`]. This can be used to substitute a custom
195    /// layout strategy, e.g. one that inlines constant array buffers for GPU reads.
196    pub fn with_flat_strategy(mut self, flat: Arc<dyn LayoutStrategy>) -> Self {
197        self.flat_strategy = Some(flat);
198        self
199    }
200
201    /// Override the default [`BtrBlocksCompressorBuilder`] used for compression.
202    ///
203    /// The builder is finalized during [`build`](Self::build), producing two compressors: one for
204    /// data (with `IntDictScheme` excluded) and one for stats.
205    pub fn with_btrblocks_builder(mut self, builder: BtrBlocksCompressorBuilder) -> Self {
206        self.compressor = CompressorConfig::BtrBlocks(builder);
207        self
208    }
209
210    /// Set the compressor to an opaque [`CompressorPlugin`].
211    ///
212    /// The compressor is used as-is for both data and stats compression.
213    pub fn with_compressor<C: CompressorPlugin>(mut self, compressor: C) -> Self {
214        self.compressor = CompressorConfig::Opaque(Arc::new(compressor));
215        self
216    }
217
218    /// Builds the canonical [`LayoutStrategy`] implementation, with the configured overrides
219    /// applied.
220    pub fn build(self) -> Arc<dyn LayoutStrategy> {
221        let flat: Arc<dyn LayoutStrategy> = if let Some(flat) = self.flat_strategy {
222            flat
223        } else if let Some(allow_encodings) = self.allow_encodings {
224            Arc::new(FlatLayoutStrategy::default().with_allow_encodings(allow_encodings))
225        } else {
226            Arc::new(FlatLayoutStrategy::default())
227        };
228
229        // 7. for each chunk create a flat layout
230        let chunked = ChunkedLayoutStrategy::new(Arc::clone(&flat));
231        // 6. buffer chunks so they end up with closer segment ids physically
232        let buffered = BufferedStrategy::new(chunked, 2 * ONE_MEG); // 2MB
233
234        // 5. compress each chunk.
235        // Exclude IntDictScheme from the data compressor because DictStrategy (step 3) already
236        // dictionary-encodes columns. Allowing IntDictScheme here would redundantly
237        // dictionary-encode the integer codes produced by that earlier step.
238        let data_compressor: Arc<dyn CompressorPlugin> = match &self.compressor {
239            CompressorConfig::BtrBlocks(builder) => Arc::new(
240                builder
241                    .clone()
242                    .exclude_schemes([IntDictScheme.id()])
243                    .build(),
244            ),
245            CompressorConfig::Opaque(compressor) => Arc::clone(compressor),
246        };
247        let compressing = CompressingStrategy::new(buffered, data_compressor);
248
249        // 4. prior to compression, coalesce up to a minimum size
250        let coalescing = RepartitionStrategy::new(
251            compressing,
252            RepartitionWriterOptions {
253                // Write stream partitions roughly become segments. Because Vortex never reads less
254                // than one segment, the size of segments and, therefore, partitions, must be small
255                // enough to both (1) allow fine-grained random access reads and (2) allow
256                // sufficient read concurrency for the desired throughput. One megabyte is small
257                // enough to achieve this for S3 (Durner et al., "Exploiting Cloud Object Storage for
258                // High-Performance Analytics", VLDB Vol 16, Iss 11).
259                block_size_minimum: ONE_MEG,
260                block_len_multiple: self.row_block_size,
261                block_size_target: Some(ONE_MEG),
262                canonicalize: true,
263            },
264        );
265
266        // 2.1. | 3.1. compress stats tables and dict values.
267        let stats_compressor: Arc<dyn CompressorPlugin> = match self.compressor {
268            CompressorConfig::BtrBlocks(builder) => Arc::new(builder.build()),
269            CompressorConfig::Opaque(compressor) => compressor,
270        };
271        let compress_then_flat = CompressingStrategy::new(flat, stats_compressor);
272
273        // 3. apply dict encoding or fallback
274        let dict = DictStrategy::new(
275            coalescing.clone(),
276            compress_then_flat.clone(),
277            coalescing,
278            Default::default(),
279        );
280
281        // 2. calculate stats for each row group
282        let stats = ZonedStrategy::new(
283            dict,
284            compress_then_flat.clone(),
285            ZonedLayoutOptions {
286                block_size: self.row_block_size,
287                ..Default::default()
288            },
289        );
290
291        // 1. repartition each column to fixed row counts
292        let repartition = RepartitionStrategy::new(
293            stats,
294            RepartitionWriterOptions {
295                // No minimum block size in bytes
296                block_size_minimum: 0,
297                // Always repartition into 8K row blocks
298                block_len_multiple: self.row_block_size,
299                block_size_target: None,
300                canonicalize: false,
301            },
302        );
303
304        // 0. start with splitting columns
305        let validity_strategy = CollectStrategy::new(compress_then_flat);
306
307        // Take any field overrides from the builder and apply them to the final strategy.
308        let table_strategy = TableStrategy::new(Arc::new(validity_strategy), Arc::new(repartition))
309            .with_field_writers(self.field_writers);
310
311        Arc::new(table_strategy)
312    }
313}