sarutak commented on code in PR #59940:
URL: https://github.com/apache/spark/pull/59940#discussion_r4471001389
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2642,15 +2650,71 @@ object PushPredicateThroughNonJoin extends
Rule[LogicalPlan] with PredicateHelpe
// 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(a =>
isExpensive(a._2.child))
- // Split the filter's components into cheap and expensive while
keeping track of
- // what each references from the projection. The cheap components
written before the
- // first expensive one are all pushed. After it, a throwable component
(e.g.
- // `raise_error`, possibly inside a projected alias) stays above with
the expensive ones,
- // so it is not evaluated on rows the components before it would have
filtered out.
- val (cheapWithUsed, expensiveWithUsed) = splitPushable(
- originalAndRewrittenConditionsWithUsedAlias)(
- canPush = { case (_, used, _) => isCheap(used) },
- throwable = { case (_, _, replaced) => replaced.throwable })
+ // Whether a component reads an expensive alias that this projection
evaluates. A scalar
+ // Python UDF is not evaluated here: `ExtractPythonUDFs` later moves
it, with its
+ // arguments, to an eval node below the projection, and pushes the
conditions below that
+ // again, except the throwable ones written after one over its output
(SPARK-60633), so
+ // holding a deterministic condition back behind one here would gain
nothing and only cost
+ // the pushdown of the batches in between. What is left of the alias
without its Python
+ // UDFs, e.g. a Scala UDF over one, is evaluated here though. Each
Python UDF is replaced
+ // with an attribute, as `ExtractPythonUDFs` does, so that
`isExpensive` sees an input
+ // that is not foldable, as in the projection it leaves.
+ def evaluatedHere(used: AttributeMap[Alias]): Boolean = used.exists {
case (_, alias) =>
+ isExpensive(alias.child) && isExpensive(alias.child.transformDown {
+ case udf if isScalarPythonUDF(udf) =>
AttributeReference("pythonUDF", udf.dataType)()
+ })
+ }
+ // Split the filter's components, in their written order, into the
cheap ones pushed
+ // below the projection and the ones kept above it. The cheap
components written before
+ // the first one kept above are all pushed, as before SPARK-60730.
After it, a cheap
+ // component is only pushed when it can be evaluated ahead of the
components before it,
+ // and how strictly that is checked depends on why a component was
kept above:
+ // - after a component over an expensive alias that the projection
does not evaluate,
+ // i.e. one only expensive through scalar Python UDFs, a cheap
component is pushed
+ // when it is deterministic and not throwable;
+ // - after a component over an expensive alias the projection
evaluates, the first one
+ // held back, a cheap component is pushed only when `canPushAhead`
accepts it, so the
+ // others are only evaluated where none of the components before
them is false.
+ // The IS NOT NULL checks that `InferFiltersFromConstraints` puts in
front of the
+ // condition are pushed as if written there, so one over a cheap alias
that can fail,
+ // e.g. a division, still computes it ahead of the components written
before the one it
+ // was inferred from.
+ val components = originalAndRewrittenConditionsWithUsedAlias
+ val firstHeldIndex = components.indexWhere {
+ case (_, used, _) => !isCheap(used) && evaluatedHere(used)
+ }
+ val legacy = SQLConf.get.legacyPushFilterAheadOfExpensiveConjunct
+ val pushed = Seq.newBuilder[(Expression, AttributeMap[Alias],
Expression)]
+ val kept = Seq.newBuilder[(Expression, AttributeMap[Alias],
Expression)]
+ val keptHeld = Seq.newBuilder[(Expression, AttributeMap[Alias],
Expression)]
+ // 0: nothing kept above yet; 1: after a component only expensive
through Python UDFs;
+ // 2: after the first component held back.
+ var strictness = 0
+ // Whether a cheap component was kept above at strictness 1, i.e. one
that is
+ // non-deterministic or throwable, before the first one held back, if
any.
+ var keptCheapBeforeHeld = false
+ components.zipWithIndex.foreach { case (component @ (cond, used,
replaced), index) =>
+ val push = isCheap(used) && (strictness match {
+ case 0 => true
+ case 1 => !replaced.throwable && (legacy || cond.deterministic)
+ case _ => canPushAhead(replaced)
+ })
+ if (push) {
+ pushed += component
+ } else {
+ kept += component
+ if (firstHeldIndex >= 0 && index > firstHeldIndex) keptHeld +=
component
Review Comment:
Reproduced: with `p = py1(id)`, `s = scalaUdf(py2(id))` and `10 / (s - 1)`,
which fails on `id` 1, the query failed with `DIVIDE_BY_ZERO` with the split on
and returned rows 2 to 9 with it off. Done as you suggest: the split is now
checked for every condition kept above after the first one kept above, not only
after the first one held back, so here the division is checked and the filter
stays as written. `FilterPushdownSuite` has a query of that shape, with an
expensive expression over a Python UDF, and a `DataFrameSuite` test runs yours,
with `scalaUDF(py2(id))`, over the combinations of the split and
`avoidDoubleFilterEval`.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2664,8 +2728,25 @@ object PushPredicateThroughNonJoin extends
Rule[LogicalPlan] with PredicateHelpe
// non-deterministic expression sees. Throwable conditions, or
conditions over throwable
// aliases, stay there too: ordering by cost could evaluate them (or
compute the aliases
// in a lower projection) ahead of the conditions written before them.
+ // For the same reason, there is no split when a condition after the
first one held back
+ // above is rejected by `canPushAhead`, with only the cheap aliases
substituted (it can
+ // tell nothing about an expensive one such as a UDF): e.g. a division
over an expensive
+ // alias, or a cheap condition held back above, which needs no
expensive alias and so
+ // would go lowest. The filter then stays above the projection as
written, as without
+ // the split. So it does when a cheap component is kept above after
one over a Python
+ // UDF before the first one held back, or with none held back, as the
split could
+ // evaluate the later expensive ones ahead of it.
+ lazy val keepsWrittenOrder = !legacy &&
Review Comment:
Reproduced with `p = py1(id)` and `q = py2(id)` as for item 1:
`DIVIDE_BY_ZERO` with the split on, rows 2 to 9 with it off. The check of item
1 covers it, and it replaces `keptCheapBeforeHeld`, since a cheap condition
kept there is one that `canPushAhead` rejects. As you note, a filter over
Python UDFs with a condition after the first one kept above that is not plain
is no longer split until SPARK-60731; the description now says that the filters
over Python UDFs are optimized as before only when their conditions kept above
after the first one are all plain.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2664,8 +2728,25 @@ object PushPredicateThroughNonJoin extends
Rule[LogicalPlan] with PredicateHelpe
// non-deterministic expression sees. Throwable conditions, or
conditions over throwable
// aliases, stay there too: ordering by cost could evaluate them (or
compute the aliases
// in a lower projection) ahead of the conditions written before them.
+ // For the same reason, there is no split when a condition after the
first one held back
+ // above is rejected by `canPushAhead`, with only the cheap aliases
substituted (it can
+ // tell nothing about an expensive one such as a UDF): e.g. a division
over an expensive
+ // alias, or a cheap condition held back above, which needs no
expensive alias and so
+ // would go lowest. The filter then stays above the projection as
written, as without
+ // the split. So it does when a cheap component is kept above after
one over a Python
+ // UDF before the first one held back, or with none held back, as the
split could
+ // evaluate the later expensive ones ahead of it.
+ lazy val keepsWrittenOrder = !legacy &&
+ (keptCheapBeforeHeld || expensiveHeld.nonEmpty && {
+ val cheapAliasMap =
+ AttributeMap(aliasMap.filter { case (_, a) =>
!isExpensive(a.child) })
+ expensiveHeld.exists {
+ case (cond, _, _) => !canPushAhead(replaceAlias(cond,
cheapAliasMap))
+ }
+ })
val (splitChild, splitAliases, stayUp) =
- if (expensiveWithUsed.nonEmpty &&
SQLConf.get.splitProjectionForExpensiveFilters) {
+ if (SQLConf.get.splitProjectionForExpensiveFilters &&
expensiveWithUsed.nonEmpty &&
Review Comment:
Agreed. I will open SPARK-60731 against master first and rebase this PR on
it, so that `master` does not turn the split off for these filters in between.
With the split in the written order, `laterQuery` should then also run in the
`DataFrameSuite` loop without whole-stage codegen and with the split on.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2642,15 +2650,71 @@ object PushPredicateThroughNonJoin extends
Rule[LogicalPlan] with PredicateHelpe
// 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(a =>
isExpensive(a._2.child))
- // Split the filter's components into cheap and expensive while
keeping track of
- // what each references from the projection. The cheap components
written before the
- // first expensive one are all pushed. After it, a throwable component
(e.g.
- // `raise_error`, possibly inside a projected alias) stays above with
the expensive ones,
- // so it is not evaluated on rows the components before it would have
filtered out.
- val (cheapWithUsed, expensiveWithUsed) = splitPushable(
- originalAndRewrittenConditionsWithUsedAlias)(
- canPush = { case (_, used, _) => isCheap(used) },
- throwable = { case (_, _, replaced) => replaced.throwable })
+ // Whether a component reads an expensive alias that this projection
evaluates. A scalar
+ // Python UDF is not evaluated here: `ExtractPythonUDFs` later moves
it, with its
+ // arguments, to an eval node below the projection, and pushes the
conditions below that
+ // again, except the throwable ones written after one over its output
(SPARK-60633), so
+ // holding a deterministic condition back behind one here would gain
nothing and only cost
+ // the pushdown of the batches in between. What is left of the alias
without its Python
+ // UDFs, e.g. a Scala UDF over one, is evaluated here though. Each
Python UDF is replaced
+ // with an attribute, as `ExtractPythonUDFs` does, so that
`isExpensive` sees an input
+ // that is not foldable, as in the projection it leaves.
+ def evaluatedHere(used: AttributeMap[Alias]): Boolean = used.exists {
case (_, alias) =>
+ isExpensive(alias.child) && isExpensive(alias.child.transformDown {
+ case udf if isScalarPythonUDF(udf) =>
AttributeReference("pythonUDF", udf.dataType)()
+ })
+ }
+ // Split the filter's components, in their written order, into the
cheap ones pushed
+ // below the projection and the ones kept above it. The cheap
components written before
+ // the first one kept above are all pushed, as before SPARK-60730.
After it, a cheap
+ // component is only pushed when it can be evaluated ahead of the
components before it,
+ // and how strictly that is checked depends on why a component was
kept above:
+ // - after a component over an expensive alias that the projection
does not evaluate,
+ // i.e. one only expensive through scalar Python UDFs, a cheap
component is pushed
+ // when it is deterministic and not throwable;
+ // - after a component over an expensive alias the projection
evaluates, the first one
+ // held back, a cheap component is pushed only when `canPushAhead`
accepts it, so the
+ // others are only evaluated where none of the components before
them is false.
+ // The IS NOT NULL checks that `InferFiltersFromConstraints` puts in
front of the
+ // condition are pushed as if written there, so one over a cheap alias
that can fail,
+ // e.g. a division, still computes it ahead of the components written
before the one it
+ // was inferred from.
+ val components = originalAndRewrittenConditionsWithUsedAlias
+ val firstHeldIndex = components.indexWhere {
+ case (_, used, _) => !isCheap(used) && evaluatedHere(used)
+ }
+ val legacy = SQLConf.get.legacyPushFilterAheadOfExpensiveConjunct
+ val pushed = Seq.newBuilder[(Expression, AttributeMap[Alias],
Expression)]
+ val kept = Seq.newBuilder[(Expression, AttributeMap[Alias],
Expression)]
+ val keptHeld = Seq.newBuilder[(Expression, AttributeMap[Alias],
Expression)]
+ // 0: nothing kept above yet; 1: after a component only expensive
through Python UDFs;
+ // 2: after the first component held back.
+ var strictness = 0
+ // Whether a cheap component was kept above at strictness 1, i.e. one
that is
+ // non-deterministic or throwable, before the first one held back, if
any.
+ var keptCheapBeforeHeld = false
+ components.zipWithIndex.foreach { case (component @ (cond, used,
replaced), index) =>
+ val push = isCheap(used) && (strictness match {
+ case 0 => true
+ case 1 => !replaced.throwable && (legacy || cond.deterministic)
+ case _ => canPushAhead(replaced)
Review Comment:
Agreed. I will open the `FilterExec` change first too, and this PR will go
on top of it, so that `f >= 0 && id + 1 > 6 && id + 1 < 100 && r > 0` keeps
working with whole-stage codegen. Without it, the interpreted projection still
computes `r` on every row, as in item 3, which the written-order split of
SPARK-60731 can only avoid with the split on.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -3086,6 +3167,38 @@ object PushPredicateThroughNonJoin extends
Rule[LogicalPlan] with PredicateHelpe
(plan, computed, conditionsOf(pending))
}
+ /**
+ * Whether a filter component written after an expensive one may be
evaluated ahead of it,
+ * on rows the components before it would have filtered out (SPARK-60730).
Such a component
+ * must not be able to fail there, e.g. on a division by zero or an invalid
cast in ANSI mode,
+ * in a UDF or in `raise_error`, nor see a different set of rows than as
written, as a
+ * non-deterministic component would.
[[ExprUtils.canEvaluateUnconditionally]] is the check
+ * for that; it only accepts deterministic comparisons, IN, IS NULL and the
like over columns
+ * and literals, so it also rejects some components that can not fail, such
as arithmetic
+ * with ANSI mode off. Two filters the optimizer adds, which have no written
position to
+ * keep, are accepted too: a runtime bloom filter over keys the check
accepts, which can not
+ * fail, and a dynamic partition pruning filter, which the scan evaluates at
run time to prune
+ * what it reads, not on the filtered rows, so its position does not matter.
The filters
+ * that `InferFiltersFromConstraints` adds in front of the condition are
pushed as if written
+ * first, also over an alias the check would reject.
+ * With [[SQLConf.LEGACY_PUSH_FILTER_AHEAD_OF_EXPENSIVE_CONJUNCT]], only
throwable components
+ * are rejected.
+ *
+ * Callers pass the component with the aliases substituted that its
evaluation would
+ * compute, so a part inside such an alias counts too.
+ */
+ private def canPushAhead(component: Expression): Boolean =
+ if (SQLConf.get.legacyPushFilterAheadOfExpensiveConjunct) {
+ !component.throwable
+ } else {
+ component match {
+ case _: DynamicPruning => true
+ case BloomFilterMightContain(_, XxHash64(keys, _)) =>
+ keys.forall(ExprUtils.canEvaluateUnconditionally)
+ case _ => ExprUtils.canEvaluateUnconditionally(component)
Review Comment:
Agreed, a full scan is the most visible part. The migration guide entry now
names it: a condition on a partition column such as `year(dt) = 2024` then no
longer prunes partitions. The allowlist change for item 6 can also add
functions that can not fail, such as `year` over a date column, which would
bring back the pruning for some of these conditions; a cast from a string, as
in `cast(dt as date)`, can fail in ANSI mode, so that one stays above.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/ExprUtils.scala:
##########
@@ -266,13 +274,25 @@ object ExprUtils extends EvalHelper with QueryErrorsBase {
e.deterministic && !e.containsPattern(PLAN_EXPRESSION) &&
canEvaluateUnconditionallyInternal(e)
+ // Only the numeric pairs are taken from `Cast.canUpCast`, which also covers
e.g. LONG to
+ // TIMESTAMP and casts to STRING or between complex types: widening a number
never fails.
+ private def isNumericUpCast(from: DataType, to: DataType): Boolean = (from,
to) match {
+ case (_: NumericType, _: NumericType) => Cast.canUpCast(from, to)
+ case _ => false
+ }
+
private def canEvaluateUnconditionallyInternal(e: Expression): Boolean = e
match {
Review Comment:
Agreed, with ANSI mode off this PR only has a cost for such conditions. I
plan a separate PR for it, before this one: `canEvaluateUnconditionally` would
accept numeric arithmetic and the casts that can not fail when their `evalMode`
is not `ANSI`. As its other callers would accept them too, it seems better
reviewed on its own.
--
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]