dongjoon-hyun commented on code in PR #59940:
URL: https://github.com/apache/spark/pull/59940#discussion_r4424301716
##########
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:
**2. [Medium] `FilterExec` computes every alias the conditions read when
they share a sub-expression**
The description says that "with whole-stage codegen, an alias is only
computed where a condition needs it". That does not hold when
`FilterExec.doConsume` takes its CSE path
(`spark.sql.subexpressionElimination.filterExec.enabled`, on by default),
because `inputVarsEvalCode` computes the input columns of all `otherPreds` at
the top of the row loop. Before this PR, cheap conditions that share a
sub-expression were pushed below the projection. Now they stay in one `Filter`
with the conditions over the aliases. For example, with `r` a UDF that requires
`id > 5`:
```scala
spark.range(0, 10, 1, 1)
.select($"id", f($"id") as "f", r($"id") as "r")
.where($"f" >= 0 && $"id" + 1 > 6 && $"id" + 1 < 100 && $"r" > 0)
```
The two `id + 1` select that path, so `r` runs on `id` 0 ahead of every
condition, and the query fails with `FAILED_EXECUTE_UDF`, also with whole-stage
codegen. It succeeds in 4.1, where `r` is inlined into its own conjunct, and
before this PR, where the additions are pushed below. A cheap alias that can
fail is not protected on this path either.
Could the prologue only compute the columns that the common sub-expressions
read? Its comment says those are the ones CSE consumes. Otherwise, could the
description and the migration guide name this case too?
##########
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:
**1. [High] An inferred `IS NOT NULL` over a cheap alias that can fail is
still pushed first**
`InferFiltersFromConstraints` puts the conditions it infers in front of the
condition. So when a held condition reads a nullable cheap alias, the batch
after inferring filters sees `isnotnull(x)` as the first condition. It is
cheap, so it is `leading`, and `canPushAhead` is only asked about the
conditions after the first one held back. For example, with `f` the no-op UDF
of the description, which is not nullable, and ANSI mode on:
```scala
spark.range(0, 10, 1, 1)
.select($"id", f($"id") as "f", lit(10L) / ($"id" - 3) as "x")
.where($"f" > 5 && $"x" > 0)
```
1. Before inferring filters, `x > 0` is rejected, so the whole filter stays
above the projection.
2. `Divide` is always nullable, so `Filter.constraints` has `isnotnull(x)`
and the `Project` does not, and the filter becomes `isnotnull(x) AND f > 5 AND
x > 0`.
3. After inferring filters, `isnotnull(x)` is pushed as `isnotnull(10 / (id
- 3))` over the `Range`, and the query fails with `DIVIDE_BY_ZERO` on `id` 3.
In 4.1, and with `avoidDoubleFilterEval` off, the whole filter is pushed, so
nothing computes the division ahead of `f > 5`, and the query returns 4 rows.
The cast goes the same way once it is inside the alias, as in `.cast("int") as
"i"` with `i > 0`, since `Cast.forceNullable` is true from a string, or `CAST(s
AS INT) AS i` in a view. A nullable expensive alias hides this, because its own
`isnotnull(f)` then comes first and holds `isnotnull(x)` back.
So the migration guide's "A column that a view, a subquery or a DataFrame
`select` defines with a cheap expression counts as that expression" does not
hold yet for these, and neither does "Other filters the optimizer adds stay
above when the check rejects them" in the Scaladoc of `canPushAhead`.
Could an `IsNotNull` whose substituted form `canPushAhead` rejects stay
above the projection too? `FilterExec` already generates such a check just
before the first conjunct that reads the column. Or, for a null-intolerant
alias, it could push the `IsNotNull` of the alias's input columns instead,
which is what 4.1 infers.
##########
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:
**4. [Low] A Python UDF under a Scala UDF now runs on the rows a held
condition used to drop**
When the alias is a Scala UDF over a Python UDF, `evaluatedHere` holds the
later conditions back. But `ExtractPythonUDFs` still moves the Python UDF below
the projection, and the alias stays expensive, so the batch after it does not
push the held conditions either. For example, with `py` a Python UDF that only
works for `id > 5` and `s` a Scala UDF:
```sql
SELECT * FROM (SELECT id, s(py(id)) AS f FROM range(0, 10, 1, 1)) WHERE f >
0 AND id + 1 > 6
```
`py` now runs on `id` 0 in the eval node below the projection, and the query
fails. In 4.1 and before this PR, `id + 1 > 6` ends up below the eval node, so
`py` only sees the rows it keeps. Holding the condition back is still right for
`s`, so this may only need wording: the migration guide says "This does not
cover Python user-defined functions". Could it also say that a Python UDF under
an expression the projection evaluates now runs on those rows?
##########
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:
**5. [Medium] A held condition on a partition column can lose partition
pruning**
This entry describes the cost of holding a condition back as "so it is no
longer pushed further down either, for example below a join". The larger cost
may be at the scan. `PhysicalOperation` and `ScanOperation` only take a
`Filter` through a `Project` when `canCollapseExpressions` allows it, and it
refuses when the condition reads an expensive alias twice. That is the usual
case for a nullable UDF, since `InferFiltersFromConstraints` adds
`isnotnull(f)`. Then `PruneFileSourcePartitions` and `FileSourceStrategy` only
see the `Project` under the filter. For example, with `u` a nullable UDF and
`t` partitioned by `dt`:
```sql
SELECT * FROM (SELECT dt, u(x) AS f FROM t) WHERE f > 0 AND substr(dt, 1, 4)
= '2024'
```
`substr(dt, 1, 4) = '2024'` stays above the projection, next to
`isnotnull(f) AND f > 0`, so the query reads every partition. In 4.1 and before
this PR, it is pruned. `year(d) = 2024` and `dt LIKE '2024%'` behave the same
way. The result does not change, but on a large table this could be the largest
cost of the change.
Could the migration guide and the description name partition pruning and
data source filter pushdown, next to the join? Item 8 would also reduce this
for some conditions.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/ExprUtils.scala:
##########
@@ -266,13 +269,22 @@ 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 {
case _: AttributeReference | _: Literal => true
case _: GetStructField | _: GetArrayStructFields | _: GetMapValue =>
e.children.forall(canEvaluateUnconditionallyInternal)
case _: And | _: Or | _: Not | _: BinaryComparison | _: IsNull | _:
IsNotNull |
- _: IsNaN | _: NullIf | _: Coalesce | _: In | _: InSet =>
+ _: IsNaN | _: NullIf | _: Coalesce | _: In | _: InSet | _: Size =>
e.children.forall(canEvaluateUnconditionallyInternal)
Review Comment:
**8. [Low] `StartsWith`, `EndsWith`, `Contains` and a field of a struct
built in the projection are rejected too**
As with `Size`, these can not fail, and they are common. `name LIKE 'abc%'`
becomes `StartsWith`, which data sources accept as `StringStartsWith`, so `f >
0 AND name LIKE 'abc%'` loses that pushdown, and with more expensive conditions
after it, the split too. `Like` and `RLike` themselves can fail on an invalid
pattern, so they should stay out.
Also, with `struct(a, b) AS s` in the projection, `s.a > 1` is checked as
`GetStructField(CreateNamedStruct(...)) > 1`, before `SimplifyExtractValueOps`
reduces it to `a > 1`, so a plain condition is rejected.
Could these go in the allowlist, with `CreateNamedStruct` taking the check
of its children? The other callers would accept them too, and item 5 would
apply to fewer conditions.
##########
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:
**3. [Medium] `ProjectExec` computes a sub-expression that aliases share on
every row**
The same statement has a second exception, in the projection itself.
`ProjectExec.doConsume` emits `subExprsCode` before `consume`, so a
sub-expression shared by two aliases is computed for every row that reaches the
projection, whatever the filter above it needs. A struct-returning UDF read
field by field is the common shape, as the Scaladoc of
`splitProjectForExpensiveConditions` notes. For example, with `p` a UDF that
requires `id > 5`:
```scala
spark.range(0, 10, 1, 1)
.select($"id", f($"id") as "f", p($"id") as "a", p($"id") + 1 as "b")
.where($"f" >= 0 && $"id" + 1 > 6 && $"a" > 0)
```
`id + 1 > 6` now stays above the projection, so `p` runs on `id` 0, and the
query fails with whole-stage codegen too. It succeeds in 4.1 and before this
PR. For the same reason, a cast or a division shared by two aliases is not
protected by the conditions written before it, even with item 1 fixed.
This entry limits the effect to "when whole-stage codegen is not used".
Could it also name a sub-expression that several aliases share?
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/Optimizer.scala:
##########
@@ -2652,8 +2681,20 @@ 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.
+ val cheapAliasMap = AttributeMap(aliasMap.filter { case (_, a) =>
!a.child.expensive })
+ val keepsWrittenOrder =
!SQLConf.get.legacyPushFilterAheadOfExpensiveConjunct &&
+ expensiveHeld.exists {
+ case (cond, _, _) => !canPushAhead(replaceAlias(cond,
cheapAliasMap))
+ }
Review Comment:
**9. [Nit] `cheapAliasMap` and `keepsWrittenOrder` are computed when nothing
needs them**
This builds a new `AttributeMap` for every `Filter` over a `Project`, on
every pass, also when `expensiveHeld` is empty or the split is off. It could be
a `lazy val`, with `splitProjectionForExpensiveFilters` checked first.
Similarly, `evaluatedHere` copies the tree of every alias in `used`, also the
cheap ones, where `alias.child.expensive && ...` would skip them.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/optimizer/FilterPushdownSuite.scala:
##########
@@ -750,6 +750,314 @@ class FilterPushdownSuite extends PlanTest {
assertTopFilterConjuncts(Optimize.execute(originalQuery),
Seq(nonDeterministic, unplaced))
}
+ test("SPARK-60730: a cheap condition that can fail is not pushed ahead of an
expensive one") {
+ Seq(true, false).foreach { split =>
+ withSQLConf(SQLConf.SPLIT_PROJECTION_FOR_EXPENSIVE_FILTERS.key ->
split.toString) {
+ val originalQuery = testStringRelation
+ .select($"a", $"e".rlike("magic") as "f", $"b")
Review Comment:
**7. [Low] The `rlike("magic")` fixture and `evaluatedHere` depend on the
regex heuristic that #59879 changes**
`$"e".rlike("magic")` is only expensive because `_expensiveRegex` looks at
`left`, the subject, not at the pattern. That comes from before this PR, and
#59879 (SPARK-60632) fixes it. With that fix `rlike("magic")` is cheap, so
whichever PR goes in second has to rewrite these tests, and the expectation of
`regexOverPythonQuery` changes too. That PR adds a helper to this suite for the
existing tests, `$"e".rlike(Concat(Seq($"e", Literal(tag))))`, which is
expensive with both heuristics. Could the new tests use the same shape?
`evaluatedHere` has a similar dependency. With that fix, `Literal(null,
udf.dataType)` makes the pattern of `e RLIKE py(a)` foldable, and a NULL
pattern counts as cheap, so the alias no longer counts as evaluated here,
although the regex is still compiled per row in the projection.
Until then, every `RLIKE` alias over a column counts as expensive, so the
hold-back and the lost split apply to those aliases too, whatever the pattern.
The two PRs may be worth ordering.
##########
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:
**6. [Low] No end-to-end test computes an expression that can fail inside an
alias**
`divideQuery` and `castQuery` keep the division and the cast in the filter,
and the alias `s` here is not nullable. "a cheap condition over an alias that
can fail" in `FilterPushdownSuite` does have it inside the alias, but
`Optimize` in that suite has no `InferFiltersFromConstraints`, so
`isnotnull(x)` never appears. Adding the query of item 1 to this loop, and
`when($"id" > 5, lit("1")).otherwise(lit("x")).cast("int") as "i"` with `i >
0`, would catch item 1. The second one is also what the "invalid cast of a
cheap alias" row of the description's table reads like.
--
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]