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;
8
9use vortex_array::dtype::FieldPath;
10use vortex_btrblocks::BtrBlocksCompressorBuilder;
11use vortex_btrblocks::SchemeExt;
12use vortex_btrblocks::schemes::integer::IntDictScheme;
13use vortex_error::VortexExpect;
14use vortex_layout::LayoutStrategy;
15use vortex_layout::layouts::buffered::BufferedStrategy;
16use vortex_layout::layouts::chunked::writer::ChunkedLayoutStrategy;
17use vortex_layout::layouts::collect::CollectStrategy;
18use vortex_layout::layouts::compressed::CompressingStrategy;
19use vortex_layout::layouts::compressed::CompressorPlugin;
20use vortex_layout::layouts::dict::writer::DictStrategy;
21use vortex_layout::layouts::flat::writer::FlatLayoutStrategy;
22use vortex_layout::layouts::list::writer::ListLayoutStrategy;
23use vortex_layout::layouts::repartition::RepartitionStrategy;
24use vortex_layout::layouts::repartition::RepartitionWriterOptions;
25use vortex_layout::layouts::table::TableStrategy;
26use vortex_layout::layouts::table::use_experimental_list_layout;
27use vortex_layout::layouts::zoned::writer::ZonedLayoutOptions;
28use vortex_layout::layouts::zoned::writer::ZonedStrategy;
29use vortex_utils::aliases::hash_map::HashMap;
30
31const ONE_MEG: u64 = 1 << 20;
32
33/// How the compressor was configured on [`WriteStrategyBuilder`].
34enum CompressorConfig {
35    /// A [`BtrBlocksCompressorBuilder`] that [`WriteStrategyBuilder::build`] will finalize.
36    /// `IntDictScheme` is automatically excluded from the data compressor to prevent recursive
37    /// dictionary encoding.
38    BtrBlocks(BtrBlocksCompressorBuilder),
39    /// An opaque compressor used as-is for both data and stats compression.
40    Opaque(Arc<dyn CompressorPlugin>),
41}
42
43/// Build a new [writer strategy](LayoutStrategy) to compress and reorganize chunks of a Vortex
44/// file.
45///
46/// Vortex provides an out-of-the-box file writer that optimizes the layout of chunks on-disk,
47/// repartitioning and compressing them to strike a balance between size on-disk,
48/// bulk decoding performance, and IOPS required to perform an indexed read.
49///
50/// The default pipeline first splits struct columns, repartitions rows into fixed-size row blocks,
51/// computes zoned statistics, applies dictionary encoding where useful, coalesces chunks toward
52/// segment-sized blocks, compresses arrays, buffers nearby chunks, and finally writes flat leaf
53/// layouts.
54pub struct WriteStrategyBuilder {
55    compressor: CompressorConfig,
56    row_block_size: usize,
57    data_block_target_bytes: Option<u64>,
58    field_writers: HashMap<FieldPath, Arc<dyn LayoutStrategy>>,
59    flat_strategy: Option<Arc<dyn LayoutStrategy>>,
60    probe_compressor: Option<Arc<dyn CompressorPlugin>>,
61    /// Whether to write list fields using [`ListLayoutStrategy`].
62    ///
63    /// [`ListLayoutStrategy`]: vortex_layout::layouts::list::writer::ListLayoutStrategy
64    use_list_layout: bool,
65}
66
67impl Default for WriteStrategyBuilder {
68    /// Create a new empty builder. It can be further configured,
69    /// and then finally built yielding the [`LayoutStrategy`].
70    fn default() -> Self {
71        Self {
72            compressor: CompressorConfig::BtrBlocks(BtrBlocksCompressorBuilder::default()),
73            row_block_size: 8192,
74            data_block_target_bytes: Some(ONE_MEG),
75            field_writers: HashMap::new(),
76            flat_strategy: None,
77            probe_compressor: None,
78            use_list_layout: use_experimental_list_layout(),
79        }
80    }
81}
82
83impl WriteStrategyBuilder {
84    /// Override the row block size used for row repartitioning and zoned statistics.
85    ///
86    /// Larger blocks reduce footer/statistics overhead. Smaller blocks can improve pruning and
87    /// random-access locality.
88    pub fn with_row_block_size(mut self, row_block_size: usize) -> Self {
89        self.row_block_size = row_block_size;
90        self
91    }
92
93    /// Override the target uncompressed byte size used to coalesce data blocks.
94    ///
95    /// Passing `None` disables byte-size coalescing, so blocks retain the row granularity set by
96    /// [`Self::with_row_block_size`].
97    pub fn with_data_block_target_bytes(mut self, target_bytes: Option<u64>) -> Self {
98        self.data_block_target_bytes = target_bytes;
99        self
100    }
101
102    /// Enable writing list fields with [`ListLayoutStrategy`].
103    ///
104    /// **Note**: this is an unstable and experimental layout that is expected to change.
105    /// Using it may lead to unreadable files in the future.
106    ///
107    /// [`ListLayoutStrategy`]: vortex_layout::layouts::list::writer::ListLayoutStrategy
108    pub fn with_list_layout(mut self) -> Self {
109        self.use_list_layout = true;
110        self
111    }
112
113    /// Override the write layout for a specific field somewhere in the nested schema tree.
114    ///
115    /// The field path is matched after the root struct is split into columns. This is useful when a
116    /// column needs a custom compression/layout policy while the rest of the file uses defaults.
117    pub fn with_field_writer(
118        mut self,
119        field: impl Into<FieldPath>,
120        writer: Arc<dyn LayoutStrategy>,
121    ) -> Self {
122        self.field_writers.insert(field.into(), writer);
123        self
124    }
125
126    /// Override the flat layout strategy used for leaf chunks.
127    ///
128    /// By default, this uses [`FlatLayoutStrategy`]. This can be used to substitute a custom
129    /// layout strategy, e.g. one that inlines constant array buffers for GPU reads.
130    pub fn with_flat_strategy(mut self, flat: Arc<dyn LayoutStrategy>) -> Self {
131        self.flat_strategy = Some(flat);
132        self
133    }
134
135    /// Override the default [`BtrBlocksCompressorBuilder`] used for compression.
136    ///
137    /// The builder produces two compressors: one for data and one for stats.
138    /// An explicitly built compressor is used as configured.
139    pub fn with_btrblocks_builder(mut self, builder: BtrBlocksCompressorBuilder) -> Self {
140        self.compressor = CompressorConfig::BtrBlocks(builder);
141        self
142    }
143
144    /// Set the compressor to an opaque [`CompressorPlugin`].
145    ///
146    /// The compressor is used as-is for both data and stats compression. Use this when the
147    /// compressor is already fully configured and should not be modified by the builder.
148    pub fn with_compressor<C: CompressorPlugin>(mut self, compressor: C) -> Self {
149        self.compressor = CompressorConfig::Opaque(Arc::new(compressor));
150        self
151    }
152
153    /// Override the compressor used to probe whether a column is dict-eligible.
154    pub fn with_probe_compressor<C: CompressorPlugin>(mut self, compressor: C) -> Self {
155        self.probe_compressor = Some(Arc::new(compressor));
156        self
157    }
158
159    /// Builds the canonical [`LayoutStrategy`] implementation, with the configured overrides
160    /// applied.
161    pub fn build(self) -> Arc<dyn LayoutStrategy> {
162        let flat: Arc<dyn LayoutStrategy> = if let Some(flat) = self.flat_strategy {
163            flat
164        } else {
165            Arc::new(FlatLayoutStrategy::default())
166        };
167
168        let compressor = self.compressor;
169
170        // 7. for each chunk create a flat layout
171        let chunked = ChunkedLayoutStrategy::new(Arc::clone(&flat));
172        // 6. buffer chunks so they end up with closer segment ids physically
173        let buffered = BufferedStrategy::new(chunked, 2 * ONE_MEG); // 2MB
174
175        // 5. compress each chunk.
176        // Exclude IntDictScheme from the data compressor because DictStrategy (step 3) already
177        // dictionary-encodes columns. Allowing IntDictScheme here would redundantly
178        // dictionary-encode the integer codes produced by that earlier step.
179        let data_compressor: Arc<dyn CompressorPlugin> = match &compressor {
180            CompressorConfig::BtrBlocks(builder) => Arc::new(
181                builder
182                    .clone()
183                    .exclude_schemes([IntDictScheme.id()])
184                    .build(),
185            ),
186            CompressorConfig::Opaque(compressor) => Arc::clone(compressor),
187        };
188        let compressing = CompressingStrategy::new(buffered, data_compressor);
189
190        // 4. prior to compression, coalesce up to a minimum size
191        let coalescing = RepartitionStrategy::new(
192            compressing,
193            RepartitionWriterOptions {
194                // Write stream partitions roughly become segments. Because Vortex never reads less
195                // than one segment, the size of segments and, therefore, partitions, must be small
196                // enough to both (1) allow fine-grained random access reads and (2) allow
197                // sufficient read concurrency for the desired throughput. One megabyte is small
198                // enough to achieve this for S3 (Durner et al., "Exploiting Cloud Object Storage for
199                // High-Performance Analytics", VLDB Vol 16, Iss 11).
200                block_size_minimum: self.data_block_target_bytes.unwrap_or(0),
201                block_len_multiple: self.row_block_size,
202                block_size_target: self.data_block_target_bytes,
203                canonicalize: true,
204            },
205        );
206
207        // 2.1. | 3.1. compress stats tables and dict values.
208        let stats_compressor: Arc<dyn CompressorPlugin> = match compressor {
209            CompressorConfig::BtrBlocks(builder) => Arc::new(builder.build()),
210            CompressorConfig::Opaque(compressor) => compressor,
211        };
212        let compress_then_flat = CompressingStrategy::new(flat, Arc::clone(&stats_compressor));
213
214        // 3. apply dict encoding or fallback
215        let probe_compressor = if let Some(probe_compressor) = self.probe_compressor {
216            probe_compressor
217        } else {
218            Arc::clone(&stats_compressor)
219        };
220        let dict = DictStrategy::new(
221            coalescing.clone(),
222            compress_then_flat.clone(),
223            coalescing,
224            Default::default(),
225            probe_compressor,
226        );
227
228        let row_block_size = NonZeroUsize::new(self.row_block_size).vortex_expect("must be non 0");
229
230        // 2. calculate stats for each row group
231        let stats = ZonedStrategy::new(
232            dict,
233            compress_then_flat.clone(),
234            ZonedLayoutOptions {
235                block_size: row_block_size,
236                ..Default::default()
237            },
238        );
239
240        // 1. repartition each column to fixed row counts
241        let repartition = RepartitionStrategy::new(
242            stats,
243            RepartitionWriterOptions {
244                // No minimum block size in bytes
245                block_size_minimum: 0,
246                // Always repartition into 8K row blocks
247                block_len_multiple: self.row_block_size,
248                block_size_target: None,
249                canonicalize: false,
250            },
251        );
252
253        // 0. start with splitting columns
254        let validity_strategy = CollectStrategy::new(compress_then_flat.clone());
255
256        // Take any field overrides from the builder and apply them to the final strategy.
257        let mut table_strategy =
258            TableStrategy::new(Arc::new(validity_strategy), Arc::new(repartition))
259                .with_field_writers(self.field_writers);
260
261        if self.use_list_layout {
262            // We need a closure here to enable recursive application of list layout.
263            table_strategy = table_strategy.with_list_layout_factory(
264                move |list_layout: ListLayoutStrategy| -> Arc<dyn LayoutStrategy> {
265                    let zoned = ZonedStrategy::new(
266                        list_layout,
267                        compress_then_flat.clone(),
268                        ZonedLayoutOptions {
269                            block_size: row_block_size,
270                            ..Default::default()
271                        },
272                    );
273                    Arc::new(RepartitionStrategy::new(
274                        zoned,
275                        RepartitionWriterOptions {
276                            block_size_minimum: 0,
277                            block_len_multiple: row_block_size.get(),
278                            block_size_target: None,
279                            canonicalize: false,
280                        },
281                    ))
282                },
283            );
284        }
285
286        Arc::new(table_strategy)
287    }
288}