Skip to main content

polars_ops/frame/join/
cross_join.rs

1use std::sync::atomic::{AtomicU64, Ordering};
2
3use polars_core::utils::{
4    _set_partition_size, CustomIterTools, NoNull, accumulate_dataframes_vertical_unchecked,
5    concat_df_unchecked, par_iter_bounded, split,
6};
7use polars_defs::join::{CrossJoinOptions, JoinType, MaintainOrderJoin};
8use polars_utils::pl_str::PlSmallStr;
9
10use super::*;
11
12fn slice_take(
13    total_rows: IdxSize,
14    n_rows_right: IdxSize,
15    slice: Option<(i64, usize)>,
16    inner: fn(IdxSize, IdxSize, IdxSize) -> IdxCa,
17) -> IdxCa {
18    match slice {
19        None => inner(0, total_rows, n_rows_right),
20        Some((offset, len)) => {
21            let (offset, len) = slice_offsets(offset, len, total_rows as usize);
22            inner(offset as IdxSize, (len + offset) as IdxSize, n_rows_right)
23        },
24    }
25}
26
27fn take_left(total_rows: IdxSize, n_rows_right: IdxSize, slice: Option<(i64, usize)>) -> IdxCa {
28    fn inner(offset: IdxSize, total_rows: IdxSize, n_rows_right: IdxSize) -> IdxCa {
29        let mut take: NoNull<IdxCa> = (offset..total_rows)
30            .map(|i| i / n_rows_right)
31            .collect_trusted();
32        take.set_sorted_flag(IsSorted::Ascending);
33        take.into_inner()
34    }
35    slice_take(total_rows, n_rows_right, slice, inner)
36}
37
38fn take_right(total_rows: IdxSize, n_rows_right: IdxSize, slice: Option<(i64, usize)>) -> IdxCa {
39    fn inner(offset: IdxSize, total_rows: IdxSize, n_rows_right: IdxSize) -> IdxCa {
40        let take: NoNull<IdxCa> = (offset..total_rows)
41            .map(|i| i % n_rows_right)
42            .collect_trusted();
43        take.into_inner()
44    }
45    slice_take(total_rows, n_rows_right, slice, inner)
46}
47
48pub trait CrossJoin: IntoDf {
49    /// Creates the Cartesian product from both frames, preserves the order of the left keys.
50    fn cross_join(
51        &self,
52        other: &DataFrame,
53        suffix: Option<PlSmallStr>,
54        slice: Option<(i64, usize)>,
55        maintain_order: MaintainOrderJoin,
56    ) -> PolarsResult<DataFrame> {
57        let (l_df, r_df) = cross_join_dfs(self.to_df(), other, slice, true, maintain_order)?;
58
59        _finish_join(l_df, r_df, suffix)
60    }
61}
62
63impl CrossJoin for DataFrame {}
64
65fn cross_join_dfs<'a>(
66    mut df_self: &'a DataFrame,
67    mut other: &'a DataFrame,
68    slice: Option<(i64, usize)>,
69    parallel: bool,
70    maintain_order: MaintainOrderJoin,
71) -> PolarsResult<(DataFrame, DataFrame)> {
72    if df_self.height() == 0 || other.height() == 0 {
73        return Ok((df_self.clear(), other.clear()));
74    }
75
76    let left_is_primary = match maintain_order {
77        MaintainOrderJoin::None => true,
78        MaintainOrderJoin::Left | MaintainOrderJoin::LeftRight => true,
79        MaintainOrderJoin::Right | MaintainOrderJoin::RightLeft => false,
80    };
81
82    if !left_is_primary {
83        core::mem::swap(&mut df_self, &mut other);
84    }
85
86    let n_rows_left = df_self.height() as IdxSize;
87    let n_rows_right = other.height() as IdxSize;
88    let Some(total_rows) = n_rows_left.checked_mul(n_rows_right) else {
89        polars_bail!(
90            ComputeError: "cross joins would produce more rows than fits into 2^32; \
91            consider compiling with polars-big-idx feature, or set 'streaming'"
92        );
93    };
94
95    // the left side has the Nth row combined with every row from right.
96    // So let's say we have the following no. of rows
97    // left: 3
98    // right: 4
99    //
100    // left take idx:   000011112222
101    // right take idx:  012301230123
102
103    let create_left_df = || {
104        // SAFETY:
105        // take left is in bounds
106        unsafe {
107            df_self.take_unchecked_impl(&take_left(total_rows, n_rows_right, slice), parallel)
108        }
109    };
110
111    let create_right_df = || {
112        // concatenation of dataframes is very expensive if we need to make the series mutable
113        // many times, these are atomic operations
114        // so we choose a different strategy at > 100 rows (arbitrarily small number)
115        if n_rows_left > 100 || slice.is_some() {
116            // SAFETY:
117            // take right is in bounds
118            unsafe {
119                other.take_unchecked_impl(&take_right(total_rows, n_rows_right, slice), parallel)
120            }
121        } else {
122            let iter = (0..n_rows_left).map(|_| other);
123            concat_df_unchecked(iter)
124        }
125    };
126    let (l_df, r_df) = if parallel {
127        try_raise_polars_abort();
128        RAYON.install(|| rayon::join(create_left_df, create_right_df))
129    } else {
130        (create_left_df(), create_right_df())
131    };
132    if left_is_primary {
133        Ok((l_df, r_df))
134    } else {
135        Ok((r_df, l_df))
136    }
137}
138
139pub(super) fn fused_cross_filter(
140    left: &DataFrame,
141    right: &DataFrame,
142    suffix: Option<PlSmallStr>,
143    cross_join_options: &CrossJoinOptions,
144    maintain_order: MaintainOrderJoin,
145    how: &JoinType,
146) -> PolarsResult<DataFrame> {
147    let emit_unmatched_left = how.emits_unmatched_left();
148    let emit_unmatched_right = how.emits_unmatched_right();
149    let unfiltered_size = (left.height() as u64).saturating_mul(right.height() as u64);
150    let chunk_size = (unfiltered_size / _set_partition_size() as u64).clamp(1, 100_000);
151    let num_chunks = (unfiltered_size / chunk_size).max(1) as usize;
152
153    let left_is_primary = match maintain_order {
154        MaintainOrderJoin::None => true,
155        MaintainOrderJoin::Left | MaintainOrderJoin::LeftRight => true,
156        MaintainOrderJoin::Right | MaintainOrderJoin::RightLeft => false,
157    };
158    // Every left row's full run of candidate matches must stay within a single chunk so a
159    // match can be decided per-chunk; that only holds when left is the (chunked) primary side.
160    polars_ensure!(
161        !(emit_unmatched_left || emit_unmatched_right) || left_is_primary,
162        InvalidOperation: "'maintain_order={:?}' is not supported for `join_where` with 'how'={}",
163        maintain_order,
164        how
165    );
166
167    let split_chunks;
168    let cartesian_prod = if left_is_primary {
169        split_chunks = split(left, num_chunks);
170        split_chunks.iter().map(|l| (l, right)).collect::<Vec<_>>()
171    } else {
172        split_chunks = split(right, num_chunks);
173        split_chunks.iter().map(|r| (left, r)).collect::<Vec<_>>()
174    };
175
176    let names = _finish_join(left.clear(), right.clear(), suffix)?;
177    let rename_names = names.get_column_names();
178    let rename_names = &rename_names[left.width()..];
179    let len_right = right.height();
180    // The right rows that matched in any chunk.
181    let right_matched: Vec<AtomicU64> = if emit_unmatched_right {
182        (0..len_right.div_ceil(64))
183            .map(|_| AtomicU64::new(0))
184            .collect()
185    } else {
186        Vec::new()
187    };
188
189    let mut dfs = RAYON.install(|| {
190        par_iter_bounded(&cartesian_prod)
191            .map(|(left_chunk, right_chunk)| {
192                let (mut joined, right_taken) =
193                    cross_join_dfs(left_chunk, right_chunk, None, false, maintain_order)?;
194                let mut right_columns = right_taken.into_columns();
195
196                for (c, name) in right_columns.iter_mut().zip(rename_names) {
197                    c.rename((*name).clone());
198                }
199
200                unsafe { joined.hstack_mut_unchecked(&right_columns) };
201
202                if !emit_unmatched_left && !emit_unmatched_right {
203                    cross_join_options.predicate.apply(joined, false)
204                } else {
205                    let mask = cross_join_options
206                        .predicate
207                        .evaluate(&joined)?
208                        .broadcast_owned_to(joined.height())?;
209
210                    let len_left = left_chunk.height();
211                    debug_assert_eq!(joined.height(), len_left * len_right);
212
213                    // Combine values and validity into one bitmap so a null bit reads as "no
214                    // match" (this is what filter has filtered)
215                    let mask_arr = mask.rechunk();
216                    let mask_arr = mask_arr.downcast_get(0).unwrap();
217                    let match_bits = match mask_arr.validity() {
218                        Some(validity) => mask_arr.values() & validity,
219                        None => mask_arr.values().clone(),
220                    };
221
222                    // Emit each left row's matches. With `emit_unmatched_left`, a left row
223                    // without a match gives a single null-extended row in its place in the
224                    // left input's order, instead of being appended after the matched ones.
225                    let capacity = match_bits.set_bits() + len_left;
226                    let mut left_idx: Vec<IdxSize> = Vec::with_capacity(capacity);
227                    let mut right_idx: Vec<NullableIdxSize> = Vec::with_capacity(capacity);
228                    for i in 0..len_left {
229                        let run = match_bits.clone().sliced(i * len_right, len_right);
230                        if run.unset_bits() == len_right {
231                            if emit_unmatched_left {
232                                left_idx.push(i as IdxSize);
233                                right_idx.push(NullableIdxSize::null());
234                            }
235                        } else {
236                            for j in run.true_idx_iter() {
237                                left_idx.push(i as IdxSize);
238                                right_idx.push(NullableIdxSize::from(j as IdxSize));
239                                if emit_unmatched_right {
240                                    let (word, bit) = (&right_matched[j / 64], 1 << (j % 64));
241                                    if word.load(Ordering::Relaxed) & bit == 0 {
242                                        word.fetch_or(bit, Ordering::Relaxed);
243                                    }
244                                }
245                            }
246                        }
247                    }
248
249                    let idx = unsafe { IdxCa::mmap_slice(PlSmallStr::EMPTY, &left_idx) };
250                    let mut out = unsafe { left_chunk.take_unchecked(&idx) };
251                    let mut right_columns = unsafe {
252                        IdxCa::with_nullable_idx(&right_idx, |idx| right.take_unchecked(idx))
253                    }
254                    .into_columns();
255                    for (c, name) in right_columns.iter_mut().zip(rename_names) {
256                        c.rename((*name).clone());
257                    }
258                    unsafe { out.hstack_mut_unchecked(&right_columns) };
259
260                    Ok(out)
261                }
262            })
263            .collect::<PolarsResult<Vec<_>>>()
264    })?;
265
266    if emit_unmatched_right {
267        // The right rows that matched no left row, null-extended on the left.
268        let unmatched: Vec<IdxSize> = (0..len_right)
269            .filter(|&j| right_matched[j / 64].load(Ordering::Relaxed) & (1 << (j % 64)) == 0)
270            .map(|j| j as IdxSize)
271            .collect();
272        if !unmatched.is_empty() {
273            let mut out = DataFrame::full_null(left.schema(), unmatched.len());
274            let mut right_columns = if unmatched.len() == len_right {
275                right.clone()
276            } else {
277                let idx = unsafe { IdxCa::mmap_slice(PlSmallStr::EMPTY, &unmatched) };
278                unsafe { right.take_unchecked(&idx) }
279            }
280            .into_columns();
281            for (c, name) in right_columns.iter_mut().zip(rename_names) {
282                c.rename((*name).clone());
283            }
284            unsafe { out.hstack_mut_unchecked(&right_columns) };
285            dfs.push(out);
286        }
287    }
288
289    Ok(accumulate_dataframes_vertical_unchecked(dfs))
290}