spark-test commented on code in PR #59984:
URL: https://github.com/apache/spark/pull/59984#discussion_r4465530255
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/codegen/CodeGenerator.scala:
##########
@@ -2330,10 +2332,64 @@ class CodegenContext extends Logging {
// We need to make sure that we do not reuse stateful expressions. This is
needed for codegen
// as well because some expressions may implement `CodegenFallback`.
val cleanedExpressions =
expressions.map(_.freshCopyIfContainsStatefulExpression())
- if (doSubexpressionElimination)
subexpressionElimination(cleanedExpressions)
+ if (doSubexpressionElimination) {
+ subexprFunctions ++=
subexpressionElimination(cleanedExpressions).map(_._2)
+ }
cleanedExpressions.map(e => e.genCode(this))
}
+ /**
+ * Returns the index of the first of `expressions` that references the
common subexpression
+ * `subexpr`, or -1. When the expressions are evaluated in order and one of
them can stop the
+ * evaluation, such as a FALSE conjunct of a predicate, the subexpression is
evaluated right
+ * before that expression instead of before all of them (SPARK-56032,
SPARK-60332). An
+ * occurrence in a conditionally evaluated part of an expression, such as a
branch of CASE WHEN,
+ * counts as a reference, so the subexpression is still evaluated before
such an expression.
+ */
+ def firstReferencingExpression(expressions: Seq[Expression], subexpr:
ExpressionEquals): Int = {
+ expressions.indexWhere(_.exists(e => ExpressionEquals(e) == subexpr))
+ }
+
+ /**
+ * Generates code for expressions with subexpression elimination, as
`generateExpressions`
+ * does, but instead of adding the evaluation of the common subexpressions to
+ * `subexprFunctionsCode`, to be put before all the expressions, returns it
per expression: the
+ * code that evaluates the subexpressions the expression is the first to
reference, to be put
+ * right before the code of the expression. The expressions are assumed to
be evaluated in the
+ * `expressions` order, so a subexpression is still evaluated before the
expressions that
+ * reference it, but not before an expression that stops the evaluation,
such as a FALSE
+ * conjunct of a predicate before the ones that share the subexpression
(SPARK-60332).
+ *
+ * Returns None, and generates nothing, when the expressions have no common
subexpression. The
+ * caller can then generate them without subexpression elimination. This
must be the only
+ * subexpression elimination on this context, so that the common
subexpressions are all found
+ * in `expressions`.
+ */
+ def generateExpressionsWithSubexprCodes(
+ expressions: Seq[Expression]): Option[Seq[(String, ExprCode)]] = {
+ val cleanedExpressions =
expressions.map(_.freshCopyIfContainsStatefulExpression())
+ val subexprCodes = subexpressionElimination(cleanedExpressions)
+ if (subexprCodes.isEmpty) {
+ None
+ } else {
+ val codes = cleanedExpressions.map(e => e.genCode(this))
+ val firstUses = subexprCodes.map { case (subexpr, code) =>
+ val first = firstReferencingExpression(cleanedExpressions,
ExpressionEquals(subexpr))
Review Comment:
**Blocking (P1):** This places each common subexpression's call before the
first conjunct that semantically references it, independently of the other
subexpressions, but the function of a subexpression that contains another one
reads that one's state instead of computing it. Under commutative
canonicalization an earlier conjunct can hold an equal copy of the outer
subexpression that contains no copy of the inner one, in a position the
analysis does not count. For example, with default configs the optimizer
filters `SELECT * FROM VALUES (3, 3, 1, 1, 0) AS t(a, b, c, d, x) WHERE IF(x >
0, a + c + b, d) > 0 AND a + b + c > 1 AND a + b + c < 10 AND a + b > 5` on the
driver through this generated predicate. `(a + c) + b` in the IF branch matches
`(a + b) + c`, so that call is emitted before conjunct 0, while `a + b` is
emitted only before conjunct 1. `(a + b) + c` is then computed from the
initial, or the previous row's, `a + b`, evaluates to 1, and `1 > 1` drops the
row although every
conjunct is TRUE. Multiply, And, Or and the bitwise operators canonicalize
the same way, and filters outside whole-stage codegen, non-codegen join
conditions and the other Predicate.create callers are affected too; the
whole-stage FilterExec branch emits a state's children first and is not.
Emitting every not-yet-emitted dependency of a call before the call, as
evaluateSubExprEliminationState does for whole-stage states, keeps the
per-conjunct placement and fixes the order.
**Verification:**
- **Regression:** A generated predicate whose earlier conjunct holds, only
in an IF or CASE WHEN branch, a commutatively reordered copy of a nested common
subexpression returns the same result as interpreted evaluation, both on a
first row and on rows that follow a row with different values.
- **Behavior:** Nested common subexpressions, whether first referenced by
the same conjunct or by different ones, still evaluate inner before outer, and
a FALSE earlier conjunct still skips both.
- **Compatibility:** A query that filters inline VALUES data with such a
predicate returns the same rows with and without the optimizer's local-relation
folding.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/codegen/GeneratePredicate.scala:
##########
@@ -38,8 +38,49 @@ object GeneratePredicate extends CodeGenerator[Expression,
BasePredicate] {
val ctx = newCodeGenContext()
// Do sub-expression elimination for predicates.
- val eval = ctx.generateExpressions(Seq(predicate),
useSubexprElimination).head
- val evalSubexpr = ctx.subexprFunctionsCode
+ val conjuncts = splitConjunctivePredicates(predicate)
+ // With a subexpression common to several top-level conjuncts, or within
one of them, the
+ // conjuncts are generated one by one, and the subexpression is evaluated
just before the
+ // first conjunct that references it, not before the whole condition, so
that a FALSE
+ // conjunct before them stops the evaluation, as `And.eval` and the
whole-stage codegen of a
+ // filter do (SPARK-60332). The conjuncts are evaluated like `And.eval`: a
FALSE one stops
+ // the evaluation, a NULL one does not, and the condition is TRUE only if
all of them are.
+ // Otherwise the condition is generated as a whole, as before.
+ val conjunctCodes = if (useSubexprElimination && conjuncts.length > 1) {
+ ctx.generateExpressionsWithSubexprCodes(conjuncts)
Review Comment:
**Blocking (P1):** Analyzing each conjunct as its own root changes which
subexpressions are eliminated, not only where they are evaluated. Inside a
task, EquivalentExpressions rejects any input that contains a plan expression,
so on the base branch a filter such as `WHERE a > (SELECT min(k) FROM u) AND
IF(q, b > 0, y * 2 + 1 > 0) AND y * 2 + 1 > 5 AND y * 2 + 1 < 10` got no
elimination at all: on a row with q = true, b = 0 and y = 2147483647 the IF
conjunct is FALSE and the row is dropped. Now only the subquery conjunct is
skipped, `y * 2 + 1` becomes common, and because its first occurrence is in the
IF's ELSE branch, its call runs before the IF conjunct and raises
ARITHMETIC_OVERFLOW in ANSI mode. That needs a FilterExec outside whole-stage
codegen, for example `SELECT *` over more than spark.sql.codegen.maxFields
columns. With spark.sql.subexpressionElimination.skipForShortcutExpr=true the
base branch analyzed only the first conjunct, so filtering inline VALUES with
`(b = 1 OR
y + 1 > 0) AND a < 0 AND y + 1 > -1 AND y + 1 > -2` dropped a row with b = 1,
a = 0 and y = 2147483647, while now `y + 1` is evaluated before the first
conjunct and overflows. Both queries returned rows on the base branch through
this generated predicate. Where whole-stage codegen applies, FilterExec already
raises for them, so for those filters the change removes the difference by
making the generated-predicate path fail too.
**Recommended change:** Decide the eliminated set from the whole cleaned
predicate, as the base branch did, and only place it per conjunct. Add the
cleaned predicate to the subexpression analysis as one tree, and split that
same cleaned predicate into its conjuncts. Keep only the common subexpressions
that some conjunct references, dropping top-level AND nodes that no conjunct
contains, and generate and place only those. When none remain, generate the
predicate as a whole without elimination, as the change already does.
**Why this works:** The analysis's whole-tree rules then apply unchanged. A
predicate that holds a plan expression inside a task, or an
objects.LambdaVariable, gets no elimination. With
spark.sql.subexpressionElimination.skipForShortcutExpr, only the first
conjunct's subexpressions are eliminated. So subexpressions those rules kept
lazy are again evaluated only where the AND and conditional code reaches them.
For every other predicate, with skipForShortcutExpr off, the whole-tree and
per-conjunct analyses find the same subexpressions apart from AND groups of the
top-level chain, so the placement fix for the queries the PR targets is
unaffected.
**Scope:** Move the subexpression analysis back to the whole predicate while
keeping the per-conjunct placement.
**Compatibility:** With default configs, each shared subexpression is still
evaluated just before the first conjunct that references it, which is the PR's
fix.
**Risks:** A predicate in which an AND group of the top-level chain occurs
again, either repeated in a non-adjacent position or nested in another conjunct
such as under NOT, loses elimination of that group's conjuncts, because the
analysis counts the AND node instead of its conjuncts. Results are unchanged,
and the optimizer already folds adjacent duplicates. Inside a task, a predicate
with a plan-expression conjunct goes back to the base branch's generation
without elimination, giving up the new elimination of its other conjuncts.
**Constraints:** The conjuncts must be split from the same cleaned predicate
the analysis saw, so that stateful expressions keep a single fresh copy. The
dependency ordering of the precompute calls must be computed over the retained
subexpressions and their representatives.
**Exclusions:**
- The whole-stage FilterExec subexpression branch already analyzed each
conjunct separately on the base branch, and it still raises for these shapes
with whole-stage codegen on.
- Placing a precompute only before always-evaluated references, which the PR
tracks as SPARK-60774.
**Success:** With spark.sql.subexpressionElimination.skipForShortcutExpr set
to true, a subexpression shared by later conjuncts is not eliminated, and a row
that an earlier conjunct drops without reaching it is dropped without
evaluating it. A predicate generated inside a task that has a scalar or IN
subquery conjunct gets no subexpression elimination, as on the base branch.
With default configs, the reproducer, the `b < 0` variant and the join case
still drop the row before the shared expression overflows.
**Verification:**
- **Regression:** With whole-stage codegen off and ANSI on, consider a
filter whose earlier conjunct is an IF or CASE WHEN that references a
subexpression shared by later conjuncts only in an untaken branch. It returns
its rows instead of raising, both with skipForShortcutExpr set to true and with
a scalar-subquery conjunct evaluated inside the task.
- **Behavior:** With default configs, the filter reproducer and the
cartesian-product join case still drop the row before the shared expression
overflows.
- **Compatibility:** Generated predicates with no plan-expression conjunct,
and with skipForShortcutExpr off, still eliminate the same subexpressions as
before, apart from top-level AND nodes.
##########
docs/sql-migration-guide.md:
##########
@@ -28,6 +28,7 @@ license: |
## Upgrading from Spark SQL 4.3 to 4.4
+- Since Spark 4.4, a predicate evaluated through generated code outside of
whole-stage codegen, such as a filter condition with whole-stage codegen
disabled or a join condition, evaluates a subexpression shared by several of
its top-level conjuncts just before the first conjunct that references it,
instead of before the whole condition. So a `FALSE` conjunct before them stops
the evaluation, as `AND` does when evaluated directly, and an error the
subexpression raises on the row, such as `ARITHMETIC_OVERFLOW` in ANSI mode, is
no longer raised. For example, `SELECT a FROM t WHERE b < 0 AND y + 1 > 0 AND y
+ 1 > -1` no longer fails on a row where `b` is `1` and `y` is `2147483647`. A
subexpression whose first occurrence is in a conditional branch of a conjunct,
such as a branch of `CASE WHEN`, is still evaluated before that conjunct.
Review Comment:
**Nit (P3):** This limits the change to a subexpression shared by several
top-level conjuncts, but a subexpression repeated inside one conjunct moves
too: GeneratePredicate takes the conjunct-wise path whenever the conjuncts have
any common subexpression. For example, with whole-stage codegen off and ANSI
mode on, `SELECT a FROM t WHERE b < 0 AND (y + 1) * (y + 1) > 0` failed with
ARITHMETIC_OVERFLOW on a row with `b = 1` and `y = 2147483647`, and now drops
the row at `b < 0`. Consider describing it as a subexpression common to the
top-level conjuncts, whether several of them share it or one of them repeats it.
**Verification:**
- **Inspection:** The 4.4 migration-guide entry names subexpressions
repeated within one conjunct alongside those shared by several conjuncts, and
its other statements still match the generator's behavior once the other
repairs are applied.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/codegen/GeneratePredicate.scala:
##########
@@ -38,8 +38,49 @@ object GeneratePredicate extends CodeGenerator[Expression,
BasePredicate] {
val ctx = newCodeGenContext()
// Do sub-expression elimination for predicates.
- val eval = ctx.generateExpressions(Seq(predicate),
useSubexprElimination).head
- val evalSubexpr = ctx.subexprFunctionsCode
+ val conjuncts = splitConjunctivePredicates(predicate)
+ // With a subexpression common to several top-level conjuncts, or within
one of them, the
+ // conjuncts are generated one by one, and the subexpression is evaluated
just before the
+ // first conjunct that references it, not before the whole condition, so
that a FALSE
+ // conjunct before them stops the evaluation, as `And.eval` and the
whole-stage codegen of a
+ // filter do (SPARK-60332). The conjuncts are evaluated like `And.eval`: a
FALSE one stops
+ // the evaluation, a NULL one does not, and the condition is TRUE only if
all of them are.
+ // Otherwise the condition is generated as a whole, as before.
+ val conjunctCodes = if (useSubexprElimination && conjuncts.length > 1) {
+ ctx.generateExpressionsWithSubexprCodes(conjuncts)
+ } else {
+ None
+ }
+ val evalCode = conjunctCodes match {
+ case Some(codes) =>
+ val nullConjunct = ctx.freshName("nullConjunct")
+ val checks = codes.map { case (subexprs, ev) =>
+ s"""
+ |$subexprs
+ |${ev.code}
+ |if (${ev.isNull}) {
+ | $nullConjunct = true;
+ |} else if (!${ev.value}) {
+ | return false;
+ |}
+ """.stripMargin
+ }
+ s"""
+ |boolean $nullConjunct = false;
+ |${checks.mkString("\n")}
Review Comment:
**Non-blocking (P2):** Every conjunct's precompute calls, code and
null/FALSE check are concatenated into eval(). Each conjunct's own code is
still bounded by reduceCodeSize, and each conjunct's calls are split
separately, but nothing bounds the total, whereas on the base branch the
condition was one And tree whose large subtrees reduceCodeSize moved into their
own methods. So a predicate with many top-level conjuncts and any shared
subexpression, even a cheap one such as a struct field read twice, gets an
eval() that grows linearly with the conjunct count when it is evaluated outside
whole-stage codegen: a filter over a wide schema, a non-codegen join, or a
filter over local data. Past HotSpot's 8000-byte limit the method is not
JIT-compiled, and the predicate runs interpreted on every row; past 64KB the
class does not compile, and every Predicate.create logs the error and falls
back to InterpretedPredicate. Results stay correct, and the conjunct counts at
which these limits are
crossed were not measured.
**Recommended change:** Group consecutive conjunct blocks (precompute calls,
conjunct code and check) into private boolean methods once their code exceeds
spark.sql.codegen.methodSplitThreshold. Each method returns false at a FALSE
conjunct and true otherwise. The NULL-conjunct flag becomes a mutable field
reset at the start of eval. eval calls the methods in order and returns false
as soon as one does. A predicate that fits within the threshold keeps the
single inline block.
**Why this works:** The existing split helper already groups code into
methods by the split threshold and nests them through inner classes. Feeding it
boolean-returning blocks, folded so that a false result returns from eval,
bounds every generated method as on the base branch, while keeping the conjunct
order, the per-conjunct placement and AND's three-valued result.
**Scope:** Split the conjunct-wise eval body into threshold-sized methods
without changing evaluation order or results.
**Risks:** The NULL-conjunct flag becomes per-instance mutable state of the
generated predicate, so it must be reset at the start of every evaluation.
**Constraints:** Split methods must receive only the input row as an
argument, never mutable state.
**Success:** For a predicate with hundreds of conjuncts and a shared
subexpression, every generated method stays within the split threshold and the
class compiles. Results and the per-conjunct placement of shared subexpressions
are unchanged.
**Verification:**
- **Regression:** A generated predicate with several hundred conjuncts plus
one shared non-leaf subexpression compiles, keeps every generated method below
the HotSpot huge-method limit, and matches interpreted evaluation on rows where
some conjunct is FALSE, rows where some conjunct is NULL, and rows where all
are TRUE.
- **Behavior:** Small predicates still generate a single inline block, and a
FALSE conjunct inside a split method still stops evaluation before any later
conjunct's precompute.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/codegen/CodegenSubexpressionEliminationSuite.scala:
##########
@@ -56,18 +56,81 @@ class CodegenSubexpressionEliminationSuite extends
SparkFunSuite {
}
}
+ test("SPARK-60332: GeneratePredicate evaluates a common subexpression of
later conjuncts " +
+ "after the conjuncts before it") {
+ // `Not(leaf)` is common to the last two conjuncts, and evaluated just
before the first of
+ // them, so the first conjunct decides whether `leaf` is evaluated at all:
a FALSE one stops
+ // the evaluation, while a TRUE or a NULL one does not, as `And.eval` does.
+ Seq(true, false).foreach { useSubexprElimination =>
+ Seq((false, false, false), (true, true, true), (null, true,
false)).foreach {
+ case (first, leafEvaluated, result) =>
+ // Distinct `id`s, so that the first conjunct and the leaf are not
the same expression.
+ val leaf = ExprWithEvaluatedState(id = 1)
+ val cond = And(And(ExprWithEvaluatedState(first, isNullable = first
== null),
+ Not(leaf)), Not(leaf))
+ val instance = GeneratePredicate.generate(cond,
useSubexprElimination)
+ instance.initialize(0)
+ withClue(s"useSubexprElimination = $useSubexprElimination, first =
$first: ") {
+ assert(instance.eval(null) === result)
+ assert(leaf.evaluated === leafEvaluated)
+ }
+ }
+ }
+ }
+
+ test("SPARK-60332: GeneratePredicate evaluates nested common subexpressions
in the order of " +
+ "the conjuncts that first reference them") {
+ // `Not(leaf)` is first referenced by the second conjunct and
`Or(Not(leaf), leaf2)`, which
+ // contains it, by the third. Each is evaluated just before its conjunct,
the inner one first,
+ // so the outer one reads the evaluated inner one, not its default. A
FALSE first conjunct
+ // evaluates neither.
+ Seq((false, false, false), (true, true, true)).foreach { case (first,
leafEvaluated, result) =>
+ val leaf = ExprWithEvaluatedState(id = 1)
+ val leaf2 = ExprWithEvaluatedState(id = 2)
+ val inner = Not(leaf)
+ val outer = Or(inner, leaf2)
+ val cond = And(And(And(ExprWithEvaluatedState(first), inner), And(inner,
outer)), outer)
+ val instance = GeneratePredicate.generate(cond, useSubexprElimination =
true)
+ instance.initialize(0)
+ withClue(s"first = $first: ") {
+ assert(instance.eval(null) === result)
+ assert(leaf.evaluated === leafEvaluated)
+ // `Or(TRUE, leaf2)` never evaluates `leaf2`.
+ assert(!leaf2.evaluated)
+ }
+ }
+ }
+
+ test("SPARK-60332: GeneratePredicate splits the evaluation of many common
subexpressions " +
+ "into functions") {
+ // The calls of the functions of 100 common subexpressions before the
conjunct that first
+ // references them exceed the length at which they are split into
functions of their own.
+ Seq((false, false, false), (true, true, true)).foreach {
+ case (first, leavesEvaluated, result) =>
+ val leaves = (1 to 100).map(i => ExprWithEvaluatedState(id = i))
+ val common = leaves.map(leaf => Not(leaf): Expression).reduce(And)
Review Comment:
**Nit (P3):** GeneratePredicate.create flattens every nested AND, so
`And(And(first, common), common)` becomes 201 conjuncts and each `Not(leaf_i)`
is first referenced by its own conjunct. Every conjunct then gets a single
call, splitExpressions inlines a single block, and the multi-block split this
test is named for never runs: the assertions would still pass if that split
were broken or removed. Reaching it needs many common subexpressions first
referenced by one conjunct, for example `And(And(first, Not(Not(common))),
common)`, where the second conjunct holds every `Not(leaf_i)` and the flattened
conjuncts after it share them. The same flattening affects the nested test's
comment above: its conjuncts are `[first, inner, inner, outer, outer]`, so
`outer` is first referenced by the fourth conjunct, not the third.
See **Shared repair plan 1** in the review body.
##########
sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/expressions/codegen/CodegenSubexpressionEliminationSuite.scala:
##########
@@ -56,18 +56,81 @@ class CodegenSubexpressionEliminationSuite extends
SparkFunSuite {
}
}
+ test("SPARK-60332: GeneratePredicate evaluates a common subexpression of
later conjuncts " +
+ "after the conjuncts before it") {
+ // `Not(leaf)` is common to the last two conjuncts, and evaluated just
before the first of
+ // them, so the first conjunct decides whether `leaf` is evaluated at all:
a FALSE one stops
+ // the evaluation, while a TRUE or a NULL one does not, as `And.eval` does.
+ Seq(true, false).foreach { useSubexprElimination =>
+ Seq((false, false, false), (true, true, true), (null, true,
false)).foreach {
+ case (first, leafEvaluated, result) =>
+ // Distinct `id`s, so that the first conjunct and the leaf are not
the same expression.
+ val leaf = ExprWithEvaluatedState(id = 1)
+ val cond = And(And(ExprWithEvaluatedState(first, isNullable = first
== null),
+ Not(leaf)), Not(leaf))
+ val instance = GeneratePredicate.generate(cond,
useSubexprElimination)
+ instance.initialize(0)
+ withClue(s"useSubexprElimination = $useSubexprElimination, first =
$first: ") {
+ assert(instance.eval(null) === result)
+ assert(leaf.evaluated === leafEvaluated)
Review Comment:
**Nit (P3):** This flag records only whether `leaf` ran, and the
expectations are the same with elimination on and off, so the test passes
whether the conjuncts reuse the shared state or recompute `Not(leaf)` at each
occurrence. None of the new tests has a common subexpression whose first
occurrence is in an IF or CASE WHEN branch of an earlier conjunct, or a
semantically equal but structurally different copy, so a call placed after a
branch that reads its state, which then sees the previous row's value, would
also go unnoticed. Counting evaluations per row, and adding such a branch-first
case evaluated over rows with different values, would give both properties a
failure signal.
See **Shared repair plan 1** in the review body.
--
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]