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