peter-toth commented on code in PR #59486:
URL: https://github.com/apache/spark/pull/59486#discussion_r4414454033


##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetStorageFilterSuite.scala:
##########
@@ -2676,6 +2723,171 @@ class ParquetStorageFilterSuite extends QueryTest with 
SharedSparkSession
     }
   }
 
+  test("FileSourceStrategy offers a conjunct over the bloom's key columns 
along with the bloom") {
+    // It adds no column to the read, so it goes to the reader after the 
bloom, an `IsNotNull`
+    // included, which the Filter keeps bare. A conjunct that also references 
another column does
+    // not, nor one over the key with no bloom to go with. Nor any of them 
with its own conf off, or
+    // where the scan cannot mark the rows the reader checked.
+    withTempDir { dir =>
+      val path = writeKeyParquetFile(dir, 0L until 50L, rowGroupSize = 64 * 
1024L)
+      withSQLConf(
+          SQLConf.PARQUET_STORAGE_FILTER_PUSHDOWN_ENABLED.key -> "true",
+          SQLConf.PARQUET_STORAGE_FILTER_PUSHDOWN_KEY_CONDITIONS_ENABLED.key 
-> "true") {
+        val relation = spark.read.parquet(path).queryExecution.optimizedPlan
+        val k = columnOf(relation, "k")
+        val v = columnOf(relation, "v")
+        val bloom = bloomOn(k)
+        val notNull = IsNotNull(k)
+        val onKey = EqualTo(Remainder(k, Literal(4L)), Literal(0L))
+        val onValue = EqualTo(v, Literal("42"))
+        val onBoth = GreaterThan(Add(k, Cast(v, LongType)), Literal(0L))
+        val condition = And(And(And(And(notNull, onKey), bloom), onValue), 
onBoth)
+        def offered(condition: Expression): Seq[Expression] =
+          scanOf(planned(LogicalFilter(condition, relation))).storageFilters
+        val withBloom = offered(condition)
+        assert(withBloom.length == 3 && withBloom.head.semanticEquals(bloom) &&
+          withBloom.tail.exists(_.semanticEquals(notNull)) &&
+          withBloom.tail.exists(_.semanticEquals(onKey)),
+          s"expected the bloom, then $notNull and $onKey; got $withBloom")
+        val filter = planned(LogicalFilter(condition, relation)).collectFirst {
+          case f: FilterExec => f
+        }.get
+        
assert(splitConjunctivePredicates(filter.condition).exists(_.semanticEquals(notNull)),
+          s"the Filter must keep $notNull bare; got ${filter.condition}")
+        assert(offered(And(onKey, onValue)).isEmpty, "nothing is offered 
without a bloom")
+        
withSQLConf(SQLConf.PARQUET_STORAGE_FILTER_PUSHDOWN_KEY_CONDITIONS_ENABLED.key 
-> "false") {
+          assert(offered(condition) == Seq(bloom), "only the bloom with the 
conf off")
+        }
+        withSQLConf(SQLConf.WHOLESTAGE_CODEGEN_ENABLED.key -> "false") {
+          assert(offered(condition) == Seq(bloom), "only the bloom without the 
checked column")
+        }
+        assert(!ParquetStorageFilter.isSupportedFilterOnKeys(
+          EqualTo(k, ScalarSubquery(spark.range(1).queryExecution.analyzed))),
+          "a conjunct holding a subquery is not offered")
+      }
+    }
+  }
+
+  // Reads keys 0 until 1000 under `condition` with the feature off and on, 
and hands `check` the
+  // rows of each and the rows the reader excluded. `condition` takes the key 
and value columns.
+  // With `oddKeysNull`, every odd key is null.
+  private def readKeysUnder(
+      condition: (Attribute, Attribute) => Expression, oddKeysNull: Boolean = 
false)(
+      check: (Seq[Long], Seq[Long], Long) => Unit): Unit = withTempDir { dir =>
+    val keys = (0L until 1000L).map(k => Option.when(!oddKeysNull || k % 2 == 
0)(k))
+    val path = writeKeyParquetFile(dir, keys, rowGroupSize = 1024L)
+    val relation = spark.read.parquet(path).queryExecution.analyzed
+    val filtered =
+      LogicalFilter(condition(columnOf(relation, "k"), columnOf(relation, 
"v")), relation)
+    def run(pushdown: Boolean): (SparkPlan, Seq[Long]) = withSQLConf(
+        SQLConf.PARQUET_STORAGE_FILTER_PUSHDOWN_ENABLED.key -> 
pushdown.toString,
+        SQLConf.PARQUET_STORAGE_FILTER_PUSHDOWN_KEY_CONDITIONS_ENABLED.key -> 
pushdown.toString) {
+      val df = classic.Dataset.ofRows(spark, filtered)
+      (df.queryExecution.executedPlan, 
df.collect().toSeq.map(_.getLong(0)).sorted)
+    }
+    val (_, plain) = run(pushdown = false)
+    val (plan, rows) = run(pushdown = true)
+    assert(scanOf(plan).storageFilters.length > 1,
+      s"the key conjuncts must be offered with the bloom.\nPlan:\n$plan")

Review Comment:
   **Finding 16.** This is met by the `isnotnull(k)` that the optimizer infers 
from any null-intolerant condition on `k`, as noted under finding 4. The 
fallback test now counts its give-up. But two others still pass when the 
conjunct they test never reaches the reader. With `supportsStorageFilterOnKeys` 
patched to accept only `IsNotNull`:
   - `a conjunct over the key columns is evaluated only on the rows the bloom 
keeps` passes. Its bloom holds only 42, so it excludes 999 rows on its own.
   - `an IsNotNull over the key columns is evaluated before the other conjuncts 
over them` passes. `assert_not_null(k) > 0` is never evaluated, so nothing is 
given up.
   
   Asserting that every key-only conjunct of the condition is offered fails 
both under that patch, and all six tests that use the helper pass at the head:
   
   ```suggestion
       val k = columnOf(relation, "k")
       val offered = scanOf(plan).storageFilters
       val onKey = splitConjunctivePredicates(filtered.condition)
         .filter(_.references.forall(_.semanticEquals(k)))
       assert(onKey.forall(c => offered.exists(_.semanticEquals(c))),
         s"every conjunct over the key must be offered; got 
$offered.\nPlan:\n$plan")
   ```
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -2088,6 +2088,21 @@ object SQLConf {
       .booleanConf
       .createWithDefault(false)
 
+  val PARQUET_STORAGE_FILTER_PUSHDOWN_KEY_CONDITIONS_ENABLED =
+    buildConf("spark.sql.parquet.storageFilterPushdown.keyConditions.enabled")
+      .doc(s"If true, and ${PARQUET_STORAGE_FILTER_PUSHDOWN_ENABLED.key} 
applies a bloom filter, " +
+        "the reader also applies the query's other filter conditions that 
reference only the " +
+        "bloom filter's columns, on the rows the bloom filter keeps. They then 
run before the " +
+        "query's conditions on other columns. Only scans that return columnar 
batches with whole-" +
+        "stage codegen take them. The answer does not change, with the same 
exceptions as " +

Review Comment:
   **Finding 20.** `StorageFilterPlanning.checkedColumn` also leaves the 
checked column out when the output with it has too many fields for whole-stage 
codegen (`isTooManyFields`), and then only the bloom is offered. Parquet's 
`supportBatch` has no such limit, so a wide scan gets the bloom but not the key 
conditions. The description names that case, but the doc doesn't:
   
   ```suggestion
           "stage codegen, and few enough fields for it, take them. The answer 
does not change, " +
           "with the same exceptions as " +
   ```
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetStorageFilter.scala:
##########
@@ -149,17 +151,30 @@ object ParquetStorageFilter {
       maxSplicedRowGroupBytes: Long): ParquetStorageFilter = {
     require(boundExpressions.nonEmpty,
       "storage filters must be non-empty; callers with nothing to push must 
not call create")
-    val expr = boundExpressions.reduce(And)
+    // The conjuncts over the blooms' key columns go under an `If` on the 
blooms, so they run only
+    // on the rows the blooms keep. In one `And`, subexpression elimination 
would compute what
+    // they share on every row.
+    val (blooms, conditions) =
+      boundExpressions.partition(_.isInstanceOf[BloomFilterMightContain])
+    // `IsNotNull` first, as `FilterExec` checks it before the conjuncts over 
its column, so a
+    // conjunct that throws on null never sees the null keys the blooms keep.
+    val onKeys = conditions.sortBy(e => if (e.isInstanceOf[IsNotNull]) 0 else 
1)

Review Comment:
   **Finding 15.** The sort I suggested for finding 9 is too broad. 
`FilterExec` hoists only an `IsNotNull` over a column:
   - `splitNotNullPreds` takes `IsNotNull(a)` only for a null-intolerant `a` 
(`basicPhysicalOperators.scala:163`). One over another expression, such as a 
UDF, stays in plan order.
   - It checks one over a null-intolerant expression only before a conjunct 
that contains the expression (`nestedNullChecks`, L370), or else after all the 
others (L388).
   
   This sort moves every `IsNotNull` first. So the reader can evaluate an 
expression the plain plan never evaluates on that row.
   
   I ran this on this head with `readKeysUnder`, under ANSI, with a bloom 
holding 0 and 42 and the condition `k <> 0 AND isnotnull(100 % k)`:
   - At the head, the rows equal a plain read's (`[42]`). But the reader 
computes `100 % 0`, gives up the row group holding `k = 0` (one 
`evaluationGivenUp` logged), and excludes 900 rows instead of 999.
   - With the change below, it gives nothing up and excludes 999. The 
key-condition tests still pass.
   
   A fatal error inside such an `IsNotNull`, for example a UDF that overflows 
the stack, would fail the query instead. The condition that guards it is on the 
same column there, so this falls outside the one exception the new conf's doc 
lists.
   
   ```suggestion
       // An `IsNotNull` over a key column first, as `FilterExec` checks one 
before the conjuncts
       // over its column, so a conjunct that throws on null never sees the 
null keys the blooms
       // keep. One over an expression keeps its place, since `FilterExec` 
checks it only before a
       // conjunct that contains the expression, or after the others.
       val onKeys = conditions.sortBy {
         case IsNotNull(_: BoundReference) => 0
         case _ => 1
       }
   ```
   
   The description's "`IS NOT NULL` goes first among them, as `FilterExec` 
checks it first" would then say "an `IS NOT NULL` on the key".
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -2088,6 +2088,21 @@ object SQLConf {
       .booleanConf
       .createWithDefault(false)
 
+  val PARQUET_STORAGE_FILTER_PUSHDOWN_KEY_CONDITIONS_ENABLED =
+    buildConf("spark.sql.parquet.storageFilterPushdown.keyConditions.enabled")
+      .doc(s"If true, and ${PARQUET_STORAGE_FILTER_PUSHDOWN_ENABLED.key} 
applies a bloom filter, " +
+        "the reader also applies the query's other filter conditions that 
reference only the " +
+        "bloom filter's columns, on the rows the bloom filter keeps. They then 
run before the " +
+        "query's conditions on other columns. Only scans that return columnar 
batches with whole-" +
+        "stage codegen take them. The answer does not change, with the same 
exceptions as " +
+        s"${PARQUET_STORAGE_FILTER_PUSHDOWN_ENABLED.key} and one more. A 
condition that raises a " +
+        "fatal error, such as a regular expression or a UDF overflowing the 
stack, on a row that " +
+        "a condition on another column would have dropped first fails the 
query.")

Review Comment:
   **Finding 17.** A fatal error does more than fail the query. The task's 
`Executor.isFatalError` check hands it to `SparkUncaughtExceptionHandler`, 
which calls `System.exit`. Finding 1's probe saw exactly that: the regex 
overflowed the stack and took the JVM down with the task. Each retry takes down 
the executor it runs on. So the stage aborts only after 
`spark.task.maxFailures` attempts, each costing an executor. That is the case 
worth warning about:
   
   ```suggestion
           "fatal error, such as a regular expression or a UDF overflowing the 
stack, on a row that " +
           "a condition on another column would have dropped first exits the 
executor. The query " +
           "fails once the task's retries have done the same.")
   ```
   
   The description's "If the error is fatal, ... the query fails" would say the 
same.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala:
##########
@@ -168,24 +168,42 @@ object FileSourceStrategy extends Strategy with 
PredicateHelper with Logging {
    *
    * Per conjunct, see [[StorageFilterPlanning.offeredConjuncts]]. The key 
candidates are the
    * projected data columns, and [[FileFormat.supportsStorageFilter]] must 
accept the conjunct.
+   *
+   * Then, where the scan gets the checked column, every other conjunct that 
references only their
+   * key columns is offered after them where 
[[FileFormat.supportsStorageFilterOnKeys]] accepts it.
+   * Without the column, the post-scan `Filter` would evaluate it again on 
every row the reader
+   * returns. Returns the offered conjuncts and the checked column, see
+   * [[StorageFilterPlanning.checkedColumn]], which is built only once a 
conjunct is offered.
    */
   private def storageFiltersFor(
       normalizedFilters: Seq[Expression],
       fsRelation: HadoopFsRelation,
       readDataColumns: Seq[Attribute],
-      readerDataSchema: StructType): Seq[Expression] = {
+      readerDataSchema: StructType,
+      scanOutput: Seq[Attribute]): (Seq[Expression], 
Option[AttributeReference]) = {
     val sparkSession = fsRelation.sparkSession
-    if (!fsRelation.fileFormat.supportsStorageFilterPushdown(sparkSession)) 
return Nil
+    if (!fsRelation.fileFormat.supportsStorageFilterPushdown(sparkSession)) 
return (Nil, None)
     if (fsRelation.schema.fieldNames.exists(
         _.equalsIgnoreCase(FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME))) {
-      return Nil
+      return (Nil, None)
     }
     val resultSchema = StructType(fsRelation.partitionSchema.fields ++ 
readerDataSchema.fields)
-    if (!fsRelation.fileFormat.supportBatch(sparkSession, resultSchema)) 
return Nil
+    if (!fsRelation.fileFormat.supportBatch(sparkSession, resultSchema)) 
return (Nil, None)
 
     val dataAttrs = AttributeSet(readDataColumns)
-    StorageFilterPlanning.offeredConjuncts(
+    val offered = StorageFilterPlanning.offeredConjuncts(
       normalizedFilters, dataAttrs, dataAttrs, 
fsRelation.fileFormat.supportsStorageFilter)
+    val checked = StorageFilterPlanning.checkedColumn(
+      sparkSession.sessionState.conf, offered, scanOutput)
+    if (checked.isEmpty) return (offered, None)
+    // A conjunct over the key columns alone adds no column to the read.
+    val storageKeyAttrs = 
AttributeSet.fromAttributeSets(offered.map(_.references))

Review Comment:
   **Finding 19.** An alternative to the new conf's fatal-error exception, not 
a request to block on. The reader evaluates the key conditions ahead of the 
query's conditions on other columns. Findings 1, 15 and 17 all come from that 
reordering, and so does the exception in the conf's doc. Offering a key 
condition only when the post-scan `Filter` evaluates no conjunct over another 
column before it removes the reordering:
   - The reader then evaluates a key condition only on rows that the plain plan 
evaluates it on too, after the same conditions. The blooms only narrow those 
rows.
   - So an error the reader meets, fatal or not, the plain plan meets on the 
same row. Nothing is lost when the reader gives a row group up, since the query 
fails anyway. The doc's "one more" exception goes, and with it finding 17.
   - An expensive condition no longer runs ahead of a cheaper one written 
before it.
   
   What it gives up are key conditions written after a condition on another 
column. The PR's motivating cases keep theirs, since every condition on the 
fact side is on the key there, in the LEFT OUTER JOIN test and in the benchmark.
   
   I prototyped it on this head. The rule is computed over `normalizedFilters`, 
whose order the post-scan `Filter` keeps. An `IsNotNull` does not count as a 
conjunct over another column, since `FilterExec` checks one only before a 
conjunct that reads its column:
   
   ```scala
       val firstOther = normalizedFilters.indexWhere { e =>
         !e.isInstanceOf[IsNotNull] && e.references.exists(a => 
dataAttrs.contains(a) &&
           !storageKeyAttrs.contains(a))
       }
       val prefix = if (firstOther < 0) normalizedFilters else 
normalizedFilters.take(firstOther)
       val onKeys = ExpressionSet(prefix).toSeq.filter { expr =>
   ```
   
   149 of the suite's 150 tests pass. The one that fails is `a conjunct over 
the key columns that throws on a row the bloom keeps gives the row group up`. 
Its key condition comes after `v <> '0'`, so it is no longer offered, which is 
the point. That test would then assert that instead.
   
   One case needs care. Once a conjunct that can be NULL is generated, 
`FilterExec` checks the `IsNotNull`s written before a later conjunct ahead of 
it, when that conjunct can raise (SPARK-60315). So an `IsNotNull` over another 
column, written before a key condition, can drop a row first there. Counting 
such an `IsNotNull` as a stop when the key condition can raise 
(`ExprUtils.canEvaluateUnconditionally` is false) would close that.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala:
##########
@@ -168,24 +168,42 @@ object FileSourceStrategy extends Strategy with 
PredicateHelper with Logging {
    *
    * Per conjunct, see [[StorageFilterPlanning.offeredConjuncts]]. The key 
candidates are the
    * projected data columns, and [[FileFormat.supportsStorageFilter]] must 
accept the conjunct.
+   *
+   * Then, where the scan gets the checked column, every other conjunct that 
references only their
+   * key columns is offered after them where 
[[FileFormat.supportsStorageFilterOnKeys]] accepts it.
+   * Without the column, the post-scan `Filter` would evaluate it again on 
every row the reader
+   * returns. Returns the offered conjuncts and the checked column, see
+   * [[StorageFilterPlanning.checkedColumn]], which is built only once a 
conjunct is offered.
    */
   private def storageFiltersFor(
       normalizedFilters: Seq[Expression],
       fsRelation: HadoopFsRelation,
       readDataColumns: Seq[Attribute],
-      readerDataSchema: StructType): Seq[Expression] = {
+      readerDataSchema: StructType,
+      scanOutput: Seq[Attribute]): (Seq[Expression], 
Option[AttributeReference]) = {
     val sparkSession = fsRelation.sparkSession
-    if (!fsRelation.fileFormat.supportsStorageFilterPushdown(sparkSession)) 
return Nil
+    if (!fsRelation.fileFormat.supportsStorageFilterPushdown(sparkSession)) 
return (Nil, None)
     if (fsRelation.schema.fieldNames.exists(
         _.equalsIgnoreCase(FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME))) {
-      return Nil
+      return (Nil, None)
     }
     val resultSchema = StructType(fsRelation.partitionSchema.fields ++ 
readerDataSchema.fields)
-    if (!fsRelation.fileFormat.supportBatch(sparkSession, resultSchema)) 
return Nil
+    if (!fsRelation.fileFormat.supportBatch(sparkSession, resultSchema)) 
return (Nil, None)
 
     val dataAttrs = AttributeSet(readDataColumns)
-    StorageFilterPlanning.offeredConjuncts(
+    val offered = StorageFilterPlanning.offeredConjuncts(
       normalizedFilters, dataAttrs, dataAttrs, 
fsRelation.fileFormat.supportsStorageFilter)
+    val checked = StorageFilterPlanning.checkedColumn(
+      sparkSession.sessionState.conf, offered, scanOutput)
+    if (checked.isEmpty) return (offered, None)
+    // A conjunct over the key columns alone adds no column to the read.
+    val storageKeyAttrs = 
AttributeSet.fromAttributeSets(offered.map(_.references))
+    val onKeys = ExpressionSet(normalizedFilters).toSeq.filter { expr =>
+      val refs = expr.references
+      refs.nonEmpty && refs.subsetOf(storageKeyAttrs) && 
!offered.contains(expr) &&
+        fsRelation.fileFormat.supportsStorageFilterOnKeys(sparkSession, expr)
+    }

Review Comment:
   **Finding 18.** This repeats the per-conjunct filter of 
`StorageFilterPlanning.offeredConjuncts` (`StorageFilterPlanning.scala:43`), 
minus its `expr.deterministic` check. That check is the one whose reason is 
"the post-scan `Filter` takes the reader's word on the rows it checked". It 
holds here only because `apply` passes `filters.filter(_.deterministic)`. This 
also builds a second `ExpressionSet` of `normalizedFilters`, and it excludes 
the blooms with a structural `Seq.contains`. The rebase note says the PR now 
uses `StorageFilterPlanning` instead of its own copies, and this is the one 
copy left.
   
   The helper can take the blooms' key columns as its key candidates. Passing 
`dataAttrs` as its read columns keeps its "nothing if they cover every read 
column" check a no-op here, since the blooms already don't cover them. The 
whole suite passes with this change (150 tests):
   
   ```suggestion
       val offeredSet = ExpressionSet(offered)
       val onKeys = StorageFilterPlanning.offeredConjuncts(normalizedFilters, 
storageKeyAttrs,
         dataAttrs, expr => !offeredSet.contains(expr) &&
           fsRelation.fileFormat.supportsStorageFilterOnKeys(sparkSession, 
expr))
   ```
   



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