Skip to content

Commit 0ada06f

Browse files
AdamGSclaude
andcommitted
Skip materializing splitless chunk children during split collection
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. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Adam Gutglick <adam@spiraldb.com>
1 parent 7274546 commit 0ada06f

8 files changed

Lines changed: 381 additions & 48 deletions

File tree

vortex-layout/src/children.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
use std::fmt::Debug;
55
use std::fmt::Formatter;
66
use std::sync::Arc;
7+
use std::sync::atomic::AtomicU32;
8+
use std::sync::atomic::Ordering;
79

810
use flatbuffers::Follow;
911
use itertools::Itertools;
@@ -35,6 +37,16 @@ pub trait LayoutChildren: 'static + Send + Sync {
3537
fn child_row_count(&self, idx: usize) -> u64;
3638

3739
fn nchildren(&self) -> usize;
40+
41+
/// Returns `true` if the child at `idx` is known — without materializing it — to register no
42+
/// split boundaries strictly inside its row range (see
43+
/// [`VTable::registers_interior_splits`](crate::VTable::registers_interior_splits)).
44+
///
45+
/// Implementations may conservatively return `false` when the answer would require
46+
/// materializing the child.
47+
fn child_has_no_interior_splits(&self, _idx: usize) -> bool {
48+
false
49+
}
3850
}
3951

4052
impl Debug for dyn LayoutChildren {
@@ -61,6 +73,10 @@ impl LayoutChildren for Arc<dyn LayoutChildren> {
6173
fn nchildren(&self) -> usize {
6274
self.as_ref().nchildren()
6375
}
76+
77+
fn child_has_no_interior_splits(&self, idx: usize) -> bool {
78+
self.as_ref().child_has_no_interior_splits(idx)
79+
}
6480
}
6581

6682
/// An implementation of [`LayoutChildren`] for in-memory owned children.
@@ -102,6 +118,10 @@ impl LayoutChildren for OwnedLayoutChildren {
102118
fn nchildren(&self) -> usize {
103119
self.0.len()
104120
}
121+
122+
fn child_has_no_interior_splits(&self, idx: usize) -> bool {
123+
!self.0[idx].dyn_registers_interior_splits()
124+
}
105125
}
106126

107127
#[derive(Clone)]
@@ -114,6 +134,10 @@ pub(crate) struct ViewedLayoutChildren {
114134
allow_unknown: bool,
115135
session: VortexSession,
116136
cache: Arc<[OnceCell<LayoutRef>]>,
137+
/// Single-slot memo for [`LayoutChildren::child_has_no_interior_splits`]: children of one
138+
/// layout node almost always share an encoding, so remember the last resolved flatbuffer
139+
/// encoding tag and its answer. Packed as `tag | (answer << 16) | (valid << 17)`.
140+
no_interior_splits_memo: Arc<AtomicU32>,
117141
}
118142

119143
impl ViewedLayoutChildren {
@@ -146,6 +170,7 @@ impl ViewedLayoutChildren {
146170
allow_unknown,
147171
session,
148172
cache,
173+
no_interior_splits_memo: Arc::new(AtomicU32::new(0)),
149174
}
150175
}
151176

@@ -270,4 +295,38 @@ impl LayoutChildren for ViewedLayoutChildren {
270295
fn nchildren(&self) -> usize {
271296
self.cache.len()
272297
}
298+
299+
fn child_has_no_interior_splits(&self, idx: usize) -> bool {
300+
if idx >= self.nchildren() {
301+
return false;
302+
}
303+
// Resolve the child's layout encoding from the flatbuffer tag alone, without
304+
// deserializing the child layout. Unknown encodings conservatively report interior
305+
// splits so callers fall back to materializing the child.
306+
let encoding = self
307+
.flatbuffer()
308+
.children()
309+
.unwrap_or_default()
310+
.get(idx)
311+
.encoding();
312+
313+
const MEMO_VALID: u32 = 1 << 17;
314+
const MEMO_ANSWER: u32 = 1 << 16;
315+
const MEMO_TAG: u32 = 0xFFFF;
316+
let memo = self.no_interior_splits_memo.load(Ordering::Relaxed);
317+
if memo & MEMO_VALID != 0 && memo & MEMO_TAG == u32::from(encoding) {
318+
return memo & MEMO_ANSWER != 0;
319+
}
320+
321+
let answer = self
322+
.layout_read_ctx
323+
.resolve(encoding)
324+
.and_then(|encoding_id| self.layouts.get(&encoding_id))
325+
.is_some_and(|encoding| !encoding.registers_interior_splits());
326+
self.no_interior_splits_memo.store(
327+
u32::from(encoding) | MEMO_VALID | if answer { MEMO_ANSWER } else { 0 },
328+
Ordering::Relaxed,
329+
);
330+
answer
331+
}
273332
}

vortex-layout/src/encoding.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ pub trait LayoutVTablePlugin: 'static + Send + Sync + Debug {
6767
children: &dyn LayoutChildren,
6868
build_ctx: &LayoutBuildContext<'_>,
6969
) -> VortexResult<LayoutRef>;
70+
71+
/// Returns `true` if readers of this layout may register natural split boundaries strictly
72+
/// inside their row range (see [`VTable::registers_interior_splits`]).
73+
fn registers_interior_splits(&self) -> bool {
74+
true
75+
}
7076
}
7177

