Skip to content

perf: filter sort-merge inner join pairs before materializing them - #27278

Open
comphead wants to merge 5 commits into
apache:mainfrom
comphead:smj-inner-filter-late-materialization
Open

comphead wants to merge 5 commits into
apache:mainfrom
comphead:smj-inner-filter-late-materialization

Conversation

@comphead

@comphead comphead commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

An Inner SortMergeJoinExec with a join filter materializes every column of every pair of rows that match on the join keys, and only then evaluates the filter and drops the pairs that fail it. When a key matches many rows on both sides and the filter keeps few of the pairs, nearly all of the join's time goes into copying rows that are then dropped.

TPC-DS q72's catalog_sales to inventory join has this shape. Each catalog_sales row matches every inventory row of its item, and inv_quantity_on_hand < cs_quantity keeps about 5% of the pairs. DataFusion Comet runs Spark's sort-merge joins on SortMergeJoinExec, and there this join makes q72 about three times slower than in Spark (datafusion-comet#4614). A CPU profile of that join in Comet at TPC-DS scale factor 10, on DataFusion 55.2.0 (before #26277), put 53% of the stage's CPU time in freeze_streamed, mostly in take and interleave of output columns, and 26% in recording pairs. Evaluating the filter itself took about 1%.

What changes are included in this PR?

The changes apply to Inner joins with a filter. Outer joins keep the deferred filtering that their null-joined rows need, and joins without a filter are unchanged.

  • Filtering before materializing at freeze. freeze_inner_filtered gathers only the columns the filter reads and evaluates the filter on them. It then filters the pairs' index arrays once, gives each chunk of pairs a slice of the result, and materializes the output columns for the pairs that passed.
  • Keeping the old order when it is cheaper. freeze_inner_filtered_whole materializes the batch and then filters it, as before. It runs in two cases:
    • when both sides' indices span a range, as in a 1:1 join. The columns then materialize as slices, and filtering first cannot save anything. spans_range checks only the first and last index, because the slicing path checks the range again.
    • when filter_first_pays says gathering the filter's columns first costs more than it saves. It decides from the share p of pairs that passed in earlier freezes: filtering first wins while p < 2(k - f - 1)/k, for k output columns and f filter columns. The model counts columns rather than bytes, and p is a lifetime average.
  • materialize_right_columns and the fetch_right_columns_* helpers take the buffered columns to gather.
  • New sort_merge_join benchmark cases for Inner joins with a filter. All but inner_1to1_filtered are in a separate first commit, so that they can be run against main.

An earlier revision also filtered long runs of buffered rows while pairing them with a streamed row. Review measurements showed it won only for selective filters on runs of 512 rows or more, and was up to 1.5x slower than main for narrow joins whose filter keeps most pairs, so it was dropped from this PR.

What is the testing strategy for this PR?

New tests in joins/sort_merge_join/tests.rs:

  • join_inner_early_filtering_matches_hash_join compares the join with HashJoinExec at batch sizes 1, 7, 300 and 8192. Its buffered side has groups of 600 rows split across 250-row batches, which freeze across several buffered batches, and groups of one or two rows. The filter columns contain NULLs. It runs five filters:

    • c1 < c2, which keeps about half of the pairs
    • a filter whose columns alternate between the two inputs
    • a high-pass filter, which takes the whole-batch path after the first freeze and still rejects some pairs
    • a filter that reads no columns and keeps every pair
    • a filter that reads no columns and keeps none

    It also checks that the output ascends on both inputs' row numbers, so the order within a key is kept. The single-pair freezes at batch size 1 cover the range path.

  • join_inner_early_filtering_with_spilled_buffered_batches forces the buffered batches to spill and compares the result with the join without spilling.

  • prepare_record_batches_for_cmp takes the schema, so that it handles a join that returns no batches.

Benchmarks: cargo bench -p datafusion-physical-plan --features test_utils --bench sort_merge_join, main plus the bench commit (9d916ab) against 8012102, with thin LTO on an Apple M5 Max. The two builds ran alternately for five rounds, and the table shows the medians.

Benchmark main This PR Change
inner_1to1/100000 1.44 ms 1.47 ms +1.7%
inner_1to1_filtered/100000 (new) * 1.45 ms 1.46 ms +0.3%
inner_1to10/100000 * 7.48 ms 7.53 ms +0.7%
inner_1to100/20000 13.76 ms 13.90 ms +1.0%
left_1to100_filtered/20000 38.86 ms 39.12 ms +0.7%
inner_1to10_filtered/100000 (new) 10.02 ms 5.53 ms -44.8%
inner_1to100_filtered/20000 (new) 19.81 ms 9.92 ms -49.9%
inner_1to10_high_pass_filtered/100000 (new) 9.73 ms 7.96 ms -18.2%
inner_1to10_high_pass_filtered_narrow/100000 (new) 3.58 ms 3.59 ms +0.1%
inner_1to1000_selective_filtered/20000 (new) 14.18 ms 3.34 ms -76.5%
left_1to1_unmatched/100000 1.43 ms 1.45 ms +0.8%
left_semi_1to10/100000 493 µs 495 µs +0.4%
left_anti_partial/100000 736 µs 746 µs +1.4%
inner_group_spans_buffered_batches/512 2.36 ms 2.32 ms -1.5%
inner_group_spans_buffered_batches/64 3.23 ms 3.18 ms -1.5%
inner_group_spans_buffered_batches/8 10.81 ms 10.80 ms -0.1%

* Medians of a separate seven-round run. In the five-round run these two read +3.7% and +5.8%, and in earlier runs inner_1to10, whose code path this PR does not change, ranged from -7% to +6%.

inner_1to1000_selective_filtered is q72's shape. The cases that this PR does not change, such as left_semi_1to10 and left_anti_partial, are unchanged within noise.

The one shape that is still slower than main is a key group spread over very small buffered batches with a filter that keeps nearly every pair: 1.10x with 8-row batches and 1.01x with 64-row ones at 95% kept.

In Comet, the earlier revision, which also filtered while pairing, backported to DataFusion 55.2.0, ran q72 at TPC-DS scale factor 10 in Spark local mode with 8 cores in 12.0 to 12.2 s. That compares with 42.9 to 49.1 s before and 13.8 s in Spark. The q72 result for this revision is to come.

Are there any user-facing changes?

No. Inner sort-merge joins with a filter return the same rows in the same order.

Adds `sort_merge_join` benchmark cases for Inner joins with a filter:

- `inner_1to10_filtered` and `inner_1to100_filtered`, in which every
  streamed row matches 10 or 100 buffered rows and `left.data <
  right.data` keeps about half of the pairs.
- `inner_1to10_high_pass_filtered`, in which `left.data <> right.data`
  keeps 90% of the pairs, and its `_narrow` variant over just the `key`
  and `data` columns, where the filter reads half of the output columns.
- `inner_1to1000_selective_filtered`, in which every streamed row matches
  1000 buffered rows and `left.data > right.data` keeps about 5% of the
  2M pairs. This is the shape of TPC-DS q72's `catalog_sales` to
  `inventory` join.
An Inner sort-merge join with a filter materialized every column of every
pair that matched on the join keys, and only then evaluated the filter and
dropped the pairs that failed it. When a key matches many rows on each side
and the filter keeps few of the pairs, most of the join's time went into
copying rows that were then dropped.

An Inner join with a filter now evaluates it before materializing:

- A run of at least 256 buffered rows is filtered as it is paired with a
  streamed row. The filter reads the run as a slice of its buffered batch
  and the streamed row's values repeated, and only the pairs that pass are
  recorded, in chunks marked as filtered.
- The pairs of shorter runs are filtered at freeze on just the columns the
  filter reads, and the output columns are materialized for the pairs that
  pass.
- When earlier freezes kept most of their pairs and the filter reads most
  of the output columns, the freeze materializes the batch whole and
  filters it, as before.

Outer joins keep the deferred filtering they need for null-joined rows,
and joins without a filter keep the original pairing loop.
- Drop the `streamed_side` field: only an Inner join filters early, so the
  streamed side is always the left input.
- Compute the buffered columns the filter reads once, not on every freeze.
- Repeat the streamed row with `take` instead of a `ScalarValue` round trip.
- Make `materialize_right_columns` generic over `Borrow<MatchedChunk>`, so
  callers pass chunks without collecting a `Vec<&MatchedChunk>` and kept
  chunks are borrowed rather than cloned.
- Reuse `evaluate_join_filter` in the outer join path.
- Build one `FilterPredicate` for both index arrays in `MatchedChunk::filter`.
- Fold the column-free filter test into the hash join comparison test.
The always-false column-free filter case makes the hash join oracle
return no batches, so take the schema as a parameter instead of reading
it from the first batch.

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up: this is an AI-assisted review pass. I've checked the points below against e2f4657, but I may have missed context or misjudged the intent, so please push back on anything that doesn't hold up.

Thanks @comphead, this is a big win for q72's shape, and the description explains the design well. Two things I particularly like: chunks filtered while pairing are marked, so the freeze never runs the filter twice, and the FILTERED_CHUNK_KEY sentinel leaves chunk_for, the 1:1 hot path, unchanged.

What I checked:

  • Results: a throwaway test ran 1,800 Inner joins against 58325af and again against 378d525, whose join code e2f4657 keeps. Each compared the join's output, rows and order, with the unfiltered join's output filtered row by row. The cases covered:

    • groups of up to 1,400 buffered rows, in buffered batches of 1 to 3,255 rows
    • batch_size from 1 to 8192
    • six filters: c1 < c2, your interleaved and high-pass ones, one that keeps about 2% of the pairs, and column-free true and false
    • spilling, in a quarter of the joins

    Every output matched. The test also catches each of the mutations listed in the inline comment on the test inputs.

  • Benchmarks: your numbers reproduce on an M3 Max. These are medians of 5 alternating rounds of saved binaries, 9d916ab (main plus your bench commit) against 378d525:

    Benchmark main This PR
    inner_1to10_filtered/100000 11.97 ms 6.48 ms
    inner_1to100_filtered/20000 23.32 ms 11.77 ms
    inner_1to10_high_pass_filtered/100000 11.55 ms 9.21 ms
    inner_1to10_high_pass_filtered_narrow/100000 4.22 ms 4.27 ms
    inner_1to1000_selective_filtered/20000 18.00 ms 2.96 ms

    The other ten cases moved by 1.6% or less.

  • Tests: the 499 SMJ tests pass on e2f4657. Thanks for the quick prepare_record_batches_for_cmp fix.

Before approving, I'd like to see:

  1. The tests reaching the code that filters while pairing. None of them do at the moment (inline), and changing one number fixes it.
  2. A look at freezes made of many small chunks when the filter keeps most pairs. These are now slower than on main: 1.19× with 8-row buffered batches (inline). It's narrow, but this PR introduces it.

On the two estimates you asked about: my measurements for the 256-row threshold are inline. In short, filtering while pairing only pays off for selective filters, on runs of about 512 rows or more. For the cost model, the small-chunk freezes in item 2 are the only cases I found where it picks the slower path.

40,
|k| if k % 3 == 0 { 600 } else { (k % 3) as usize },
2,
250,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: scanning_remaining stops at the end of the scanning batch, so no run in these inputs is longer than 250 rows. Filtering while pairing is removed now, so I probed the freeze branches instead. The tests reach:

  • the whole-batch path, through the range check and through the cost model
  • filtering first, with one and with several buffered batches, each with partial and full passes

Three mutations each make the tests fail: dropping the kept-offset advance, skipping the whole-batch filter, and reversing the kept chunks. Skipping the whole-batch filter used to pass, because the high-pass filter kept every pair. It is now c2 <> c1 OR b2 < b1, which rejects the pairs with a NULL or equal c.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you consider raising the threshold to 512, and filtering while pairing only while the share of pairs that pass stays low?

Your table makes me want to go one step further and drop filtering while pairing from this PR. Filtering at freeze is as fast or faster in 7 of its 9 cells. It is slower only for selective filters on runs of 512 rows or more (0.87 and 0.70).

There is also a case where filtering while pairing is slower than main. I ran the shape of inner_1to1000_selective_filtered (20 keys, 100 streamed and 1,000 buffered rows per key) with left.data > right.data keeping 90% of the pairs at random. The baseline is 9d916ab (main plus the bench commit). These are medians of 3 alternating rounds on Apple silicon with thin LTO, and speedup is main time divided by PR time:

Schema per side main This PR Speedup
key, data, payload 19.51 ms 15.49 ms 1.26x
key, data 6.06 ms 7.12 ms 0.85x

The bench code is in the comment on filter_first_pays.

The second pairing loop, the 256-row threshold, FILTERED_CHUNK_KEY, the filtered flag on chunks, the Cow handling, and the fallback for spilled batches exist only for filtering while pairing. As your other thread found, no test reaches that path yet.

@comphead, could you rerun q72 in Comet with filtering at freeze only? Setting FILTERED_RUN_MIN_ROWS to usize::MAX is enough for the measurement. If that keeps most of the drop from 270 s to 40 s, I'd ship filtering at freeze here, and filtering while pairing can come as its own PR if q72 shows it's needed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for measuring this. Your table matches what I see. Rather than tune the threshold, 8012102 follows @mbutrovich's suggestion and drops filtering while pairing. The numbers are in the top-level comment. I'll post the q72 result from Comet with filtering at freeze only once I have it.

if count == len {
kept.push(Cow::Borrowed(chunk));
} else if count > 0 {
kept.push(Cow::Owned(chunk.filter(&mask)?));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Diff against e2f4657
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;
                 }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 8012102, thanks. The freeze filters the concatenated index arrays once, and each chunk takes a slice of the result. Medians against 9d916ab, as a ratio to main, with both builds filtering at freeze only:

Case chunk by chunk all at once
8-row batches, 95% kept 1.18 1.10
64-row batches, 95% kept 1.07 1.01
8-row batches, 5% kept 0.98 0.90
64-row batches, 5% kept 0.68 0.57

I left the remaining 1.10x with 8-row batches. Counting chunks in filter_first_pays would need a per-chunk cost that I'd have to guess, and buffered batches that small should be rare once input is coalesced.

@vinycalves vinycalves left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

four logic questions

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test's 600-row groups are split into 250-row batches, but the pairing threshold is 256. Does any test actually hit the filtered pairing path, including its spilled-batch branch?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, as @andygrove found too: no run here is longer than 250 rows. 8012102 removes that path, and the tests now reach every branch of the freeze path (details in andygrove's thread).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if the join filter is volatile or stateful? Early evaluation changes when and how often it runs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each pair is still evaluated exactly once, as on main. Either the filter runs before materializing, or it runs on the whole batch, and no pair is evaluated twice or skipped. What changes is which batch of pairs an evaluation sees, and that already depends on batch_size on main. column_free_early_filter checks that a filter reading no columns still runs for every pair.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests compare sorted results and only assert key order. Does anything verify exact row order within a key?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing did. In 8012102, assert_streamed_order asserts that (b1, b2) ascends. These are the row numbers of the two inputs, so the check covers the order within a key. Reversing the kept chunks now fails it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does the pairing path use only a fixed 256-row threshold while the freeze path uses a cost model? Could high-pass wide joins regress?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fixed threshold belonged to filtering while pairing, which 8012102 removes. Its high-pass regression was real: up to 1.5x slower than main with a narrow schema (table in the top-level comment). The freeze path keeps the cost model and adds a range check for 1:1 joins.

