Skip to main content

vortex_array/arrays/listview/compute/
zip.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4use std::mem::MaybeUninit;
5use std::ops::BitAnd;
6use std::ops::BitOr;
7use std::ops::Not;
8
9use vortex_buffer::Buffer;
10use vortex_buffer::BufferMut;
11use vortex_error::VortexExpect;
12use vortex_error::VortexResult;
13use vortex_mask::Mask;
14
15use crate::ArrayRef;
16use crate::ExecutionCtx;
17use crate::IntoArray;
18use crate::array::ArrayView;
19use crate::arrays::Chunked;
20use crate::arrays::ChunkedArray;
21use crate::arrays::ListView;
22use crate::arrays::ListViewArray;
23use crate::arrays::chunked::ChunkedArrayExt;
24use crate::arrays::listview::ListViewArrayExt;
25use crate::builtins::ArrayBuiltins;
26use crate::dtype::DType;
27use crate::dtype::Nullability;
28use crate::dtype::PType;
29use crate::scalar_fn::fns::zip::ZipKernel;
30use crate::validity::Validity;
31
32/// Zip two [`ListViewArray`]s by selecting whole list views per row.
33///
34/// A [`ListViewArray`] addresses each list by an `(offset, size)` pair into a shared `elements`
35/// array, and unlike [`ListArray`](crate::arrays::ListArray) it does not require lists to be stored
36/// contiguously or in order. Zipping two list views is therefore a metadata-only operation over the
37/// `offsets`, `sizes` and `validity` child arrays: we concatenate the two `elements` arrays
38/// (without rewriting them) and, for each row, select the `(offset, size)` pair from `if_true` or
39/// `if_false` per the mask. `if_false` views are shifted past the end of `if_true`'s elements so
40/// they continue to address the correct half of the concatenated elements array.
41impl ZipKernel for ListView {
42    fn zip(
43        if_true: ArrayView<'_, ListView>,
44        if_false: &ArrayRef,
45        mask: &ArrayRef,
46        ctx: &mut ExecutionCtx,
47    ) -> VortexResult<Option<ArrayRef>> {
48        let Some(if_false) = if_false.as_opt::<ListView>() else {
49            return Ok(None);
50        };
51
52        // Null mask entries select `if_false`, matching `Zip`'s SQL ELSE semantics.
53        let mask = mask.clone().null_as_false().execute(ctx)?;
54        match &mask {
55            // Defer the trivial masks to the generic zip, which just casts one side.
56            Mask::AllTrue(_) | Mask::AllFalse(_) => return Ok(None),
57            Mask::Values(_) => {}
58        }
59
60        let len = if_true.len();
61
62        let result_elements_dtype = if_true
63            .elements()
64            .dtype()
65            .union_nullability(if_false.elements().dtype().nullability());
66
67        // `if_false`'s elements share the element dtype up to nullability; normalize so both chunks
68        // of the concatenated elements array have an identical dtype.
69        let true_elements = if_true.elements().cast(result_elements_dtype.clone())?;
70        let false_elements = if_false.elements().cast(result_elements_dtype.clone())?;
71
72        // `if_false` views index into the second half of the concatenated elements.
73        let false_shift = true_elements.len() as u64;
74
75        // Concatenate the two `elements` arrays without copying. If either side is already a
76        // `ChunkedArray` (e.g. the result of a previous list-view zip), splice its chunks in
77        // directly rather than nesting chunked arrays.
78        let mut chunks = Vec::with_capacity(2);
79        push_element_chunks(true_elements, &mut chunks);
80        push_element_chunks(false_elements, &mut chunks);
81        let elements = ChunkedArray::try_new(chunks, result_elements_dtype)?.into_array();
82
83        let true_offsets = to_u64(if_true.offsets(), ctx)?;
84        let true_sizes = to_u64(if_true.sizes(), ctx)?;
85        let false_offsets = to_u64(if_false.offsets(), ctx)?;
86        let false_sizes = to_u64(if_false.sizes(), ctx)?;
87
88        let mut offsets = BufferMut::<u64>::with_capacity(len);
89        let mut sizes = BufferMut::<u64>::with_capacity(len);
90        {
91            let true_offsets = true_offsets.as_slice();
92            let true_sizes = true_sizes.as_slice();
93            let false_offsets = false_offsets.as_slice();
94            let false_sizes = false_sizes.as_slice();
95
96            let offsets_out = offsets.spare_capacity_mut();
97            let sizes_out = sizes.spare_capacity_mut();
98
99            // We matched `Mask::Values` above, so the bit buffer is materialized. `unaligned_chunks`
100            // iterates faster than `chunks`: it exposes the byte-aligned body as a plain `&[u64]`
101            // with no per-word reshifting, isolating any bit misalignment into a leading `prefix`
102            // and trailing `suffix` word. We blend both sides branchlessly per row so the compiler
103            // vectorizes the inner select instead of mispredicting a data-dependent branch.
104            let mask_bits = mask
105                .values()
106                .vortex_expect("mask is Mask::Values")
107                .bit_buffer();
108            let unaligned = mask_bits.unaligned_chunks();
109            // The prefix word's low `lead` bits are padding; shifting them out aligns row 0 to bit 0,
110            // after which every chunk and the suffix start cleanly on a row boundary.
111            let lead = unaligned.lead_padding();
112
113            let mut select_block = |word: u64, base: usize, n: usize| {
114                let end = base + n;
115                // `if_false` views address the second half of the concatenated elements, so shift
116                // their offsets by `false_shift`; sizes are taken verbatim from the chosen side.
117                select_column(
118                    word,
119                    &true_offsets[base..end],
120                    &false_offsets[base..end],
121                    false_shift,
122                    &mut offsets_out[base..end],
123                );
124                select_column(
125                    word,
126                    &true_sizes[base..end],
127                    &false_sizes[base..end],
128                    0,
129                    &mut sizes_out[base..end],
130                );
131            };
132
133            let mut base = 0;
134            if let Some(prefix) = unaligned.prefix() {
135                let n = (64 - lead).min(len);
136                select_block(prefix >> lead, base, n);
137                base += n;
138            }
139            for &word in unaligned.chunks() {
140                select_block(word, base, 64);
141                base += 64;
142            }
143            if let Some(suffix) = unaligned.suffix() {
144                select_block(suffix, base, len - base);
145            }
146        }
147
148        // SAFETY: `select_column` initialized exactly `len` slots in both buffers.
149        unsafe {
150            offsets.set_len(len);
151            sizes.set_len(len);
152        }
153
154        let validity = zip_validity(if_true.validity()?, if_false.validity()?, &mask, ctx)?;
155
156        Ok(Some(
157            ListViewArray::try_new(
158                elements,
159                offsets.freeze().into_array(),
160                sizes.freeze().into_array(),
161                validity,
162            )?
163            .into_array(),
164        ))
165    }
166}
167
168/// Branchlessly select one `u64` column per row from `if_true` or `if_false`.
169///
170/// `word` holds the mask bits for this block, bit `j` (LSB-first) selecting row `j`: a set bit keeps
171/// `true_vals[j]`, an unset bit keeps `false_vals[j] + false_add`. The bit is expanded to a
172/// full-width lane mask and blended, so the inner loop is branch-free and auto-vectorizable. Inputs
173/// are sliced to the output length up front so the compiler can elide bounds checks across the block.
174#[inline]
175fn select_column(
176    word: u64,
177    true_vals: &[u64],
178    false_vals: &[u64],
179    false_add: u64,
180    out: &mut [MaybeUninit<u64>],
181) {
182    let n = out.len();
183    let true_vals = &true_vals[..n];
184    let false_vals = &false_vals[..n];
185    for j in 0..n {
186        // 0 for an unset bit, `u64::MAX` for a set bit.
187        let lane = 0u64.wrapping_sub((word >> j) & 1);
188        out[j].write((true_vals[j] & lane) | ((false_vals[j] + false_add) & !lane));
189    }
190}
191
192/// Appends `array`'s element chunks to `chunks`, flattening a top-level [`ChunkedArray`] so the
193/// concatenated elements never nest chunked arrays.
194fn push_element_chunks(array: ArrayRef, chunks: &mut Vec<ArrayRef>) {
195    match array.as_opt::<Chunked>() {
196        Some(chunked) => chunks.extend(chunked.iter_chunks().cloned()),
197        None => chunks.push(array),
198    }
199}
200
201/// Read a non-nullable integer array into a `u64` buffer.
202fn to_u64(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<Buffer<u64>> {
203    array
204        .clone()
205        .cast(DType::Primitive(PType::U64, Nullability::NonNullable))?
206        .execute::<Buffer<u64>>(ctx)
207}
208
209/// Combine the two list-level validities, taking `if_true`'s validity where `mask` is set and
210/// `if_false`'s where it is not.
211fn zip_validity(
212    if_true: Validity,
213    if_false: Validity,
214    mask: &Mask,
215    ctx: &mut ExecutionCtx,
216) -> VortexResult<Validity> {
217    Ok(match (&if_true, &if_false) {
218        (Validity::NonNullable, Validity::NonNullable) => Validity::NonNullable,
219        (Validity::AllValid, Validity::AllValid) => Validity::AllValid,
220        (Validity::AllInvalid, Validity::AllInvalid) => Validity::AllInvalid,
221        _ => {
222            let true_mask = if_true.execute_mask(mask.len(), ctx)?;
223            let false_mask = if_false.execute_mask(mask.len(), ctx)?;
224            let combined = true_mask
225                .bitand(mask)
226                .bitor(&false_mask.bitand(&mask.not()));
227            Validity::from_mask(combined, if_true.nullability() | if_false.nullability())
228        }
229    })
230}
231
232#[cfg(test)]
233mod tests {
234    #![allow(
235        clippy::cast_possible_truncation,
236        reason = "test fixtures use small indices that fit the target widths"
237    )]
238
239    use vortex_buffer::Buffer;
240    use vortex_buffer::buffer;
241    use vortex_error::VortexResult;
242    use vortex_mask::Mask;
243
244    use crate::ArrayRef;
245    use crate::IntoArray;
246    use crate::VortexSessionExecute;
247    use crate::array_session;
248    use crate::arrays::BoolArray;
249    use crate::arrays::Chunked;
250    use crate::arrays::ChunkedArray;
251    use crate::arrays::ListView;
252    use crate::arrays::ListViewArray;
253    use crate::arrays::chunked::ChunkedArrayExt;
254    use crate::arrays::listview::ListViewArrayExt;
255    use crate::assert_arrays_eq;
256    use crate::builtins::ArrayBuiltins;
257    use crate::dtype::DType;
258    use crate::dtype::Nullability;
259    use crate::dtype::PType;
260    use crate::validity::Validity;
261
262    fn list_view(
263        elements: ArrayRef,
264        offsets: ArrayRef,
265        sizes: ArrayRef,
266        validity: Validity,
267    ) -> ArrayRef {
268        ListViewArray::try_new(elements, offsets, sizes, validity)
269            .unwrap()
270            .into_array()
271    }
272
273    /// `zip` of two list views selects whole lists per the mask and keeps the list encoding.
274    #[test]
275    fn zip_selects_lists() -> VortexResult<()> {
276        let mut ctx = array_session().create_execution_ctx();
277        // [[1, 2], [3], [4, 5, 6]]
278        let if_true = list_view(
279            buffer![1i32, 2, 3, 4, 5, 6].into_array(),
280            buffer![0u32, 2, 3].into_array(),
281            buffer![2u32, 1, 3].into_array(),
282            Validity::NonNullable,
283        );
284        // [[10], [20, 21], [30]]
285        let if_false = list_view(
286            buffer![10i32, 20, 21, 30].into_array(),
287            buffer![0u32, 1, 3].into_array(),
288            buffer![1u32, 2, 1].into_array(),
289            Validity::NonNullable,
290        );
291        let mask = Mask::from_iter([true, false, true]);
292
293        let result = mask
294            .into_array()
295            .zip(if_true, if_false)?
296            .execute::<ArrayRef>(&mut ctx)?;
297
298        // The kernel should keep the list-view encoding rather than canonicalizing.
299        assert!(result.is::<ListView>());
300
301        // Expected: [[1, 2], [20, 21], [4, 5, 6]]
302        let expected = list_view(
303            buffer![1i32, 2, 20, 21, 4, 5, 6].into_array(),
304            buffer![0u32, 2, 4].into_array(),
305            buffer![2u32, 2, 3].into_array(),
306            Validity::NonNullable,
307        );
308        assert_arrays_eq!(result, expected, &mut ctx);
309        Ok(())
310    }
311
312    /// `zip` selects list-level validity from the chosen side and widens nullability.
313    #[test]
314    fn zip_selects_validity() -> VortexResult<()> {
315        let mut ctx = array_session().create_execution_ctx();
316        // [[1], null, [2]] (list-level nulls)
317        let if_true = list_view(
318            buffer![1i32, 2].into_array(),
319            buffer![0u32, 1, 1].into_array(),
320            buffer![1u32, 0, 1].into_array(),
321            Validity::Array(BoolArray::from_iter([true, false, true]).into_array()),
322        );
323        // [[10], [20], null]
324        let if_false = list_view(
325            buffer![10i32, 20].into_array(),
326            buffer![0u32, 1, 2].into_array(),
327            buffer![1u32, 1, 0].into_array(),
328            Validity::Array(BoolArray::from_iter([true, true, false]).into_array()),
329        );
330        // true -> if_true, false -> if_false
331        let mask = Mask::from_iter([false, true, true]);
332
333        let result = mask
334            .into_array()
335            .zip(if_true, if_false)?
336            .execute::<ArrayRef>(&mut ctx)?;
337
338        // Row 0 -> if_false[0] = [10]; row 1 -> if_true[1] = null; row 2 -> if_true[2] = [2]
339        let expected = list_view(
340            buffer![10i32, 2].into_array(),
341            buffer![0u32, 1, 1].into_array(),
342            buffer![1u32, 0, 1].into_array(),
343            Validity::Array(BoolArray::from_iter([true, false, true]).into_array()),
344        );
345        assert_arrays_eq!(result, expected, &mut ctx);
346        Ok(())
347    }
348
349    /// `zip` handles out-of-order/non-contiguous offsets and widens nullability when only one side
350    /// is nullable.
351    #[test]
352    fn zip_out_of_order_offsets_and_widening() -> VortexResult<()> {
353        let mut ctx = array_session().create_execution_ctx();
354        // [[5, 6], [7], [8, 9]] expressed with out-of-order offsets.
355        let if_true = list_view(
356            buffer![7i32, 8, 9, 5, 6].into_array(),
357            buffer![3u32, 0, 1].into_array(),
358            buffer![2u32, 1, 2].into_array(),
359            Validity::NonNullable,
360        );
361        // [[100], null, [200, 201]]
362        let if_false = list_view(
363            buffer![100i32, 200, 201].into_array(),
364            buffer![0u32, 1, 1].into_array(),
365            buffer![1u32, 0, 2].into_array(),
366            Validity::Array(BoolArray::from_iter([true, false, true]).into_array()),
367        );
368        let mask = Mask::from_iter([true, true, false]);
369
370        let result = mask
371            .into_array()
372            .zip(if_true, if_false)?
373            .execute::<ArrayRef>(&mut ctx)?;
374        assert!(result.is::<ListView>());
375
376        // [[5, 6], [7], [200, 201]], all valid but nullable (widened by if_false).
377        let expected = list_view(
378            buffer![5i32, 6, 7, 200, 201].into_array(),
379            buffer![0u32, 2, 3].into_array(),
380            buffer![2u32, 1, 2].into_array(),
381            Validity::AllValid,
382        );
383        assert_arrays_eq!(result, expected, &mut ctx);
384        Ok(())
385    }
386
387    /// Zipping more rows than fit in a single 64-bit mask chunk exercises both the chunked select
388    /// loop and the trailing remainder, including the `false_shift` applied to `if_false` views.
389    #[test]
390    fn zip_spans_multiple_mask_chunks() -> VortexResult<()> {
391        let mut ctx = array_session().create_execution_ctx();
392        // 130 single-element lists per side: `if_true[i] = [i]`, `if_false[i] = [1000 + i]`.
393        let len = 130usize;
394        let true_elements: Vec<i32> = (0..len as i32).collect();
395        let false_elements: Vec<i32> = (0..len as i32).map(|i| 1000 + i).collect();
396        let offsets: Vec<u64> = (0..len as u64).collect();
397        let sizes: Vec<u64> = vec![1; len];
398
399        let if_true = list_view(
400            true_elements
401                .iter()
402                .copied()
403                .collect::<Buffer<i32>>()
404                .into_array(),
405            offsets
406                .iter()
407                .copied()
408                .collect::<Buffer<u64>>()
409                .into_array(),
410            sizes.iter().copied().collect::<Buffer<u64>>().into_array(),
411            Validity::NonNullable,
412        );
413        let if_false = list_view(
414            false_elements
415                .iter()
416                .copied()
417                .collect::<Buffer<i32>>()
418                .into_array(),
419            offsets
420                .iter()
421                .copied()
422                .collect::<Buffer<u64>>()
423                .into_array(),
424            sizes.iter().copied().collect::<Buffer<u64>>().into_array(),
425            Validity::NonNullable,
426        );
427
428        // A non-trivial pattern that straddles the chunk boundary (index 63/64) and the remainder.
429        let mask_bits: Vec<bool> = (0..len).map(|i| i.is_multiple_of(3) || i == 64).collect();
430        let mask = Mask::from_iter(mask_bits.iter().copied());
431
432        let result = mask
433            .into_array()
434            .zip(if_true, if_false)?
435            .execute::<ArrayRef>(&mut ctx)?;
436        assert!(result.is::<ListView>());
437
438        // Each row collapses to a single element: `i` when the mask is set, else `1000 + i`.
439        let expected_elements: Vec<i32> = (0..len)
440            .map(|i| {
441                if mask_bits[i] {
442                    i as i32
443                } else {
444                    1000 + i as i32
445                }
446            })
447            .collect();
448        let expected = list_view(
449            expected_elements
450                .iter()
451                .copied()
452                .collect::<Buffer<i32>>()
453                .into_array(),
454            offsets
455                .iter()
456                .copied()
457                .collect::<Buffer<u64>>()
458                .into_array(),
459            sizes.iter().copied().collect::<Buffer<u64>>().into_array(),
460            Validity::NonNullable,
461        );
462        assert_arrays_eq!(result, expected, &mut ctx);
463        Ok(())
464    }
465
466    /// A mask whose bit buffer starts at a non-byte-aligned offset (here from slicing a bool array)
467    /// has non-zero `unaligned_chunks` lead padding, exercising the prefix word alongside the
468    /// aligned chunk body and the suffix.
469    #[test]
470    fn zip_handles_offset_mask() -> VortexResult<()> {
471        let mut ctx = array_session().create_execution_ctx();
472        // 200 single-element lists per side: `if_true[i] = [i]`, `if_false[i] = [1000 + i]`. With a
473        // 3-bit lead offset the mask spans more than 16 bytes, so `unaligned_chunks` exposes a
474        // non-empty aligned `chunks` body between the prefix and suffix words.
475        let len = 200usize;
476        let true_elements: Vec<i32> = (0..len as i32).collect();
477        let false_elements: Vec<i32> = (0..len as i32).map(|i| 1000 + i).collect();
478        let offsets: Vec<u64> = (0..len as u64).collect();
479        let sizes: Vec<u64> = vec![1; len];
480
481        let single_element_view = |elements: &[i32]| {
482            list_view(
483                elements
484                    .iter()
485                    .copied()
486                    .collect::<Buffer<i32>>()
487                    .into_array(),
488                offsets
489                    .iter()
490                    .copied()
491                    .collect::<Buffer<u64>>()
492                    .into_array(),
493                sizes.iter().copied().collect::<Buffer<u64>>().into_array(),
494                Validity::NonNullable,
495            )
496        };
497        let if_true = single_element_view(&true_elements);
498        let if_false = single_element_view(&false_elements);
499
500        // Slice off the first `offset` bits so the mask's bit buffer keeps a sub-byte offset while
501        // remaining `len` rows long. A non-trivial pattern straddles the prefix/body and chunk
502        // boundaries within the sliced window.
503        let offset = 3usize;
504        let mask_bits: Vec<bool> = (0..offset + len)
505            .map(|i| i.is_multiple_of(3) || i == offset + 64)
506            .collect();
507        let mask = BoolArray::from_iter(mask_bits.iter().copied())
508            .into_array()
509            .slice(offset..offset + len)?;
510
511        let result = mask.zip(if_true, if_false)?.execute::<ArrayRef>(&mut ctx)?;
512        assert!(result.is::<ListView>());
513
514        // Each row collapses to a single element: `i` when the sliced mask is set, else `1000 + i`.
515        let expected_elements: Vec<i32> = (0..len)
516            .map(|i| {
517                if mask_bits[offset + i] {
518                    i as i32
519                } else {
520                    1000 + i as i32
521                }
522            })
523            .collect();
524        let expected = single_element_view(&expected_elements);
525        assert_arrays_eq!(result, expected, &mut ctx);
526        Ok(())
527    }
528
529    /// When an input's `elements` is already a [`ChunkedArray`], its chunks are spliced in rather
530    /// than nesting a chunked array inside the concatenated elements.
531    #[test]
532    fn zip_flattens_chunked_elements() -> VortexResult<()> {
533        let mut ctx = array_session().create_execution_ctx();
534        // elements [1, 2, 3] stored as two chunks; lists [[1, 2], [3]].
535        let chunked_elements = ChunkedArray::try_new(
536            vec![buffer![1i32, 2].into_array(), buffer![3i32].into_array()],
537            DType::Primitive(PType::I32, Nullability::NonNullable),
538        )?
539        .into_array();
540        let if_true = list_view(
541            chunked_elements,
542            buffer![0u32, 2].into_array(),
543            buffer![2u32, 1].into_array(),
544            Validity::NonNullable,
545        );
546        // [[10], [20]]
547        let if_false = list_view(
548            buffer![10i32, 20].into_array(),
549            buffer![0u32, 1].into_array(),
550            buffer![1u32, 1].into_array(),
551            Validity::NonNullable,
552        );
553        let mask = Mask::from_iter([true, false]);
554
555        let result = mask
556            .into_array()
557            .zip(if_true, if_false)?
558            .execute::<ArrayRef>(&mut ctx)?;
559
560        // The concatenated elements are chunked, but no chunk is itself a `ChunkedArray`.
561        let result_lv = result
562            .as_opt::<ListView>()
563            .expect("zip keeps the list-view encoding");
564        let chunked = result_lv
565            .elements()
566            .as_opt::<Chunked>()
567            .expect("zip concatenates elements into a chunked array");
568        assert!(
569            chunked.iter_chunks().all(|chunk| !chunk.is::<Chunked>()),
570            "chunked elements must be flattened, not nested",
571        );
572
573        // [[1, 2], [20]]
574        let expected = list_view(
575            buffer![1i32, 2, 20].into_array(),
576            buffer![0u32, 2].into_array(),
577            buffer![2u32, 1].into_array(),
578            Validity::NonNullable,
579        );
580        assert_arrays_eq!(result, expected, &mut ctx);
581        Ok(())
582    }
583}