Skip to content

Commit d1fcb0a

Browse files
committed
Benchmark normalize and fix what it found
Adds `encode_non_nullable` and `encode_nullable` arms to `vortex-tensor/benches/normalized.rs`, so the file covers both directions of the encoding rather than decode alone. They vary width over the same `WIDTHS` as the decode arms, which brackets where the cost lands: a narrow vector leaves the per-row work visible, a wide one buries it under the per-element division. The arms reuse the file's `ELEMENTS` budget, sized for CodSpeed's CPU simulation rather than a desktop, which is what holds every case under the 1 ms per-iteration limit. The benchmark contradicted the previous commit at embedding width, and found two things. Reading the validity off the unexecuted norms array left `L2Norm` to run a second time under the `fill_null` execution, so the cost grew with the tensor width. Canonicalizing first computes the norms once. `fill_null` on a column with no nulls has nothing to fill and still charges a cast, so it is skipped there. Against the hand-rolled mask loop, measured on desktop-sized versions of these arms at dimensions 8 and 768 by the fastest sample over two runs: 12-17% faster at dimension 8, where the numbers are stable to within 0.1% across runs, and neutral at dimension 768, where run-to-run variance of roughly 10% exceeds any difference. Signed-off-by: Connor Tsui <connor@spiraldb.com>
1 parent d041493 commit d1fcb0a

2 files changed

Lines changed: 72 additions & 13 deletions

File tree

vortex-tensor/benches/normalized.rs

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
// SPDX-License-Identifier: Apache-2.0
22
// SPDX-FileCopyrightText: Copyright the Vortex contributors
33

4-
//! Baseline throughput for decoding the `Normalized` encoding over tensor columns.
4+
//! Baseline throughput for the `Normalized` encoding over tensor columns, in both directions:
5+
//! `non_nullable` and `nullable` decode, `encode_non_nullable` and `encode_nullable` split a plain
6+
//! tensor column into the two children.
57
//!
68
//! The arms vary vector width and input nullability. Their names are intended to remain stable
79
//! across implementation changes so CodSpeed can compare them against `develop`.
810
//!
911
//! Rows are derived from a fixed element budget rather than fixed per arm, so widening a vector
1012
//! trades rows for elements instead of multiplying the work. See [`ELEMENTS`].
13+
//!
14+
//! Width is the axis that separates the two costs the encode path pays. A narrow vector leaves the
15+
//! per-row work visible, while a wide one buries it under the per-element division.
1116
1217
#![expect(clippy::unwrap_used)]
1318

@@ -19,14 +24,16 @@ use vortex_array::IntoArray;
1924
use vortex_array::VortexSessionExecute;
2025
use vortex_array::arrays::ExtensionArray;
2126
use vortex_array::arrays::FixedSizeListArray;
27+
use vortex_array::arrays::MaskedArray;
2228
use vortex_array::arrays::PrimitiveArray;
2329
use vortex_array::validity::Validity;
2430
use vortex_buffer::Buffer;
2531
use vortex_tensor::encodings::normalized::Normalized;
32+
use vortex_tensor::encodings::normalized::normalize;
2633
use vortex_tensor::vector::Vector;
2734

28-
// Decoding allocates the output inside the timed region, so use the vendored allocator instead
29-
// of measuring glibc differences between CodSpeed runner images.
35+
// Both directions allocate their output inside the timed region, so use the vendored allocator
36+
// instead of measuring glibc differences between CodSpeed runner images.
3037
#[global_allocator]
3138
static GLOBAL: MiMalloc = MiMalloc;
3239

@@ -55,7 +62,24 @@ fn normalized_vectors(width: usize) -> ArrayRef {
5562
Vector::try_new_vector_array(storage).unwrap()
5663
}
5764

