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