Skip to content

Commit e843b51

Browse files
committed
Isolate primitive comparison migration above the RowFn framework
Keep the comparison type and operator dispatch, constant orientation, borrowed operand dtypes, and comparison coverage in this layer. Shared decoding, validity planning, and packed output belong to the framework below. The required input classification runs before decoding, and this layer adds no one-pass decoder hook or eager validity conjunction. Signed-off-by: Connor Tsui <connor.tsui20@gmail.com>
1 parent 5cf73d7 commit e843b51

7 files changed

Lines changed: 155 additions & 246 deletions

File tree

‎vortex-array/benches/compare.rs‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,11 @@
33

44
//! Benchmarks for the binary comparison path, over every array kind it accepts.
55
//!
6-
//! The primitive cases carry `#[cpu_features]`, so they are measured on every walltime
7-
//! CPU-feature leg rather than in simulation. Each is written once and compiled differently
8-
//! per leg: today the primitive comparison path is a portable lane kernel, and how well it
9-
//! auto-vectorizes is decided by the build. That is the baseline a hand-written kernel
10-
//! selected through `cfg(target_feature)` has to beat, measured on the silicon it would run
11-
//! on.
6+
//! The primitive cases carry `#[cpu_features]`, so they are measured on every walltime CPU-feature
7+
//! leg rather than in simulation. They all exercise the same [`RowFn`] comparison path, including
8+
//! its runtime-selected packed Boolean collector.
9+
//!
10+
//! [`RowFn`]: vortex_array::scalar_fn::unstable::row::RowFn
1211
//!
1312
//! Every case here compares one array against another, which is the shape the path is tuned for.
1413
//! The three constant cases that remain — boolean, integer, and string — are a regression guard on