58-
/// Masks every eighth row. The row count is `ELEMENTS / width`, matching the vector builder.
65+
/// Vectors of varying magnitude, so no row is a zero vector and every row takes the dividing
66+
/// branch in [`normalize`].
67+
fn plain_vectors(width: usize) -> ArrayRef {
68+
let rows = ELEMENTS / width;
69+
let elements: Buffer<f64> = (0..rows)
70+
.flat_map(|row| (0..width).map(move |i| (row + 1) as f64 * (i + 1) as f64))
71+
.collect();
72+
let storage = FixedSizeListArray::new(
73+
elements.into_array(),
74+
u32::try_from(width).unwrap(),
75+
Validity::NonNullable,
76+
rows,
77+
)
78+
.into_array();
79+
Vector::try_new_vector_array(storage).unwrap()
80+
}
81+
82+
/// Masks every eighth row. The row count is `ELEMENTS / width`, matching both vector builders.
5983
fn sparse_nulls(width: usize) -> Validity {
6084
Validity::from_iter((0..ELEMENTS / width).map(|i| !i.is_multiple_of(8)))
6185
}
@@ -90,6 +114,15 @@ fn bench_decode(bencher: Bencher, normalized: ArrayRef, validity: Validity) {
90114
});
91115
}
92116

117+
fn bench_encode(bencher: Bencher, input: ArrayRef) {
118+
let session = vortex_array::array_session();
119+
let rows = input.len();
120+
bencher
121+
.counter(ItemsCount::new(rows))
122+
.with_inputs(|| (input.clone(), session.create_execution_ctx()))
123+
.bench_values(|(input, mut ctx)| normalize(input, &mut ctx).unwrap());
124+
}
125+
93126
#[divan::bench(args = WIDTHS)]
94127
fn non_nullable(bencher: Bencher, width: usize) {
95128
bench_decode(bencher, normalized_vectors(width), Validity::NonNullable);
@@ -99,3 +132,17 @@ fn non_nullable(bencher: Bencher, width: usize) {
99132
fn nullable(bencher: Bencher, width: usize) {
100133
bench_decode(bencher, normalized_vectors(width), sparse_nulls(width));
101134
}
135+
136+
#[divan::bench(args = WIDTHS)]
137+
fn encode_non_nullable(bencher: Bencher, width: usize) {
138+
bench_encode(bencher, plain_vectors(width));
139+
}
140+
141+
#[divan::bench(args = WIDTHS)]
142+
fn encode_nullable(bencher: Bencher, width: usize) {
143+
let input = MaskedArray::try_new(plain_vectors(width), sparse_nulls(width))
144+
.unwrap()
145+
.into_array();
146+
147+
bench_encode(bencher, input);
148+
}

vortex-tensor/src/encodings/normalized/compress.rs

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -159,16 +159,28 @@ pub fn normalize(input: ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<Normal
159159
.try_new_array(row_count, EmptyOptions, [input.clone()])?
160160
.execute(ctx)?;
161161

162+
// Canonicalize before reading the validity so the norms are computed once. Taking the validity
163+
// off the unexecuted array instead leaves the norms to be evaluated again below, which costs
164+
// the whole `L2Norm` pass a second time and grows with the tensor width.
165+
let primitive_norms: PrimitiveArray = norms_array.execute(ctx)?;
166+
162167
// `L2Norm` propagates the input's validity, so this is the column's null map.
163-
let validity = norms_array.validity()?;
164-
165-
// Filling the nulls with zero is what moves them off the child, and it leaves the row loop
166-
// below a single rule to follow: a zero norm means a zeroed row. A non-nullable input makes
167-
// this a cast rather than a copy.
168-
let element_dtype = DType::Primitive(tensor_match.element_ptype(), Nullability::NonNullable);
169-
let norms: PrimitiveArray = norms_array
170-
.fill_null(Scalar::zero_value(&element_dtype))?
171-
.execute(ctx)?;
168+
let validity = primitive_norms.validity()?;
169+
170+
// Filling the nulls with zero moves them off the child and leaves the row loop below a single
171+
// rule to follow: a zero norm means a zeroed row. A column with no nulls has nothing to fill,
172+
// and `fill_null` still charges it a cast, so skip it there.
173+
let norms: PrimitiveArray = if validity.nullability().is_nullable() {
174+
let element_dtype =
175+
DType::Primitive(tensor_match.element_ptype(), Nullability::NonNullable);
176+
177+
primitive_norms
178+
.into_array()
179+
.fill_null(Scalar::zero_value(&element_dtype))?
180+
.execute(ctx)?
181+
} else {
182+
primitive_norms
183+
};
172184

173185
let input: ExtensionArray = input.execute(ctx)?;
174186
let normalized_dtype = input.dtype().as_nonnullable();

0 commit comments

Comments
 (0)