Skip to content

Commit e250d3a

Browse files
committed
perf(sparse): build the fixed-size-list fill tile once, not once per gap
`append_fill` handed the fill scalar to `Constant::append_to_builder` per gap, which materialized the scalar's elements into an array every time. Hoist that out of the loop the way the list path already does: the fill's elements become an array once, up front, and every gap tiles that same array through `FixedSizeListBuilder::append_array_as_repeated_list`. A fixed-size list holds its elements back to back, so it cannot point a run of rows at one shared range the way a list view can - but it need not copy the tile per row either. The run goes in as a `ChunkedArray` of `n` clones of the tile, which costs `n` reference bumps and no element data at all. The child keeps that whole run as a single chunk: unpacking it would spill a chunk per row into the child's chunk list, which every later append and the final `finish` walk, and that list is what makes the difference - unpacking measured 36.0 us against 26.0 us on `(512, 7, 4)`. Elements that are all the same scalar do better still, collapsing to a single constant chunk however many rows they cover. `canonicalize_sparse_fixed_size_list` medians, against develop: (512, 7, 4) 21.1 us -> 26.0 us (was 79.0 us) (1024, 17, 8) 29.1 us -> 22.7 us (was 139.5 us) (8192, 1024, 4) 150.1 us -> 34.5 us (was 375.9 us) Signed-off-by: Robert Kruszewski <robert@spiraldb.com>
1 parent 16bbe01 commit e250d3a

3 files changed

Lines changed: 129 additions & 75 deletions

File tree

‎encodings/sparse/src/canonical.rs‎

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,12 @@ fn execute_sparse_fixed_size_list_inner<I: IntegerPType>(
369369
nullability,
370370
array_len,
371371
);
372+
// The fill's elements become an array once, up front, so that a gap does not rebuild them.
373+
// They are tiled per row rather than shared - a fixed-size list holds its elements back to
374+
// back - unless they are all the same scalar, in which case the tile stays constant-encoded
375+
// and the tiling costs nothing.
376+
let fill_elements = fixed_size_list_fill_tile(fill_value.as_list(), list_size);
377+
372378
// One mask for the whole patch array rather than a validity lookup per patch.
373379
let patch_validity = values
374380
.validity()
@@ -385,7 +391,12 @@ fn execute_sparse_fixed_size_list_inner<I: IntegerPType>(
385391
let sparse_idx = sparse_idx
386392
.to_usize()
387393
.vortex_expect("patch index must fit in usize");
388-
append_fill(&mut builder, fill_value, sparse_idx - next_index, ctx);
394+
append_fixed_size_list_fill(
395+
&mut builder,
396+
fill_elements.as_ref(),
397+
sparse_idx - next_index,
398+
ctx,
399+
);
389400

390401
// Take each patch's elements rather than slicing the patch array itself: slicing a
391402
// `FixedSizeList` slices its elements and its validity, and every one of those slices pays
@@ -405,38 +416,56 @@ fn execute_sparse_fixed_size_list_inner<I: IntegerPType>(
405416
}
406417

407418
// Fill remaining positions after last patch.
408-
append_fill(&mut builder, fill_value, array_len - next_index, ctx);
419+
append_fixed_size_list_fill(
420+
&mut builder,
421+
fill_elements.as_ref(),
422+
array_len - next_index,
423+
ctx,
424+
);
409425

410426
builder.finish_into_fixed_size_list()
411427
}
412428

