Skip to content

Commit 9ea80e0

Browse files
committed
array tree layouts
Signed-off-by: Onur Satici <onur@spiraldb.com>
1 parent eb11f18 commit 9ea80e0

9 files changed

Lines changed: 1975 additions & 21 deletions

File tree

vortex-file/public-api.lock

Lines changed: 421 additions & 0 deletions
Large diffs are not rendered by default.

vortex-file/src/strategy.rs

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ use vortex_fastlanes::FoR;
4141
use vortex_fastlanes::RLE;
4242
use vortex_fsst::FSST;
4343
use vortex_layout::LayoutStrategy;
44+
use vortex_layout::layouts::array_tree::writer as array_tree_writer;
4445
use vortex_layout::layouts::buffered::BufferedStrategy;
4546
use vortex_layout::layouts::chunked::writer::ChunkedLayoutStrategy;
4647
use vortex_layout::layouts::collect::CollectStrategy;
@@ -149,6 +150,7 @@ pub struct WriteStrategyBuilder {
149150
field_writers: HashMap<FieldPath, Arc<dyn LayoutStrategy>>,
150151
allow_encodings: Option<HashSet<ArrayId>>,
151152
flat_strategy: Option<Arc<dyn LayoutStrategy>>,
153+
array_tree: bool,
152154
}
153155

