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