sarutak commented on code in PR #59940:
URL: https://github.com/apache/spark/pull/59940#discussion_r4471004349
##########
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 {
Review Comment:
Right, `CombineFilters` appends the filter inferred for a join side as
`And(nc, ac)`. The `canPushAhead` doc now says that only the filters inferred
for the `Filter` itself come first, and that the ones inferred for a join side
are checked like conditions written last, as does the description. Accepting
them as they are would compute a key such as `cast(s as int)` ahead of the
condition again, so I would rather look at them in the
`InferFiltersFromConstraints` PR.
##########
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
Review Comment:
Done as you suggest: `spark.sql.legacy.pushFilterAheadOfExpensiveConjunct`
is now in `OffsetSeqMetadata.relevantSQLConfs`, and a checkpoint that does not
record it uses `true`, so a restarted query keeps pushing the conditions this
PR holds back, as before this PR. SPARK-47672's own change and the split are
not recorded, so a query from a 4.1 checkpoint still gets those, as the
description now says. `OffsetSeqLogSuite` checks the recorded and the default
values, and `StreamingQueryManagerSuite` that a query restarted from a
checkpoint without it pushes `value + 1 > 6` below the projection again. The
migration guide entry, the config doc and the description say that such a query
has to start from a new checkpoint to get the new behavior.
##########
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;
Review Comment:
Agreed, and I plan to do it in that order, as in the top-level comment:
SPARK-60731 and the `InferFiltersFromConstraints` change first, then this PR on
top, where I expect the levels of `strictness`, the Python UDF rules and the
split check to shrink to what you describe. I would like to look at the
`Aggregate` and `Window` cases in a follow-up after this one.
##########
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)
Review Comment:
Done: the `indexWhere` only calls `evaluatedHere`, and `canPushAhead` no
longer reads the legacy config; the caller checks `legacy` once, and only
throwable conditions are rejected under it, as before.
##########
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) =>
Review Comment:
Done: `evaluatedHere` now caches its answer per alias for the matched
`Project`, and all the placeholders of one projection share one `ExprId`. It is
still computed again on each pass of the batch, as the rest of the rule is.
--
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]