dongjoon-hyun commented on code in PR #59940:
URL: https://github.com/apache/spark/pull/59940#discussion_r4470080337


##########
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:
   **1. [Medium] The first condition held back skips the split check even when 
a condition over a Python UDF is kept before it**
   
   `keepsWrittenOrder` only checks the components after `firstHeldIndex` 
(`keptHeld`), on the grounds that every component before the first one held 
back is pushed. That does not hold when a condition over an alias that is only 
expensive through Python UDFs is kept above before it: the first held condition 
is then never checked, and the split can still place it below that condition.
   
   For example, with `p = py1(a)`, `s = scalaUdf(py2(b))` and ANSI on:
   
   ```scala
   df.where($"p" > 1 && lit(10) / ($"s" - 3) > 0 && $"s" < 100)
   ```
   
   `p > 1` is kept above and sets `strictness` to 1, the division is the first 
held condition, and `keptHeld` is only `s < 100`, which `canPushAhead` accepts, 
so the split goes ahead. The `s` group has two conditions, so `minBy` places it 
first, and the division runs below `p > 1`, failing with `DIVIDE_BY_ZERO` on a 
row that `p > 1` drops. In 4.1, `p > 1` was pushed below the projection and the 
division stayed above it, so the query succeeded.
   
   Checking every component kept after the first one kept above, rather than 
after the first one held back, would close this, and item 2 too.



##########
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:
   **3. [Medium] Keeping the whole filter unsplit makes an interpreted 
projection compute every alias on every row**
   
   When `keepsWrittenOrder` is true, the split is turned off for the whole 
filter, not only from the rejected condition on. Without whole-stage codegen, 
i.e. when it is disabled or the plan is wider than 
`spark.sql.codegen.maxFields`, the projection then computes every expensive 
alias on every row the pushed conditions keep.
   
   The split on master happens to protect some of these queries: `laterQuery` 
(`f >= 0 && g + 1 > 6 && r > 0`, with `r` requiring `id > 5`) succeeds on 
master with the split on, since the split computes `r` only on the rows `g + 1 
> 6` keeps, but fails on this PR without codegen, which is why the 
`DataFrameSuite` loop runs it only `if (codegen || !avoid)`. On a wide table it 
is also a cost regression, since every UDF runs on every row.
   
   The description notes this, and SPARK-60731, which splits in the written 
order, would keep `laterQuery` working with the split on. Could SPARK-60731 
land before this PR, or with it, so that `master` does not turn the split off 
for these filters in between?



##########
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:
   **6. [Low] The allowlist does not look at `evalMode`**
   
   With ANSI off (`EvalMode.LEGACY`), and under `try_*` (`EvalMode.TRY`), 
numeric arithmetic and most casts return a wrapped value or NULL instead of 
failing, but the allowlist rejects them in every mode. So with 
`spark.sql.ansi.enabled=false`, `f > 5 && a + 1 > 6`, `cast(s as int) > 0` or 
`a / b > 1` stay above the projection, lose their pushdown below joins and to 
the scan, and turn the split off, with no correctness gain. The only way back 
is the legacy config, which also pushes the conditions that can fail.
   
   The description lists this as a known limitation. Accepting numeric `Add`, 
`Subtract`, `Multiply`, `Divide` and the like when their `evalMode` is not 
`ANSI`, and the casts that can not fail in that mode, would recover most of the 
cost for users who run with ANSI off. Since `canEvaluateUnconditionally` has 
other callers, a follow-up may fit better, but it would help to know whether 
one is planned.



##########
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:
   **2. [Medium] With only Python UDF aliases, the split still orders a 
condition that can fail by cost**
   
   With no condition held back, `keptHeld` is empty, and `keptCheapBeforeHeld` 
is only set by a cheap component, so a condition that can fail over a Python 
UDF alias still goes through the cost order of the split, as on master. For 
example, with `p = py1(a)` and `q = py2(b)`, two different Python UDFs, and 
ANSI on:
   
   ```scala
   df.where($"p" > 1 && lit(10) / ($"q" - 3) > 0 && $"q" < 100)
   ```
   
   The `q` group has two conditions, so the split places it lowest, and the 
division runs on the rows `p > 1` drops, failing with `DIVIDE_BY_ZERO`. In 4.1, 
the three conditions stay in their written order above one `BatchEvalPython`. 
Neither SPARK-59693 nor the split is released, so this is not a regression of a 
release, but the description says that such filters are optimized as before, 
while this is the reordering the PR is about.
   
   Applying the check of item 1 to every component kept after the first one 
kept above would cover this case, and would also subsume `keptCheapBeforeHeld`, 
since a cheap component kept there is one that `canPushAhead` rejects. The cost 
is that some filters over Python UDFs would no longer be split until 
SPARK-60731.



##########
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:
   **9. [Low] Most of these cases come from the cost order of the split and the 
position of the inferred filters**
   
   Items 1 to 3 and 7 here, and several items of the earlier rounds, come from 
two places: the split orders the conditions by cost, and the optimizer adds 
filters without a written position. Each round has added a case to this rule 
for them: the three levels of `strictness`, `keptCheapBeforeHeld`, the Python 
UDF rules, and the dynamic pruning and bloom filter shapes in `canPushAhead`.
   
   The description plans both fixes: SPARK-60731 for the written order of the 