@mbutrovich mbutrovich left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @comphead. The q72 profile makes a clear case for filtering pairs before materializing them.

In the context of #26257, I'm concerned that this PR complicates a stream that #23976 refactored for simplicity, and does it for a degenerate case in Comet: a many-to-many join whose filter rejects most pairs. I'd like to make sure we're optimizing for the common case, joins where each streamed row matches one or a few buffered rows, and that this PR is the smallest change that fixes q72.

Comment on lines +2170 to +2188
/// Whether filtering the pairs of a freeze before materializing them is cheaper
/// than materializing every column of every pair and filtering the batch.
///
/// Filtering first gathers the filter's `f` columns for every pair, filters the
/// pairs' two index arrays, and gathers all `k` output columns for the share
/// `p` of pairs that pass. The other way gathers the `k` columns for every
/// pair and copies the share `p` of them to filter the batch. Counting a
/// gathered value as 1 and a copied one as 1/2, filtering first is cheaper
/// while `f + 1 + k·p < k + k·p/2`, that is while `p < 2·(k - f - 1)/k`. `p` is
/// the share that passed in the earlier freezes, or 0 before the first.
fn filter_first_pays(&self, filter: &JoinFilter) -> bool {
let k = self.schema.fields().len() as f64;
let f = filter.column_indices().len() as f64;
let p = if self.freeze_filter_evaluated == 0 {
0.0
} else {
self.freeze_filter_passed as f64 / self.freeze_filter_evaluated as f64
};
p < 2.0 * (k - f - 1.0) / k

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This model assumes that the whole-batch path gathers all k columns for every pair. When a freeze's indices form a contiguous range, main slices the columns instead (materialize_left_columns and the buffered side). That is the usual case in a 1:1 join where every row matches, and there the whole-batch path costs little more than filter_record_batch. Filtering first keeps the slices only when every pair of a chunk passes. Otherwise chunk.filter leaves gaps in the indices, and every output column goes through take. With the bench schema (k = 6, f = 2), the threshold is 2(6 - 2 - 1)/6 = 1.0, so every freeze below 100% pass filters first.

I measured 1:1 Inner joins of 100K rows per side, with the bench's three-column schema and left.data < right.data, against 9d916ab. These are medians of 3 alternating rounds on Apple silicon with thin LTO:

Case main This PR Speedup
1:1, no filter (unchanged path) 1.403 ms 1.433 ms 0.98x
1:1, 100% pass 1.424 ms 1.530 ms 0.93x
1:1, 90% pass, every 10th row fails 1.692 ms 2.018 ms 0.84x
1:1, 90% pass, random 1.788 ms 2.037 ms 0.88x
1:1, 50% pass, random 1.745 ms 1.876 ms 0.93x
1:1, 5% pass, random 1.483 ms 1.590 ms 0.93x
N:1, 10 streamed rows per key, 90% pass 1.531 ms 1.522 ms 1.01x
N:1, 10 streamed rows per key, 50% pass 1.452 ms 1.349 ms 1.08x

The 100% case keeps the slices and is still 7% slower, against 2% for the unfiltered control. Every streamed row of these joins goes through pair_streamed_row_with_group_filtered, so dropping filtering while pairing may remove that part. The 90% cases, which lose the slices, drop further to 0.84x and 0.88x.

Could the freeze take the whole-batch path when its streamed and buffered indices are both contiguous? That checks the freeze at hand, so it adds no state. A 1:1 high-pass case in benches/sort_merge_join.rs would cover it.

Bench cases (appended to benches/sort_merge_join.rs)
fn mix(i: u64) -> u64 {
    let mut z = i.wrapping_add(0x9E37_79B9_7F4A_7C15);
    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
    z ^ (z >> 31)
}

/// Batches of 8192 rows with the given (already key-sorted) keys and data.
fn degen_batches(keys: Vec<i64>, data: Vec<i64>, narrow: bool) -> (SchemaRef, Vec<RecordBatch>) {
    let s = if narrow { narrow_schema() } else { schema() };
    let mut cols: Vec<arrow::array::ArrayRef> = vec![
        Arc::new(Int64Array::from(keys)),
        Arc::new(Int64Array::from(data.clone())),
    ];
    if !narrow {
        let payload: Vec<String> = (0..data.len()).map(|i| format!("val_{i}")).collect();
        cols.push(Arc::new(StringArray::from(payload)));
    }
    let batch = RecordBatch::try_new(Arc::clone(&s), cols).unwrap();
    let mut out = vec![];
    let mut off = 0;
    while off < batch.num_rows() {
        let len = (batch.num_rows() - off).min(8192);
        out.push(batch.slice(off, len));
        off += len;
    }
    (s, out)
}

fn bench_degenerate(c: &mut Criterion) {
    let rt = Runtime::new().unwrap();
    let mut group = c.benchmark_group("smj_degen");
    let n: usize = 100_000;

    // 1:1, unique keys on both sides, every streamed row matches.
    // left.data < right.data where right.data = t, left.data in 0..100.
    for (name, filtered, t, random) in [
        ("1to1_unfiltered", false, 0, true),
        ("1to1_pass100", true, 100, true),
        ("1to1_pass90_regular", true, 90, false),
        ("1to1_pass90_random", true, 90, true),
        ("1to1_pass50_random", true, 50, true),
        ("1to1_pass5_random", true, 5, true),
    ] {
        let keys: Vec<i64> = (0..n as i64).collect();
        let ldata: Vec<i64> = (0..n as u64)
            .map(|i| if random { (mix(i) % 100) as i64 } else { (i % 10 * 10) as i64 })
            .collect();
        let (s, left) = degen_batches(keys.clone(), ldata, false);
        let (_, right) = degen_batches(keys, vec![t; n], false);
        group.bench_function(BenchmarkId::new(name, n), |b| {
            b.iter(|| {
                do_join_with_filter(
                    make_exec(&left, &s),
                    make_exec(&right, &s),
                    datafusion_common::JoinType::Inner,
                    filtered.then(|| data_filter(Operator::Lt)),
                    &rt,
                )
            })
        });
    }

    // N:1 (FK to PK): 10 streamed rows per key, one buffered row per key.
    for (name, t) in [("nto1_pass90_random", 90), ("nto1_pass50_random", 50)] {
        let lkeys: Vec<i64> = (0..n as i64).map(|i| i / 10).collect();
        let ldata: Vec<i64> = (0..n as u64).map(|i| (mix(i) % 100) as i64).collect();
        let (s, left) = degen_batches(lkeys, ldata, false);
        let (_, right) = degen_batches((0..(n / 10) as i64).collect(), vec![t; n / 10], false);
        group.bench_function(BenchmarkId::new(name, n), |b| {
            b.iter(|| {
                do_join_with_filter(
                    make_exec(&left, &s),
                    make_exec(&right, &s),
                    datafusion_common::JoinType::Inner,
                    Some(data_filter(Operator::Lt)),
                    &rt,
                )
            })
        });
    }

    // 1:1000, 20 keys, 100 streamed and 1000 buffered rows per key (2M pairs).
    // left.data = 90 > right.data in 0..100 keeps 90% of pairs, at random.
    for (name, narrow) in [("1to1000_pass90", false), ("1to1000_pass90_narrow", true)] {
        let keys = 20i64;
        let lkeys: Vec<i64> = (0..keys * 100).map(|i| i / 100).collect();
        let rkeys: Vec<i64> = (0..keys * 1000).map(|i| i / 1000).collect();
        let rdata: Vec<i64> = (0..(keys * 1000) as u64).map(|i| (mix(i) % 100) as i64).collect();
        let (s, left) = degen_batches(lkeys, vec![90; (keys * 100) as usize], narrow);
        let (_, right) = degen_batches(rkeys, rdata, narrow);
        group.bench_function(BenchmarkId::new(name, keys * 1000), |b| {
            b.iter(|| {
                do_join_with_filter(
                    make_exec(&left, &s),
                    make_exec(&right, &s),
                    datafusion_common::JoinType::Inner,
                    Some(data_filter(Operator::Gt)),
                    &rt,
                )
            })
        });
    }
    group.finish();
}

criterion_group!(benches, bench_smj, bench_degenerate);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to the contiguous check. Two more things the model leaves out, in case it stays: it counts columns rather than bytes, so a string payload weighs the same as an Int64, and p is a lifetime average that pairing-time evaluations never feed. Neither needs solving here, but the doc comment should say so.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, reproduced and fixed in 8012102. The freeze takes the whole-batch path when both sides' indices span a range. The check looks only at the first and last index, because the slicing path checks the full range again anyway. A full scan here kept 1:1 joins about 5% slower than main. I also added inner_1to1_filtered to the bench. Medians as a ratio to main (9d916ab):

Case e2f4657 8012102
1:1, no filter 1.03 1.00
1:1, 100% pass 1.09 0.99
1:1, 90% pass, every 10th row fails 1.20 0.98
1:1, 90% pass, random 1.17 0.98
1:1, 50% pass, random 1.09 0.99
1:1, 5% pass, random 1.09 0.99
inner_1to1_filtered (new) n/a 0.99

@viirya, I added both caveats to the filter_first_pays doc: it counts columns rather than bytes, and p is a lifetime average. Since pairing no longer evaluates the filter, every evaluation now feeds p.

@comphead
comphead requested review from sunchao and viirya November 10, 2026 16:24

@sunchao sunchao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All 499 existing sort-merge join tests passed locally. I also ran two additional SortMergeJoinExec tests, one with narrow dictionary keys and one with the streamed input held open. Both passed on the merge base and failed on this head. Setting only FILTERED_RUN_MIN_ROWS to usize::MAX on this head made both pass again. The inline comments include the inputs and results, along with separate findings on index allocation and the lifetime selectivity counters.

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)?;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is specific to this PR. A 256-row batch with 200 distinct values cannot be encoded with Int8 keys by any path. The base passes with these inputs because each freeze leaves 150 rows, which is over half of batch_size, so the coalescer passes them through without concatenating. On main, would 128-row buffered batches (two chunks per freeze, through interleave) or a filter keeping 100 rows per batch (through the coalescer's concat) fail the same way?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@viirya is right that main already fails this way. On 9d916ab, with this schema and batch_size = 256, both variants fail with Dictionary key bigger than the key type:

  • four 128-row buffered batches, keeping every row, which goes through interleave
  • two 256-row batches keeping 100 rows each, which goes through the coalescer's concat

With filtering while pairing removed in 8012102, your exact input returns all 300 rows, as on main.

None
};
match filtered {
Some(appended) => room -= appended,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

main already polls the streamed child while holding fewer than batch_size unfrozen pairs: an unfiltered join with 200 and then 360 pairs from two one-row streamed batches would hold 560 rows the same way. The bound is still about 2 × batch_size. Freezing before the poll when the coalescer plus unfrozen pairs reach batch_size would help both cases, so it may fit better as its own change.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This came with filtering while pairing, which 8012102 removes. The join again freezes once batch_size candidate pairs accumulate, as on main. As @viirya noted, freezing before polling the child when the coalesced rows and the unfrozen pairs can fill a batch would help main as well, so I'd keep that as a separate change.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This came with filtering while pairing. 8012102 removes it along with start_filtered_chunk.

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8012102: both counters are u64, and the per-freeze increments are widened.

@viirya viirya left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @comphead. Filtering before materializing is the right fix for q72, and I found no correctness problem in the result or its order.

I agree with @mbutrovich on scope. Filtering at freeze is the broadly useful half. Filtering while pairing brings most of the new state (second pairing loop, threshold, sentinel key, filtered flag, spill fallback), wins only for selective filters on runs of 512+ rows, and is not reached by any test yet. Could you measure q72 with FILTERED_RUN_MIN_ROWS = usize::MAX? If most of the gain stays, I'd land freeze-time filtering here and the pairing part separately.

For the freeze path, the 1:1 slowdown should be fixed before merge, since that is the common shape for a filtered inner join. Taking the whole-batch path when both index arrays are contiguous is exactly what main runs today, so it cannot be slower than main there. A 1:1 filtered inner case in the bench would keep it visible.

On two of the reported regressions, I think both are existing behavior reached by different inputs rather than something new here; details inline. The capacity and u64 counter fixes look right to me.

Smaller points inline.

};

