peter-toth opened a new pull request, #59866:
URL: https://github.com/apache/spark/pull/59866

   ### What changes were proposed in this pull request?
   
   A runtime bloom filter holds the join keys of the whole creation side. When 
the join key of each side comes from a V2 scan, and the two scans report a 
keyed partitioning by the same transform of that key, a partition of the 
application side only meets the keys of one partition of the creation side. 
This PR adds one bloom filter per partition key for that case, behind the new 
`spark.sql.optimizer.runtime.bloomFilter.perPartition.enabled` (off by default).
   
   `InjectRuntimeFilter` adds a left semi join of the application side with an 
aggregate that builds one bloom filter per partition key of the creation side:
   
   ```
   Project [fact columns]
   +- Join LeftSemi, (fk = pk) AND might_contain(bloom, xxhash64(fact.k))
      :- Project [fact columns, bucket(4, fact.k) AS fk]
      :  +- <application side>                           
KeyedPartitioning(bucket(4, k))
      +- Aggregate [bucket(4, dim.k)], [bucket(4, dim.k) AS pk, 
bloom_filter_agg(xxhash64(dim.k)) AS bloom]
         +- <creation side>                              
KeyedPartitioning(bucket(4, k))
   ```
   
   - The equality on the partition keys gives each application row the bloom of 
its partition, and lets `EnsureRequirements` pair the two sides as a 
storage-partitioned join does, without a shuffle.
   - The new physical rule `ChoosePerPartitionBloomFilters` runs before 
`EnsureRequirements`. It keeps the semi join only when the semi join is free. A 
free semi join adds no shuffle, and the stage that reads the application side 
runs as many tasks as without it. That holds when the join above is a 
storage-partitioned join that groups the application side's splits the way the 
semi join does.
     - When the semi join is free, the global bloom filter of the same key 
goes, from the `Filter` and from the scan's runtime filters. It stays when a V2 
scan also applies it as a storage filter.
     - Otherwise the semi join goes, and the global bloom filter, if any, stays.
   - Each bloom filter is sized for the creation side's rows spread over its 
partitions. The creation side's size threshold applies to one partition the 
same way.
   
   It applies when:
   - the conf and `spark.sql.sources.v2.bucketing.enabled` are on;
   - the join qualifies for a runtime bloom filter, with the size threshold per 
partition as above;
   - each side's join key comes from a column of a V2 scan below it, and the 
scan reports a keyed partitioning by a single transform (not `identity`) of 
that column;
   - the two transforms are the same function, with the same bucket count;
   - Spark can evaluate the transform as is, i.e. it is a scalar function that 
asks for exactly the types of its arguments.
   
   Three commits are prerequisites:
   1. A partition transform generates the code of its function call. So a 
projection or a grouping by it stays in whole-stage codegen when its function 
has an `invoke` method.
   2. `might_contain` reads its bloom filter from a column, or from any other 
expression without subqueries, and reuses the bloom it deserialized while the 
same array comes back.
   3. A hash semi join condition reads a binary build column again only when 
the build row changes, rather than for every streamed row. This one also helps 
such a join that a query writes.
   
   ### Why are the changes needed?
   
   A storage-partitioned join does not shuffle the application side. So a bloom 
filter applied above the scan saves only the work between the scan and the 
join. It pays off when the scan applies it, skipping the rows it rules out 
while it reads. That is a follow-up, with the storage-filter API of #59715. It 
needs a bloom filter per partition, and this PR builds it.
   
   Compared with the global bloom filter that `InjectRuntimeFilter` adds to 
such a join today, the per-partition one:
   - is sized for one partition's keys, which also gets it a lower false 
positive rate;
   - is built in the task of its partition, with no separate job, no single 
task merging all partial filters and no filter shipped to every task;
   - also exists for a creation side too big for one bloom filter of all its 
keys.
   
   ### Does this PR introduce _any_ user-facing change?
   
   Yes, with `spark.sql.optimizer.runtime.bloomFilter.perPartition.enabled` on, 
which is off by default. A join that qualifies gets a left semi 
`ShuffledHashJoin` with a per-partition bloom filter aggregate, in place of the 
global bloom filter. That happens when the semi join adds no shuffle and does 
not cut the tasks that read the application side. `EXPLAIN` shows it.
   
   ### How was this patch tested?
   
   - `TransformExpressionSuite`: the generated code computes what `eval` does 
