Skip to content

Commit 260badd

Browse files
authored
Fix cosine similarity optimization bug (#7724)
## Summary Tracking issue: #7297 `CosineSimilarity` fast paths for `L2Denorm` used only the decoded normalized children, so lossy encodings could return a non-zero cosine for rows whose actual stored norm was 0. This fix makes those fast paths also execute the stored norm children and return 0 when a denorm stored norm, or the plain-side norm in the one-denorm case, is zero while preserving the existing validity mask. ## Testing Some regression tests. Signed-off-by: Connor Tsui <connor.tsui20@gmail.com>
1 parent 0bb712b commit 260badd

1 file changed

Lines changed: 85 additions & 18 deletions

File tree

vortex-tensor/src/scalar_fns/cosine_similarity.rs

Lines changed: 85 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ use vortex_array::arrays::ScalarFnArray;
1414
use vortex_array::arrays::scalar_fn::ScalarFnArrayView;
1515
use vortex_array::arrays::scalar_fn::plugin::ScalarFnArrayParts;
1616
use vortex_array::arrays::scalar_fn::plugin::ScalarFnArrayVTable;
17-
use vortex_array::builtins::ArrayBuiltins;
1817
use vortex_array::dtype::DType;
1918
use vortex_array::dtype::Nullability;
2019
use vortex_array::expr::Expression;
@@ -28,7 +27,6 @@ use vortex_array::scalar_fn::ScalarFnId;
2827
use vortex_array::scalar_fn::ScalarFnVTable;
2928
use vortex_array::scalar_fn::TypedScalarFnInstance;
3029
use vortex_array::serde::ArrayChildren;
31-
use vortex_array::validity::Validity;
3230
use vortex_buffer::Buffer;
3331
use vortex_error::VortexResult;
3432
use vortex_session::VortexSession;
@@ -144,7 +142,7 @@ impl ScalarFnVTable for CosineSimilarity {
144142
// Take any L2Denorm-wrapped fast path that applies.
145143
match DenormOrientation::classify(&lhs_ref, &rhs_ref) {
146144
DenormOrientation::Both { lhs, rhs } => {
147-
return self.execute_both_denorm(lhs, rhs, len);
145+
return self.execute_both_denorm(lhs, rhs, len, ctx);
148146
}
149147
DenormOrientation::One { denorm, plain } => {
150148
return self.execute_one_denorm(denorm, plain, len, ctx);
@@ -244,22 +242,39 @@ impl CosineSimilarity {
244242
lhs_ref: &ArrayRef,
245243
rhs_ref: &ArrayRef,
246244
len: usize,
245+
ctx: &mut ExecutionCtx,
247246
) -> VortexResult<ArrayRef> {
248247
let validity = lhs_ref.validity()?.and(rhs_ref.validity()?)?;
249248

250-
let (normalized_l, _) = extract_l2_denorm_children(lhs_ref);
251-
let (normalized_r, _) = extract_l2_denorm_children(rhs_ref);
249+
let (normalized_l, norms_l) = extract_l2_denorm_children(lhs_ref);
250+
let (normalized_r, norms_r) = extract_l2_denorm_children(rhs_ref);
252251

253252
// `L2Denorm` makes the normalized children authoritative, so their dot product is the
254-
// cosine similarity even for lossy storage wrappers.
255-
let dot = InnerProduct::try_new_array(normalized_l, normalized_r, len)?.into_array();
256-
257-
if !matches!(validity, Validity::NonNullable) {
258-
// Masking always changes the nullability to nullable.
259-
dot.mask(validity.to_array(len))
260-
} else {
261-
Ok(dot)
262-
}
253+
// cosine similarity even for lossy storage wrappers, except that a zero stored norm still
254+
// represents a zero vector.
255+
let dot: PrimitiveArray = InnerProduct::try_new_array(normalized_l, normalized_r, len)?
256+
.into_array()
257+
.execute(ctx)?;
258+
let norms_l: PrimitiveArray = norms_l.execute(ctx)?;
259+
let norms_r: PrimitiveArray = norms_r.execute(ctx)?;
260+
261+
match_each_float_ptype!(dot.ptype(), |T| {
262+
let dots = dot.as_slice::<T>();
263+
let norms_l = norms_l.as_slice::<T>();
264+
let norms_r = norms_r.as_slice::<T>();
265+
let buffer: Buffer<T> = (0..len)
266+
.map(|i| {
267+
if norms_l[i] == T::zero() || norms_r[i] == T::zero() {
268+
T::zero()
269+
} else {
270+
dots[i]
271+
}
272+
})
273+
.collect();
274+
275+
// SAFETY: The buffer length equals `len`, which matches the source validity length.
276+
Ok(unsafe { PrimitiveArray::new_unchecked(buffer, validity) }.into_array())
277+
})
263278
}
264279

265280
/// One side is `L2Denorm`: treat the normalized child as authoritative, so
@@ -275,25 +290,28 @@ impl CosineSimilarity {
275290
) -> VortexResult<ArrayRef> {
276291
let validity = denorm_ref.validity()?.and(plain_ref.validity()?)?;
277292

278-
let (normalized, _) = extract_l2_denorm_children(denorm_ref);
293+
let (normalized, denorm_norms) = extract_l2_denorm_children(denorm_ref);
279294

280295
let dot_arr = InnerProduct::try_new_array(normalized, plain_ref.clone(), len)?;
281296
let dot: PrimitiveArray = dot_arr.into_array().execute(ctx)?;
282297

298+
let denorm_norms: PrimitiveArray = denorm_norms.execute(ctx)?;
299+
283300
let norm_arr = L2Norm::try_new_array(plain_ref.clone(), len)?;
284301
let plain_norm: PrimitiveArray = norm_arr.into_array().execute(ctx)?;
285302

286303
// TODO(connor): Ideally we would have a `SafeDiv` binary numeric operation.
287304
// TODO(connor): This can be written in a more SIMD-friendly manner.
288305
match_each_float_ptype!(dot.ptype(), |T| {
289306
let dots = dot.as_slice::<T>();
290-
let norms = plain_norm.as_slice::<T>();
307+
let denorm_norms = denorm_norms.as_slice::<T>();
308+
let plain_norms = plain_norm.as_slice::<T>();
291309
let buffer: Buffer<T> = (0..len)
292310
.map(|i| {
293-
if norms[i] == T::zero() {
311+
if denorm_norms[i] == T::zero() || plain_norms[i] == T::zero() {
294312
T::zero()
295313
} else {
296-
dots[i] / norms[i]
314+
dots[i] / plain_norms[i]
297315
}
298316
})
299317
.collect();
@@ -596,6 +614,55 @@ mod tests {
596614
Ok(())
597615
}
598616

617+
#[test]
618+
fn both_denorm_lossy_zero_stored_norm_returns_zero() -> VortexResult<()> {
619+
// Mimics a lossy encoding (e.g. TurboQuant) where the stored norm is authoritative but
620+
// the decoded normalized child is physically nonzero. With a stored norm of `0.0`, cosine
621+
// similarity for that row must be `0.0` even though the dot product of the normalized
622+
// children is nonzero.
623+
let normalized_l = tensor_array(&[2], &[0.6, 0.8])?;
624+
let norms_l = PrimitiveArray::from_iter([0.0f64]).into_array();
625+
// SAFETY: This is a focused test that intentionally violates the unit-norm invariant by
626+
// pairing a nonzero normalized row with a stored norm of `0.0`, mimicking lossy storage.
627+
let lhs = unsafe { L2Denorm::new_array_unchecked(normalized_l, norms_l, 1)? }.into_array();
628+
629+
let normalized_r = tensor_array(&[2], &[0.6, 0.8])?;
630+
let norms_r = PrimitiveArray::from_iter([0.0f64]).into_array();
631+
// SAFETY: Same as above for the rhs operand.
632+
let rhs = unsafe { L2Denorm::new_array_unchecked(normalized_r, norms_r, 1)? }.into_array();
633+
634+
// `dot(normalized_l, normalized_r) = 1.0`, but the authoritative stored norms are both
635+
// `0.0`, so cosine similarity must be `0.0`.
636+
assert_close(&eval_cosine_similarity(lhs, rhs, 1)?, &[0.0]);
637+
Ok(())
638+
}
639+
640+
#[test]
641+
fn one_side_denorm_lossy_zero_stored_norm_returns_zero() -> VortexResult<()> {
642+
// Mimics a lossy encoding (e.g. TurboQuant) where the stored norm is authoritative but
643+
// the decoded normalized child is physically nonzero. The plain side is a normal nonzero
644+
// tensor with positive norm. cosine similarity must still be `0.0` because the
645+
// authoritative stored norm on the denorm side is `0.0`.
646+
let normalized = tensor_array(&[2], &[0.6, 0.8])?;
647+
let norms = PrimitiveArray::from_iter([0.0f64]).into_array();
648+
// SAFETY: This is a focused test that intentionally pairs a nonzero normalized row with a
649+
// stored norm of `0.0`, mimicking lossy storage where the stored norm is authoritative.
650+
let denorm = unsafe { L2Denorm::new_array_unchecked(normalized, norms, 1)? }.into_array();
651+
652+
let plain = tensor_array(&[2], &[1.0, 0.0])?;
653+
654+
// Denorm on the lhs: `One { denorm: lhs, plain: rhs }`.
655+
assert_close(
656+
&eval_cosine_similarity(denorm.clone(), plain.clone(), 1)?,
657+
&[0.0],
658+
);
659+
660+
// Denorm on the rhs: `One { denorm: rhs, plain: lhs }`. The same zero-norm guard must
661+
// fire regardless of operand order.
662+
assert_close(&eval_cosine_similarity(plain, denorm, 1)?, &[0.0]);
663+
Ok(())
664+
}
665+
599666
#[test]
600667
fn constant_lhs_matches_plain_tensor() -> VortexResult<()> {
601668
// The constant query `[1, 2, 2]` has norm 3, so its normalized form is `[1/3, 2/3, 2/3]`.

0 commit comments

Comments
 (0)