// Keep the pairs that passed, in order.
let mut kept: Vec<Cow<MatchedChunk>> = Vec::with_capacity(matched_chunks.len());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MatchedChunk::clone only bumps the refcounts of its two index buffers, so Vec<MatchedChunk> with a plain clone would do here. That drops Cow, and materialize_right_columns can go back to taking &[MatchedChunk] instead of being generic over Borrow. With @andygrove's diff above, MatchedChunk::filter goes away as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8012102. Cow, MatchedChunk::filter and the Borrow bound are gone, and materialize_right_columns takes &[MatchedChunk] again. With andygrove's change, the kept chunks are slices of the filtered index arrays, so no clone is needed.

})
.collect();
assert!(
keys.is_sorted(),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checks only that keys ascend, so a reorder within a key passes. b1 and b2 are row numbers on each side, so an Inner join's output should ascend on (b1, b2). Asserting that covers the order within a key too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8012102: it asserts that (b1, b2) ascends.

Address review feedback:
- Drop filtering while pairing. It wins only for selective filters on
  runs of 512+ rows and regresses narrow high-pass joins. This removes
  the run threshold, `FILTERED_CHUNK_KEY`, the `filtered` flag, `Cow`
  and the spill fallback.
- Take the whole-batch path when both sides' indices span a range, as
  in a 1:1 join, where the columns materialize as slices.
- Filter the concatenated index arrays once and slice them per chunk.
- Count the cost model's pairs in `u64`, and document its limits.
- Assert the order within a key, and make the high-pass test filter
  reject some pairs so that the whole-batch filter is covered.
- Add a filtered 1:1 Inner join benchmark.
@comphead

Copy link
Copy Markdown
Contributor Author

Thanks @andygrove, @vinycalves, @mbutrovich, @sunchao and @viirya. 8012102 takes the scope suggestion: the join now filters at freeze only. Filtering while pairing is gone, and with it the run threshold, FILTERED_CHUNK_KEY, the filtered flag, Cow and the spill fallback. materializing_stream.rs is now +371/-91 against main, down from +667/-104.

These are local medians of 3 alternating rounds on an Apple M5 Max, against 9d916ab (main plus the bench commit):

Case main with filtering while pairing at freeze only
inner_1to1000_selective_filtered 13.56 ms 2.28 ms 3.18 ms
1:256 key, data, 90% kept 5.71 ms 8.68 ms 5.67 ms
1:512 key, data, 90% kept 5.75 ms 7.69 ms 5.74 ms

Filtering while pairing was up to 1.4x faster for selective filters on runs of 512 rows or more. It was also up to 1.5x slower than main when the filter keeps most pairs and the schema is narrow. I haven't rerun q72 in Comet with filtering at freeze only yet, and will post that number here. If q72 needs filtering while pairing, it can come back as its own PR.

The inline replies cover the rest.

@vinycalves

Copy link
Copy Markdown

Sounds good for me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

physical-plan Changes to the physical-plan crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants