andygrove commented on code in PR #27278:
URL: https://github.com/apache/datafusion/pull/27278#discussion_r4520699769


##########
datafusion/physical-plan/src/joins/sort_merge_join/tests.rs:
##########
@@ -8395,3 +8399,322 @@ async fn 
left_join_with_partial_filter_preserves_streamed_order() -> Result<()>
     );
     Ok(())
 }
+
+// ============================================================================
+// Early filtering of Inner joins
+// ============================================================================
+
+/// Rows for the early-filtering tests, sorted by the key column `names[0]`: 
key
+/// `k` of `0..keys` has `rows_per_key(k)` rows. `names[1]` is the row number 
and
+/// `names[2]` a pseudo-random value in `0..1000` that is NULL in every 37th 
row.
+/// The rows are split into batches of `rows_per_batch`.
+fn early_filtering_batches(
+    names: [&str; 3],
+    keys: i32,
+    rows_per_key: impl Fn(i32) -> usize,
+    seed: u64,
+    rows_per_batch: usize,
+) -> (Vec<RecordBatch>, SchemaRef) {
+    let mut key_values = vec![];
+    let mut row_numbers = vec![];
+    let mut values = vec![];
+    let mut state = seed;
+    for key in 0..keys {
+        for _ in 0..rows_per_key(key) {
+            state = state
+                .wrapping_mul(6364136223846793005)
+                .wrapping_add(1442695040888963407);
+            let row = key_values.len() as i32;
+            key_values.push(key);
+            row_numbers.push(row);
+            values.push((row % 37 != 0).then_some(((state >> 33) % 1000) as 
i32));
+        }
+    }
+    let schema = Arc::new(Schema::new(vec![
+        Field::new(names[0], DataType::Int32, false),
+        Field::new(names[1], DataType::Int32, false),
+        Field::new(names[2], DataType::Int32, true),
+    ]));
+    let batch = RecordBatch::try_new(
+        Arc::clone(&schema),
+        vec![
+            Arc::new(Int32Array::from(key_values)),
+            Arc::new(Int32Array::from(row_numbers)),
+            Arc::new(Int32Array::from(values)),
+        ],
+    )
+    .unwrap();
+    let batches = (0..batch.num_rows())
+        .step_by(rows_per_batch)
+        .map(|offset| batch.slice(offset, rows_per_batch.min(batch.num_rows() 
- offset)))
+        .collect();
+    (batches, schema)
+}
+
+/// Inputs for the early-filtering tests. Every third key has a group of 600
+/// buffered rows, long enough to be filtered as it is paired, and the 250-row
+/// buffered batches split each such group. The other keys have groups of one 
or
+/// two buffered rows, whose pairs are filtered at freeze. Keys repeat up to 
four
+/// times on the streamed side.
+fn early_filtering_inputs()
+-> ((Vec<RecordBatch>, SchemaRef), (Vec<RecordBatch>, SchemaRef)) {
+    let left =
+        early_filtering_batches(["a1", "b1", "c1"], 40, |k| 1 + (k % 4) as 
usize, 1, 7);
+    let right = early_filtering_batches(
+        ["a2", "b2", "c2"],
+        40,
+        |k| if k % 3 == 0 { 600 } else { (k % 3) as usize },
+        2,
+        250,

Review Comment:
   A run never crosses a buffered batch, so with 250-row batches every run is 
shorter than `FILTERED_RUN_MIN_ROWS` (256). That means no test reaches 
`pair_filtered_run`, its fallback for spilled batches, or a freeze holding 
chunks that were filtered while pairing. I added probes to those branches, and 
none of them fired in any of the 499 SMJ tests.
   
   These mutations all pass the tests today:
   
   - `pair_filtered_run` ignores the filter result and keeps every pair.
   - `start_filtered_chunk` sets the chunk key to `Some(buffered_batch_idx)` 
rather than `FILTERED_CHUNK_KEY`, so later unfiltered pairs land in the 
filtered chunk.
   - `freeze_inner_filtered` drops the chunks that were filtered while pairing.
   - `pair_filtered_run` returns `Ok(Some(0))` for a spilled batch, which drops 
the run.
   
   With 300 here, all of those branches run, and each mutation makes 
`join_inner_early_filtering_matches_hash_join` or the spill test fail. Could 
you change it to 300? Deriving it from `FILTERED_RUN_MIN_ROWS` (made 
`pub(super)`) would keep the tests on this path if the threshold changes.
   



##########
datafusion/physical-plan/src/joins/sort_merge_join/materializing_stream.rs:
##########
@@ -1771,33 +2008,215 @@ impl MaterializingSortMergeJoinStream {
         Ok(())
     }
 
-    /// Materializes right-side columns across all matched chunks.
+    /// Freezes the matched pairs of an Inner join with a filter. The filter 
runs
+    /// on just the columns it reads, for the pairs of the chunks that
+    /// [`Self::pair_filtered_run`] did not filter already, and only the pairs 
that
+    /// pass have their output columns materialized. A selective filter over a
+    /// many-to-many join rejects most pairs, so materializing every column of
+    /// every pair before filtering would spend most of the freeze on rows that
+    /// are then dropped. When no chunk was filtered already and earlier 
freezes
+    /// kept enough of their pairs, [`Self::filter_first_pays`] says the 
opposite,
+    /// and the batch is materialized whole and then filtered.
+    fn freeze_inner_filtered(
+        &mut self,
+        matched_chunks: &[MatchedChunk],
+        total_matched_rows: usize,
+    ) -> Result<()> {
+        let Some(filter) = &self.filter else {
+            return internal_err!("early filtering requires a join filter");
+        };
+        debug_assert_eq!(
+            self.join_type,
+            JoinType::Inner,
+            "only an Inner join filters early, so the streamed side is the 
left one"
+        );
+
+        // Evaluate the filter over the pairs of the chunks not filtered yet.
+        let unfiltered: Vec<&MatchedChunk> =
+            matched_chunks.iter().filter(|c| !c.filtered).collect();
+        if unfiltered.len() == matched_chunks.len() && 
!self.filter_first_pays(filter) {
+            return self.freeze_inner_filtered_whole(matched_chunks, 
total_matched_rows);
+        }
+        let passed = if unfiltered.is_empty() {
+            None
+        } else {
+            let num_rows = unfiltered.iter().map(|c| c.len()).sum();
+            let streamed_indices =
+                concat_indices(unfiltered.iter().map(|c| 
&c.streamed_indices))?;
+            let mut buffered_arrays = self
+                .materialize_right_columns(
+                    &unfiltered,
+                    num_rows,
+                    &self.buffered_filter_columns,
+                )?
+                .into_iter();
+            let columns = filter
+                .column_indices()
+                .iter()
+                .map(|column_index| match column_index.side {
+                    JoinSide::Left => take_column(
+                        self.streamed_batch.batch.column(column_index.index),
+                        &streamed_indices,
+                    ),
+                    JoinSide::Right => buffered_arrays.next().ok_or_else(|| {
+                        internal_datafusion_err!("missing buffered filter 
column")
+                    }),
+                    JoinSide::None => {
+                        internal_err!("Unexpected JoinSide::None in filter")
+                    }
+                })
+                .collect::<Result<Vec<_>>>()?;
+            self.freeze_filter_evaluated += num_rows;
+            Some(evaluate_join_filter(
+                filter,
+                filter.schema(),
+                columns,
+                num_rows,
+            )?)
+        };
+
+        // Keep the pairs that passed, in order.
+        let mut kept: Vec<Cow<MatchedChunk>> = 
Vec::with_capacity(matched_chunks.len());
+        let mut kept_rows = 0;
+        let mut offset = 0;
+        for chunk in matched_chunks {
+            let len = chunk.len();
+            match (&passed, chunk.filtered) {
+                (Some(passed), false) => {
+                    let mask = passed.slice(offset, len);
+                    offset += len;
+                    let count = mask.count_set_bits();
+                    if count == len {
+                        kept.push(Cow::Borrowed(chunk));
+                    } else if count > 0 {
+                        kept.push(Cow::Owned(chunk.filter(&mask)?));

Review Comment:
   When a key group spans many small buffered batches, a freeze holds about one 
chunk per buffered batch. This line then builds a `FilterBuilder` and runs two 
filter kernels for every chunk that partly passes. I tried the shape of 
`inner_group_spans_buffered_batches` with `left.data < right.data` keeping 95% 
of the pairs. Against `main`, this PR takes 1.19× as long with 8-row buffered 
batches (14.8 to 17.7 ms) and 1.05× with 64-row ones.
   
   Filtering the concatenated indices of the unfiltered chunks once, and giving 
each partly passing chunk a slice of the result, brings those to 1.11× and 
0.94×. It also helps when the filter keeps 5% of the pairs: 0.89× to 0.82× with 
8-row batches, and 0.51× to 0.43× with 64-row ones. The diff is below. It 
passes the SMJ tests and the differential test from the review summary.
   
   The rest of the 8-row slowdown might need `filter_first_pays` to count 
chunks, so that a freeze of many tiny chunks that keep most pairs takes the 
whole-batch path.
   
   <details><summary>Diff against e2f465717</summary>
   
   ```diff
   diff --git 
a/datafusion/physical-plan/src/joins/sort_merge_join/materializing_stream.rs 
b/datafusion/physical-plan/src/joins/sort_merge_join/materializing_stream.rs
   index 8f0b5b1ce..5f222db4b 100644
   --- 
a/datafusion/physical-plan/src/joins/sort_merge_join/materializing_stream.rs
   +++ 
b/datafusion/physical-plan/src/joins/sort_merge_join/materializing_stream.rs
   @@ -79,25 +79,6 @@ impl MatchedChunk {
        fn len(&self) -> usize {
            self.streamed_indices.len()
        }
   -
   -    /// Keeps the pairs whose bit is set in `mask`.
   -    fn filter(&self, mask: &BooleanBuffer) -> Result<Self> {
   -        let predicate = FilterBuilder::new(&BooleanArray::new(mask.clone(), 
None))
   -            .optimize()
   -            .build();
   -        Ok(Self {
   -            buffered_batch_idx: self.buffered_batch_idx,
   -            streamed_indices: as_uint64_array(
   -                &predicate.filter(&self.streamed_indices)?,
   -            )?
   -            .clone(),
   -            buffered_indices: as_uint64_array(
   -                &predicate.filter(&self.buffered_indices)?,
   -            )?
   -            .clone(),
   -            filtered: self.filtered,
   -        })
   -    }
    }
    
    /// Represents a chunk of joined data from streamed and buffered side
   @@ -2067,30 +2048,44 @@ impl MaterializingSortMergeJoinStream {
                    })
                    .collect::<Result<Vec<_>>>()?;
                self.freeze_filter_evaluated += num_rows;
   -            Some(evaluate_join_filter(
   -                filter,
   -                filter.schema(),
   -                columns,
   -                num_rows,
   -            )?)
   +            let passed =
   +                evaluate_join_filter(filter, filter.schema(), columns, 
num_rows)?;
   +            // Filter the index arrays of all unfiltered chunks at once; 
each partly
   +            // passing chunk below takes a slice of the result.
   +            let predicate = 
FilterBuilder::new(&BooleanArray::new(passed.clone(), None))
   +                .optimize()
   +                .build();
   +            let buffered_indices =
   +                concat_indices(unfiltered.iter().map(|c| 
&c.buffered_indices))?;
   +            let kept_streamed =
   +                
as_uint64_array(&predicate.filter(&streamed_indices)?)?.clone();
   +            let kept_buffered =
   +                
as_uint64_array(&predicate.filter(&buffered_indices)?)?.clone();
   +            Some((passed, kept_streamed, kept_buffered))
            };
    
            // Keep the pairs that passed, in order.
            let mut kept: Vec<Cow<MatchedChunk>> = 
Vec::with_capacity(matched_chunks.len());
            let mut kept_rows = 0;
            let mut offset = 0;
   +        let mut kept_offset = 0;
            for chunk in matched_chunks {
                let len = chunk.len();
                match (&passed, chunk.filtered) {
   -                (Some(passed), false) => {
   -                    let mask = passed.slice(offset, len);
   +                (Some((passed, kept_streamed, kept_buffered)), false) => {
   +                    let count = passed.slice(offset, len).count_set_bits();
                        offset += len;
   -                    let count = mask.count_set_bits();
                        if count == len {
                            kept.push(Cow::Borrowed(chunk));
                        } else if count > 0 {
   -                        kept.push(Cow::Owned(chunk.filter(&mask)?));
   +                        kept.push(Cow::Owned(MatchedChunk {
   +                            buffered_batch_idx: chunk.buffered_batch_idx,
   +                            streamed_indices: 
kept_streamed.slice(kept_offset, count),
   +                            buffered_indices: 
kept_buffered.slice(kept_offset, count),
   +                            filtered: false,
   +                        }));
                        }
   +                    kept_offset += count;
                        self.freeze_filter_passed += count;
                        kept_rows += count;
                    }
   ```
   
   </details>
   



##########
datafusion/physical-plan/src/joins/sort_merge_join/materializing_stream.rs:
##########
@@ -47,10 +50,63 @@ use 
datafusion_physical_expr_common::physical_expr::PhysicalExprRef;
 
 use futures::StreamExt;
 
+/// The shortest run of buffered rows that an Inner join with a filter filters 
as
+/// it pairs the run with a streamed row. Each such evaluation has a fixed cost
+/// that a long run amortizes. Shorter runs are paired unfiltered, and the
+/// freeze filters a whole batch of their pairs at once.
+const FILTERED_RUN_MIN_ROWS: usize = 256;

Review Comment:
   Since you asked about this threshold, I measured it with Inner joins of 16 
streamed rows and R buffered rows per key, 2M pairs each, using the bench's 
`key`/`data`/`payload` schema. The filters keep 5%, 50% or 95% of the pairs. 
The table shows this PR's time divided by the time of the same build with the 
threshold at `usize::MAX`, which filters everything at freeze (median of 5 
rounds):
   
   | R | 5% kept | 50% kept | 95% kept |
   |---:|---:|---:|---:|
   | 256 | 1.17 | 1.14 | 1.19 |
   | 512 | 0.87 | 1.05 | 1.13 |
   | 2048 | 0.70 | 1.00 | 1.11 |
   
   So filtering while pairing only wins for selective filters, and only from 
about 512 rows. A lower threshold is worse: at 64, R = 128 with 5% kept took 
1.75× as long as filtering at freeze. Everything in the table is still faster 
than `main` (0.13× to 0.96×), so this is speed left on the table rather than a 
regression.
   
   Would you consider raising the threshold to 512, and filtering while pairing 
only while the share of pairs that pass stays low? The pairing-time evaluations 
could feed the same counts that `filter_first_pays` uses. 
`inner_1to1000_selective_filtered` would keep its win.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to