Skip to content

Commit b53d487

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 6c44824 commit b53d487

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
@@ -368,6 +368,12 @@ fn execute_sparse_fixed_size_list_inner<I: IntegerPType>(
368368
nullability,
369369
array_len,
370370
);
371+
// The fill's elements become an array once, up front, so that a gap does not rebuild them.
372+
// They are tiled per row rather than shared - a fixed-size list holds its elements back to
373+
// back - unless they are all the same scalar, in which case the tile stays constant-encoded
374+
// and the tiling costs nothing.
375+
let fill_elements = fixed_size_list_fill_tile(fill_value.as_list(), list_size);
376+
371377
// One mask for the whole patch array rather than a validity lookup per patch.
372378
let patch_validity = values
373379
.validity()
@@ -384,7 +390,12 @@ fn execute_sparse_fixed_size_list_inner<I: IntegerPType>(
384390
let sparse_idx = sparse_idx
385391
.to_usize()
386392
.vortex_expect("patch index must fit in usize");
387-
append_fill(&mut builder, fill_value, sparse_idx - next_index, ctx);
393+
append_fixed_size_list_fill(
394+
&mut builder,
395+
fill_elements.as_ref(),
396+
sparse_idx - next_index,
397+
ctx,
398+
);
388399

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

406417
// Fill remaining positions after last patch.
407-
append_fill(&mut builder, fill_value, array_len - next_index, ctx);
418+
append_fixed_size_list_fill(
419+
&mut builder,
420+
fill_elements.as_ref(),
421+
array_len - next_index,
422+
ctx,
423+
);
408424

409425
builder.finish_into_fixed_size_list()
410426
}
411427

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

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

441470
fn execute_sparse_bools(

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

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

8692
self.flush_pending();
8793
self.chunks_len += array.len();
94+
8895
self.chunks.push(array.clone());
8996

9097
Ok(())
9198
}
9299

93-
/// Appends the *values* of `array` to the child, copying them instead of keeping the array as a
94-
/// chunk of its own.
95-
///
96-
/// [`append_array`](Self::append_array) is the right choice almost always: it references the
97-
/// array rather than decoding it. This is for the caller that would otherwise append the same
98-
/// tiny array over and over - the elements of one fixed-size list, repeated for a run of rows -
99-
/// where a chunk per append costs more in indirection than copying the values costs outright.
100-
/// Only the caller can see that, which is why the builder does not guess at it.
101-
pub fn append_array_values(
102-
&mut self,
103-
array: &ArrayRef,
104-
ctx: &mut ExecutionCtx,
105-
) -> VortexResult<()> {
106-
vortex_ensure!(
107-
array.dtype() == &self.dtype,
108-
"Cannot append an array of dtype {} to a child builder of dtype {}",
109-
array.dtype(),
110-
self.dtype,
111-
);
112-
113-
array.append_to_builder(self.pending.as_mut(), ctx)
114-
}
115-
116100
/// Appends a single [`Scalar`] to the child.
117101
pub fn append_scalar(&mut self, scalar: &Scalar) -> VortexResult<()> {
118102
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
@@ -15,6 +15,7 @@ use vortex_mask::Mask;
1515
use crate::ArrayRef;
1616
use crate::ExecutionCtx;
1717
use crate::IntoArray;
18+
use crate::arrays::ChunkedArray;
1819
use crate::arrays::ConstantArray;
1920
use crate::arrays::FixedSizeListArray;
2021
use crate::arrays::fixed_size_list::FixedSizeListArraySlotsExt;
@@ -107,19 +108,69 @@ impl FixedSizeListBuilder {
107108
Ok(())
108109
}
109110

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

193+
let list_size = self.list_size() as usize;
142194
vortex_ensure!(
143-
elements.len() == self.list_size() as usize,
195+
elements.len() == list_size,
144196
"Scalar list length {} does not match fixed list size {}",
145197
elements.len(),
146-
self.list_size()
198+
list_size
147199
);
148200

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

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

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

0 commit comments

Comments
 (0)