sarutak commented on code in PR #59940:
URL: https://github.com/apache/spark/pull/59940#discussion_r4429290160


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2627,17 +2633,40 @@ 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(_._2.child.expensive)
+        // 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, so holding a 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.
+        def evaluatedHere(used: AttributeMap[Alias]): Boolean = used.exists { 
case (_, alias) =>
+          alias.child.transformDown {
+            case udf if isScalarPythonUDF(udf) => Literal(null, udf.dataType)
+          }.expensive
+        }
         // 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.
+        // first expensive one are all pushed. After the first component over 
an expensive
+        // alias the projection evaluates, which is the first one held back, 
only the cheap
+        // components that `canPushAhead` accepts are pushed; the others stay 
above with the
+        // expensive ones, so they are only evaluated where none of the 
components before them
+        // is false. Before that one, the cheap components that are not 
throwable are pushed, as
+        // before SPARK-60730.
         val (leading, rest) = originalAndRewrittenConditionsWithUsedAlias.span 
{
           case (_, used, _) => isCheap(used)
         }

Review Comment:
   Reproduced, thanks: with the non-nullable `f`, `isnotnull(10 / (id - 3))` 
was pushed over the `Range`. Master fails these queries too, with a nullable 
`f` as well, since it pushes `x > 0` itself, but this PR claimed to cover them. 
Now an `IS NOT NULL` check written, or inferred, before the first condition 
held back stays above the projection when it reads a cheap alias that a held 
condition after the first one reads and `canPushAhead` rejects it with the 
alias substituted, as you suggested first. That includes the case where a 
condition over a Python UDF comes first, e.g. `p > 0 && f > 5 && x > 0`, where 
the inferred `isnotnull(p)` comes before `isnotnull(x)`. With whole-stage 
codegen, `FilterExec` then evaluates it just before the first conjunct that 
reads the column, so after `f > 5`. The same holds for such a check the user 
wrote in front, so `f > 5` now also sees the rows it drops; the description and 
the migration guide say so. A held condition that reads the alias is rejecte
 d too, so the projection is not split then. Under the legacy config, 
`canPushAhead` only rejects throwable components, so the check is pushed as 
before unless the alias is throwable. A condition written first that reads the 
alias is still pushed, since it is evaluated first anyway. I did not take the 
second option: the rule can not tell an inferred check from one the user wrote, 
which no later condition may imply, and an expression can be NULL on non-null 
inputs, e.g. a division by zero with ANSI off, so `IS NOT NULL` over its inputs 
could only be added next to the check, not replace it. Two limits, now in the 
description: without whole-stage codegen these queries still fail, since an 
interpreted projection computes `x` on every row it gets, as before this PR; 
and the other conditions that `InferFiltersFromConstraints` infers in front, 
from equalities, are still pushed as if written first, also over such an alias. 
Keeping those above would not help, since `FilterExec` evaluates them
  in their position, which is first. In my first-round reply I wrote that I 
would rather not special-case the inferred conditions; that was about the 
projection split, which SPARK-60731 still handles, while this one is about the 
pushdown, where only the `IS NOT NULL` checks get the lazy placement that makes 
keeping them above help.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2627,17 +2633,40 @@ 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(_._2.child.expensive)
+        // 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, so holding a 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.
+        def evaluatedHere(used: AttributeMap[Alias]): Boolean = used.exists { 
case (_, alias) =>
+          alias.child.transformDown {
+            case udf if isScalarPythonUDF(udf) => Literal(null, udf.dataType)
+          }.expensive
+        }
         // 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.
+        // first expensive one are all pushed. After the first component over 
an expensive
+        // alias the projection evaluates, which is the first one held back, 
only the cheap
+        // components that `canPushAhead` accepts are pushed; the others stay 
above with the
+        // expensive ones, so they are only evaluated where none of the 
components before them
+        // is false. Before that one, the cheap components that are not 
throwable are pushed, as
+        // before SPARK-60730.
         val (leading, rest) = originalAndRewrittenConditionsWithUsedAlias.span 
{
           case (_, used, _) => isCheap(used)
         }