‎vortex-array/src/scalar_fn/fns/binary/compare/mod.rs‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
//! Native comparison kernels.
55
//!
66
//! [`execute_compare`] dispatches on the logical [`DType`] of its operands and evaluates every
7-
//! comparison directly over Vortex canonical arrays — bit buffers for booleans, lane kernels from
8-
//! `vortex-compute` for primitives and decimals, binary views for strings/bytes, and a row-wise
9-
//! comparator for nested types. There is no Arrow fallback.
7+
//! comparison directly over Vortex canonical arrays: bit buffers for booleans, [`RowFn`] for
8+
//! primitives, lane kernels from `vortex-compute` for decimals, binary views for strings/bytes, and
9+
//! a row-wise comparator for nested types. There is no Arrow fallback.
10+
//!
11+
//! [`RowFn`]: crate::scalar_fn::unstable::row::RowFn
1012
//!
1113
//! Floating point values compare with Vortex's total ordering (`NaN` is the largest value,
1214
//! `-0.0 < +0.0`, and equality is bitwise), matching [`Scalar`] comparison semantics.
@@ -209,7 +211,7 @@ fn compare_arrays(
209211
)
210212
.into_array()),
211213
DType::Bool(_) => boolean::compare_bool(lhs, rhs, op, nullability, ctx),
212-
DType::Primitive(..) => primitive::compare_primitive(lhs, rhs, op, nullability, ctx),
214+
DType::Primitive(..) => primitive::compare_primitive(lhs, rhs, op, ctx),
213215
DType::Decimal(..) => decimal::compare_decimal(lhs, rhs, op, nullability, ctx),
214216
DType::Utf8(_) | DType::Binary(_) => bytes::compare_bytes(lhs, rhs, op, nullability, ctx),
215217
DType::Struct(..) | DType::List(..) | DType::FixedSizeList(..) | DType::Map(..) => {
Lines changed: 112 additions & 166 deletions
Original file line numberDiff line numberDiff line change
@@ -1,213 +1,159 @@
11
// SPDX-License-Identifier: Apache-2.0
22
// SPDX-FileCopyrightText: Copyright the Vortex contributors
33

4-
//! Native comparison of primitive arrays with packed bitmap output.
5-
6-
use vortex_buffer::BitBuffer;
7-
use vortex_buffer::BufferAllocatorRef;
8-
use vortex_buffer::BufferMut;
9-
use vortex_buffer::collect_bool_word;
10-
use vortex_compute::lane_kernels::bit_buffer_from_words;
11-
use vortex_compute::lane_kernels::collect_bits;
12-
use vortex_compute::lane_kernels::collect_zip_bits;
4+
//! Primitive comparison execution through [`RowFn`].
5+
//!
6+
//! [`PrimitiveCompare`] delegates decoding, constant handling, validity, and packed Boolean output
7+
//! to the row executor. Its row kernel contains only the comparison selected by [`CompareOperator`].
8+
139
use vortex_error::VortexResult;
1410
use vortex_error::vortex_bail;
1511

1612
use crate::ArrayRef;
1713
use crate::ExecutionCtx;
18-
use crate::IntoArray;
19-
use crate::arrays::BoolArray;
20-
use crate::arrays::ConstantArray;
14+
use crate::array::VTable;
15+
use crate::arrays::Constant;
2116
use crate::dtype::DType;
2217
use crate::dtype::NativePType;
23-
use crate::dtype::Nullability;
2418
use crate::dtype::PType;
2519
use crate::match_each_native_ptype;
26-
use crate::scalar::Scalar;
27-
use crate::scalar_fn::fns::binary::compare::compare_validity;
28-
use crate::scalar_fn::fns::binary::primitive_operand::PrimitiveOperand;
20+
use crate::scalar_fn::ScalarFnId;
21+
use crate::scalar_fn::ScalarFnVTable;
22+
use crate::scalar_fn::fns::binary::Binary;
2923
use crate::scalar_fn::fns::operators::CompareOperator;
24+
use crate::scalar_fn::unstable::row::RowFn;
25+
use crate::scalar_fn::unstable::row::RowInputs;
26+
use crate::scalar_fn::unstable::row::RowVisitor;
27+
use crate::scalar_fn::unstable::row::execute_row_inputs;
3028

3129
/// Compare two primitive arrays of the same [`PType`].
3230
///
33-
/// Floats use a total ordering over their bit patterns, including NaN signs and payloads.
34-
/// Equality is bitwise, so signed zeros and distinct NaN payloads remain distinct.
31+
/// Floats compare with Vortex's total ordering: `NaN` is the largest value, `-0.0 < +0.0`, and
32+
/// equality is bitwise.
3533
pub(super) fn compare_primitive(
3634
lhs: &ArrayRef,
3735
rhs: &ArrayRef,
3836
op: CompareOperator,
39-
nullability: Nullability,
4037
ctx: &mut ExecutionCtx,
4138
) -> VortexResult<ArrayRef> {
4239
let ptype = PType::try_from(lhs.dtype())?;
43-
match_each_native_ptype!(ptype, |T| {
44-
compare_primitive_typed::<T>(lhs, rhs, op, nullability, ctx)
45-
})
46-
}
4740

48-
fn compare_primitive_typed<T: NativePType>(
49-
lhs: &ArrayRef,
50-
rhs: &ArrayRef,
51-
op: CompareOperator,
52-
nullability: Nullability,
53-
ctx: &mut ExecutionCtx,
54-
) -> VortexResult<ArrayRef> {
55-
let len = lhs.len();
56-
let lhs = PrimitiveOperand::<T>::try_new(lhs, ctx)?;
57-
let rhs = PrimitiveOperand::<T>::try_new(rhs, ctx)?;
58-
if lhs.len() != rhs.len() {
59-
vortex_bail!(
60-
"compare operator requires equal lengths, got {} and {}",
61-
lhs.len(),
62-
rhs.len()
63-
);
41+
// The row loop vectorizes worse with a constant first operand, so keep the constant on the
42+
// right. Comparing encodings avoids two dynamic downcasts on every call.
43+
let constant = VTable::id(&Constant);
44+
if lhs.encoding_id() == constant && rhs.encoding_id() != constant {
45+
let args = CompareArgs::new(rhs, lhs, ptype);
46+
return execute_row_inputs(&PrimitiveCompare, &op.swap(), &args, ctx);
6447
}
6548

66-
let validity = compare_validity(lhs.validity(), rhs.validity(), nullability)?;
67-
68-
let bits = match (&lhs, &rhs) {
69-
(
70-
PrimitiveOperand::Array { values: lhs, .. },
71-
PrimitiveOperand::Array { values: rhs, .. },
72-
) => compare_slices(lhs, rhs, op, ctx.allocator()),
73-
(
74-
PrimitiveOperand::Array { values: lhs, .. },
75-
PrimitiveOperand::Constant { value: rhs, .. },
76-
) => compare_slice_constant(lhs, *rhs, op, ctx.allocator()),
77-
(
78-
PrimitiveOperand::Constant { value: lhs, .. },
79-
PrimitiveOperand::Array { values: rhs, .. },
80-
) => compare_slice_constant(rhs, *lhs, op.swap(), ctx.allocator()),
81-
(
82-
PrimitiveOperand::Constant { value: lhs, .. },
83-
PrimitiveOperand::Constant { value: rhs, .. },
84-
) => {
85-
// Unreachable through `execute_compare` (constant-constant is folded there), but
86-
// cheap to answer anyway.
87-
BitBuffer::full_in(apply_op(*lhs, *rhs, op), len, ctx.allocator().clone())
88-
}
89-
(PrimitiveOperand::Null(_), _) | (_, PrimitiveOperand::Null(_)) => {
90-
return Ok(
91-
ConstantArray::new(Scalar::null(DType::Bool(Nullability::Nullable)), len)
92-
.into_array(),
93-
);
94-
}
95-
};
96-
97-
Ok(BoolArray::try_new(bits, validity)?.into_array())
49+
execute_row_inputs(
50+
&PrimitiveCompare,
51+
&op,
52+
&CompareArgs::new(lhs, rhs, ptype),
53+
ctx,
54+
)
9855
}
9956

100-
#[allow(clippy::inline_always)]
101-
#[inline(always)]
102-
fn apply_op<T: NativePType>(lhs: T, rhs: T, op: CompareOperator) -> bool {
103-
match op {
104-
CompareOperator::Eq => lhs.is_eq(rhs),
105-
CompareOperator::NotEq => !lhs.is_eq(rhs),
106-
CompareOperator::Gt => lhs.is_gt(rhs),
107-
CompareOperator::Gte => lhs.is_ge(rhs),
108-
CompareOperator::Lt => lhs.is_lt(rhs),
109-
CompareOperator::Lte => lhs.is_le(rhs),
110-
}
57+
/// Borrowed comparison operands with their dtypes, so a call does not allocate.
58+
struct CompareArgs<'a> {
59+
arrays: [&'a ArrayRef; 2],
60+
dtypes: [DType; 2],
11161
}
11262

113-
fn compare_slices<T: NativePType>(
114-
lhs: &[T],
115-
rhs: &[T],
116-
op: CompareOperator,
117-
allocator: &BufferAllocatorRef,
118-
) -> BitBuffer {
119-
// Dispatch the operator outside the lane loop so each instantiation vectorizes a single
120-
// branch-free predicate.
121-
match op {
122-
CompareOperator::Eq => {
123-
collect_zip_bits_dispatch(lhs, rhs, |a: T, b: T| a.is_eq(b), allocator)
63+
impl<'a> CompareArgs<'a> {
64+
/// Pair two primitive operands of the same [`PType`] with their dtypes.
65+
///
66+
/// The dtypes are rebuilt as primitive values rather than cloned, so the compiler knows their
67+
/// variant and drops them without a runtime match.
68+
#[expect(clippy::inline_always)]
69+
#[inline(always)]
70+
fn new(lhs: &'a ArrayRef, rhs: &'a ArrayRef, ptype: PType) -> Self {
71+
Self {
72+
arrays: [lhs, rhs],
73+
dtypes: [
74+
DType::Primitive(ptype, lhs.dtype().nullability()),
75+
DType::Primitive(ptype, rhs.dtype().nullability()),
76+
],
12477
}
125-
CompareOperator::NotEq => {
126-
collect_zip_bits_dispatch(lhs, rhs, |a: T, b: T| !a.is_eq(b), allocator)
127-
}
128-
CompareOperator::Gt => collect_zip_bits_dispatch(lhs, rhs, T::is_gt, allocator),
129-
CompareOperator::Gte => collect_zip_bits_dispatch(lhs, rhs, T::is_ge, allocator),
130-
CompareOperator::Lt => collect_zip_bits_dispatch(lhs, rhs, T::is_lt, allocator),
131-
CompareOperator::Lte => collect_zip_bits_dispatch(lhs, rhs, T::is_le, allocator),
13278
}
13379
}
13480

135-
fn compare_slice_constant<T: NativePType>(
136-
lhs: &[T],
137-
rhs: T,
138-
op: CompareOperator,
139-
allocator: &BufferAllocatorRef,
140-
) -> BitBuffer {
141-
match op {
142-
CompareOperator::Eq => collect_bits_dispatch(lhs, |a: T| a.is_eq(rhs), allocator),
143-
CompareOperator::NotEq => collect_bits_dispatch(lhs, |a: T| !a.is_eq(rhs), allocator),
144-
CompareOperator::Gt => collect_bits_dispatch(lhs, |a: T| a.is_gt(rhs), allocator),
145-
CompareOperator::Gte => collect_bits_dispatch(lhs, |a: T| a.is_ge(rhs), allocator),
146-
CompareOperator::Lt => collect_bits_dispatch(lhs, |a: T| a.is_lt(rhs), allocator),
147-
CompareOperator::Lte => collect_bits_dispatch(lhs, |a: T| a.is_le(rhs), allocator),
81+
impl RowInputs for CompareArgs<'_> {
82+
fn num_inputs(&self) -> usize {
83+
2
14884
}
149-
}
15085

151-
fn collect_bits_dispatch<T: NativePType>(
152-
values: &[T],
153-
f: impl Fn(T) -> bool,
154-
allocator: &BufferAllocatorRef,
155-
) -> BitBuffer {
156-
// This type check folds away during monomorphization. Word packing improves float32
157-
// comparisons; wider types retain the lane kernel to avoid AVX2 constant-case regressions.
158-
if matches!(T::PTYPE, PType::I8 | PType::U8 | PType::F32) {
159-
collect_bits_narrow(values, f, allocator)
160-
} else {
161-
collect_bits(values, f, allocator)
86+
fn row_count(&self) -> usize {
87+
self.arrays[0].len()
88+
}
89+
90+
fn input(&self, index: usize) -> &ArrayRef {
91+
self.arrays[index]
16292
}
163-
}
16493

165-
fn collect_zip_bits_dispatch<T: NativePType>(
166-
lhs: &[T],
167-
rhs: &[T],
168-
f: impl Fn(T, T) -> bool,
169-
allocator: &BufferAllocatorRef,
170-
) -> BitBuffer {
171-
if matches!(T::PTYPE, PType::I8 | PType::U8 | PType::F32) {
172-
collect_zip_bits_narrow(lhs, rhs, f, allocator)
173-
} else {
174-
collect_zip_bits(lhs, rhs, f, allocator)
94+
fn dtypes(&self) -> Option<&[DType]> {
95+
Some(&self.dtypes)
17596
}
17697
}
17798

178-
fn collect_bits_narrow<T: Copy>(
179-
values: &[T],
180-
f: impl Fn(T) -> bool,
181-
allocator: &BufferAllocatorRef,
182-
) -> BitBuffer {
183-
let (chunks, tail) = values.as_chunks::<64>();
184-
let mut words = BufferMut::<u64>::zeroed_in(values.len().div_ceil(64), allocator.clone());
185-
// Fixed-size chunks let the compiler prove the predicate's indexing stays in bounds.
186-
for (word, chunk) in words.iter_mut().zip(chunks) {
187-
*word = collect_bool_word(64, |i| f(chunk[i]));
99+
/// Internal row execution for primitive comparison operators.
100+
#[derive(Clone)]
101+
struct PrimitiveCompare;
102+
103+
impl RowFn for PrimitiveCompare {
104+
type Options = CompareOperator;
105+
106+
const ARG_NAMES: &'static [&'static str] = &["lhs", "rhs"];
107+
108+
const INFALLIBLE: bool = true;
109+
110+
fn id(&self) -> ScalarFnId {
111+
// `PrimitiveCompare` is a private implementation detail of `Binary`: it is never registered
112+
// or serialized independently. Reusing the public ID keeps execution errors attributed to
113+
// `Binary`. If this type becomes registrable, it needs its own ID and persistence contract.
114+
ScalarFnVTable::id(&Binary)
188115
}
189-
if !tail.is_empty() {
190-
words[chunks.len()] = collect_bool_word(tail.len(), |i| f(tail[i]));
116+
117+
fn dispatch<V: RowVisitor>(
118+
&self,
119+
op: &Self::Options,
120+
args: &[DType],
121+
visitor: V,
122+
) -> VortexResult<V::VisitResult> {
123+
let [lhs_dtype, _] = args else {
124+
vortex_bail!(
125+
"a primitive comparison requires two operands, got {}",
126+
args.len(),
127+
);
128+
};
129+
let ptype = PType::try_from(lhs_dtype)?;
130+
131+
match_each_native_ptype!(ptype, |T| { visit_compare::<T, V>(*op, visitor) })
191132
}
192-
bit_buffer_from_words(words, values.len())
193133
}
194134

195-
fn collect_zip_bits_narrow<T: Copy>(
196-
lhs: &[T],
197-
rhs: &[T],
198-
f: impl Fn(T, T) -> bool,
199-
allocator: &BufferAllocatorRef,
200-
) -> BitBuffer {
201-
assert_eq!(lhs.len(), rhs.len());
202-
let (left_chunks, left_tail) = lhs.as_chunks::<64>();
203-
let (right_chunks, right_tail) = rhs.as_chunks::<64>();
204-
let mut words = BufferMut::<u64>::zeroed_in(lhs.len().div_ceil(64), allocator.clone());
205-
for ((word, left), right) in words.iter_mut().zip(left_chunks).zip(right_chunks) {
206-
*word = collect_bool_word(64, |i| f(left[i], right[i]));
207-
}
208-
if !left_tail.is_empty() {
209-
words[left_chunks.len()] =
210-
collect_bool_word(left_tail.len(), |i| f(left_tail[i], right_tail[i]));
135+
fn visit_compare<T, V>(op: CompareOperator, visitor: V) -> VortexResult<V::VisitResult>
136+
where
137+
T: NativePType,
138+
V: RowVisitor,
139+
{
140+
match op {
141+
CompareOperator::Eq => visit_compare_with::<T, V>(visitor, T::is_eq),
142+
CompareOperator::NotEq => visit_compare_with::<T, V>(visitor, |lhs, rhs| !lhs.is_eq(rhs)),
143+
CompareOperator::Gt => visit_compare_with::<T, V>(visitor, T::is_gt),
144+
CompareOperator::Gte => visit_compare_with::<T, V>(visitor, T::is_ge),
145+
CompareOperator::Lt => visit_compare_with::<T, V>(visitor, T::is_lt),
146+
CompareOperator::Lte => visit_compare_with::<T, V>(visitor, T::is_le),
211147
}
212-
bit_buffer_from_words(words, lhs.len())
148+
}
149+
150+
fn visit_compare_with<T, V>(
151+
visitor: V,
152+
compare: impl Fn(T, T) -> bool,
153+
) -> VortexResult<V::VisitResult>
154+
where
155+
T: NativePType,
156+
V: RowVisitor,
157+
{
158+
visitor.visit_bool::<(T, T), true>(move |(lhs, rhs)| compare(lhs, rhs))
213159
}

0 commit comments

Comments
 (0)