413-
/// Appends the run of `count` fill values that covers the gap before the next patch.
429+
/// Materializes the elements a fixed-size-list fill value covers each of its rows with, or `None`
430+
/// if the fill is null.
414431
///
415-
/// The run goes in as a single [`ConstantArray`] rather than one appended value per row. For a
416-
/// list dtype that is the difference between storing the fill value once per gap and once per row:
417-
/// canonicalizing a constant list array points every view at one copy of the value, and the
418-
/// builder keeps that layout.
419-
fn append_fill(
420-
builder: &mut dyn ArrayBuilder,
421-
fill_value: &Scalar,
432+
/// Elements that are all the same scalar stay a constant array, so tiling them over a gap costs
433+
/// nothing however many rows it covers.
434+
fn fixed_size_list_fill_tile(fill: ListScalar, list_size: u32) -> Option<ArrayRef> {
435+
let elements = fill.elements()?;
436+
437+
Some(match elements.iter().all_equal_value() {
438+
Ok(uniform) => ConstantArray::new(uniform.clone(), list_size as usize).into_array(),
439+
Err(_) => {
440+
let mut builder = builder_with_capacity(fill.element_dtype(), elements.len());
441+
for element in &elements {
442+
builder
443+
.append_scalar(element)
444+
.vortex_expect("fixed-size-list element scalar was invalid");
445+
}
446+
builder.finish()
447+
}
448+
})
449+
}
450+
451+
/// Appends the run of `count` fill lists that covers the gap before the next patch.
452+
fn append_fixed_size_list_fill(
453+
builder: &mut FixedSizeListBuilder,
454+
fill_elements: Option<&ArrayRef>,
422455
count: usize,
423456
ctx: &mut ExecutionCtx,
424457
) {
425458
if count == 0 {
426459
return;
427460
}
428461

429-
if fill_value.is_null() {
430-
// A null fill has no elements to share, and the builder can record the nulls without
431-
// going through an array at all.
432-
builder.append_nulls(count);
433-
return;
462+
match fill_elements {
463+
Some(fill_elements) => builder
464+
.append_array_as_repeated_list(fill_elements, count, ctx)
465+
.vortex_expect("Failed to append sparse fixed-size-list fill value"),
466+
// A null fill has no elements of its own, only the placeholders the builder writes.
467+
None => builder.append_nulls(count),
434468
}
435-
436-
ConstantArray::new(fill_value.clone(), count)
437-
.into_array()
438-
.append_to_builder(builder, ctx)
439-
.vortex_expect("Failed to append sparse fill value");
440469
}
441470

442471
fn execute_sparse_bools(

‎vortex-array/src/builders/child.rs‎

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ impl ChildBuilder {
6363
/// at a boundary only the caller can see. Callers that would rather have the values copied
6464
/// should append them as scalars.
6565
///
66+
/// An appended [`ChunkedArray`] stays one chunk rather than giving up its own. Unpacking it
67+
/// would spill a chunk per row into this child for a caller expressing a repeated run as
68+
/// chunks - a tiled fixed-size list, say - and the child's chunk list is what every later
69+
/// append and the final [`finish`](Self::finish) walk. Nesting costs one level, not one per
70+
/// appended array.
71+
///
6672
/// Nothing is decoded here, so `_ctx` goes unused; it stays in the signature so that the
6773
/// nested builders forwarding their [`ExecutionCtx`] here do not have to explain why they
6874
/// don't.
@@ -80,34 +86,12 @@ impl ChildBuilder {
8086

8187
self.flush_pending();
8288
self.chunks_len += array.len();
89+
8390
self.chunks.push(array.clone());
8491

8592
Ok(())
8693
}
8794

88-
/// Appends the *values* of `array` to the child, copying them instead of keeping the array as a
89-
/// chunk of its own.
90-
///
91-
/// [`append_array`](Self::append_array) is the right choice almost always: it references the
92-
/// array rather than decoding it. This is for the caller that would otherwise append the same
93-
/// tiny array over and over - the elements of one fixed-size list, repeated for a run of rows -
94-
/// where a chunk per append costs more in indirection than copying the values costs outright.
95-
/// Only the caller can see that, which is why the builder does not guess at it.
96-
pub fn append_array_values(
97-
&mut self,
98-
array: &ArrayRef,
99-
ctx: &mut ExecutionCtx,
100-
) -> VortexResult<()> {
101-
vortex_ensure!(
102-
array.dtype() == &self.dtype,
103-
"Cannot append an array of dtype {} to a child builder of dtype {}",
104-
array.dtype(),
105-
self.dtype,
106-
);
107-
108-
array.append_to_builder(self.pending.as_mut(), ctx)
109-
}
110-
11195
/// Appends a single [`Scalar`] to the child.
11296
pub fn append_scalar(&mut self, scalar: &Scalar) -> VortexResult<()> {
11397
self.pending.append_scalar(scalar)

‎vortex-array/src/builders/fixed_size_list.rs‎

Lines changed: 73 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use vortex_error::vortex_panic;
1414
use crate::ArrayRef;
1515
use crate::ExecutionCtx;
1616
use crate::IntoArray;
17+
use crate::arrays::ChunkedArray;
1718
use crate::arrays::ConstantArray;
1819
use crate::arrays::FixedSizeListArray;
1920
use crate::arrays::fixed_size_list::FixedSizeListArraySlotsExt;
@@ -106,19 +107,69 @@ impl FixedSizeListBuilder {
106107
Ok(())
107108
}
108109

109-
/// Appends the same fixed-size list `value` `n` times.
110+
/// Appends `array` as `n` identical non-null lists.
110111
///
111-
/// A fixed-size list array holds its elements back to back, so a run of `n` identical lists is
112-
/// the value's elements tiled `n` times - there is no layout that lets the rows share one copy
113-
/// the way a list view's can. Two cases avoid paying for the tiling anyway:
112+
/// A fixed-size list array holds its elements back to back, so `n` identical lists are the
113+
/// array's elements tiled `n` times - there is no layout that lets the rows share one range of
114+
/// elements the way a list view's can. The tiling costs nothing to build even so: the elements
115+
/// go in as a [`ChunkedArray`] of `n` clones of the same array, so the tile's values are stored
116+
/// once however many rows reference them, and the child holds the whole run as one chunk. A
117+
/// constant tile does better still, collapsing to a single constant chunk.
114118
///
115-
/// - a null value needs no elements at all, only placeholders, which
116-
/// [`append_nulls`](ArrayBuilder::append_nulls) writes in bulk;
117-
/// - a value whose elements are all the same scalar tiles to a constant array, which goes in as
118-
/// a single chunk.
119+
/// A caller with a run of appends to make should hand over the same `array` each time rather
120+
/// than rebuild it, which is the whole reason this takes an array instead of a scalar.
121+
pub fn append_array_as_repeated_list(
122+
&mut self,
123+
array: &ArrayRef,
124+
n: usize,
125+
ctx: &mut ExecutionCtx,
126+
) -> VortexResult<()> {
127+
vortex_ensure!(
128+
array.dtype() == self.element_dtype(),
129+
"Array dtype {:?} does not match list element dtype {:?}",
130+
array.dtype(),
131+
self.element_dtype()
132+
);
133+
vortex_ensure!(
134+
array.len() == self.list_size() as usize,
135+
"Array length {} does not match fixed list size {}",
136+
array.len(),
137+
self.list_size()
138+
);
139+
140+
if n == 0 {
141+
return Ok(());
142+
}
143+
144+
let tiled_len = n * array.len();
145+
match array.as_constant() {
146+
Some(element) => {
147+
let tiled = ConstantArray::new(element, tiled_len).into_array();
148+
self.elements_builder.append_array(&tiled, ctx)?;
149+
}
150+
None => {
151+
// SAFETY: every chunk is `array` itself, so they share its dtype and none is empty.
152+
let tiled = unsafe {
153+
ChunkedArray::new_unchecked(
154+
std::iter::repeat_n(array.clone(), n).collect::<Vec<_>>(),
155+
self.element_dtype().clone(),
156+
)
157+
};
158+
self.elements_builder
159+
.append_array(&tiled.into_array(), ctx)?;
160+
}
161+
}
162+
self.nulls.append_n_non_nulls(n);
163+
164+
Ok(())
165+
}
166+
167+
/// Appends the same fixed-size list `value` `n` times.
119168
///
120-
/// Otherwise the tile is copied in per row. That is one copy of each element, where
121-
/// canonicalizing the run first would build the whole tiled array and then copy it again.
169+
/// This materializes the value's elements and hands them to
170+
/// [`append_array_as_repeated_list`](Self::append_array_as_repeated_list) - as a constant array
171+
/// when they are all the same scalar, so that the tiling costs nothing. A caller with a run of
172+
/// appends to make should materialize the elements once itself and call that directly.
122173
pub(crate) fn append_constant(
123174
&mut self,
124175
value: ListScalar,
@@ -138,36 +189,26 @@ impl FixedSizeListBuilder {
138189
return Ok(());
139190
};
140191

192+
let list_size = self.list_size() as usize;
141193
vortex_ensure!(
142-
elements.len() == self.list_size() as usize,
194+
elements.len() == list_size,
143195
"Scalar list length {} does not match fixed list size {}",
144196
elements.len(),
145-
self.list_size()
197+
list_size
146198
);
147199

148-
let tiled_len = n * elements.len();
149-
if let Ok(uniform) = elements.iter().all_equal_value() {
150-
let tile = ConstantArray::new(uniform.clone(), tiled_len).into_array();
151-
self.elements_builder.append_array(&tile, ctx)?;
152-
self.nulls.append_n_non_nulls(n);
153-
return Ok(());
154-
}
155-
156-
let tile = {
157-
let mut tile_builder = builder_with_capacity(self.element_dtype(), elements.len());
158-
for element in &elements {
159-
tile_builder.append_scalar(element)?;
200+
let tile = match elements.iter().all_equal_value() {
201+
Ok(uniform) => ConstantArray::new(uniform.clone(), list_size).into_array(),
202+
Err(_) => {
203+
let mut tile_builder = builder_with_capacity(self.element_dtype(), list_size);
204+
for element in &elements {
205+
tile_builder.append_scalar(element)?;
206+
}
207+
tile_builder.finish()
160208
}
161-
tile_builder.finish()
162209
};
163210

164-
self.elements_builder.reserve_exact(tiled_len);
165-
for _ in 0..n {
166-
self.elements_builder.append_array_values(&tile, ctx)?;
167-
}
168-
self.nulls.append_n_non_nulls(n);
169-
170-
Ok(())
211+
self.append_array_as_repeated_list(&tile, n, ctx)
171212
}
172213

173214
/// Appends the values of a canonical [`FixedSizeListArray`] to the builder, recursing into the

0 commit comments

Comments
 (0)