-        val (cheapRest, expensiveWithUsed) = rest.partition {
+        val (beforeHeld, fromHeld) = rest.span {
+          case (_, used, _) => isCheap(used) || !evaluatedHere(used)
+        }
+        val (firstHeld, held) = fromHeld.splitAt(1)
+        val (cheapBefore, expensiveBefore) = beforeHeld.partition {
           case (_, used, replaced) => isCheap(used) && !replaced.throwable
         }
+        val (cheapHeld, expensiveHeld) = held.partition {
+          case (_, used, replaced) => isCheap(used) && canPushAhead(replaced)
+        }

Review Comment:
   Reproduced. The mechanism is older than this PR: on master, with the split 
off, `f >= 0 && g + 1 > 6 && g + 1 < 100 && r > 0`, with `g` a UDF too, fails 
the same way, because those conditions are held back there too. This PR widens 
it to conditions over plain columns. I also corrected the description, which 
said "with whole-stage codegen, an alias is only computed where a condition 
needs it", as did my reply in the first round; it now names this path and item 
3. I would rather not change `FilterExec` in this PR. Computing only the 
columns that the common subexpressions read would not cover a shared expression 
over the alias itself, e.g. `x + 1 > 6 && x + 1 < 100` with `x` a division, so 
the prologue would have to emit each column just before its first use. I would 
like to do that in a separate PR. With the split on, SPARK-60731 should also 
avoid your example, since the conditions written before `r > 0` would then sit 
below the projection that computes `r`; I will check it there.



##########
docs/sql-migration-guide.md:
##########
@@ -63,6 +63,7 @@ license: |
 - Since Spark 4.4, when `spark.sql.parquet.fieldId.read.enabled` is true, a 
Parquet read fails with `PARQUET_FIELD_REQUESTED_MORE_THAN_ONCE` if two or more 
requested fields of a struct (or of the top-level schema) resolve to the same 
Parquet field. This happens when one requested field is matched by field ID and 
another by name, or when two requested fields have the same field ID. In Spark 
4.3 and earlier, such a read returned wrong values without an error: with the 
vectorized reader one of the fields read as non-null default values (for 
example `0`) or uninitialized data, and with the parquet-mr reader 
(`spark.sql.parquet.enableVectorizedReader` set to `false`) one of the fields 
read as `null`. To read the Parquet field more than once, request it once and 
refer to the resulting column more than once in the query.
 - Since Spark 4.4, when `spark.sql.groupByOrdinal` is true (the default), the 
pipe `AGGREGATE` operator rejects an aliased integer literal in its `GROUP BY` 
clause, such as `TABLE t |> AGGREGATE sum(x) AS s GROUP BY 2 AS grp`, with 
`UNSUPPORTED_FEATURE.PIPE_OPERATOR_AGGREGATE_UNSUPPORTED_CASE`, because a bare 
integer literal in the same position is an ordinal. Previously the aliased 
literal was grouped as a constant. Bare ordinals and aliases on other grouping 
expressions are not affected. To restore the previous behavior, set 
`spark.sql.legacy.allowAliasedPipeAggregateOrdinals` to `true`.
 - Since Spark 4.4, with whole-stage codegen enabled, a filter condition, 
including the `FILTER` clause of an aggregate function, evaluates the remaining 
top-level conjuncts of the condition on a row after one of them evaluates to 
`NULL`, as the SQL standard requires, and does so in the order they appear in 
the condition, as Spark already did with whole-stage codegen disabled, which 
also evaluates a subexpression shared by several conjuncts before the 
condition. Only a `FALSE` conjunct stops the evaluation. One exception: an `IS 
NOT NULL` check on a column, or on an expression over it, that a later conjunct 
references is still evaluated before that conjunct and the conjuncts up to the 
check's own position, even when it is written after them, and can drop the row 
before an error one of them raises. Once a condition has a conjunct that can be 
`NULL`, the `IS NOT NULL` checks after that conjunct are evaluated in the order 
of the condition on every row, not only on rows where a conjunct 
 is `NULL`. A row whose condition is `NULL` is still dropped, but an error 
raised by a later conjunct on that row, such as `ARITHMETIC_OVERFLOW` in ANSI 
mode, now fails the query. For example, `SELECT a FROM t WHERE NOT (a > 0 AND b 
> 0) AND y + 1 > 0` can fail with `ARITHMETIC_OVERFLOW` on a row where `a` is 
`NULL`, `b` is `1`, and `y` is `2147483647`, unless the row is dropped earlier 
by a `FALSE` conjunct or `IS NOT NULL` check that precedes `y + 1 > 0` in the 
condition, or skipped before the filter, for example by data source filter 
pushdown such as Parquet row group statistics. Previously the query skipped 
such a row with whole-stage codegen enabled. The later conjuncts also run on 
more rows than before, so a nondeterministic conjunct such as `rand(seed)` can 
return different values on the remaining rows, and a side-effecting 
user-defined function is called on rows that are dropped. To restore the 
previous behavior, set `spark.sql.legacy.filterShortCircuitOnNullConjunct` to 
`tru
 e`.
+- Since Spark 4.4, when `spark.sql.optimizer.avoidDoubleFilterEval` is true 
(the default), a filter condition written after a condition over an expensive 
expression that a projection evaluates, such as a Scala user-defined function, 
is evaluated after it, as in Spark 4.1, unless it is a plain deterministic 
condition over columns and literals, such as a comparison, `IN`, `IS NULL` or 
`size`, including a comparison with an implicit numeric widening cast, such as 
between an `INT` and a `BIGINT` column. A column that a view, a subquery or a 
DataFrame `select` defines with a cheap expression counts as that expression. 
In Spark 4.2 and 4.3, such a condition could be pushed below the projection and 
evaluated first, also on the rows the earlier condition drops, so a query could 
fail with an error such as `DIVIDE_BY_ZERO` or `CAST_INVALID_INPUT` in ANSI 
mode, or with an exception from a user-defined function. A condition that is 
not plain, such as one with arithmetic, another cast, a string 
 function or a subquery, is now kept above the projection even when it can not 
fail, so it is no longer pushed further down either, for example below a join. 
The earlier condition now also runs on the rows that such a condition used to 
drop first, and when whole-stage codegen is not used, for example when it is 
disabled or the plan is wider than `spark.sql.codegen.maxFields`, so does every 
expensive expression the projection computes, since an interpreted projection 
computes them all on every row it gets. So a query that relied on that can now 
fail, and a non-deterministic condition such as `rand(seed)` can select 
different rows than in Spark 4.2 and 4.3. This does not cover Python 
user-defined functions, which are evaluated below the projection, so the 
conditions written after a condition over one, and before any condition over an 
expensive expression the projection computes, are still pushed below it. With 
`spark.sql.optimizer.splitProjectionForExpensiveFilters` (the default), the 
 projection split, which orders the conditions over expensive expressions by 
cost, may still compute the expensive expression a later condition reads ahead 
of the conditions written before it. To push such conditions below the 
projection again, except throwable ones such as `raise_error`, set 
`spark.sql.legacy.pushFilterAheadOfExpensiveConjunct` to `true`.

Review Comment:
   Reproduced. As with item 2, master has the same exposure for conditions it 
holds back: with the split off, `f >= 0 && g > 5 && a > 0`, with `g` a UDF and 
`a` and `b` sharing `p`, fails there too. The migration guide entry and the 
description now name an expression that two projected expressions share, such 
as a UDF read field by field. I left `ProjectExec` as is. Deferring a shared 
subexpression past the filter is a general codegen change, and SPARK-60731 is 
the structural answer for this shape with the split on.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2627,17 +2633,40 @@ 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(_._2.child.expensive)
+        // 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, so holding a 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.
+        def evaluatedHere(used: AttributeMap[Alias]): Boolean = used.exists { 
case (_, alias) =>
+          alias.child.transformDown {
+            case udf if isScalarPythonUDF(udf) => Literal(null, udf.dataType)
+          }.expensive
+        }

Review Comment:
   Confirmed: `id + 1 > 6` now stays above the projection, over 
`BatchEvalPython`. I kept the code. `f > 0` is written first and needs `py(id)` 
on every row it gets, so `py` seeing those rows follows the written order; 4.1 
and master only avoided it by evaluating `id + 1 > 6` first, which is the 
reordering this PR removes for `s`. The migration guide and the description now 
say that the Python UDF under such an expression runs on the rows that the 
conditions after it used to drop first.



##########
docs/sql-migration-guide.md:
##########
@@ -63,6 +63,7 @@ license: |
 - Since Spark 4.4, when `spark.sql.parquet.fieldId.read.enabled` is true, a 
Parquet read fails with `PARQUET_FIELD_REQUESTED_MORE_THAN_ONCE` if two or more 
requested fields of a struct (or of the top-level schema) resolve to the same 
Parquet field. This happens when one requested field is matched by field ID and 
another by name, or when two requested fields have the same field ID. In Spark 
4.3 and earlier, such a read returned wrong values without an error: with the 
vectorized reader one of the fields read as non-null default values (for 
example `0`) or uninitialized data, and with the parquet-mr reader 
(`spark.sql.parquet.enableVectorizedReader` set to `false`) one of the fields 
read as `null`. To read the Parquet field more than once, request it once and 
refer to the resulting column more than once in the query.
 - Since Spark 4.4, when `spark.sql.groupByOrdinal` is true (the default), the 
pipe `AGGREGATE` operator rejects an aliased integer literal in its `GROUP BY` 
clause, such as `TABLE t |> AGGREGATE sum(x) AS s GROUP BY 2 AS grp`, with 
`UNSUPPORTED_FEATURE.PIPE_OPERATOR_AGGREGATE_UNSUPPORTED_CASE`, because a bare 
integer literal in the same position is an ordinal. Previously the aliased 
literal was grouped as a constant. Bare ordinals and aliases on other grouping 
expressions are not affected. To restore the previous behavior, set 
`spark.sql.legacy.allowAliasedPipeAggregateOrdinals` to `true`.
 - Since Spark 4.4, with whole-stage codegen enabled, a filter condition, 
including the `FILTER` clause of an aggregate function, evaluates the remaining 
top-level conjuncts of the condition on a row after one of them evaluates to 
`NULL`, as the SQL standard requires, and does so in the order they appear in 
the condition, as Spark already did with whole-stage codegen disabled, which 
also evaluates a subexpression shared by several conjuncts before the 
condition. Only a `FALSE` conjunct stops the evaluation. One exception: an `IS 
NOT NULL` check on a column, or on an expression over it, that a later conjunct 
references is still evaluated before that conjunct and the conjuncts up to the 
check's own position, even when it is written after them, and can drop the row 
before an error one of them raises. Once a condition has a conjunct that can be 
`NULL`, the `IS NOT NULL` checks after that conjunct are evaluated in the order 
of the condition on every row, not only on rows where a conjunct 
 is `NULL`. A row whose condition is `NULL` is still dropped, but an error 
raised by a later conjunct on that row, such as `ARITHMETIC_OVERFLOW` in ANSI 
mode, now fails the query. For example, `SELECT a FROM t WHERE NOT (a > 0 AND b 
> 0) AND y + 1 > 0` can fail with `ARITHMETIC_OVERFLOW` on a row where `a` is 
`NULL`, `b` is `1`, and `y` is `2147483647`, unless the row is dropped earlier 
by a `FALSE` conjunct or `IS NOT NULL` check that precedes `y + 1 > 0` in the 
condition, or skipped before the filter, for example by data source filter 
pushdown such as Parquet row group statistics. Previously the query skipped 
such a row with whole-stage codegen enabled. The later conjuncts also run on 
more rows than before, so a nondeterministic conjunct such as `rand(seed)` can 
return different values on the remaining rows, and a side-effecting 
user-defined function is called on rows that are dropped. To restore the 
previous behavior, set `spark.sql.legacy.filterShortCircuitOnNullConjunct` to 
`tru
 e`.
+- Since Spark 4.4, when `spark.sql.optimizer.avoidDoubleFilterEval` is true 
(the default), a filter condition written after a condition over an expensive 
expression that a projection evaluates, such as a Scala user-defined function, 
is evaluated after it, as in Spark 4.1, unless it is a plain deterministic 
condition over columns and literals, such as a comparison, `IN`, `IS NULL` or 
`size`, including a comparison with an implicit numeric widening cast, such as 
between an `INT` and a `BIGINT` column. A column that a view, a subquery or a 
DataFrame `select` defines with a cheap expression counts as that expression. 
In Spark 4.2 and 4.3, such a condition could be pushed below the projection and 
evaluated first, also on the rows the earlier condition drops, so a query could 
fail with an error such as `DIVIDE_BY_ZERO` or `CAST_INVALID_INPUT` in ANSI 
mode, or with an exception from a user-defined function. A condition that is 
not plain, such as one with arithmetic, another cast, a string 
 function or a subquery, is now kept above the projection even when it can not 
fail, so it is no longer pushed further down either, for example below a join. 
The earlier condition now also runs on the rows that such a condition used to 
drop first, and when whole-stage codegen is not used, for example when it is 
disabled or the plan is wider than `spark.sql.codegen.maxFields`, so does every 
expensive expression the projection computes, since an interpreted projection 
computes them all on every row it gets. So a query that relied on that can now 
fail, and a non-deterministic condition such as `rand(seed)` can select 
different rows than in Spark 4.2 and 4.3. This does not cover Python 
user-defined functions, which are evaluated below the projection, so the 
conditions written after a condition over one, and before any condition over an 
expensive expression the projection computes, are still pushed below it. With 
`spark.sql.optimizer.splitProjectionForExpensiveFilters` (the default), the 
 projection split, which orders the conditions over expensive expressions by 
cost, may still compute the expensive expression a later condition reads ahead 
of the conditions written before it. To push such conditions below the 
projection again, except throwable ones such as `raise_error`, set 
`spark.sql.legacy.pushFilterAheadOfExpensiveConjunct` to `true`.

Review Comment:
   Confirmed for `substr(dt, 1, 4) = '2024'` and `dt LIKE '2024%'`, also with a 
UDF marked `asNonNullable`, since a Scala UDF with a primitive parameter over a 
nullable column is wrapped in a null check, so `isnotnull(f)` is inferred and 
`f` is read twice. With item 8, `LIKE '2024%'` is pushed again and prunes the 
partitions; `substr` and `year` are not yet. The migration guide and the 
description now name partition pruning and data source filter pushdown next to 
the join, for a filter that reads a column the projection computes more than 
once. I did not special-case partition columns: pruning evaluates the condition 
on every partition value, so one that can fail there, e.g. `cast(dt AS INT) > 
0` in ANSI mode, would be evaluated ahead of `f > 0` again. Adding more 
functions that can not fail to the allowlist, such as `substr` with literal 
positions, `year` over a date or `lower`, which `ILIKE` goes through, would 
reduce this further; I think that is better done separately, since each
  one changes the other callers of `canEvaluateUnconditionally` too.



##########
sql/core/src/test/scala/org/apache/spark/sql/DataFrameSuite.scala:
##########
@@ -2949,6 +2949,117 @@ class DataFrameSuite extends SharedSparkSession
     }
   }
 
