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::ListViewArraySlotsExt;
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().fill_null(false)?.execute::<Mask>(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                ctx,
163            )?
164            .into_array(),
165        ))
166    }
167}
168
169/// Branchlessly select one `u64` column per row from `if_true` or `if_false`.
170///
171/// `word` holds the mask bits for this block, bit `j` (LSB-first) selecting row `j`: a set bit keeps
172/// `true_vals[j]`, an unset bit keeps `false_vals[j] + false_add`. The bit is expanded to a
173/// full-width lane mask and blended, so the inner loop is branch-free and auto-vectorizable. Inputs
174/// are sliced to the output length up front so the compiler can elide bounds checks across the block.
175#[inline]
176fn select_column(
177    word: u64,
178    true_vals: &[u64],
179    false_vals: &[u64],
180    false_add: u64,
181    out: &mut [MaybeUninit<u64>],
182) {
183    let n = out.len();
184    let true_vals = &true_vals[..n];
185    let false_vals = &false_vals[..n];
186    for j in 0..n {
187        // 0 for an unset bit, `u64::MAX` for a set bit.
188        let lane = 0u64.wrapping_sub((word >> j) & 1);
189        out[j].write((true_vals[j] & lane) | ((false_vals[j] + false_add) & !lane));
190    }
191}
192
193/// Appends `array`'s element chunks to `chunks`, flattening a top-level [`ChunkedArray`] so the
194/// concatenated elements never nest chunked arrays.
195fn push_element_chunks(array: ArrayRef, chunks: &mut Vec<ArrayRef>) {
196    match array.as_opt::<Chunked>() {
197        Some(chunked) => chunks.extend(chunked.iter_chunks().cloned()),
198        None => chunks.push(array),
199    }
200}
201
202/// Read a non-nullable integer array into a `u64` buffer.
203fn to_u64(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<Buffer<u64>> {
204    array
205        .clone()
206        .cast(DType::Primitive(PType::U64, Nullability::NonNullable))?
207        .execute::<Buffer<u64>>(ctx)
208}
209
210/// Combine the two list-level validities, taking `if_true`'s validity where `mask` is set and
211/// `if_false`'s where it is not.
212fn zip_validity(
213    if_true: Validity,
214    if_false: Validity,
215    mask: &Mask,
216    ctx: &mut ExecutionCtx,
217) -> VortexResult<Validity> {
218    Ok(match (&if_true, &if_false) {
219        (Validity::NonNullable, Validity::NonNullable) => Validity::NonNullable,
220        (Validity::AllValid, Validity::AllValid) => Validity::AllValid,
221        (Validity::AllInvalid, Validity::AllInvalid) => Validity::AllInvalid,
222        _ => {
223            let true_mask = if_true.execute_mask(mask.len(), ctx)?;
224            let false_mask = if_false.execute_mask(mask.len(), ctx)?;
225            let combined = true_mask
226                .bitand(mask)
227                .bitor(&false_mask.bitand(&mask.not()));
228            Validity::from_mask(combined, if_true.nullability() | if_false.nullability())
229        }
230    })
231}
232
233#[cfg(test)]
234mod tests {
235    #![allow(
236        clippy::cast_possible_truncation,
237        reason = "test fixtures use small indices that fit the target widths"
238    )]
239
240    use vortex_buffer::Buffer;
241    use vortex_buffer::buffer;
242    use vortex_error::VortexResult;
243    use vortex_mask::Mask;
244
245    use crate::ArrayRef;
246    use crate::IntoArray;
247    use crate::VortexSessionExecute;
248    use crate::array_session;
249    use crate::arrays::BoolArray;
250    use crate::arrays::Chunked;
251    use crate::arrays::ChunkedArray;
252    use crate::arrays::ListView;
253    use crate::arrays::ListViewArray;
254    use crate::arrays::chunked::ChunkedArrayExt;
255    use crate::arrays::listview::ListViewArraySlotsExt;
256    use crate::assert_arrays_eq;
257    use crate::builtins::ArrayBuiltins;
258    use crate::dtype::DType;
259    use crate::dtype::Nullability;
260    use crate::dtype::PType;
261    use crate::validity::Validity;
262
263    fn list_view(
264        elements: ArrayRef,
265        offsets: ArrayRef,
266        sizes: ArrayRef,
267        validity: Validity,
268    ) -> ArrayRef {
269        let mut ctx = array_session().create_execution_ctx();
270        ListViewArray::try_new(elements, offsets, sizes, validity, &mut ctx)
271            .unwrap()
272            .into_array()
273    }
274
275    /// `zip` of two list views selects whole lists per the mask and keeps the list encoding.
276    #[test]
277    fn zip_selects_lists() -> VortexResult<()> {
278        let mut ctx = array_session().create_execution_ctx();
279        // [[1, 2], [3], [4, 5, 6]]
280        let if_true = list_view(
281            buffer![1i32, 2, 3, 4, 5, 6].into_array(),
282            buffer![0u32, 2, 3].into_array(),
283            buffer![2u32, 1, 3].into_array(),
284            Validity::NonNullable,
285        );
286        // [[10], [20, 21], [30]]
287        let if_false = list_view(
288            buffer![10i32, 20, 21, 30].into_array(),
289            buffer![0u32, 1, 3].into_array(),
290            buffer![1u32, 2, 1].into_array(),
291            Validity::NonNullable,
292        );
293        let mask = Mask::from_iter([true, false, true]);
294
295        let result = mask
296            .into_array()
297            .zip(if_true, if_false)?
298            .execute::<ArrayRef>(&mut ctx)?;
299
300        // The kernel should keep the list-view encoding rather than canonicalizing.
301        assert!(result.is::<ListView>());
302
303        // Expected: [[1, 2], [20, 21], [4, 5, 6]]
304        let expected = list_view(
305            buffer![1i32, 2, 20, 21, 4, 5, 6].into_array(),
306            buffer![0u32, 2, 4].into_array(),
307            buffer![2u32, 2, 3].into_array(),
308            Validity::NonNullable,
309        );
310        assert_arrays_eq!(result, expected, &mut ctx);
311        Ok(())
312    }
313
314    /// `zip` selects list-level validity from the chosen side and widens nullability.
315    #[test]
316    fn zip_selects_validity() -> VortexResult<()> {
317        let mut ctx = array_session().create_execution_ctx();
318        // [[1], null, [2]] (list-level nulls)
319        let if_true = list_view(
320            buffer![1i32, 2].into_array(),
321            buffer![0u32, 1, 1].into_array(),
322            buffer![1u32, 0, 1].into_array(),
323            Validity::Array(BoolArray::from_iter([true, false, true]).into_array()),
324        );
325        // [[10], [20], null]
326        let if_false = list_view(
327            buffer![10i32, 20].into_array(),
328            buffer![0u32, 1, 2].into_array(),
329            buffer![1u32, 1, 0].into_array(),
330            Validity::Array(BoolArray::from_iter([true, true, false]).into_array()),
331        );
332        // true -> if_true, false -> if_false
333        let mask = Mask::from_iter([false, true, true]);
334
335        let result = mask.into_array().zip(if_true, if_false)?;
336
337        // Row 0 -> if_false[0] = [10]; row 1 -> if_true[1] = null; row 2 -> if_true[2] = [2]
338        let expected = list_view(
339            buffer![10i32, 2].into_array(),
340            buffer![0u32, 1, 1].into_array(),
341            buffer![1u32, 0, 1].into_array(),
342            Validity::Array(BoolArray::from_iter([true, false, true]).into_array()),
343        );
344        assert_arrays_eq!(result, expected, &mut ctx);
345        Ok(())
346    }
347
348    /// `zip` handles out-of-order/non-contiguous offsets and widens nullability when only one side
349    /// is nullable.
350    #[test]
351    fn zip_out_of_order_offsets_and_widening() -> VortexResult<()> {
352        let mut ctx = array_session().create_execution_ctx();
353        // [[5, 6], [7], [8, 9]] expressed with out-of-order offsets.
354        let if_true = list_view(
355            buffer![7i32, 8, 9, 5, 6].into_array(),
356            buffer![3u32, 0, 1].into_array(),
357            buffer![2u32, 1, 2].into_array(),
358            Validity::NonNullable,
359        );
360        // [[100], null, [200, 201]]
361        let if_false = list_view(
362            buffer![100i32, 200, 201].into_array(),
363            buffer![0u32, 1, 1].into_array(),
364            buffer![1u32, 0, 2].into_array(),
365            Validity::Array(BoolArray::from_iter([true, false, true]).into_array()),
366        );
367        let mask = Mask::from_iter([true, true, false]);
368
369        let result = mask
370            .into_array()
371            .zip(if_true, if_false)?
372            .execute::<ArrayRef>(&mut ctx)?;
373        assert!(result.is::<ListView>());
374
375        // [[5, 6], [7], [200, 201]], all valid but nullable (widened by if_false).
376        let expected = list_view(
377            buffer![5i32, 6, 7, 200, 201].into_array(),
378            buffer![0u32, 2, 3].into_array(),
379            buffer![2u32, 1, 2].into_array(),
380            Validity::AllValid,
381        );
382        assert_arrays_eq!(result, expected, &mut ctx);
383        Ok(())
384    }
385
386    /// Zipping more rows than fit in a single 64-bit mask chunk exercises both the chunked select
387    /// loop and the trailing remainder, including the `false_shift` applied to `if_false` views.
388    #[test]
389    fn zip_spans_multiple_mask_chunks() -> VortexResult<()> {
390        let mut ctx = array_session().create_execution_ctx();
391        // 130 single-element lists per side: `if_true[i] = [i]`, `if_false[i] = [1000 + i]`.
392        let len = 130usize;
393        let true_elements: Vec<i32> = (0..len as i32).collect();
394        let false_elements: Vec<i32> = (0..len as i32).map(|i| 1000 + i).collect();
395        let offsets: Vec<u64> = (0..len as u64).collect();
396        let sizes: Vec<u64> = vec![1; len];
397
398        let if_true = list_view(
399            true_elements
400                .iter()
401                .copied()
402                .collect::<Buffer<i32>>()
403                .into_array(),
404            offsets
405                .iter()
406                .copied()
407                .collect::<Buffer<u64>>()
408                .into_array(),
409            sizes.iter().copied().collect::<Buffer<u64>>().into_array(),
410            Validity::NonNullable,
411        );
412        let if_false = list_view(
413            false_elements
414                .iter()
415                .copied()
416                .collect::<Buffer<i32>>()
417                .into_array(),
418            offsets
419                .iter()
420                .copied()
421                .collect::<Buffer<u64>>()
422                .into_array(),
423            sizes.iter().copied().collect::<Buffer<u64>>().into_array(),
424            Validity::NonNullable,
425        );
426
427        // A non-trivial pattern that straddles the chunk boundary (index 63/64) and the remainder.
428        let mask_bits: Vec<bool> = (0..len).map(|i| i.is_multiple_of(3) || i == 64).collect();
429        let mask = Mask::from_iter(mask_bits.iter().copied());
430
431        let result = mask
432            .into_array()
433            .zip(if_true, if_false)?
434            .execute::<ArrayRef>(&mut ctx)?;
435        assert!(result.is::<ListView>());
436
437        // Each row collapses to a single element: `i` when the mask is set, else `1000 + i`.
438        let expected_elements: Vec<i32> = (0..len)
439            .map(|i| {
440                if mask_bits[i] {
441                    i as i32
442                } else {
443                    1000 + i as i32
444                }
445            })
446            .collect();
447        let expected = list_view(
448            expected_elements
449                .iter()
450                .copied()
451                .collect::<Buffer<i32>>()
452                .into_array(),
453            offsets
454                .iter()
455                .copied()
456                .collect::<Buffer<u64>>()
457                .into_array(),
458            sizes.iter().copied().collect::<Buffer<u64>>().into_array(),
459            Validity::NonNullable,
460        );
461        assert_arrays_eq!(result, expected, &mut ctx);
462        Ok(())
463    }
464
465    /// A mask whose bit buffer starts at a non-byte-aligned offset (here from slicing a bool array)
466    /// has non-zero `unaligned_chunks` lead padding, exercising the prefix word alongside the
467    /// aligned chunk body and the suffix.
468    #[test]
469    fn zip_handles_offset_mask() -> VortexResult<()> {
470        let mut ctx = array_session().create_execution_ctx();
471        // 200 single-element lists per side: `if_true[i] = [i]`, `if_false[i] = [1000 + i]`. With a
472        // 3-bit lead offset the mask spans more than 16 bytes, so `unaligned_chunks` exposes a
473        // non-empty aligned `chunks` body between the prefix and suffix words.
474        let len = 200usize;
475        let true_elements: Vec<i32> = (0..len as i32).collect();
476        let false_elements: Vec<i32> = (0..len as i32).map(|i| 1000 + i).collect();
477        let offsets: Vec<u64> = (0..len as u64).collect();
478        let sizes: Vec<u64> = vec![1; len];
479
480        let single_element_view = |elements: &[i32]| {
481            list_view(
482                elements
483                    .iter()
484                    .copied()
485                    .collect::<Buffer<i32>>()
486                    .into_array(),
487                offsets
488                    .iter()
489                    .copied()
490                    .collect::<Buffer<u64>>()
491                    .into_array(),
492                sizes.iter().copied().collect::<Buffer<u64>>().into_array(),
493                Validity::NonNullable,
494            )
495        };
496        let if_true = single_element_view(&true_elements);
497        let if_false = single_element_view(&false_elements);
498
499        // Slice off the first `offset` bits so the mask's bit buffer keeps a sub-byte offset while
500        // remaining `len` rows long. A non-trivial pattern straddles the prefix/body and chunk
501        // boundaries within the sliced window.
502        let offset = 3usize;
503        let mask_bits: Vec<bool> = (0..offset + len)
504            .map(|i| i.is_multiple_of(3) || i == offset + 64)
505            .collect();
506        let mask = BoolArray::from_iter(mask_bits.iter().copied())
507            .into_array()
508            .slice(offset..offset + len)?;
509
510        let result = mask.zip(if_true, if_false)?.execute::<ArrayRef>(&mut ctx)?;
511        assert!(result.is::<ListView>());
512
513        // Each row collapses to a single element: `i` when the sliced mask is set, else `1000 + i`.
514        let expected_elements: Vec<i32> = (0..len)
515            .map(|i| {
516                if mask_bits[offset + i] {
517                    i as i32
518                } else {
519                    1000 + i as i32
520                }
521            })
522            .collect();
523        let expected = single_element_view(&expected_elements);
524        assert_arrays_eq!(result, expected, &mut ctx);
525        Ok(())
526    }
527
528    /// When an input's `elements` is already a [`ChunkedArray`], its chunks are spliced in rather
529    /// than nesting a chunked array inside the concatenated elements.
530    #[test]
531    fn zip_flattens_chunked_elements() -> VortexResult<()> {
532        let mut ctx = array_session().create_execution_ctx();
533        // elements [1, 2, 3] stored as two chunks; lists [[1, 2], [3]].
534        let chunked_elements = ChunkedArray::try_new(
535            vec![buffer![1i32, 2].into_array(), buffer![3i32].into_array()],
536            DType::Primitive(PType::I32, Nullability::NonNullable),
537        )?
538        .into_array();
539        let if_true = list_view(
540            chunked_elements,
541            buffer![0u32, 2].into_array(),
542            buffer![2u32, 1].into_array(),
543            Validity::NonNullable,
544        );
545        // [[10], [20]]
546        let if_false = list_view(
547            buffer![10i32, 20].into_array(),
548            buffer![0u32, 1].into_array(),
549            buffer![1u32, 1].into_array(),
550            Validity::NonNullable,
551        );
552        let mask = Mask::from_iter([true, false]);
553
554        let result = mask
555            .into_array()
556            .zip(if_true, if_false)?
557            .execute::<ArrayRef>(&mut ctx)?;
558
559        // The concatenated elements are chunked, but no chunk is itself a `ChunkedArray`.
560        let result_lv = result
561            .as_opt::<ListView>()
562            .expect("zip keeps the list-view encoding");
563        let chunked = result_lv
564            .elements()
565            .as_opt::<Chunked>()
566            .expect("zip concatenates elements into a chunked array");
567        assert!(
568            chunked.iter_chunks().all(|chunk| !chunk.is::<Chunked>()),
569            "chunked elements must be flattened, not nested",
570        );
571
572        // [[1, 2], [20]]
573        let expected = list_view(
574            buffer![1i32, 2, 20].into_array(),
575            buffer![0u32, 2].into_array(),
576            buffer![2u32, 1].into_array(),
577            Validity::NonNullable,
578        );
579        assert_arrays_eq!(result, expected, &mut ctx);
580        Ok(())
581    }
582}