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


##########
datafusion/physical-plan/src/joins/sort_merge_join/materializing_stream.rs:
##########
@@ -211,12 +317,28 @@ impl StreamedBatch {
         let capacity = batch_size - self.num_output_rows;
         self.output_indices.push(StreamedJoinedChunk {
             buffered_batch_idx,
+            filtered: false,
             streamed_indices: Vec::with_capacity(capacity),
             buffered_indices: Vec::with_capacity(capacity),
             buffered_nulls: NullBufferBuilder::new(capacity),
         });
         self.buffered_batch_idx = buffered_batch_idx;
     }
+
+    /// Starts a chunk of filtered pairs for `buffered_batch_idx`. It sets the 
key
+    /// that [`Self::chunk_for`] compares to [`FILTERED_CHUNK_KEY`], so that 
the next
+    /// unfiltered pair starts a chunk of its own.
+    fn start_filtered_chunk(&mut self, buffered_batch_idx: usize, batch_size: 
usize) {
+        let capacity = batch_size - self.num_output_rows;

Review Comment:
   Each newly encountered buffered batch reserves the entire remaining output 
capacity for both index vectors, even when the filter keeps a single row. With 
one streamed row, 1,024 same-key buffered batches of 256 rows, one survivor per 
batch, and `batch_size = 8192`, a probe using the base and head chunk methods 
measured peak live index-vector capacity of 2.06 MiB on the base and 120 MiB 
here. The head has just 16 KiB of actual indices. The base freezes every 8,192 
candidates, while all 1,024 filtered chunks stay live together here.
   
   `append_filtered_pairs` already has `count` before starting the chunk. 
Passing it as the initial capacity reduced the same probe to 16 KiB and still 
lets a reused chunk grow.



##########
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)?));
+                    }
+                    self.freeze_filter_passed += count;
+                    kept_rows += count;
+                }
+                _ => {
+                    kept.push(Cow::Borrowed(chunk));
+                    kept_rows += len;
+                }
+            }
+        }
+        if kept_rows == 0 {
+            return Ok(());
+        }
+
+        let streamed_indices = concat_indices(kept.iter().map(|c| 
&c.streamed_indices))?;
+        let streamed_columns =
+            materialize_left_columns(&self.streamed_batch.batch, 
&streamed_indices)?;
+        let buffered_columns =
+            self.materialize_right_columns(&kept, kept_rows, 
&self.buffered_columns)?;

Review Comment:
   A valid `Dictionary<Int8, Utf8>` payload can now fail here with 
`DictionaryKeyOverflowError`. I reproduced it with `batch_size = 256`, one 
streamed row, and two 256-row buffered batches for the same key. Each batch 
uses 100 distinct strings, disjoint between batches, with dictionary key `row % 
100`. A Boolean filter keeps the first 150 rows of each batch. The base returns 
all 300 rows. This path puts the first 150 survivors and 106 from the next 
batch into the same output. `interleave` now needs 200 distinct dictionary 
keys, which `Int8` cannot encode.
   
   Could we preserve the candidate-batch boundary when filtering during 
pairing, or split the output when the combined dictionary cannot be 
represented? This two-batch case would make a small regression test.



##########
datafusion/physical-plan/src/joins/sort_merge_join/materializing_stream.rs:
##########
@@ -805,6 +967,91 @@ impl MaterializingSortMergeJoinStream {
         true
     }
 
+    /// [`Self::pair_streamed_row_with_group`] for an Inner join with a filter,
+    /// which keeps only the pairs that pass it. For a run of at least
+    /// [`FILTERED_RUN_MIN_ROWS`] buffered rows, the filter runs as the run is
+    /// paired, and the pairs it rejects are never recorded.
+    fn pair_streamed_row_with_group_filtered(&mut self) -> Result<bool> {
+        let mut room = 
self.batch_size.saturating_sub(self.num_unfrozen_pairs());
+        while !self.buffered_data.scanning_finished() && room > 0 {
+            let rows = self.buffered_data.scanning_remaining();
+            let len = rows.len().min(room);
+            let filtered = if len >= FILTERED_RUN_MIN_ROWS {
+                self.pair_filtered_run(rows.start, len)?
+            } else {
+                None
+            };
+            match filtered {
+                Some(appended) => room -= appended,

Review Comment:
   Counting only survivors against `room` can make the join wait for more 
streamed input even when it already has a full batch to emit. I tested with 
`batch_size = 512`, one buffered batch of 512 rows for a single key, and two 
one-row streamed batches whose predicates keep 200 and 360 rows respectively. I 
held the streamed child open after its second batch with `BarrierExec`.
   
   The base emits 512 rows before the barrier is released. This head emits 
zero, then returns all 560 after release. The coalescer holds the first 200 and 
the remaining 360 are still pair indices when the join polls the child again. A 
downstream `LIMIT 512` can therefore wait on input it does not need. Could we 
trigger the freeze before polling the child when the accumulated rows can 
complete an output batch?



##########
datafusion/physical-plan/src/joins/sort_merge_join/materializing_stream.rs:
##########
@@ -360,6 +482,24 @@ pub(super) struct MaterializingSortMergeJoinStream {
     /// since NULL-padded rows reach filter evaluation. `None` unless
     /// `deferred_filtering`.
     pub filter_schema: Option<SchemaRef>,
+    /// Whether this is an Inner join with a filter. Such a join drops every 
pair
+    /// the filter rejects, so the filter runs before any output column is
+    /// materialized: on a long run of buffered rows as it is paired with a
+    /// streamed row (see [`Self::pair_filtered_run`]), and at freeze for the
+    /// remaining pairs (see [`Self::freeze_inner_filtered`]). Outer joins keep
+    /// a rejected pair for the deferred pipeline, which may null-join its row.
+    /// Only an Inner join filters early, so its streamed side is the left 
input.
+    pub early_filtering: bool,
+    /// The indices of every buffered column, for materializing all of them
+    pub buffered_columns: Vec<usize>,
+    /// The buffered columns that the join filter reads, in the filter's order.
+    /// Empty unless `early_filtering`.
+    pub buffered_filter_columns: Vec<usize>,
+    /// How many pairs the Inner join's filter has evaluated at freeze so far
+    /// (see [`Self::filter_first_pays`])
+    pub freeze_filter_evaluated: usize,

Review Comment:
   These two counters total pairs across the life of a partition. On wasm32, 
65,536 rows with one key on each side yield 2³² candidate pairs even if the 
filter rejects every row. Splitting the buffered input into 128-row batches 
takes the freeze path for every pair, so `freeze_filter_evaluated` overflows. 
Checked builds panic, and in release the wrapped denominator can make the 
estimated pass rate exceed 1. Could both totals use `u64`, widening the 
per-freeze increments?



-- 
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