7278
/// Backwards-compatible name for the object-safe layout-vtable plugin.
@@ -102,6 +108,10 @@ impl<V: VTable> LayoutVTablePlugin for V {
102108
)?
103109
.into_layout())
104110
}
111+
112+
fn registers_interior_splits(&self) -> bool {
113+
VTable::registers_interior_splits(self)
114+
}
105115
}
106116

107117
impl Display for dyn LayoutVTablePlugin + '_ {

vortex-layout/src/layout.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,12 @@ pub trait DynLayout: 'static + Send + Sync + Debug {
267267
session: &VortexSession,
268268
ctx: &LayoutReaderContext,
269269
) -> VortexResult<LayoutReaderRef>;
270+
271+
/// Returns `true` if readers of this layout may register natural split boundaries strictly
272+
/// inside their row range (see [`crate::VTable::registers_interior_splits`]).
273+
fn dyn_registers_interior_splits(&self) -> bool {
274+
true
275+
}
270276
}
271277

272278
impl<V: VTable> DynLayout for Layout<V> {
@@ -323,6 +329,10 @@ impl<V: VTable> DynLayout for Layout<V> {
323329
) -> VortexResult<LayoutReaderRef> {
324330
Layout::new_reader(self, name, segment_source, session, ctx)
325331
}
332+
333+
fn dyn_registers_interior_splits(&self) -> bool {
334+
self.vtable().registers_interior_splits()
335+
}
326336
}
327337

328338
/// Identifies how a layout child relates to its parent.

vortex-layout/src/layouts/chunked/reader.rs

Lines changed: 109 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-FileCopyrightText: Copyright the Vortex contributors
33

44
use std::future;
5+
use std::iter::once;
56
use std::ops::Range;
67
use std::sync::Arc;
78
use std::sync::LazyLock;
@@ -10,6 +11,7 @@ use futures::FutureExt;
1011
use futures::TryStreamExt;
1112
use futures::future::BoxFuture;
1213
use futures::stream::FuturesOrdered;
14+
use once_cell::sync::OnceCell;
1315
use tracing::trace;
1416
use vortex_array::ArrayRef;
1517
use vortex_array::Canonical;
@@ -40,6 +42,20 @@ pub struct ChunkedReader {
4042
layout: ChunkedLayout,
4143
name: Arc<str>,
4244
lazy_children: LazyReaderChildren,
45+
/// Lazily computed classification of which chunks register no interior splits, letting
46+
/// [`ChunkedReader::register_splits`] avoid materializing those chunks' readers.
47+
chunk_skips: OnceCell<ChunkSkips>,
48+
}
49+
50+
/// Which chunks of a chunked layout are known to register no interior splits.
51+
enum ChunkSkips {
52+
/// Every chunk end is a split boundary and no chunk has interior splits (e.g. all-flat
53+
/// chunks): splits come straight from the chunk offsets.
54+
All,
55+
/// No chunk can be skipped.
56+
None,
57+
/// Per-chunk answers.
58+
Mixed(Box<[bool]>),
4359
}
4460