split, and a separate PR for the inferred checks. With those in place, much of 
this state may not be needed: a condition after the first one kept above would 
be pushed when `canPushAhead` accepts it, and kept in its written order 
otherwise. Could those two land first, with this PR on top of them? The 
`Aggregate` and `Window` cases also still only check `throwable` for a 
condition written after one that stays up, so the same rule could apply there 
too, here or in a follow-up.



##########
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:
   **7. [Low] Filters inferred from a join are appended after the condition, 
and so held back**
   
   The doc above covers the filters `InferFiltersFromConstraints` puts in front 
of a `Filter`'s condition, but for a join side it adds a new `Filter` on top 
(`inferNewFilter`), which `CombineFilters` merges as `And(nc, ac)`, i.e. after 
the existing condition. So when a join key is a cheap alias that the allowlist 
rejects, e.g. `k = cast(s as int)` or `k = a + 1`, the `isnotnull(k)`, `k = 10` 
or `k IN (...)` inferred from the other side land after the held UDF condition: 
they stay above the projection, lose their pushdown below it, and, through 
`keptHeld`, turn the split off.
   
   Like the dynamic pruning and bloom filters, they have no written position of 
their own. The separate PR that moves the inferred checks may want to cover 
this case too; until then, the doc could say that only the filters inferred for 
the `Filter` itself come first.



##########
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:
   **4. [Medium] The `FilterExec` subexpression path now also fails queries 
that pass on every release**
   
   This is the second item of the second round, and the description now 
documents it and plans the `FilterExec` change for a separate PR. I raise it 
again because this PR widens it from conditions over expensive aliases to 
plain-column conditions that `canPushAhead` rejects.
   
   With `r` requiring `id > 5`, `f >= 0 && id + 1 > 6 && id + 1 < 100 && r > 0` 
succeeds on 4.1, where the UDFs are inlined, and on 4.2, 4.3 and master, where 
the additions are pushed below the projection, but fails on this PR with or 
without whole-stage codegen, since `id + 1` is shared and `FilterExec` computes 
`r` before the first condition. Holding back a condition such as `id + 1 > 6` 
behind a UDF condition is a common shape, so could the `FilterExec` change land 
before this PR, or with it?



##########
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:
   **8. [Low] The new pushdown is not pinned for streaming checkpoints**
   
   The description explains why this is not recorded in the checkpoint: the 
change only keeps conditions higher than before. But for a `Filter` over a 
`Project` with a UDF over an `EventTimeWatermark`, a cheap condition that the 
batches before a restart pushed below the watermark stays above it after the 
restart. The watermark then also counts the rows the condition drops, can 
advance further, and drops more rows as late, within the same query and 
checkpoint.
   
   `REGEX_PATTERN_COST_ESTIMATE_VERSION` is in 
`OffsetSeqMetadata.relevantSQLConfs`, with `0` for checkpoints that do not 
record it, for the same kind of plan change. Adding 
`LEGACY_PUSH_FILTER_AHEAD_OF_EXPENSIVE_CONJUNCT` there, with `true` for older 
checkpoints, would keep a restarted query on its earlier plan, while new 
queries 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) =>

Review Comment:
   **11. [Nit] `evaluatedHere` transforms the aliases again for every component 
and pass**
   
   `indexWhere` calls `evaluatedHere` for each component up to the first held 
one, and each call runs `transformDown` on every expensive alias the component 
uses, so an alias read by several components is transformed once per component, 
on every pass of the batch. Each transform also creates a new 
`AttributeReference` per Python UDF, which takes a new `ExprId` from the global 
counter. Computing the answer once per alias for the matched `Project`, with 
one placeholder attribute reused, would avoid both.



##########
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:
   **10. [Nit] `evaluatedHere` already implies `!isCheap`, and `canPushAhead` 
reads the legacy config again**
   
   `evaluatedHere(used)` requires an alias with `isExpensive(alias.child)`, so 
`!isCheap(used) &&` adds nothing here. Similarly, `canPushAhead` reads 
`legacyPushFilterAheadOfExpensiveConjunct` again, although the caller already 
has it as `legacy`, and `keepsWrittenOrder` only calls it when `!legacy`. 
Passing `legacy` in, or checking it once in the caller, would make the two 
paths easier to follow.



##########
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:
   **5. [Medium] Partition conditions outside the allowlist, such as 
`year(dt)`, lose pruning**
   
   The second round's item 5 led to `LIKE '2024%'` being pushed again, but many 
partition conditions are not plain: `year(dt) = 2024`, `cast(dt as date) >= 
'2024-01-01'`, `substr(dt, 1, 4) = '2024'`. Written after a condition over a 
nullable UDF, e.g. `df.withColumn("f", u($"x")).where($"f" === "a" && 
year($"dt") === 2024)` on a table partitioned by `dt`, they now stay above the 
projection, and the inferred `isnotnull(f)` reads `f` a second time, so the 
scan does not take the filter through the projection and reads every partition.
   
   In 4.1, `avoidDoubleFilterEval` did not exist yet, so the whole filter was 
pushed with the UDF inlined and the scan pruned with `year(dt) = 2024`. For 
these queries the change is a full table scan compared with 4.1 as well, not 
only with 4.2 and 4.3, while the migration guide entry says the evaluation is 
as in Spark 4.1. Telling partition columns apart is probably not possible in 
this rule, but the entry could name partition pruning explicitly, since a full 
scan is likely the most visible cost for users who upgrade.



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