cloud-fan commented on code in PR #59573:
URL: https://github.com/apache/spark/pull/59573#discussion_r4309696444


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -148,6 +148,52 @@ trait GeneratePredicateHelper extends PredicateHelper {
       nonNullAttrExprIds)
   }
 
+  /**
+   * Returns a fresh name for the flag that records a conjunct evaluated to 
NULL, or None if the
+   * row is dropped at the first NULL conjunct (the legacy behavior) or none 
of `conjuncts`, bound
+   * with the nullability they are generated with, can be NULL.
+   *
+   * A row is kept only when the whole condition is TRUE, so both FALSE and 
NULL drop it. Only
+   * a FALSE conjunct decides the result of the AND on its own, though: after 
a NULL one, the
+   * remaining conjuncts must still be evaluated, as in `And.eval`, so that an 
error they raise
+   * on the row is raised (SPARK-60315). The flag drops the row once all of 
them are evaluated.
+   */
+  protected def nullConjunctFlag(
+      ctx: CodegenContext,
+      conjuncts: Seq[Expression]): Option[String] = {
+    if (conf.legacyFilterShortCircuitOnNullConjunct || 
!conjuncts.exists(_.nullable)) {
+      None
+    } else {
+      Some(ctx.freshName("nullConjunct"))
+    }
+  }
+
+  /**
+   * Generates the check that drops the row when the conjunct evaluated into 
`ev` is FALSE, or
+   * when it is NULL and `nullFlag` is None. With `nullFlag`, a NULL conjunct 
sets the flag.
+   */
+  protected def conjunctCheck(ev: ExprCode, nullable: Boolean, nullFlag: 
Option[String]): String = {
+    (nullable, nullFlag) match {
+      case (false, _) => s"if (!${ev.value}) continue;"
+      case (true, None) => s"if (${ev.isNull} || !${ev.value}) continue;"
+      case (true, Some(flag)) =>
+        s"if (${ev.isNull}) { $flag = true; } else if (!${ev.value}) continue;"

Review Comment:
   **Non-blocking (P2):** With the flag, a NULL conjunct no longer ends the 
row's evaluation. The IsNotNull checks from `notNullPreds`, however, are still 
emitted lazily, in both `generatePredicateCode` and the CSE branch of 
`doConsume`: just before the first conjunct that references their column, or 
after all conjuncts. With whole-stage codegen off, `And` evaluates the 
condition in order, and the `isnotnull(...)` filters that 
`InferFiltersFromConstraints` prepends come first.
   
   Example, in ANSI mode, on a row `(a, b, y, x) = (NULL, 1, 2147483647, NULL)` 
that reaches the filter:
   
   ```sql
   SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0 AND x > 0
   ```
   
   The optimizer prepends `isnotnull(y)` and `isnotnull(x)`. With codegen off, 
evaluation stops at `isnotnull(x)` = FALSE and the row is dropped. With this 
PR, codegen sets the flag on the NULL conjunct, passes the `isnotnull(y)` check 
emitted before `y + 1 > 0`, evaluates `y + 1`, and fails with 
`ARITHMETIC_OVERFLOW`, because `isnotnull(x)` is only checked before `x > 0`. 
Before the PR, both modes dropped the row without an error. A user-written 
guard placed first behaves the same way: in `WHERE x IS NOT NULL AND NOT (a > 0 
AND b > 0) AND y + 1 > 0`, no other conjunct references `x`, so `isnotnull(x)` 
is emitted after all of them. The aggregate `FILTER` clause goes through 
`generatePredicateCode` and has the same problem.
   
   So with the default config, some queries now fail only with whole-stage 
codegen on. That is the inverse of SPARK-60315, and it contradicts the 
migration guide's statement that this is what Spark "already did with 
whole-stage codegen disabled". The new tests miss it because `NOT (a > 0 AND b 
> 0)` infers no `IsNotNull`, and no later conjunct references a column that is 
NULL on the overflowing row.
   
   This extends an [existing 
discussion](https://github.com/apache/spark/pull/59573#issuecomment-6233128726).
   
   **Recommended change:** In both FilterExec predicate codegen paths, 
generatePredicateCode (which the aggregate FILTER clause also uses) and the CSE 
branch of FilterExec.doConsume, when a NULL-conjunct flag is generated, emit 
before each conjunct that follows the first nullable conjunct, and before that 
conjunct's column loads or CSE precomputes, every not-yet-emitted IsNotNull 
check on a plain column that precedes it in the condition's conjunct order. 
Keep each conjunct's position from splitConjunctivePredicates when the 
condition is partitioned into IsNotNull checks and other conjuncts. Add 
whole-stage codegen filter regression tests for an inferred IsNotNull, a 
user-written IsNotNull written first, and a user-written IsNotNull written 
after the overflowing conjunct, each on a column that is NULL on the row where 
that conjunct overflows.
   
   **Why this works:** With codegen off, the condition is evaluated in its 
conjunct order, and the physical condition keeps that order: 
InferFiltersFromConstraints puts the inferred isnotnull(...) filters first, and 
a user-written IsNotNull stays where it was written. So a FALSE plain-column 
isnotnull drops the row before any conjunct that follows it, but not before a 
conjunct that comes earlier. Once a nullable conjunct can fall through, 
whole-stage codegen must not reach a later conjunct before the plain-column 
IsNotNull checks that precede it in that order, and must not run a check before 
a conjunct that precedes the check. Emitting the pending preceding checks just 
before each conjunct that follows the first nullable one meets both rules: a 
FALSE check placed earlier in the condition drops the row first, and a check 
placed after the conjunct still runs after it, so the conjunct's error is 
raised in both modes. Plain-column checks are never NULL and cannot throw, so 
running them e
 arlier changes no result. Checks already emitted, complex IsNotNull(expr) 
checks, and code with no flag keep their current placement.
   
   **Scope:** When a NULL-conjunct flag is in play, emit each pending 
plain-column IsNotNull check just before the first later conjunct that it 
precedes in the condition, in both lockstep FilterExec predicate codegen paths, 
and add regression tests for the codegen-only failure and for a check written 
after the failing conjunct.
   
   **Compatibility:** Outside the NULL-flag case, IsNotNull checks keep their 
lazy placement just before the first conjunct that references them; codegen-off 
evaluation is unchanged; a NULL-conjunct row with no FALSE IsNotNull ahead of a 
raising later conjunct in the condition still raises that conjunct's error, as 
the PR intends; and a plain-column IsNotNull written after that conjunct still 
runs after it.
   
   **Risks:** Moving the checks earlier loads their columns on rows that a 
later FALSE conjunct would have rejected first. The cost is limited to 
plain-column null checks and applies only when a flag is generated. The change 
must not move any complex IsNotNull(expr), whose inner expression can throw. 
The two lockstep paths both need each conjunct's condition position. If only 
one path carries it, the IsNotNull checks are reordered in only one branch, and 
the non-CSE and CSE paths disagree for the same filter.
   
   **Constraints:** The non-CSE and CSE paths must make the same change, 
because their comments require them to stay in lockstep. With 
spark.sql.legacy.filterShortCircuitOnNullConjunct set to true, or when no 
conjunct can be NULL after binding, the generated predicate code must stay 
exactly as the PR emits it today. Every IsNotNull check must still run before 
any CSE precompute or conjunct that references its column under tightened 
nullability. The new placement must not run a plain-column IsNotNull check 
before a conjunct that precedes it in the condition; the existing placement 
just before the first conjunct that references its column is unchanged.
   
   **Exclusions:**
   - Complex IsNotNull(expr) checks stay where they are, so one that is FALSE 
on a non-null input can still be reached after a raising conjunct; this 
narrower residual is not repaired here.
   - The older divergence where codegen checks a user-written IsNotNull earlier 
than its position in the condition, and therefore skips a raising conjunct that 
codegen off evaluates, is left unchanged.
   
   **Success:** With whole-stage codegen on and the legacy flag off, `SELECT a 
FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0 AND x > 0` drops the row 
(NULL, 1, 2147483647, NULL) without an error, as with codegen off. The same 
holds for `WHERE x IS NOT NULL AND NOT (a > 0 AND b > 0) AND y + 1 > 0` and for 
the same guard in an aggregate FILTER clause. `SELECT a FROM t WHERE NOT (a > 0 
AND b > 0) AND y + 1 > 0 AND x IS NOT NULL` still fails with 
ARITHMETIC_OVERFLOW on the row (NULL, 1, 2147483647, NULL) with whole-stage 
codegen on and off, because the user-written check follows the overflowing 
conjunct. The existing SPARK-60315 error-versus-skip matrix and generated-code 
assertions still hold, and the generated code is unchanged when no conjunct can 
be NULL or when the legacy flag is set.
   
   **Verification:**
   - **Regression:** With whole-stage codegen on and off and the legacy flag 
off, a filter whose first conjunct is NULL on a row, whose later conjunct 
overflows on that row, and whose plain-column IsNotNull (inferred from a later 
comparison, or written first by the user) is FALSE on that row drops the row 
without an error in both the non-CSE and the filterExec CSE paths.
   - **Regression:** With whole-stage codegen on and off and the legacy flag 
off, a filter whose first conjunct is NULL on a row, whose later conjunct 
overflows on that row, and whose user-written plain-column IsNotNull follows 
the overflowing conjunct and is FALSE on that row still fails with the overflow 
error in both the non-CSE and the filterExec CSE paths.
   - **Regression:** An aggregate FILTER clause with a user-written IsNotNull 
guard first, a NULL second conjunct, and an overflowing third conjunct returns 
the same count with whole-stage codegen on as with it off, without an error.
   - **Compatibility:** With the legacy flag set, or when no conjunct can be 
NULL after binding, the generated predicate code matches the current output, 
and the existing error-versus-skip results across whole-stage codegen on/off 
and legacy on/off are unchanged.



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/WholeStageCodegenSuite.scala:
##########
@@ -2814,4 +2815,144 @@ class WholeStageCodegenSuite extends SharedSparkSession
       }
     }
   }
+
+  // A Parquet table, so that the filter is not evaluated by the optimizer on 
a local relation.
+  // On the first row `NOT (a > 0 AND b > 0)` is NULL, and no `isnotnull(a)` 
is inferred from it.
+  // The second row passes every condition of the tests below, except `b < 0`. 
The rows can be
+  // written to different files, so the filter is not pushed down, which would 
skip the first
+  // row's file by its statistics.
+  private def withNullConjunctTable(y: Int)(f: => Unit): Unit = {
+    withTable("t") {
+      sql("CREATE TABLE t USING parquet AS " +
+        s"SELECT * FROM VALUES (NULL, 1, $y), (0, 1, 0) AS v(a, b, y)")
+      f
+    }
+  }
+
+  // Runs `f` with whole-stage codegen on and off and with the legacy behavior 
on and off, and
+  // passes it whether the filter drops a row at its first NULL conjunct.
+  private def withNullConjunctConfs(otherConfs: (String, String)*)(f: Boolean 
=> Unit): Unit = {
+    for (codegen <- Seq(true, false); legacy <- Seq(true, false)) {
+      withSQLConf(otherConfs ++ Seq(
+          SQLConf.PARQUET_FILTER_PUSHDOWN_ENABLED.key -> "false",
+          SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> codegen.toString,
+          SQLConf.LEGACY_FILTER_SHORT_CIRCUIT_ON_NULL_CONJUNCT.key -> 
legacy.toString): _*) {
+        withClue(s"wholeStage=$codegen, legacy=$legacy: ") {
+          f(codegen && legacy)
+        }
+      }
+    }
+  }
+
+  test("SPARK-60315: a filter evaluates the conjuncts after a NULL one") {
+    withNullConjunctTable(Int.MaxValue) {
+      // A common subexpression after the NULL conjunct takes the CSE path of 
FilterExec.
+      val cseQuery = "SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 
0 AND y + 1 > -1"
+      // The CSE path evaluates the common `y + 1` once, the non-CSE path at 
each use. Both
+      // generate the flag that keeps the row's evaluation going after a NULL 
conjunct.
+      def cseQueryCode(filterExecCSE: Boolean): String = {
+        withSQLConf(
+            SQLConf.ANSI_ENABLED.key -> "true",
+            SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
filterExecCSE.toString) {
+          val code = codegenString(sql(cseQuery).queryExecution.executedPlan)
+          assert(code.contains("nullConjunct"))
+          code
+        }
+      }
+      assert("addExact".r.findAllIn(cseQueryCode(filterExecCSE = true)).length 
<
+        "addExact".r.findAllIn(cseQueryCode(filterExecCSE = false)).length)
+
+      Seq("true", "false").foreach { filterExecCSE =>
+        Seq(
+          "SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y + 1 > 0",
+          cseQuery
+        ).foreach { query =>
+          withNullConjunctConfs(
+              SQLConf.ANSI_ENABLED.key -> "true",
+              SQLConf.SUBEXPRESSION_ELIMINATION_FILTER_EXEC_ENABLED.key -> 
filterExecCSE) {
+            skipsAtNull =>
+              if (skipsAtNull) {
+                checkAnswer(sql(query), Row(0))
+              } else {
+                checkError(
+                  exception = intercept[SparkArithmeticException] {
+                    sql(query).collect()
+                  },
+                  condition = "ARITHMETIC_OVERFLOW",
+                  parameters = Map(
+                    "message" -> "overflow",
+                    "alternative" -> " Use 'try_add' to tolerate overflow and 
return NULL instead.",
+                    "config" -> toSQLConf(SQLConf.ANSI_ENABLED.key)),
+                  context = ExpectedContext(
+                    fragment = "y + 1",
+                    start = query.indexOf("y + 1"),
+                    stop = query.indexOf("y + 1") + 4))
+              }
+          }
+        }
+      }
+    }
+  }
+
+  test("SPARK-60315: a filter evaluates the conjuncts after a NULL one in 
non-ANSI mode") {
+    // Year-month interval arithmetic raises on overflow in every mode.
+    withNullConjunctTable(178956970) {
+      val query = "SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND " +
+        "make_ym_interval(y, 0) + INTERVAL '1' YEAR > INTERVAL '0' YEAR"
+      withNullConjunctConfs(SQLConf.ANSI_ENABLED.key -> "false") { skipsAtNull 
=>
+        if (skipsAtNull) {
+          checkAnswer(sql(query), Row(0))
+        } else {
+          checkError(
+            exception = intercept[SparkArithmeticException] {
+              sql(query).collect()
+            },
+            condition = "INTERVAL_ARITHMETIC_OVERFLOW.WITH_SUGGESTION",
+            parameters = Map("functionName" -> toSQLId("try_add")))
+        }
+      }
+    }
+  }
+
+  test("SPARK-60315: a filter drops a row with a NULL conjunct and stops at a 
FALSE one") {
+    withNullConjunctTable(Int.MaxValue) {
+      withNullConjunctConfs(SQLConf.ANSI_ENABLED.key -> "true") { _ =>
+        // The first row is dropped after its last conjunct is evaluated.
+        checkAnswer(sql("SELECT a FROM t WHERE NOT (a > 0 AND b > 0) AND y > 
-1"), Row(0))

Review Comment:
   **Nit (P3):** This drop assertion, like the FALSE-stop one after it, has no 
common subexpression, so it only exercises `generatePredicateCode`. The only 
query that takes the FilterExec CSE branch is `cseQuery` in the first test, and 
it runs only on the `Int.MaxValue` table. With the legacy config off, it raises 
before the CSE branch's own trailing `if (flag) continue;` 
(`withNullConjunctFlag(predCode + "\n" + leftoverNotNull, nullFlag)` in 
`doConsume`). With it on, no flag is generated. The `nullConjunct` substring 
check only shows that the flag exists. If that branch stopped dropping a row 
whose condition is NULL, the suite would still pass, even though the comments 
require the branch to be kept in lockstep with `generatePredicateCode` by hand. 
To close the gap, run `cseQuery` against `withNullConjunctTable(0)` with 
filterExec CSE on, and check that it returns `Row(0)` in every 
`withNullConjunctConfs` combination.
   
   **Verification:**
   - **Behavior:** With filterExec subexpression elimination on, a filter with 
a NULL first conjunct followed by conjuncts sharing a non-cheap common 
subexpression returns, on non-overflowing data, only the rows whose condition 
is TRUE in every combination of whole-stage codegen and the legacy flag, and 
the default configuration's generated code takes the CSE path.



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