dongjoon-hyun commented on code in PR #59321:
URL: https://github.com/apache/spark/pull/59321#discussion_r4294718882
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/planmerging/PlanMerger.scala:
##########
@@ -319,14 +325,26 @@ class PlanMerger(
* pair that is NOT the innermost one still recognize the
merge below it and
* apply the DSv2-symmetric exemption. It only ever gates
behaviour when
* `dsv2SymmetricFilterPropagationEnabled` is on.
+ * @param cachedSideFiltered Whether every plan merged into the cached plan
filters this subtree
+ * with a filter that the new plan doesn't share.
It is set whenever
+ * `cachedPlanFilter` is. It is also set when a
merged Filter from an
+ * earlier round filters the subtree, unless the
new plan reuses one of
+ * its sides. That Filter's condition is not in
+ * `cachedPlanFilter`, because the cached
aggregates already carry it.
+ * The fact goes up to the nearest Aggregate,
which consumes the filters
+ * below it, so a Join arm can tell which of its
children the cached
+ * plans filter. The merged Filter case only
matters without
+ * `symmetricFilterPropagationEnabled`, where such
a Filter can only
+ * come from the DSv2-symmetric exemption.
*/
case class TryMergeResult(
mergedPlan: LogicalPlan,
newPlanMapping: AttributeMap[Attribute],
newPlanFilter: Option[Attribute] = None,
cachedPlanFilter: Option[Attribute] = None,
dsv2DeferredScan: Option[DSv2DeferredScan] = None,
- dsv2Merged: Boolean = false)
+ dsv2Merged: Boolean = false,
+ cachedSideFiltered: Boolean = false)
Review Comment:
**This flag defaults to `false`, which is the unsafe direction.** `false`
lets the Join arm at L704-706 allow a merge that builds cross pairs, and
nothing fails when that happens, because the flag only ever declines merges.
This PR already had to forward it by hand in the identical-condition Filter
branch (L539-540) and in the `(np: Filter, cp)` arm (L662-663). A future arm,
or a refactor that rebuilds a `TryMergeResult` with named arguments, would
compile without it and silently bring back the 1/(p+q) join work. Could you
drop the default, so every construction site has to pass it explicitly? The
sites that want `false` today (`identical` at L428, the two Aggregate results,
and the DSv2 leaf) would then each say so once.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -8050,7 +8051,11 @@ object SQLConf {
"common join to be merged into a single scan. A filter attribute is
only propagated " +
"through a join when it originates from the non-nullable (preserved)
side: the left side " +
"of LeftOuter/LeftSemi/LeftAnti, the right side of RightOuter, or
either side of " +
- "Inner/Cross. FullOuter joins are never eligible. " +
+ "Inner/Cross. FullOuter joins are never eligible. When the two
subplans filter " +
+ "different children of the join, the merge also needs " +
Review Comment:
**`spark.sql.optimizer.mergeSubplans.filterPropagation.throughJoin.enabled`
shipped in Apache Spark 4.2.0, and this PR changes what it does.** A user who
enabled it without the symmetric config (the 4.2.0 doc only says the two are
"usually" set together) will see subqueries that filter different join children
stop merging after upgrading, with nothing in the release notes to explain the
plan change. `docs/sql-migration-guide.md` already has a "Since Spark 4.3"
entry for `MergeSubplans`. Could you add one for this change too, naming
`spark.sql.optimizer.mergeSubplans.filterPropagation.symmetricFilterPropagation.enabled`
as the config that brings these merges back?
##########
docs/sql-performance-tuning.md:
##########
@@ -354,7 +354,7 @@ SELECT
(SELECT avg(ss_net_paid) FROM store_sales WHERE ss_quantity BETWEEN 21 AND
40)
```
-In TPC-DS benchmark runs, enabling symmetric filter propagation made `q9` and
`q28` about 3.5x faster, and enabling it together with propagation through
joins made `q88` about 7x and `q90` about 2x faster. See
[SPARK-40193](https://issues.apache.org/jira/browse/SPARK-40193) and
[SPARK-56677](https://issues.apache.org/jira/browse/SPARK-56677) for these
measurements. The trade-off depends on the tables: the gain is largest when the
differing filters are on columns the data source cannot prune on, and the risk
is highest on heavily partitioned or file-pruned tables where the widened
filter loses that pruning. Validate it on your own workload before enabling it
in production.
+In TPC-DS benchmark runs, enabling symmetric filter propagation made `q9` and
`q28` about 3.5x faster, and enabling it together with propagation through
joins made `q88` about 7x and `q90` about 2x faster. See
[SPARK-40193](https://issues.apache.org/jira/browse/SPARK-40193) and
[SPARK-56677](https://issues.apache.org/jira/browse/SPARK-56677) for these
measurements. The trade-off depends on the tables: the gain is largest when the
differing filters are on columns the data source cannot prune on, and the risk
is highest on heavily partitioned or file-pruned tables where the widened
filter loses that pruning. With propagation through joins, two subplans that
filter different join children also merge, and so do two that filter both join
children with different conditions. The merged join then builds join pairs that
neither subplan needs, which can multiply its work. Validate it on your own
workload before enabling it in production.
Review Comment:
Minor: "With propagation through joins, two subplans that filter different
join children also merge" reads as if the through-join config alone is enough.
After this PR they merge only when symmetric propagation is on as well. Since
the paragraph starts from the two configs enabled together, could this sentence
say so explicitly, e.g. "With both configs on, two subplans that filter
different join children also merge, ..."?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/planmerging/MergeSubplansSuite.scala:
##########
@@ -2597,6 +2653,70 @@ class MergeSubplansSuite extends PlanTest {
}
}
+ gridTest("SPARK-60106: Merge over a merged DSv2 filter on a join child")(
+ for {
+ v2OnLeft <- Seq(true, false)
+ shape <- Seq("dropped", "extended", "reused", "firstMerge")
+ } yield (v2OnLeft, shape)) { case (v2OnLeft, shape) =>
+ // sub1 and sub2 filter the DSv2 scan differently and merge under the DSv2
exemption into a
+ // merged Filter. A subplan that also filters the other join child would
join a child that the
+ // cached plans filter with a child that it filters, so without symmetric
filter propagation
+ // it stays apart. The shapes:
+ // - dropped: sub3 filters only the other child, so its merge drops the
merged Filter.
+ // - extended: sub3 filters the DSv2 scan too, so its merge extends the
merged Filter.
+ // - reused: sub3 repeats sub1's filter on the DSv2 scan, so sub1 covers
sub3's rows there and
+ // the merge builds no extra pairs. It merges without symmetric filter
propagation.
+ // - firstMerge: sub2 itself filters the other child too, so the first
merge declines.
+ // The DSv2 scan is tried as both the left and the right join child.
+ def join(v2: LogicalPlan, other: LogicalPlan): LogicalPlan = if (v2OnLeft)
{
+ v2.join(other, Inner, Some($"a" === $"d"))
+ } else {
+ other.join(v2, Inner, Some($"a" === $"d"))
+ }
+ val sub1 = ScalarSubquery(
+ join(v2ScanReading("a", "b").where($"a" > 1), testRelation2)
+ .groupBy()(sum($"a").as("sum_a")))
+ val other2 = if (shape == "firstMerge") testRelation2.where($"e" > 3) else
testRelation2
+ val sub2 = ScalarSubquery(
+ join(v2ScanReading("a", "b").where($"b" > 2), other2)
+ .groupBy()(max($"a").as("max_a")))
+ val v2In3 = shape match {
+ case "extended" => v2ScanReading("a", "b").where($"a" < 0)
+ case "reused" => v2ScanReading("a", "b").where($"a" > 1)
+ case _ => v2ScanReading("a", "b")
+ }
+ val sub3 = ScalarSubquery(
+ join(v2In3, testRelation2.where($"e" > 3))
+ .groupBy()(min($"a").as("min_a")))
+ val originalQuery = if (shape == "firstMerge") {
+ testRelation.select(sub1, sub2)
+ } else {
+ testRelation.select(sub1, sub2, sub3)
+ }
+
+ def mergedAggregates(symmetric: Boolean): Seq[Seq[String]] = withSQLConf(
Review Comment:
Minor: `mergedAggregates` only compares aggregate names. In the `reused`
case, which merges without symmetric propagation, nothing checks that `min_a`
gets the reused filter attribute together with the `e > 3` alias. If either
went wrong, the names would still be `sum_a, max_a, min_a`, the test would
pass, and the merged query would return wrong results. The only `checkAnswer`
test (`PlanMergingSuite`) covers the plain V1 shape. Could the `reused` case
also compare the merged plan, or at least check each aggregate's FILTER?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/planmerging/PlanMerger.scala:
##########
@@ -660,18 +688,23 @@ class PlanMerger(
mergedChild.output.toList :+ newCPFilterAlias,
mergedChild)
TryMergeResult(project, npMapping, npFilter, Some(newCPFilter),
- dsv2Merged = dsv2Merged)
+ dsv2Merged = dsv2Merged, cachedSideFiltered = true)
}
}
case (np: Join, cp: Join) if np.joinType == cp.joinType && np.hint ==
cp.hint =>
tryMergePlans(np.left, cp.left, context.copy(filterAboveScan =
false)).flatMap {
case TryMergeResult(mergedLeft, leftNPMapping, leftNPFilter,
leftCPFilter, _,
- leftDsv2Merged) =>
+ leftDsv2Merged, leftCPSideFiltered) =>
tryMergePlans(np.right, cp.right, context.copy(filterAboveScan =
false)).flatMap {
case TryMergeResult(mergedRight, rightNPMapping,
rightNPFilter, rightCPFilter, _,
- rightDsv2Merged)
- if // Gate join-crossing filter propagation behind its own
config flag.
+ rightDsv2Merged, rightCPSideFiltered)
+ if // When the two plans filter different children, the
merge is symmetric
+ // filter propagation and needs its config, see the
class doc.
+ (symmetricFilterPropagationEnabled ||
Review Comment:
Nit: The new check is a fifth clause in a guard that already spans about 20
lines, and it relies on `&&` binding tighter than `||` inside the negation.
Could you pull it out into a named value, e.g. `val crossFiltered =
leftNPFilter.isDefined && rightCPSideFiltered || rightNPFilter.isDefined &&
leftCPSideFiltered`, and test `symmetricFilterPropagationEnabled ||
!crossFiltered`? Moving the guard into the case body with a final `else None`
keeps the same behavior.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/planmerging/PlanMerger.scala:
##########
@@ -430,27 +448,30 @@ class PlanMerger(
(newPlan, cachedPlan) match {
case (np: Project, cp: Project) =>
tryMergePlans(np.child, cp.child, context).map {
- case TryMergeResult(mergedChild, npMapping, npFilter, cpFilter,
deferred, dsv2Merged) =>
+ case TryMergeResult(mergedChild, npMapping, npFilter, cpFilter,
deferred, dsv2Merged,
Review Comment:
Nit: Adding one field meant editing every positional `case
TryMergeResult(...)` extractor, about 10 of them. With seven positional fields,
including two `Option[Attribute]` and two `Boolean`, swapping `dsv2Merged` and
`cachedSideFiltered` in one of them would still compile. The three Project arms
only change `mergedPlan` and `newPlanMapping`. Could they bind the result as
`r` and return `r.copy(mergedPlan = Project(mergedProjectList, r.mergedPlan),
newPlanMapping = newNPMapping)`?
--
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]