Skip to main content

vortex_array/arrays/primitive/compute/
zip.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::mem::MaybeUninit;
5
6use vortex_buffer::BufferMut;
7use vortex_error::VortexExpect;
8use vortex_error::VortexResult;
9use vortex_error::vortex_bail;
10use vortex_mask::Mask;
11
12use crate::ArrayRef;
13use crate::ExecutionCtx;
14use crate::IntoArray;
15use crate::array::ArrayView;
16use crate::arrays::Primitive;
17use crate::arrays::PrimitiveArray;
18use crate::builtins::ArrayBuiltins;
19use crate::dtype::NativePType;
20use crate::match_each_native_ptype;
21use crate::scalar_fn::fns::zip::ZipKernel;
22use crate::scalar_fn::fns::zip::zip_validity;
23
24/// A dedicated primitive zip kernel that selects values branchlessly per row.
25///
26/// The generic zip path copies runs of `if_true`/`if_false` between mask boundaries, which is fast
27/// for clustered masks but degrades to per-element work on fragmented masks. This kernel instead
28/// walks the mask as 64-bit chunks and blends both sides per row without a data-dependent branch,
29/// so the inner loop stays branch-free and auto-vectorizable regardless of mask shape.
30impl ZipKernel for Primitive {
31    fn zip(
32        if_true: ArrayView<'_, Primitive>,
33        if_false: &ArrayRef,
34        mask: &ArrayRef,
35        ctx: &mut ExecutionCtx,
36    ) -> VortexResult<Option<ArrayRef>> {
37        let Some(if_false) = if_false.as_opt::<Primitive>() else {
38            return Ok(None);
39        };
40
41        if if_true.ptype() != if_false.ptype() {
42            vortex_bail!(
43                "zip requires if_true and if_false to share a primitive type, got {} and {}",
44                if_true.ptype(),
45                if_false.ptype()
46            );
47        }
48
49        // Null mask entries select `if_false`, matching `Zip`'s SQL ELSE semantics.
50        let mask = mask.clone().fill_null(false)?.execute::<Mask>(ctx)?;
51        match &mask {
52            // Defer trivial masks to the generic zip, which just casts the surviving side.
53            Mask::AllTrue(_) | Mask::AllFalse(_) => return Ok(None),
54            Mask::Values(_) => {}
55        }
56
57        let validity = zip_validity(if_true.validity()?, if_false.validity()?, &mask)?;
58
59        // TODO(perf): inspect the mask's true_count (and validity) to special-case heavily-skewed
60        // masks. When one side dominates (true_count near 0 or near len), it is cheaper to bulk
61        // copy — or mutate in place, if the dominant side is uniquely owned — that side's values
62        // and validity, then conditionally pull in only the minority rows from the other side,
63        // rather than blending every row.
64        let array = match_each_native_ptype!(if_true.ptype(), |T| {
65            let values =
66                select_values::<T>(if_true.as_slice::<T>(), if_false.as_slice::<T>(), &mask);
67            PrimitiveArray::new(values.freeze(), validity).into_array()
68        });
69        Ok(Some(array))
70    }
71}
72
73/// Branchlessly blend `if_true` and `if_false` per row into a fresh value buffer.
74fn select_values<T: NativePType>(
75    true_values: &[T],
76    false_values: &[T],
77    mask: &Mask,
78) -> BufferMut<T> {
79    let len = true_values.len();
80    let mut out = BufferMut::<T>::with_capacity(len);
81    {
82        let out_slice = out.spare_capacity_mut();
83
84        let mask_bits = mask
85            .values()
86            .vortex_expect("mask is Mask::Values")
87            .bit_buffer();
88        // TODO(perf): `unaligned_chunks` is a faster single-buffer iterator than `chunks`; switch to
89        // it here, handling its lead/trailing padding.
90        let chunks = mask_bits.chunks();
91
92        let mut base = 0;
93        for word in chunks.iter() {
94            let end = base + 64;
95            select_block(
96                word,
97                &true_values[base..end],
98                &false_values[base..end],
99                &mut out_slice[base..end],
100            );
101            base = end;
102        }
103
104        let remainder = chunks.remainder_len();
105        if remainder > 0 {
106            let end = base + remainder;
107            select_block(
108                chunks.remainder_bits(),
109                &true_values[base..end],
110                &false_values[base..end],
111                &mut out_slice[base..end],
112            );
113        }
114    }
115
116    // SAFETY: `select_block` initialized every slot covered by the chunks plus remainder, i.e. `len`.
117    unsafe { out.set_len(len) };
118    out
119}
120
121/// Blend one 64-bit mask chunk's worth of rows: bit `j` (LSB-first) keeps `true_values[j]`, an unset
122/// bit keeps `false_values[j]`. Slices are trimmed to the output length up front so the compiler can
123/// elide bounds checks and lower the body to a vector blend / conditional move.
124#[inline]
125fn select_block<T: NativePType>(
126    word: u64,
127    true_values: &[T],
128    false_values: &[T],
129    out: &mut [MaybeUninit<T>],
130) {
131    let n = out.len();
132    let true_values = &true_values[..n];
133    let false_values = &false_values[..n];
134    for j in 0..n {
135        let pick = (word >> j) & 1 == 1;
136        out[j].write(if pick {
137            true_values[j]
138        } else {
139            false_values[j]
140        });
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    #![allow(
147        clippy::cast_possible_truncation,
148        reason = "test fixtures use small indices that fit the target widths"
149    )]
150
151    use vortex_error::VortexResult;
152    use vortex_mask::Mask;
153
154    use crate::ArrayRef;
155    use crate::IntoArray;
156    use crate::VortexSessionExecute;
157    use crate::array_session;
158    use crate::arrays::Primitive;
159    use crate::arrays::PrimitiveArray;
160    use crate::assert_arrays_eq;
161    use crate::builtins::ArrayBuiltins;
162
163    /// The branchless kernel must agree with the scalar reference across the chunk boundary (index
164    /// 63/64) and the trailing remainder, for non-nullable inputs.
165    #[test]
166    fn zip_nonnull_spans_mask_chunks() -> VortexResult<()> {
167        let len = 150usize;
168        let if_true = PrimitiveArray::from_iter(0..len as i64).into_array();
169        let if_false = PrimitiveArray::from_iter((0..len as i64).map(|i| 1_000 + i)).into_array();
170
171        let bits: Vec<bool> = (0..len).map(|i| i.is_multiple_of(3) || i == 64).collect();
172        let mask = Mask::from_iter(bits.iter().copied());
173
174        let mut ctx = array_session().create_execution_ctx();
175        let result = mask
176            .into_array()
177            .zip(if_true, if_false)?
178            .execute::<ArrayRef>(&mut ctx)?;
179        assert!(result.is::<Primitive>());
180
181        let expected = PrimitiveArray::from_iter(
182            (0..len).map(|i| if bits[i] { i as i64 } else { 1_000 + i as i64 }),
183        )
184        .into_array();
185        assert_arrays_eq!(result, expected, &mut ctx);
186        Ok(())
187    }
188
189    /// With `Validity::Array` on both sides the kernel must select values and validity from the
190    /// chosen side across the chunk boundary.
191    #[test]
192    fn zip_nullable_selects_values_and_validity() -> VortexResult<()> {
193        let len = 130usize;
194        let if_true =
195            PrimitiveArray::from_option_iter((0..len as i64).map(|i| (i % 4 != 0).then_some(i)))
196                .into_array();
197        let if_false = PrimitiveArray::from_option_iter(
198            (0..len as i64).map(|i| (i % 5 != 0).then_some(1_000 + i)),
199        )
200        .into_array();
201
202        let bits: Vec<bool> = (0..len).map(|i| i.is_multiple_of(2)).collect();
203        let mask = Mask::from_iter(bits.iter().copied());
204
205        let mut ctx = array_session().create_execution_ctx();
206        let result = mask
207            .into_array()
208            .zip(if_true, if_false)?
209            .execute::<ArrayRef>(&mut ctx)?;
210        assert!(result.is::<Primitive>());
211
212        let expected = PrimitiveArray::from_option_iter((0..len).map(|i| {
213            let v = i as i64;
214            if bits[i] {
215                (!i.is_multiple_of(4)).then_some(v)
216            } else {
217                (!i.is_multiple_of(5)).then_some(1_000 + v)
218            }
219        }))
220        .into_array();
221        assert_arrays_eq!(result, expected, &mut ctx);
222        Ok(())
223    }
224}