|
1 | 1 | // SPDX-License-Identifier: Apache-2.0 |
2 | 2 | // SPDX-FileCopyrightText: Copyright the Vortex contributors |
3 | 3 |
|
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 | +
|
13 | 9 | use vortex_error::VortexResult; |
14 | 10 | use vortex_error::vortex_bail; |
15 | 11 |
|
16 | 12 | use crate::ArrayRef; |
17 | 13 | 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; |
21 | 16 | use crate::dtype::DType; |
22 | 17 | use crate::dtype::NativePType; |
23 | | -use crate::dtype::Nullability; |
24 | 18 | use crate::dtype::PType; |
25 | 19 | 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; |
29 | 23 | 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; |
30 | 28 |
|
31 | 29 | /// Compare two primitive arrays of the same [`PType`]. |
32 | 30 | /// |
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. |
35 | 33 | pub(super) fn compare_primitive( |
36 | 34 | lhs: &ArrayRef, |
37 | 35 | rhs: &ArrayRef, |
38 | 36 | op: CompareOperator, |
39 | | - nullability: Nullability, |
40 | 37 | ctx: &mut ExecutionCtx, |
41 | 38 | ) -> VortexResult<ArrayRef> { |
42 | 39 | 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 | | -} |
47 | 40 |
|
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); |
64 | 47 | } |
65 | 48 |
|
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 | + ) |
98 | 55 | } |
99 | 56 |
|
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], |
111 | 61 | } |
112 | 62 |
|
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 | + ], |
124 | 77 | } |
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), |
132 | 78 | } |
133 | 79 | } |
134 | 80 |
|
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 |
148 | 84 | } |
149 | | -} |
150 | 85 |
|
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] |
162 | 92 | } |
163 | | -} |
164 | 93 |
|
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) |
175 | 96 | } |
176 | 97 | } |
177 | 98 |
|
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) |
188 | 115 | } |
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) }) |
191 | 132 | } |
192 | | - bit_buffer_from_words(words, values.len()) |
193 | 133 | } |
194 | 134 |
|
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), |
211 | 147 | } |
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)) |
213 | 159 | } |
0 commit comments