for each of the three calls a function can resolve to, a transform whose keys a 
join reduced generates no code, and a transform whose call falls back keeps its 
arguments out of subexpression elimination and of a `With`.
   - `WholeStageCodegenSuite`: a transform over a function with an `invoke` 
method stays in whole-stage codegen, and one with only `produceResult` runs 
outside it.
   - `BloomFilterAggregateQuerySuite`: `might_contain` reads a bloom filter 
from a column and from an inlined aggregate, and is null for a null value, with 
and without whole-stage codegen and with no codegen at all. A hash semi join 
checks each row against the bloom of its build row, in the same three modes. 
`might_contain` reads the bloom again only for another array, and a semi join 
of the same shape that a query wrote is left alone.
   - `JoinSuite`: a hash semi join condition that reads a binary build column, 
and gets one array per build row, for a shuffled hash join and a broadcast hash 
join, in the same three modes.
   - New `PerPartitionRuntimeFilterSuite`:
     - `InjectRuntimeFilter` adds the semi join next to the global bloom filter;
     - with AQE on and off, the semi join stays and the global bloom filter 
goes when the partitions pair, and the other way around when they would not;
     - with AQE on and off, the semi join goes when the join above shuffles the 
application side anyway;
     - with partially clustered distribution, the semi join keeps the 
application side's splits when the aggregate is the smaller side, and goes when 
it would group the splits the join keeps, also under a limit, a top-N and a 
write;
     - when `MergeSubplans` merged the global bloom filters of two join keys 
into one subquery, the semi join drops only its own key's;
     - without a row count, each bloom filter expects one bucket's share of the 
default number of items, or one partition's when the scan reports fewer 
partitions than buckets;
     - the creation side's size threshold applies to one partition;
     - a later join on the same key still gets its global bloom filter when the 
semi join has none, and no second semi join;
     - the semi join follows the join key through a storage-partitioned join 
below it, with AQE on and off;
     - the semi join replaces the global bloom filter of a renamed join key, 
with AQE on and off;
     - with AQE on and off, a scan that offers its bucket column for runtime 
filtering, as Iceberg's does, also loses the global bloom filter's copy among 
its runtime filters;
     - no semi join with the conf off, for an identity partitioning, for 
different transforms, or for a transform whose input types differ from the 
column's.
   
   Known limits:
   - a partitioning by a single transform, the same on both sides;
   - the semi join is a shuffled hash join only, since its build side holds one 
row per partition key;
   - the semi join stays only when it is free, so a join above that shuffles 
the application side gets the global bloom filter;
   - each bloom filter is sized for an even share of the creation side's rows, 
counted over the partitions the scan reports, or over the buckets when there 
are fewer buckets. So a bloom filter can be too small, with more false 
positives:
     - for a skewed partition key;
     - for every key, when the scan reports several partitions per key, as a 
transform other than `bucket` with many files per value can;
   - when `MergeSubplans` merged the global bloom filter with others into one 
subquery, that subquery still builds it after the semi join replaced its check;
   - with dynamic partition pruning on, a scan that offers the key column for 
runtime filtering, as Iceberg's does for a `bucket` source column, gets no 
runtime bloom filter on that key. `InjectRuntimeFilter` skips a key that has a 
dynamic pruning filter, and a storage-partitioned join then drops that filter, 
since it has no broadcast to reuse. #58635 would lift this.
   
   Possible follow-ups:
   - Different transforms on the two sides, such as `bucket(4)` against 
`bucket(8)`, or an unpartitioned creation side. The aggregate can group the 
creation side by the application side's transform over the creation key, and 
the physical check becomes "no new shuffle on the application side".
   - Applying the per-partition bloom filter inside the scan, with the 
storage-filter API of #59715.
   - Sizing the bloom filters from the real number of keys, and a creation-side 
size threshold of their own.
   - Pruning a replaced bloom filter from a merged subquery.
   - Generated code for `ApplyFunctionExpression`, so a transform over a 
function with only `produceResult` stays in whole-stage codegen too.
   
   ### Was this patch authored or co-authored using generative AI tooling?
   
   Generated-by: Claude Code with Claude Opus 5.5
   


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