+  test("SPARK-60730: a filter condition that can fail is not evaluated ahead 
of its order") {
+    val f = udf((i: Long) => i)
+    val g = udf((i: Long) => i)
+    val h = udf((i: Long) => { require(i > 5, s"h called with $i"); i })
+    // Each query fails if the conjunct after the first is evaluated on an id 
<= 5, which the
+    // first conjunct filters out.
+    def divideQuery(): DataFrame = spark.range(0, 10, 1, 1)
+      .select($"id", f($"id") as "f")
+      .where($"f" > 5 && lit(10L) / ($"id" - 3) > 0)
+    def castQuery(): DataFrame = spark.range(0, 10, 1, 1)
+      .select($"id", f($"id") as "f", when($"id" > 5, 
lit("1")).otherwise(lit("x")) as "s")
+      .where($"f" > 5 && $"s".cast("int") > 0)

Review Comment:
   Added. The `DataFrameSuite` loop now has the division and the cast inside 
the projected alias, each with a non-nullable and a nullable `f`; the 
non-nullable ones failed on the previous head, and all of them on master. Like 
`laterQuery` and `inferredQuery`, they only run where whole-stage codegen is 
used or the filter is pushed whole. `FilterPushdownSuite` has a test with the 
`IS NOT NULL` check written in front, since its `Optimize` does not infer 
filters, with the split on and off, after a condition over a Python UDF and 
under the legacy config, and with the cases where it is still pushed: no held 
condition after the first one reads the alias, the alias can not fail, or it is 
not an `IS NOT NULL` check. The description has a row for the alias case now, 
next to the "invalid cast" one, which keeps the cast in the filter.



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