154156
impl Default for WriteStrategyBuilder {
@@ -161,6 +163,7 @@ impl Default for WriteStrategyBuilder {
161163
field_writers: HashMap::new(),
162164
allow_encodings: Some(ALLOWED_ENCODINGS.clone()),
163165
flat_strategy: None,
166+
array_tree: false,
164167
}
165168
}
166169
}
@@ -193,11 +196,32 @@ impl WriteStrategyBuilder {
193196
///
194197
/// By default, this uses [`FlatLayoutStrategy`]. This can be used to substitute a custom
195198
/// layout strategy, e.g. one that inlines constant array buffers for GPU reads.
199+
///
200+
/// Passing a custom flat strategy implicitly disables the array-tree outlining feature
201+
/// (see [`Self::with_array_tree`]), since the custom strategy owns the leaf format.
196202
pub fn with_flat_strategy(mut self, flat: Arc<dyn LayoutStrategy>) -> Self {
197203
self.flat_strategy = Some(flat);
198204
self
199205
}
200206

207+
/// Enable array-tree outlining: each chunk's encoding tree (without per-chunk statistics)
208+
/// is collected into a single auxiliary segment per column rather than being inlined
209+
/// alongside the chunk's data.
210+
///
211+
/// Disabled by default. When enabled, the written file uses two encodings that older
212+
/// readers will not understand:
213+
/// [`vortex_layout::layouts::array_tree::ArrayTreeFlatLayout`] at the data leaves, and a
214+
/// wrapping [`vortex_layout::layouts::array_tree::ArrayTreeLayout`] that owns the
215+
/// consolidated auxiliary segment. Files written by this builder with the feature on
216+
/// require a reader that recognizes both encodings.
217+
///
218+
/// Has no effect if a custom flat strategy is provided via
219+
/// [`Self::with_flat_strategy`] — the user-supplied leaf format wins.
220+
pub fn with_array_tree(mut self, array_tree: bool) -> Self {
221+
self.array_tree = array_tree;
222+
self
223+
}
224+
201225
/// Override the default [`BtrBlocksCompressorBuilder`] used for compression.
202226
///
203227
/// The builder is finalized during [`build`](Self::build), producing two compressors: one for
@@ -218,23 +242,17 @@ impl WriteStrategyBuilder {
218242
/// Builds the canonical [`LayoutStrategy`] implementation, with the configured overrides
219243
/// applied.
220244
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))
245+
let flat: Arc<dyn LayoutStrategy> = if let Some(flat) = &self.flat_strategy {
246+
Arc::clone(flat)
247+
} else if let Some(allow_encodings) = &self.allow_encodings {
248+
Arc::new(FlatLayoutStrategy::default().with_allow_encodings(allow_encodings.clone()))
225249
} else {
226250
Arc::new(FlatLayoutStrategy::default())
227251
};
228252

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.
253+
// Data compressor: excludes IntDictScheme because DictStrategy (step 3 below) already
254+
// dictionary-encodes columns; allowing it here would redundantly dictionary-encode the
255+
// integer codes produced by that earlier step.
238256
let data_compressor: Arc<dyn CompressorPlugin> = match &self.compressor {
239257
CompressorConfig::BtrBlocks(builder) => Arc::new(
240258
builder
@@ -244,6 +262,37 @@ impl WriteStrategyBuilder {
244262
),
245263
CompressorConfig::Opaque(compressor) => Arc::clone(compressor),
246264
};
265+
// Stats compressor: used for zone-map tables, dict values, and (when enabled) the
266+
// consolidated array-trees segment.
267+
let stats_compressor: Arc<dyn CompressorPlugin> = match &self.compressor {
268+
CompressorConfig::BtrBlocks(builder) => Arc::new(builder.clone().build()),
269+
CompressorConfig::Opaque(compressor) => Arc::clone(compressor),
270+
};
271+
let compress_then_flat =
272+
CompressingStrategy::new(Arc::clone(&flat), Arc::clone(&stats_compressor));
273+
let compress_then_flat_arc: Arc<dyn LayoutStrategy> = Arc::new(compress_then_flat.clone());
274+
275+
let array_tree_enabled = self.array_tree && self.flat_strategy.is_none();
276+
let (data_leaf, array_tree_collector): (Arc<dyn LayoutStrategy>, _) = if !array_tree_enabled
277+
{
278+
(Arc::clone(&flat), None)
279+
} else {
280+
let data_flat = if let Some(allow_encodings) = &self.allow_encodings {
281+
FlatLayoutStrategy::default().with_allow_encodings(allow_encodings.clone())
282+
} else {
283+
FlatLayoutStrategy::default()
284+
};
285+
let (collector, leaf) =
286+
array_tree_writer::writer(data_flat, Arc::clone(&compress_then_flat_arc));
287+
(Arc::new(leaf), Some(collector))
288+
};
289+
290+
// 7. for each chunk create a flat layout
291+
let chunked = ChunkedLayoutStrategy::new(data_leaf);
292+
// 6. buffer chunks so they end up with closer segment ids physically
293+
let buffered = BufferedStrategy::new(chunked, 2 * ONE_MEG); // 2MB
294+
295+
// 5. compress each chunk.
247296
let compressing = CompressingStrategy::new(buffered, data_compressor);
248297

249298
// 4. prior to compression, coalesce up to a minimum size
@@ -263,13 +312,6 @@ impl WriteStrategyBuilder {
263312
},
264313
);
265314

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-
273315
// 3. apply dict encoding or fallback
274316
let dict = DictStrategy::new(
275317
coalescing.clone(),
@@ -278,9 +320,16 @@ impl WriteStrategyBuilder {
278320
Default::default(),
279321
);
280322

323+
// 2.5. wrap dict in the array-tree collector if outlining is enabled.
324+
let data_pipeline: Arc<dyn LayoutStrategy> = if let Some(collector) = array_tree_collector {
325+
Arc::new(collector.wrap(dict))
326+
} else {
327+
Arc::new(dict)
328+
};
329+
281330
// 2. calculate stats for each row group
282331
let stats = ZonedStrategy::new(
283-
dict,
332+
data_pipeline,
284333
compress_then_flat.clone(),
285334
ZonedLayoutOptions {
286335
block_size: self.row_block_size,

vortex-file/src/tests.rs

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1990,3 +1990,233 @@ async fn test_can_prune_composite_predicates() -> VortexResult<()> {
19901990

19911991
Ok(())
19921992
}
1993+
1994+
#[tokio::test]
1995+
#[cfg_attr(miri, ignore)]
1996+
async fn test_segment_ordering_array_trees_consolidated_and_after_data() -> VortexResult<()> {
1997+
// Multi-column struct large enough to produce chunked data, so each column will have
1998+
// many ArrayTreeFlat leaves. The collector should consolidate their compact trees into a
1999+
// single segment per ArrayTreeLayout.
2000+
let n = 100_000;
2001+
let values: Vec<&str> = (0..n).map(|i| ["alpha", "beta", "gamma"][i % 3]).collect();
2002+
let strings = VarBinArray::from(values).into_array();
2003+
let numbers = PrimitiveArray::from_iter(0..n as i32).into_array();
2004+
let floats = PrimitiveArray::from_iter((0..n).map(|i| i as f64 * 0.1)).into_array();
2005+
2006+
let st = StructArray::from_fields(&[
2007+
("strings", strings),
2008+
("numbers", numbers),
2009+
("floats", floats),
2010+
])
2011+
.unwrap();
2012+
2013+
let mut buf = ByteBufferMut::empty();
2014+
let strategy = crate::WriteStrategyBuilder::default()
2015+
.with_array_tree(true)
2016+
.build();
2017+
let summary = SESSION
2018+
.write_options()
2019+
.with_strategy(strategy)
2020+
.write(&mut buf, st.into_array().to_array_stream())
2021+
.await?;
2022+
2023+
let footer = summary.footer();
2024+
let segment_specs = footer.segment_map();
2025+
let root = footer.layout();
2026+
2027+
// For each ArrayTreeLayout in the tree, assert:
2028+
// 1. **Consolidation:** the auxiliary `array_trees` child writes exactly one segment.
2029+
// 2. **Per-column ordering:** every data segment under child 0 appears before the
2030+
// array_trees segment under child 1.
2031+
fn check_array_tree_layouts(
2032+
layout: &dyn Layout,
2033+
segment_specs: &[SegmentSpec],
2034+
found_any: &mut bool,
2035+
) {
2036+
if layout.encoding_id().as_ref() == "vortex.array_tree" {
2037+
*found_any = true;
2038+
2039+
let data_child = layout.child(0).unwrap();
2040+
let array_trees_child = layout.child(1).unwrap();
2041+
2042+
let data_offsets = collect_segment_offsets(data_child.as_ref(), segment_specs);
2043+
let array_trees_offsets =
2044+
collect_segment_offsets(array_trees_child.as_ref(), segment_specs);
2045+
2046+
assert_eq!(
2047+
array_trees_offsets.len(),
2048+
1,
2049+
"array_tree: auxiliary child must consolidate to exactly 1 segment, got {} segments at offsets {:?}",
2050+
array_trees_offsets.len(),
2051+
array_trees_offsets,
2052+
);
2053+
2054+
assert!(
2055+
!data_offsets.is_empty(),
2056+
"array_tree: data child must have at least one segment"
2057+
);
2058+
2059+
assert_offsets_ordered(
2060+
&data_offsets,
2061+
&array_trees_offsets,
2062+
"array_tree: all data segments should come before the array_trees segment",
2063+
);
2064+
}
2065+
2066+
for child in layout.children().unwrap() {
2067+
check_array_tree_layouts(child.as_ref(), segment_specs, found_any);
2068+
}
2069+
}
2070+
2071+
let mut found_any = false;
2072+
check_array_tree_layouts(root.as_ref(), segment_specs, &mut found_any);
2073+
assert!(
2074+
found_any,
2075+
"test setup expected the default write strategy to produce at least one ArrayTreeLayout"
2076+
);
2077+
2078+
Ok(())
2079+
}
2080+
2081+
#[tokio::test]
2082+
#[cfg_attr(miri, ignore)]
2083+
async fn test_segment_ordering_array_trees_before_zones() -> VortexResult<()> {
2084+
// The write strategy wraps every column in `ZonedStrategy { data: ArrayTree, zones }`.
2085+
// Assert per-Zoned-layout that the array_trees segment (inside the data child) appears
2086+
// before every zone-map segment in the same column.
2087+
let n = 100_000;
2088+
let values: Vec<&str> = (0..n).map(|i| ["alpha", "beta", "gamma"][i % 3]).collect();
2089+
let strings = VarBinArray::from(values).into_array();
2090+
let numbers = PrimitiveArray::from_iter(0..n as i32).into_array();
2091+
let floats = PrimitiveArray::from_iter((0..n).map(|i| i as f64 * 0.1)).into_array();
2092+
2093+
let st = StructArray::from_fields(&[
2094+
("strings", strings),
2095+
("numbers", numbers),
2096+
("floats", floats),
2097+
])
2098+
.unwrap();
2099+
2100+
let mut buf = ByteBufferMut::empty();
2101+
let strategy = crate::WriteStrategyBuilder::default()
2102+
.with_array_tree(true)
2103+
.build();
2104+
let summary = SESSION
2105+
.write_options()
2106+
.with_strategy(strategy)
2107+
.write(&mut buf, st.into_array().to_array_stream())
2108+
.await?;
2109+
2110+
let footer = summary.footer();
2111+
let segment_specs = footer.segment_map();
2112+
let root = footer.layout();
2113+
2114+
fn check_zoned_with_array_tree(
2115+
layout: &dyn Layout,
2116+
segment_specs: &[SegmentSpec],
2117+
found_any: &mut bool,
2118+
) {
2119+
if layout.encoding_id().as_ref() == "vortex.stats" {
2120+
let data_child = layout.child(0).unwrap();
2121+
let zones_child = layout.child(1).unwrap();
2122+
2123+
if data_child.encoding_id().as_ref() == "vortex.array_tree" {
2124+
*found_any = true;
2125+
let array_trees_offsets =
2126+
collect_segment_offsets(data_child.child(1).unwrap().as_ref(), segment_specs);
2127+
let zones_offsets = collect_segment_offsets(zones_child.as_ref(), segment_specs);
2128+
2129+
assert_offsets_ordered(
2130+
&array_trees_offsets,
2131+
&zones_offsets,
2132+
"zoned wrapping array_tree: the array_trees segment should come before zone-map segments",
2133+
);
2134+
}
2135+
}
2136+
2137+
for child in layout.children().unwrap() {
2138+
check_zoned_with_array_tree(child.as_ref(), segment_specs, found_any);
2139+
}
2140+
}
2141+
2142+
let mut found_any = false;
2143+
check_zoned_with_array_tree(root.as_ref(), segment_specs, &mut found_any);
2144+
assert!(
2145+
found_any,
2146+
"test setup expected the default write strategy to produce at least one Zoned wrapping an ArrayTree"
2147+
);
2148+
2149+
Ok(())
2150+
}
2151+
2152+
#[tokio::test]
2153+
#[cfg_attr(miri, ignore)]
2154+
async fn test_roundtrip_array_tree_layout() -> VortexResult<()> {
2155+
// End-to-end coverage: write with `with_array_tree(true)`, then read back through the
2156+
// source-publishing reader-ctx path and assert the data matches. Exercises:
2157+
// - ArrayTreeCollectorStrategy collecting compact trees from leaf transient state
2158+
// - ArrayTreeLayout::new_reader publishing the ArrayTreesSource into the reader ctx
2159+
// - ArrayTreeFlatReader pulling the source and resolving its tree by segment_id
2160+
// - ColumnarSerializedArray::from_segment_and_tree decoding the data segment
2161+
let mut ctx = SESSION.create_execution_ctx();
2162+
2163+
let n = 10_000;
2164+
let strings_in: Vec<&str> = (0..n).map(|i| ["alpha", "beta", "gamma"][i % 3]).collect();
2165+
let strings = VarBinArray::from(strings_in.clone()).into_array();
2166+
let numbers_in: Vec<i32> = (0..n as i32).collect();
2167+
let numbers = PrimitiveArray::from_iter(numbers_in.iter().copied()).into_array();
2168+
2169+
let st = StructArray::from_fields(&[("strings", strings), ("numbers", numbers)])?.into_array();
2170+
let dtype = st.dtype().clone();
2171+
2172+
let mut buf = ByteBufferMut::empty();
2173+
let strategy = crate::WriteStrategyBuilder::default()
2174+
.with_array_tree(true)
2175+
.build();
2176+
SESSION
2177+
.write_options()
2178+
.with_strategy(strategy)
2179+
.write(&mut buf, st.to_array_stream())
2180+
.await?;
2181+
2182+
// Sanity-check we actually wrote ArrayTreeLayout nodes — otherwise the test would
2183+
// silently pass on the default code path.
2184+
let file = SESSION.open_options().open_buffer(buf)?;
2185+
fn has_array_tree(layout: &dyn Layout) -> bool {
2186+
if layout.encoding_id().as_ref() == "vortex.array_tree" {
2187+
return true;
2188+
}
2189+
layout
2190+
.children()
2191+
.map(|cs| cs.iter().any(|c| has_array_tree(c.as_ref())))
2192+
.unwrap_or(false)
2193+
}
2194+
assert!(
2195+
has_array_tree(file.footer().layout().as_ref()),
2196+
"test expected ArrayTreeLayout in the written file"
2197+
);
2198+
2199+
let result = file.scan()?.into_array_stream()?.read_all().await?;
2200+
assert_eq!(result.len(), n);
2201+
assert_eq!(result.dtype(), &dtype);
2202+
2203+
let struct_array = result.execute::<StructArray>(&mut ctx)?;
2204+
2205+
let read_numbers = struct_array.unmasked_field_by_name("numbers").cloned()?;
2206+
let expected_numbers = PrimitiveArray::from_iter(numbers_in.iter().copied()).into_array();
2207+
assert_arrays_eq!(read_numbers, expected_numbers);
2208+
2209+
let read_strings = struct_array
2210+
.unmasked_field_by_name("strings")
2211+
.cloned()?
2212+
.execute::<VarBinViewArray>(&mut ctx)?
2213+
.with_iterator(|iter| {
2214+
iter.map(|s| s.map(|st| unsafe { String::from_utf8_unchecked(st.to_vec()) }))
2215+
.collect::<Vec<_>>()
2216+
});
2217+
let expected_strings: Vec<Option<String>> =
2218+
strings_in.iter().map(|s| Some((*s).to_string())).collect();
2219+
assert_eq!(read_strings, expected_strings);
2220+
2221+
Ok(())
2222+
}

0 commit comments

Comments
 (0)