spark-test commented on code in PR #59848:
URL: https://github.com/apache/spark/pull/59848#discussion_r4395819842
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2644,15 +2647,20 @@ object PushPredicateThroughNonJoin extends
Rule[LogicalPlan] with PredicateHelpe
// Case 3: split the projection around the expensive conditions.
Non-deterministic
// conditions stay in the top filter, as they do everywhere else in
this rule -- the
// split orders conditions by cost, and cost is no way to decide how
many rows a
- // non-deterministic expression sees.
+ // non-deterministic expression sees. Throwable conditions stay there
too: ordering by
+ // cost could evaluate them ahead of the conditions written before
them.
val (splitChild, splitAliases, stayUp) =
if (expensiveWithUsed.nonEmpty &&
SQLConf.get.splitProjectionForExpensiveFilters) {
- val (deterministic, nonDeterministic) =
- expensiveWithUsed.partition { case (cond, _, _) =>
cond.deterministic }
+ val (splittable, notSplittable) = expensiveWithUsed.partition {
+ case (cond, _, _) => cond.deterministic && !cond.throwable
+ }
val (child, aliases, unplaced) =
splitProjectForExpensiveConditions(
project, aliasMap, baseChild,
- deterministic.map { case (cond, used, _) => (cond, used) })
- (child, aliases, unplaced ++ nonDeterministic.map(_._1))
+ splittable.map { case (cond, used, _) => (cond, used) })
+ // Keep the top filter in the order the conditions were written,
so a throwable
+ // condition still only sees the rows the conditions before it
kept.
+ val stayUpSet = (unplaced ++ notSplittable.map(_._1)).toSet
+ (child, aliases,
expensiveWithUsed.map(_._1).filter(stayUpSet.contains))
Review Comment:
**Nit (P3):** Emitting the top filter in written order also changes filters
that have no throwable conjunct. With the split on, a non-deterministic
expensive conjunct used to go after the conjuncts the split could not place
(`unplaced ++ nonDeterministic`). Now it keeps its written position whenever
the rule rewrites the plan. Take `(rand(10) > 0.5 || f) && (f || g) && a > 1`
over `rlike` aliases `f` and `g`: `a > 1` is pushed, `f || g` is left unplaced,
and the top filter goes from `(f || g) AND (rand(10) > 0.5 || f)` to `(rand(10)
> 0.5 || f) AND (f || g)`. `rand(10)` now runs on every row that passes `a >
1`, so a seeded query keeps a different set of rows than on master.
The new position looks like the better choice. It is what the split-disabled
branch does, and no release ever had the old order, which arrived with
SPARK-55014. But it contradicts the description's "Filters without throwable
conditions are optimized as before", and no test pins either order:
`comparePlans` sorts each Filter's conjuncts, and the next fixed-point pass
leaves this filter as it is. I'd keep written order, mention the change in the
description, and add a plan test that asserts the top filter's conjunct
sequence directly. If you'd rather keep master's order, `unplaced ++
notSplittable.map(_._1)` is just as safe for throwable conjuncts.
**Verification:**
- **Regression:** Take a filter whose non-deterministic expensive conjunct
is written before a conjunct that needs every expensive alias, together with a
cheap conjunct that is pushed below the projection. The optimized top filter
lists its conjuncts in written order, and the check fails if the
non-deterministic conjunct is moved after the other one.
- **Inspection:** The PR description's statement about filters without
throwable conditions says that non-deterministic conjuncts left above the
projection now keep their written position instead of moving after the
conjuncts the split could not place.
##########
sql/core/src/test/scala/org/apache/spark/sql/DataFrameSuite.scala:
##########
@@ -2905,6 +2905,29 @@ class DataFrameSuite extends SharedSparkSession
}
}
+ test("SPARK-60602: a throwable filter condition is not evaluated ahead of
its order") {
+ val f = udf((i: Long) => i)
+ val g = udf((i: Long) => i)
+ // `assert_true(g > 5)` only holds for the ids the first conjunct (`f + g
> 10`) keeps, so the
+ // `AND` short-circuit must keep it away from the other rows however the
filter is pushed.
+ def splitQuery(): DataFrame = spark.range(0, 10, 1, 1)
+ .select($"id", f($"id") as "f", g($"id") as "g")
+ .where($"f" + $"g" > 10 && assert_true($"g" > 5).isNull)
Review Comment:
**Nit (P3):** Neither query here reaches the plans this PR rebuilds. Neither
has a conjunct the rule can push or a group it can split. With
`avoidDoubleFilterEval` on, the rule returns the original Filter, and with it
off, it pushes the whole condition. So the end-to-end matrix never runs a plan
where other conjuncts move while `assert_true` is held in the top filter.
The gap matters most for this first example. `assert_true($"g" > 5)` uses
the expensive alias `g`, so no later pass pushes it, and in a rebuilt plan only
the written order of the top filter keeps it behind `f + g > 10`. Adding one
pushable conjunct, e.g. `&& $"id" >= 0`, produces such a plan: `id >= 0` is
pushed and the top filter is `f + g > 10 AND assert_true(g > 5) IS NULL`. If
the stay-up list were emitted with the non-splittable conjuncts first, this
variant would raise with the split on, while both current queries would keep
passing. `FilterPushdownSuite`'s cheap-throwable test would still catch that
particular regression today, but only indirectly. `comparePlans` ignores
conjunct order, and the test fails only because its fixed-point batch re-pushes
the misplaced cheap throwable. Running this variant, and `cheapQuery` with the
same extra conjunct, in the existing matrix would test the PR's written-order
change directly.
**Verification:**
- **Regression:** When the conjuncts left above the projection are emitted
with the non-splittable ones ahead of the unplaced ones, the added
first-example query fails with USER_RAISED_EXCEPTION in the configurations with
both avoidDoubleFilterEval and splitProjectionForExpensiveFilters enabled, and
it passes with the written-order emission.
- **Behavior:** The added queries return exactly the rows that written-order
AND evaluation allows (ids 6 to 9 for both example shapes with the extra `id >=
0` conjunct) under every combination of whole-stage codegen,
avoidDoubleFilterEval and splitProjectionForExpensiveFilters.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2618,22 +2618,25 @@ object PushPredicateThroughNonJoin extends
Rule[LogicalPlan] with PredicateHelpe
val (replaced, usedAliases) = replaceAliasWhileTracking(cond,
aliasMap)
(cond, usedAliases, replaced)
}
+ // A component is cheap if it uses nothing expensive from the
projection: either it
+ // uses nothing at all, or only inexpensive items. Then we can push it
because it might
+ // eliminate more data quickly and it may also be something which
could be evaluated at
+ // the storage layer. We may wish to improve this heuristic in the
future.
+ def isCheap(used: AttributeMap[Alias]): Boolean =
!used.exists(_._2.child.expensive)
+ val firstExpensive =
originalAndRewrittenConditionsWithUsedAlias.indexWhere {
+ case (_, used, _) => !isCheap(used)
+ }
// Split the filter's components into cheap and expensive while
keeping track of
- // what each references from the projection.
- val (cheapWithUsed, expensiveWithUsed) =
originalAndRewrittenConditionsWithUsedAlias
- .partition { case (cond, used, replaced) =>
- // Didn't use anything? We're good
- if (used.isEmpty) {
- true
- } else if (!used.exists(_._2.child.expensive)) {
- // If it's cheap we can push it because it might eliminate more
data quickly and
- // it may also be something which could be evaluated at the
storage layer.
- // We may wish to improve this heuristic in the future.
- true
- } else {
- false
- }
+ // what each references from the projection. A throwable component
(e.g. `raise_error`)
+ // must not be evaluated on rows the components before it would have
filtered out, so
+ // it is only pushed when no expensive component, which stays above,
comes before it.
+ val (cheapWithIndex, expensiveWithIndex) =
originalAndRewrittenConditionsWithUsedAlias
+ .zipWithIndex
+ .partition { case ((cond, used, _), idx) =>
+ isCheap(used) && (!cond.throwable || firstExpensive < 0 || idx <
firstExpensive)
Review Comment:
**Nit (P3):** The algorithm overview at the top of this case is now out of
date. It still says that parts referencing nothing, or only inexpensive items,
are "pushed", that "We handle each part separately according to the logic
above.", and that SPARK-13473 is the only "Additional restriction:". With this
change, a cheap throwable part written after an expensive one stays above the
projection, so where it goes depends on the parts before it. Throwable parts
also never enter the split. Only the inline comments here and at the split say
so, which makes the restriction easy to drop in a later edit without noticing
that SPARK-60602 depends on it. Could you add it to the overview, e.g. as a
second additional restriction? Something like: a throwable part is pushed only
when no expensive part is written before it, it never enters the projection
split, and the parts left above keep their written order.
**Verification:**
- **Inspection:** The overview's account of where cheap and expensive parts
go matches the partition and split predicates, including the throwable
restriction, and the updated comment lines stay ASCII and within the
100-character line limit.
--
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]