4561
static UNKNOWN: LazyLock<Arc<str>> = LazyLock::new(|| Arc::from("chunked-child"));
@@ -52,32 +68,38 @@ impl ChunkedReader {
5268
session: &VortexSession,
5369
ctx: LayoutReaderContext,
5470
) -> Self {
55-
let nchildren = layout.nchildren();
56-
let dtypes = vec![layout.dtype().clone(); nchildren];
57-
58-
// format!() has non-marginal overhead for short queries like random
59-
// access benchmarks
60-
let names = if cfg!(debug_assertions) {
61-
(0..nchildren)
71+
let lazy_children = if cfg!(debug_assertions) {
72+
// format!() has non-marginal overhead for short queries like random
73+
// access benchmarks
74+
let nchildren = layout.nchildren();
75+
let dtypes = vec![layout.dtype().clone(); nchildren];
76+
let names = (0..nchildren)
6277
.map(|idx| Arc::from(format!("{name}.[{idx}]")))
63-
.collect()
78+
.collect();
79+
LazyReaderChildren::new(
80+
Arc::clone(layout.children()),
81+
dtypes,
82+
names,
83+
segment_source,
84+
session.clone(),
85+
ctx,
86+
)
6487
} else {
65-
vec![Arc::clone(&*UNKNOWN); nchildren]
88+
LazyReaderChildren::new_uniform(
89+
Arc::clone(layout.children()),
90+
layout.dtype().clone(),
91+
Arc::clone(&*UNKNOWN),
92+
segment_source,
93+
session.clone(),
94+
ctx,
95+
)
6696
};
6797

68-
let lazy_children = LazyReaderChildren::new(
69-
Arc::clone(layout.children()),
70-
dtypes,
71-
names,
72-
segment_source,
73-
session.clone(),
74-
ctx,
75-
);
76-
7798
Self {
7899
layout,
79100
name,
80101
lazy_children,
102+
chunk_skips: OnceCell::new(),
81103
}
82104
}
83105

@@ -86,6 +108,28 @@ impl ChunkedReader {
86108
self.lazy_children.get(idx)
87109
}
88110

111+
/// Classify which chunks are known to register no interior splits, without materializing
112+
/// any chunk layouts or readers.
113+
fn chunk_skips(&self) -> &ChunkSkips {
114+
self.chunk_skips.get_or_init(|| {
115+
let children = self.layout.children();
116+
let nchildren = self.layout.nchildren();
117+
if nchildren == 0 {
118+
return ChunkSkips::None;
119+
}
120+
let skips = (0..nchildren)
121+
.map(|idx| children.child_has_no_interior_splits(idx))
122+
.collect::<Box<[bool]>>();
123+
if skips.iter().all(|&skip| skip) {
124+
ChunkSkips::All
125+
} else if skips.iter().all(|&skip| !skip) {
126+
ChunkSkips::None
127+
} else {
128+
ChunkSkips::Mixed(skips)
129+
}
130+
})
131+
}
132+
89133
fn chunk_offset(&self, idx: usize) -> u64 {
90134
if idx >= self.layout.chunk_offsets.len() {
91135
vortex_panic!(
@@ -185,24 +229,59 @@ impl LayoutReader for ChunkedReader {
185229
return Ok(());
186230
}
187231

188-
let iter = self.ranges(split_range.row_range());
189-
splits.reserve(iter.size_hint().0);
232+
let row_range = split_range.row_range();
233+
let row_offset = split_range.row_offset();
190234

191-
for (chunk_idx, chunk_start, child_range, _) in iter {
192-
let child = self.chunk_reader(chunk_idx)?;
193-
let child_row_offset = split_range
194-
.row_offset()
195-
.checked_add(chunk_start)
235+
// Fast path: no chunk has interior splits (e.g. all-flat chunks), so the splits are
236+
// exactly the chunk-end boundaries — no chunk layouts or readers needed.
237+
if matches!(self.chunk_skips(), ChunkSkips::All) {
238+
let chunk_range = self.chunk_range(row_range);
239+
if chunk_range.is_empty() {
240+
return Ok(());
241+
}
242+
let offsets = &self.layout.chunk_offsets;
243+
// The boundaries below are all bounded by the last one, so a single overflow check
244+
// covers the whole batch.
245+
let last = row_offset
246+
.checked_add(offsets[chunk_range.end].min(row_range.end))
196247
.vortex_expect("Chunked layout split offset overflow");
197-
let child_split_range = SplitRange::try_new(child_row_offset, child_range)?;
248+
splits.reserve(chunk_range.len());
249+
splits.extend_ascending(
250+
offsets[chunk_range.start + 1..chunk_range.end]
251+
.iter()
252+
.map(|&offset| row_offset + offset)
253+
.chain(once(last)),
254+
);
255+
return Ok(());
256+
}
257+
258+
let iter = self.ranges(row_range);
259+
splits.reserve(iter.size_hint().0);
198260

199-
child.register_splits(field_mask, &child_split_range, splits)?;
261+
for (chunk_idx, chunk_start, child_range, _) in iter {
262+
let child_end = child_range.end;
263+
264+
// Children without interior splits (e.g. flat) would only re-register this chunk's
265+
// end boundary, so skip materializing a layout and reader for them.
266+
let skip_child = match self.chunk_skips() {
267+
ChunkSkips::All => true,
268+
ChunkSkips::None => false,
269+
ChunkSkips::Mixed(skips) => skips[chunk_idx],
270+
};
271+
if !skip_child {
272+
let child = self.chunk_reader(chunk_idx)?;
273+
let child_row_offset = row_offset
274+
.checked_add(chunk_start)
275+
.vortex_expect("Chunked layout split offset overflow");
276+
let child_split_range = SplitRange::try_new(child_row_offset, child_range)?;
277+
278+
child.register_splits(field_mask, &child_split_range, splits)?;
279+
}
200280

201281
// Register the split indicating the end of this chunk
202282
splits.push(
203-
split_range
204-
.row_offset()
205-
.checked_add(chunk_start + child_split_range.row_range().end)
283+
row_offset
284+
.checked_add(chunk_start + child_end)
206285
.vortex_expect("Chunked layout split offset overflow"),
207286
);
208287
}

vortex-layout/src/layouts/flat/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ impl VTable for Flat {
6565
*ID
6666
}
6767

68+
/// Flat readers only ever register the end of the requested range, so split collection can
69+
/// skip materializing flat children.
70+
fn registers_interior_splits(&self) -> bool {
71+
false
72+
}
73+
6874
fn metadata(layout: &Layout<Self>) -> Self::Metadata {
6975
ProstMetadata(FlatLayoutMetadata {
7076
array_encoding_tree: layout.array_tree.as_ref().map(|bytes| bytes.to_vec()),

vortex-layout/src/layouts/struct_/reader.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -371,9 +371,6 @@ impl LayoutReader for StructReader {
371371
split_range: &SplitRange,
372372
splits: &mut RowSplits,
373373
) -> VortexResult<()> {
374-
// In the case of an empty struct, we need to register the end split.
375-
splits.push(split_range.root_row_range().end);
376-
377374
// Register splits for the validity child, if there is one
378375
if let Some(validity_ref) = self.validity()? {
379376
validity_ref.register_splits(field_mask, split_range, splits)?;
@@ -382,7 +379,13 @@ impl LayoutReader for StructReader {
382379
self.layout.matching_fields(field_mask, |mask, idx| {
383380
self.field_reader_by_index(idx)?
384381
.register_splits(&[mask], split_range, splits)
385-
})
382+
})?;
383+
384+
// In the case of an empty struct, we need to register the end split. Pushed last so it
385+
// extends the final field's ascending run rather than starting a run of its own.
386+
splits.push(split_range.root_row_range().end);
387+
388+
Ok(())
386389
}
387390

388391
fn pruning_evaluation(

0 commit comments

Comments
 (0)