peter-toth commented on code in PR #59486:
URL: https://github.com/apache/spark/pull/59486#discussion_r4358549563
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -2052,6 +2052,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
exceptions of " +
Review Comment:
**Finding 13.** As rendered, this says the answer does not change "with the
exceptions of spark.sql.parquet.storageFilterPushdown.enabled and one more",
which names a conf as the exception. The exceptions meant are the two that
conf's doc lists:
```suggestion
"stage codegen take them. The answer does not change, with the same
exceptions as " +
```
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetStorageFilterSuite.scala:
##########
@@ -1066,6 +1073,41 @@ class ParquetStorageFilterSuite extends QueryTest with
SharedSparkSession
}
}
+ test("a LEFT OUTER JOIN applies a condition on the fact side's join key with
its runtime bloom") {
+ // The fact side of a LEFT OUTER JOIN filters on its join key in a derived
table. The
+ // optimizer does not infer that condition onto the side the bloom is
built from, so the bloom
+ // keeps keys 0 until 20 while the condition keeps only every fourth of
them. With both in the
+ // reader, it excludes more rows than the bloom alone can.
+ withBloomFilterTables {
+ val query =
+ """SELECT bf2.k, f.v
+ |FROM bf2 LEFT JOIN (SELECT * FROM bf1 WHERE k % 4 = 0) f ON f.k =
bf2.k
+ |WHERE bf2.v < 20
+ |""".stripMargin
+ def run(pushdown: Boolean): (SparkPlan, Seq[Row]) = withSQLConf(
+ SQLConf.PARQUET_STORAGE_FILTER_PUSHDOWN_ENABLED.key ->
pushdown.toString,
+ SQLConf.PARQUET_STORAGE_FILTER_PUSHDOWN_KEY_CONDITIONS_ENABLED.key
-> pushdown.toString,
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+ val df = spark.sql(query)
+ (df.queryExecution.executedPlan,
df.collect().toSeq.sortBy(_.getLong(0)))
+ }
+ val (_, plain) = run(pushdown = false)
+ val (plan, rows) = run(pushdown = true)
+ assert(rows == plain && rows.size == 20, s"got $rows, a plain read got
$plain")
+ val offered = collect(plan) { case s: FileSourceScanExec =>
s.storageFilters }.flatten
+ assert(offered.exists(_.isInstanceOf[BloomFilterMightContain]) &&
+ offered.exists(_.exists(_.isInstanceOf[Remainder])) &&
+ offered.exists(_.isInstanceOf[IsNotNull]),
+ s"expected the bloom, k % 4 = 0 and isnotnull(k); got
$offered.\nPlan:\n$plan")
+ val bareNotNull = collect(plan) { case f: FilterExec => f.condition }
+ .flatMap(splitConjunctivePredicates).exists(_.isInstanceOf[IsNotNull])
Review Comment:
**Finding 12.** This passes without the `IsNotNull` exception it checks. The
`collect` takes every `FilterExec` in the plan, and `bf2`'s own Filter is
`isnotnull(v) AND (v < 20)`. With `&& !f.isInstanceOf[IsNotNull]` removed at
`FileSourceStrategy.scala:457`, this test still passes, and only the planner
test fails. Scoped to the Filter that reads the checked column, it fails there
and passes at the head:
```suggestion
// Only the fact side's Filter reads the checked column. bf2's own
Filter holds a bare
// `isnotnull(v)`, which would satisfy this on its own.
val bareNotNull = collect(plan) {
case f: FilterExec
if f.references.exists(_.name ==
FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME) =>
f.condition
}.flatMap(splitConjunctivePredicates).exists(_.isInstanceOf[IsNotNull])
```
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetStorageFilterSuite.scala:
##########
@@ -2249,6 +2291,151 @@ 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")
+ check(plain, rows, rowsExcludedByStorageFilters(plan))
+ }
+
+ test("a conjunct over the key columns narrows the rows the reader keeps") {
+ // The reader keeps exactly the rows the query returns, where the bloom
alone would also keep
+ // the even keys. The small bloom has false positives too, which the
conjunct keeps if odd.
+ readKeysUnder { (k, _) =>
+ And(BloomFilterMightContain(bloomLiteralOf(41L, 42L, 43L, 501L), new
XxHash64(Seq(k))),
+ EqualTo(Remainder(k, Literal(2L)), Literal(1L)))
+ } { (plain, rows, excluded) =>
+ assert(rows == plain, s"got $rows, a plain read got $plain")
+ assert(Seq(41L, 43L, 501L).forall(rows.contains) && rows.forall(_ % 2 ==
1),
+ s"expected the odd keys the bloom holds; got $rows")
+ assert(excluded == 1000L - rows.size, "the reader must keep exactly the
rows it returns")
+ }
+ }
+
+ test("a conjunct over the key columns is evaluated only on the rows the
bloom keeps") {
+ // The two conjuncts share `100 % k`, which throws on k = 0 under ANSI. In
one `And` with the
+ // bloom it would be computed on every row, k = 0 included, and the reader
would give up that
+ // row group. Under an `If` on the bloom it is computed only on the rows
the bloom keeps.
+ withSQLConf(SQLConf.ANSI_ENABLED.key -> "true") {
+ readKeysUnder { (k, _) =>
+ val mod = Remainder(Literal(100L), k)
+ And(And(bloomOn(k), GreaterThanOrEqual(mod, Literal(0L))),
+ LessThanOrEqual(mod, Literal(99L)))
+ } { (plain, rows, excluded) =>
+ assert(rows == Seq(42L) && rows == plain, s"got $rows, a plain read
got $plain")
+ assert(excluded == 999L, s"the reader must not give a row group up; it
excluded $excluded")
+ }
+ }
+ }
+
+ test("every conjunct over the key columns is enforced, not only the first") {
+ // Each conjunct rejects a key the bloom keeps that the other keeps: 3
fails `k % 3 <> 0` and 5
+ // fails `k % 5 <> 0`. The Filter skips both on the rows the reader marks,
so the reader has to
+ // apply them together. Neither is pushed to Parquet, so no statistic
drops those rows first.
+ readKeysUnder { (k, _) =>
+ And(And(BloomFilterMightContain(bloomLiteralOf(3L, 5L, 7L), new
XxHash64(Seq(k))),
+ Not(EqualTo(Remainder(k, Literal(3L)), Literal(0L)))),
+ Not(EqualTo(Remainder(k, Literal(5L)), Literal(0L))))
+ } { (plain, rows, excluded) =>
+ assert(rows == plain, s"got $rows, a plain read got $plain")
+ assert(rows.contains(7L) && !rows.contains(3L) && !rows.contains(5L),
+ s"expected 7 and neither 3 nor 5; got $rows")
+ assert(excluded == 1000L - rows.size, "the reader must keep exactly the
rows it returns")
+ }
+ }
+
+ test("a conjunct over the key columns that throws on a row the bloom keeps
gives the row " +
+ "group up") {
+ // Under ANSI, `100 % k` throws on k = 0, which the bloom keeps, while `v
<> '0'` drops it
+ // first in the Filter. The reader evaluates the key conjunct first, so it
gives that row group
+ // up, and the Filter then decides in plan order, with the same rows as a
plain read.
+ withSQLConf(SQLConf.ANSI_ENABLED.key -> "true") {
+ readKeysUnder { (k, v) =>
+ And(And(BloomFilterMightContain(bloomLiteralOf(0L, 42L), new
XxHash64(Seq(k))),
+ Not(EqualTo(v, Literal("0")))),
GreaterThanOrEqual(Remainder(Literal(100L), k),
+ Literal(0L)))
+ } { (plain, rows, excluded) =>
+ assert(rows == plain, s"got $rows, a plain read got $plain")
+ assert(rows.contains(42L) && !rows.contains(0L), s"expected 42 and not
0; got $rows")
+ assert(excluded < 1000L - rows.size,
Review Comment:
**Finding 4.** This holds whether or not the reader gave anything up:
- The bloom keeps `0` and `42`, so a reader that never sees `100 % k >= 0`
still excludes 998 rows, and `998 < 999`.
- The guard in `readKeysUnder` (`storageFilters.length > 1`, L2358) is met
by the inferred `isnotnull(k)` alone.
With `supportsStorageFilterOnKeys` patched to accept only `IsNotNull`, the
throwing conjunct is never offered, and this test still passes. The suite's
`withLogged(evaluationGivenUp)`, as used at L839, pins the give-up itself.
Wrapping this test's `readKeysUnder` call in it counts 1 at the head and 0 with
that patch:
```scala
val (_, givenUp) = withLogged(evaluationGivenUp) {
readKeysUnder { (k, v) => ... } { (plain, rows, excluded) => ... }
}
assert(givenUp > 0, "the reader must give up the row group holding k =
0")
```
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala:
##########
@@ -345,20 +359,23 @@ object FileSourceStrategy extends Strategy with
PredicateHelper with Logging {
// Offered conjuncts become `storageFilters` on the scan and stay in the
post-scan Filter too.
// This runs here because eligibility depends on the data columns the
reader reads.
val readerDataColumns = readDataColumns ++ generatedMetadataColumns
- val storageFilters = storageFiltersFor(
- normalizedFilters, fsRelation, readDataColumns,
readerDataColumns.toStructType)
// The column the reader marks the rows it checked in, see
// `FileFormat.buildReaderWithStorageFilters`. It is added only where
the scan still returns
// columnar batches with it, as `FileSourceScanExec.supportsColumnar`
decides, so the column
// never changes how the scan runs. Above a row-based scan, the Project
that drops the
// column would copy every surviving row.
- val storageFilterCheckedColumn = Option.when(storageFilters.nonEmpty) {
+ val checkedColumn =
AttributeReference(FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME,
BooleanType)()
- }.filter { checked =>
+ val marksChecked = {
Review Comment:
**Finding 14.** Before this push, the checked column and the
`isTooManyFields` walk ran only when `storageFilters` was non-empty
(`Option.when(storageFilters.nonEmpty) { ... }.filter { ... }`). Now every file
scan the planner sees builds the `AttributeReference` with a fresh `ExprId`,
builds the output `StructType`, and walks its fields, even with the feature off.
Making both `lazy val`s, and having `storageFiltersFor` take `marksChecked:
=> Boolean`, forces them only after a bloom is offered.
`Option.when(storageFilters.nonEmpty && marksChecked)` then short-circuits too.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetStorageFilter.scala:
##########
@@ -142,17 +144,26 @@ 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, onKeys) =
boundExpressions.partition(_.isInstanceOf[BloomFilterMightContain])
Review Comment:
**Finding 9.** `onKeys` keeps the order of the plan's conjuncts. So for
`cond AND isnotnull(k)` the reader evaluates `cond` first, on every key the
bloom keeps, null keys included.
`FilterExec` does the opposite. Before each conjunct, it emits the
`IsNotNull` checks of the columns that conjunct references
(`basicPhysicalOperators.scala:193-209`). So in a plain read `cond` never sees
a null `k`. Null keys pass the bloom whenever its build side has one, which is
why `IsNotNull` is offered.
I ran a probe with `readKeysUnder` and `oddKeysNull = true`. The bloom held
`42` and a null key, and the condition was `AssertNotNull(k) > 0 AND
isnotnull(k)`. That conjunct throws on null, as a UDF over a boxed or string
key can.
- At the head, the rows equal a plain read's (`[42]`), but the reader gives
every row group up, so `excluded = 0`.
- With the change below, the rows are the same and `excluded = 999`. The
suite still passes.
Moving `IsNotNull` first is enough (with `IsNotNull` added to the import):
```suggestion
val (blooms, conditions) =
boundExpressions.partition(_.isInstanceOf[BloomFilterMightContain])
// `IsNotNull` first, as `FilterExec` checks it before the conjuncts
over its column.
val onKeys = conditions.sortBy(e => if (e.isInstanceOf[IsNotNull]) 0
else 1)
```
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileFormat.scala:
##########
@@ -193,9 +193,13 @@ trait FileFormat {
* A format that does not apply storage filters returns `None`, the default,
and the caller then
* builds an ordinary reader. It is an `Option` rather than a call to that
builder, so the two
* builders cannot call each other. `storageFilters` arrive with their
scalar subqueries
- * materialized, bound to `requiredSchema`, while [[supportsStorageFilter]]
is asked about the
- * unbound expression. `storageFilterMetrics` are the ones this format's
[[storageFilterMetrics]]
- * created, which the scan shows in the SQL UI.
+ * materialized, bound to `requiredSchema`, while [[supportsStorageFilter]]
and
Review Comment:
**Finding 10.** Two sentences of this doc no longer hold for the key
conditions:
- L177-178, "A reader must not fail the query for that". A key condition's
fatal error does fail it now, as the new conf's doc says, since
`rethrowIfMustPropagate` rethrows it.
- L183, the `Filter` "evaluates each storage filter as `IF(checked, true,
filter)`". An offered `IsNotNull` stays bare (`FileSourceStrategy.scala:457`).
Both are outside the diff, so here is the text rather than a suggestion:
```scala
* an error on a row a plain scan never evaluates it on. A reader must not
fail the query for
* a non-fatal one. It gives the filter up there and lets the `Filter`
decide.
```
```scala
* of the relation, and the `Filter` evaluates each storage filter as
`IF(checked, true, filter)`,
* except an `IsNotNull`, which it keeps bare.
```
--
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]