Skip to content

Commit adab5b8

Browse files
AdamGSclaude
andauthored
Skip materializing splitless chunk children during split collection (#9271)
## What changes are included in this PR? Split collection previously built a full Layout + LayoutReader for every chunk of every column just so flat chunks could re-register the chunk-end boundary the parent already knows from its chunk offsets. - `VTable::registers_interior_splits()` (default true; flat returns false) lets a layout declare that its readers only ever push the end of the requested range, forwarded through LayoutVTablePlugin and DynLayout. - `LayoutChildren::child_has_no_interior_splits(idx)` answers that without materializing the child: owned children ask the vtable, viewed children resolve only the flatbuffer encoding tag against the registry, with a single-slot memo since siblings almost always share one encoding. - `ChunkedReader` caches a lazy per-chunk classification; when no chunk has interior splits it bulk-extends boundaries straight from chunk_offsets. - `RowSplits` drops consecutive identical ascending runs (columns with aligned chunk boundaries) and skips the final sort when a single run survives. StructReader pushes its end boundary after the field walk so the common case stays one ascending run. - `LazyReaderChildren::new_uniform` avoids a DType and name clone per chunk at reader construction. Split-collection bench medians (64 columns x 256 chunks): cold 916us -> 181us, warm 247us -> 13us; with fully misaligned columns cold 1223us -> 321us, warm 387us -> 146us. --------- Signed-off-by: Adam Gutglick <adam@spiraldb.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 39fde8c commit adab5b8

12 files changed

Lines changed: 630 additions & 53 deletions

File tree

.github/workflows/codspeed.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ jobs:
4949
strategy:
5050
matrix:
5151
include:
52-
- { shard: 1, name: "Core foundation", packages: "vortex-buffer vortex-error vortex-mask vortex-compute" }
52+
- { shard: 1, name: "Core foundation", packages: "vortex-buffer vortex-error vortex-mask vortex-compute vortex-file" }
5353
- { shard: 2, name: "Arrays", packages: "vortex-array", features: "--features _test-harness" }
5454
- { shard: 3, name: "Main library", packages: "vortex" }
5555
- { shard: 4, name: "Encodings 1", packages: "vortex-alp vortex-bytebool vortex-datetime-parts" }

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vortex-file/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ vortex-zigzag = { workspace = true }
6161
vortex-zstd = { workspace = true, optional = true }
6262

6363
[dev-dependencies]
64+
divan = { workspace = true }
6465
rstest = { workspace = true }
6566
tokio = { workspace = true, features = ["full"] }
6667
vortex-array = { workspace = true, features = ["_test-harness"] }
@@ -71,6 +72,10 @@ vortex-scan = { workspace = true }
7172
[lints]
7273
workspace = true
7374

75+
[[bench]]
76+
name = "split_collection"
77+
harness = false
78+
7479
[features]
7580
object_store = ["dep:object_store", "vortex-io/object_store", "tokio"]
7681
tokio = [
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! Benchmarks scan split collection (`SplitBy::Layout`) over written files.
5+
//!
6+
//! The default write strategy produces `struct -> zoned -> chunked -> flat` per column, so
7+
//! split collection walks every chunk of every column. `cold` builds a fresh reader tree per
8+
//! iteration (as a first scan over a file would); `warm` reuses the reader tree so lazily
9+
//! cached child readers persist across iterations. `cold_misaligned` scans a file whose
10+
//! columns share no interior chunk boundaries, so the split set cannot be collapsed by run
11+
//! deduplication.
12+
13+
#![expect(clippy::unwrap_used)]
14+
15+
use std::sync::Arc;
16+
use std::sync::LazyLock;
17+
18+
use divan::Bencher;
19+
use vortex_array::IntoArray;
20+
use vortex_array::arrays::ChunkedArray;
21+
use vortex_array::arrays::StructArray;
22+
use vortex_array::dtype::Field;
23+
use vortex_array::dtype::FieldMask;
24+
use vortex_array::session::ArraySessionExt;
25+
use vortex_buffer::Buffer;
26+
use vortex_buffer::ByteBufferMut;
27+
use vortex_edition::Edition;
28+
use vortex_edition::EditionId;
29+
use vortex_edition::EditionInclusion;
30+
use vortex_edition::EditionSessionExt;
31+
use vortex_file::OpenOptionsSessionExt;
32+
use vortex_file::VortexFile;
33+
use vortex_file::WriteOptionsSessionExt;
34+
use vortex_io::session::RuntimeSession;
35+
use vortex_io::session::RuntimeSessionExt;
36+
use vortex_layout::layouts::chunked::writer::ChunkedLayoutStrategy;
37+
use vortex_layout::layouts::flat::writer::FlatLayoutStrategy;
38+
use vortex_layout::layouts::repartition::RepartitionStrategy;
39+
use vortex_layout::layouts::repartition::RepartitionWriterOptions;
40+
use vortex_layout::scan::split_by::SplitBy;
41+
use vortex_layout::session::LayoutSession;
42+
use vortex_session::VortexSession;
43+
use vortex_utils::aliases::hash_map::HashMap;
44+
45+
fn main() {
46+
divan::main();
47+
}
48+
49+
const ROWS_PER_CHUNK: usize = 1024;
50+
51+
/// (columns, chunks) configurations.
52+
const CONFIGS: &[(usize, usize)] = &[(64, 256)];
53+
54+
static RUNTIME: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| {
55+
tokio::runtime::Builder::new_current_thread()
56+
.enable_all()
57+
.build()
58+
.unwrap()
59+
});
60+
61+
static SESSION: LazyLock<VortexSession> = LazyLock::new(|| {
62+
let _guard = RUNTIME.enter();
63+
let session = vortex_array::array_session()
64+
.with::<LayoutSession>()
65+
.with::<RuntimeSession>()
66+
.with_tokio();
67+
vortex_file::register_default_encodings(&session);
68+
enable_all_registered_array_encodings(&session);
69+
session
70+
});
71+
72+
const BENCH_EDITION: EditionId = EditionId::new("bench", 2026, 8, 0);
73+
74+
fn enable_all_registered_array_encodings(session: &VortexSession) {
75+
let editions = session.editions();
76+
editions
77+
.declare_edition(Edition {
78+
id: BENCH_EDITION,
79+
min_vortex_version: None,
80+
})
81+
.unwrap();
82+
let ids = session
83+
.arrays()
84+
.registry()
85+
.read(|map| map.keys().copied().collect::<Vec<_>>());
86+
for id in ids {
87+
editions
88+
.declare_inclusion(EditionInclusion::new(&id, BENCH_EDITION))
89+
.unwrap();
90+
}
91+
session.enable_edition(BENCH_EDITION).unwrap();
92+
}
93+
94+
fn make_file(columns: usize, chunks: usize) -> VortexFile {
95+
let field_names = (0..columns).map(|c| format!("col_{c}")).collect::<Vec<_>>();
96+
let struct_chunks = (0..chunks)
97+
.map(|chunk| {
98+
let fields = field_names
99+
.iter()
100+
.map(|name| {
101+
let start = (chunk * ROWS_PER_CHUNK) as i64;
102+
let values =
103+
Buffer::from_iter(start..start + ROWS_PER_CHUNK as i64).into_array();
104+
(name.as_str(), values)
105+
})
106+
.collect::<Vec<_>>();
107+
StructArray::from_fields(&fields).unwrap().into_array()
108+
})
109+
.collect::<Vec<_>>();
110+
let array = ChunkedArray::from_iter(struct_chunks).into_array();
111+
112+
let strategy = vortex_file::WriteStrategyBuilder::default()
113+
.with_row_block_size(ROWS_PER_CHUNK)
114+
.with_data_block_target_bytes(None)
115+
.build();
116+
117+
let mut buf = ByteBufferMut::empty();
118+
RUNTIME
119+
.block_on(
120+
SESSION
121+
.write_options()
122+
.with_strategy(strategy)
123+
.write(&mut buf, array.to_array_stream()),
124+
)
125+
.unwrap();
126+
127+
SESSION.open_options().open_buffer(buf).unwrap()
128+
}
129+
130+
static FILES: LazyLock<HashMap<(usize, usize), VortexFile>> = LazyLock::new(|| {
131+
CONFIGS
132+
.iter()
133+
.map(|&(columns, chunks)| ((columns, chunks), make_file(columns, chunks)))
134+
.collect()
135+
});
136+
137+
/// (columns, average chunks per column) for the misaligned files. A single column cannot be
138+
/// misaligned, so only multi-column configs are used.
139+
const MISALIGNED_CONFIGS: &[(usize, usize)] = &[(64, 256)];
140+
141+
/// Per-column repartition block length: all distinct, so no two columns share interior chunk
142+
/// boundaries. Mirrors real files where byte-size coalescing gives each column its own chunking.
143+
fn misaligned_block_len(column: usize) -> usize {
144+
384 + column * 8
145+
}
146+
147+
/// Like [`make_file`], but each column is chunked at a different row granularity so chunk
148+
/// boundaries never align across columns: run deduplication in split collection cannot collapse
149+
/// them, exercising the sort fallback.
150+
fn make_misaligned_file(columns: usize, chunks: usize) -> VortexFile {
151+
let mean_block_len = (0..columns).map(misaligned_block_len).sum::<usize>() / columns.max(1);
152+
let rows = chunks * mean_block_len;
153+
154+
let fields = (0..columns)
155+
.map(|c| {
156+
let values = Buffer::from_iter(0..rows as i64).into_array();
157+
(format!("col_{c}"), values)
158+
})
159+
.collect::<Vec<_>>();
160+
let array = StructArray::from_fields(
161+
&fields
162+
.iter()
163+
.map(|(name, values)| (name.as_str(), values.clone()))
164+
.collect::<Vec<_>>(),
165+
)
166+
.unwrap()
167+
.into_array();
168+
169+
let mut strategy = vortex_file::WriteStrategyBuilder::default();
170+
for (c, (name, _)) in fields.iter().enumerate() {
171+
let field_strategy = RepartitionStrategy::new(
172+
ChunkedLayoutStrategy::new(FlatLayoutStrategy::default()),
173+
RepartitionWriterOptions {
174+
block_size_minimum: 0,
175+
block_len_multiple: misaligned_block_len(c),
176+
block_size_target: None,
177+
canonicalize: false,
178+
},
179+
);
180+
strategy = strategy.with_field_writer(Field::from(name.as_str()), Arc::new(field_strategy));
181+
}
182+
183+
let mut buf = ByteBufferMut::empty();
184+
RUNTIME
185+
.block_on(
186+
SESSION
187+
.write_options()
188+
.with_strategy(strategy.build())
189+
.write(&mut buf, array.to_array_stream()),
190+
)
191+
.unwrap();
192+
193+
SESSION.open_options().open_buffer(buf).unwrap()
194+
}
195+
196+
static MISALIGNED_FILES: LazyLock<HashMap<(usize, usize), VortexFile>> = LazyLock::new(|| {
197+
MISALIGNED_CONFIGS
198+
.iter()
199+
.map(|&(columns, chunks)| ((columns, chunks), make_misaligned_file(columns, chunks)))
200+
.collect()
201+
});
202+
203+
fn collect_splits(file: &VortexFile) -> Vec<u64> {
204+
let reader = file.layout_reader().unwrap();
205+
SplitBy::Layout
206+
.splits(reader.as_ref(), &(0..file.row_count()), &[FieldMask::All])
207+
.unwrap()
208+
}
209+
210+
/// Builds a fresh reader tree per iteration, so split collection pays child reader
211+
/// construction for every chunk of every column, like the first scan over a file.
212+
#[divan::bench(args = CONFIGS)]
213+
fn cold(bencher: Bencher, config: &(usize, usize)) {
214+
let file = &FILES[config];
215+
// Sanity-check the written chunk structure once per config.
216+
let n_splits = collect_splits(file).len();
217+
assert!(
218+
n_splits > config.1 / 2,
219+
"expected roughly one split per chunk, got {n_splits} splits for {} chunks",
220+
config.1
221+
);
222+
223+
bencher.bench(|| collect_splits(file));
224+
}
225+
226+
/// Reuses the reader tree across iterations, so lazily constructed child readers are cached
227+
/// and split collection measures only the layout walk.
228+
#[divan::bench(args = CONFIGS)]
229+
fn warm(bencher: Bencher, config: &(usize, usize)) {
230+
let file = &FILES[config];
231+
let reader = file.layout_reader().unwrap();
232+
let row_count = file.row_count();
233+
234+
bencher.bench(|| {
235+
SplitBy::Layout
236+
.splits(reader.as_ref(), &(0..row_count), &[FieldMask::All])
237+
.unwrap()
238+
});
239+
}
240+
241+
/// Like `cold`, but over a file whose columns share no interior chunk boundaries, so the split
242+
/// set cannot be collapsed by run deduplication and must be sorted.
243+
#[divan::bench(args = MISALIGNED_CONFIGS)]
244+
fn cold_misaligned(bencher: Bencher, config: &(usize, usize)) {
245+
let file = &MISALIGNED_FILES[config];
246+
// Sanity-check the misalignment once per config: boundaries should be mostly distinct
247+
// across columns, i.e. roughly columns × chunks in total.
248+
let n_splits = collect_splits(file).len();
249+
assert!(
250+
n_splits > config.0 * config.1 / 2,
251+
"expected mostly-distinct boundaries, got {n_splits} splits for {} columns x {} chunks",
252+
config.0,
253+
config.1,
254+
);
255+
256+
bencher.bench(|| collect_splits(file));
257+
}

vortex-layout/src/children.rs

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ pub trait LayoutChildren: 'static + Send + Sync {
3535
fn child_row_count(&self, idx: usize) -> u64;
3636

3737
fn nchildren(&self) -> usize;
38+
39+
/// Returns `true` if the child at `idx` is known — without materializing it — to be
40+
/// indivisible: it registers no split boundaries strictly inside its row range (see
41+
/// [`VTable::is_indivisible`](crate::VTable::is_indivisible)).
42+
///
43+
/// Implementations must conservatively return `false` when answering would require
44+
/// materializing the child.
45+
fn child_is_indivisible(&self, _idx: usize) -> bool {
46+
false
47+
}
3848
}
3949

4050
impl Debug for dyn LayoutChildren {
@@ -61,6 +71,10 @@ impl LayoutChildren for Arc<dyn LayoutChildren> {
6171
fn nchildren(&self) -> usize {
6272
self.as_ref().nchildren()
6373
}
74+
75+
fn child_is_indivisible(&self, idx: usize) -> bool {
76+
self.as_ref().child_is_indivisible(idx)
77+
}
6478
}
6579

6680
/// An implementation of [`LayoutChildren`] for in-memory owned children.
@@ -102,6 +116,10 @@ impl LayoutChildren for OwnedLayoutChildren {
102116
fn nchildren(&self) -> usize {
103117
self.0.len()
104118
}
119+
120+
fn child_is_indivisible(&self, idx: usize) -> bool {
121+
self.0[idx].dyn_is_indivisible()
122+
}
105123
}
106124

107125
#[derive(Clone)]
@@ -114,6 +132,9 @@ pub(crate) struct ViewedLayoutChildren {
114132
allow_unknown: bool,
115133
session: VortexSession,
116134
cache: Arc<[OnceCell<LayoutRef>]>,
135+
/// Per-child answers to [`LayoutChildren::child_is_indivisible`], precomputed at
136+
/// construction from the flatbuffer encoding tags alone.
137+
indivisible: Arc<[bool]>,
117138
}
118139

119140
impl ViewedLayoutChildren {
@@ -132,11 +153,23 @@ impl ViewedLayoutChildren {
132153
session: VortexSession,
133154
) -> Self {
134155
// SAFETY: guaranteed by caller
135-
let nchildren = unsafe { fbl::Layout::follow(flatbuffer.as_ref(), flatbuffer_loc) }
156+
let fb_children = unsafe { fbl::Layout::follow(flatbuffer.as_ref(), flatbuffer_loc) }
136157
.children()
137-
.unwrap_or_default()
138-
.len();
139-
let cache = vec![OnceCell::new(); nchildren].into_boxed_slice().into();
158+
.unwrap_or_default();
159+
let cache = vec![OnceCell::new(); fb_children.len()]
160+
.into_boxed_slice()
161+
.into();
162+
// Unknown encodings are conservatively not indivisible so callers fall back to
163+
// materializing the child.
164+
let indivisible = fb_children
165+
.iter()
166+
.map(|child| {
167+
layout_read_ctx
168+
.resolve(child.encoding())
169+
.and_then(|encoding_id| layouts.get(&encoding_id))
170+
.is_some_and(|encoding| encoding.is_indivisible())
171+
})
172+
.collect::<Arc<[bool]>>();
140173
Self {
141174
flatbuffer,
142175
flatbuffer_loc,
@@ -146,6 +179,7 @@ impl ViewedLayoutChildren {
146179
allow_unknown,
147180
session,
148181
cache,
182+
indivisible,
149183
}
150184
}
151185

@@ -270,4 +304,8 @@ impl LayoutChildren for ViewedLayoutChildren {
270304
fn nchildren(&self) -> usize {
271305
self.cache.len()
272306
}
307+
308+
fn child_is_indivisible(&self, idx: usize) -> bool {
309+
self.indivisible.get(idx).copied().unwrap_or(false)
310+
}
273311
}

0 commit comments

Comments
 (0)