dongjoon-hyun commented on code in PR #59269:
URL: https://github.com/apache/spark/pull/59269#discussion_r4210240482
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/LateMaterializationParquetRecordReader.java:
##########
@@ -595,6 +634,8 @@ protected boolean loadNextRowGroup() throws IOException {
m.rowsExcludedWithinRowGroup().add(baselineRows - finalRowCount);
}
+ // The rows phase 2 read are exactly the survivors unless the filter was
given up.
+ rowsChecked = !filterGivenUp;
Review Comment:
**A row group that gave up only splicing is marked checked, but its key
values then come from a second read through the offset index, not from the
values the filter evaluated.** Splicing alone is given up when the survivor
buffer passes `maxSplicedRowGroupBytes` while the ranges still fit (L857-864),
or when buffering a survivor throws (L843-855). `abandonSplicing()` clears
`spliceCurrentRowGroup`, so phase 2 reads `requestedColumns`, key columns
included, over the survivor ranges (L592, L604, L641), and this line still
marks every row true. If the offset index of a key column is wrong, or a
row-range bug shifts the rows, phase 2 emits rows whose re-read keys the bloom
rejects, and `Or(checked, bloom)` keeps them. Before this PR the post-scan
Filter dropped them, and only a key/value mispairing could get through. That is
the new caveat in the conf doc and at L189-192. It is also why
`FileFormat.scala:184` ("evaluated on the row's own values") and
`FileSourceStrategy.scala:161` ("t
he plan keeps the exact check") no longer hold for this case.
Could this be `rowsChecked = spliceCurrentRowGroup;` instead?
- A spliced row group emits exactly the key values the predicate evaluated.
L811 evaluates the scratch row, L842 copies the same values into the
accumulators, and `nextBatch` swaps them into the key slots (L401, L414). So
skipping the bloom there cannot change any row's outcome, even with a wrong
index.
- `spliceCurrentRowGroup` is false after every `abandonSplicing()` (L579,
L616, L853, L860) and whenever the row group cannot be narrowed (L563), and
true implies that the filter was kept.
- The cost is one bloom evaluation per survivor, only in row groups that
already re-read and re-decode their key columns.
The "or return rows the filter rejects" caveat could then go instead of
being documented, and the expectation at `ParquetStorageFilterSuite.scala:2006`
would flip to false. @sunchao listed this as an accepted behavior change; this
would remove it instead.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala:
##########
@@ -194,6 +200,10 @@ object FileSourceStrategy extends Strategy with
PredicateHelper with Logging {
outputDataSchema: StructType): Seq[Expression] = {
val sparkSession = fsRelation.sparkSession
if (!fsRelation.fileFormat.supportsStorageFilterPushdown(sparkSession))
return Nil
+ if (fsRelation.schema.fieldNames.exists(
Review Comment:
**A file that physically holds a column with this name is not covered by the
guard, and the late-materialization reader then reads that column.** The check
looks at the relation schema only, so the column can still be in the files,
e.g. under `spark.read.schema("k BIGINT, v STRING")`, a catalog schema, or a
schema inferred from another file with `mergeSchema` off. Then:
- `ParquetReadSupport.clipParquetGroupFields` matches the planner's field to
the file's column by name, case-insensitively by default, and keeps the file's
physical type (`ParquetReadSupport.scala:460-475` and `:262-265`). So
`checkColumn` does not treat the column as missing.
- `suppliesOwnVector`
(`LateMaterializationParquetRecordReader.java:652-659`) answers true only for
key slots while splicing, so `installRowGroup` builds a column reader for the
checked slot. The column is also in `nonKey` (L479-483), which phase 2 reads
(L592). The plain path for a declined file reads it the same way.
- If the column is not BOOLEAN, e.g. INT64,
`ParquetVectorUpdaterFactory.getUpdater` throws
`SchemaColumnConvertNotSupportedException`, and the task fails with
`FAILED_READ_FILE.PARQUET_COLUMN_DATA_TYPE_MISMATCH`. The same query succeeds
today, since the base never requests this column.
- If it is BOOLEAN, the chunk is read and decoded into super's vector for
nothing. A file that has none of the projected non-key columns is also no
longer declined, because this column now counts as one.
So the reader javadoc's "the file's values never reach it"
(`LateMaterializationParquetRecordReader.java:106`) holds for the emitted
batch, but not for the read. Could the reader answer `suppliesOwnVector` true
for the checked slot (a primitive boolean, as that hook requires) and leave its
path out of `nonKey`, so the file's column is never decoded? @sunchao's comment
on the conf doc covers the parquet-mr side of the same name collision.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala:
##########
@@ -350,23 +360,36 @@ object FileSourceStrategy extends Strategy with
PredicateHelper with Logging {
}
}
- val outputDataSchema = (readDataColumns ++
generatedMetadataColumns).toStructType
-
// Offered conjuncts become `storageFilters` on the scan and stay in the
post-scan Filter too.
- // This runs here because eligibility depends on `outputDataSchema`.
- val storageFilters =
- storageFiltersFor(normalizedFilters, fsRelation, readDataColumns,
outputDataSchema)
+ // This runs here because eligibility depends on the data columns the
reader reads.
+ val storageFilters = storageFiltersFor(normalizedFilters, fsRelation,
readDataColumns,
+ (readDataColumns ++ generatedMetadataColumns).toStructType)
+ // The column the reader marks the rows it checked in, which the
post-scan Filter reads. See
+ // `FileFormat.buildReaderWithStorageFilters`. It is left out where it
alone would push the
+ // scan past the whole-stage codegen field limit, which would cost the
scan its columnar
+ // output. The post-scan Filter then evaluates the offered conjuncts on
every row.
+ val storageFilterCheckedColumn = Option.when(storageFilters.nonEmpty) {
+ AttributeReference(FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME,
BooleanType)()
+ }.filterNot { checked =>
Review Comment:
**Scans that read rows anyway keep the column, and the top `ProjectExec` it
forces copies every surviving row.** This rule mirrors only the
`isTooManyFields` term of `FileSourceScanExec.supportsColumnar`
(`DataSourceScanExec.scala:757-764`), not `wholeStageEnabled`. So a scan
already past `spark.sql.codegen.maxFields`, or one planned with whole-stage
codegen off, keeps the column. The new tests assert both (the `2 -> true` case,
and the e2e row arm). Then:
- `FilterExec` outputs one more attribute, so `projects ==
withFilter.output` at L460 never holds. `RemoveRedundantProjects` cannot drop
the `ProjectExec` either, since it compares exprIds
(`RemoveRedundantProjects.scala:111` and `:116`). Before this PR that Project
was never built, or it was removed, because `checkNullability` (L98 there)
tolerates the `isnotnull(k)` tightening.
- Neither node is whole-stage codegen'd there
(`WholeStageCodegenExec.scala:929-933`, or `:989` with codegen off), so
`ProjectEvaluatorFactory` runs an `UnsafeProjection` per surviving row (L36-38
there). That is a second full-row copy after the scan's own, to save one
`xxhash64` plus the bloom probes on checked rows, and nothing on unchecked ones.
Example: `SELECT f.*, d.name FROM fact f JOIN dim d ON f.k = d.k WHERE
d.region = 'X'` with a runtime bloom on an unpartitioned 150-column `fact`. In
the other direction, with whole-stage codegen off and the output at exactly
`maxFields`, the column is dropped although no columnar output is at stake.
Could the column be left out of scans that read rows anyway, at least the
ones already past `maxFields`, which are wide by definition? For example, the
decision could be `conf.wholeStageEnabled && !isTooManyFields(conf, (output :+
checked).toStructType)`.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala:
##########
@@ -350,23 +360,36 @@ object FileSourceStrategy extends Strategy with
PredicateHelper with Logging {
}
}
- val outputDataSchema = (readDataColumns ++
generatedMetadataColumns).toStructType
-
// Offered conjuncts become `storageFilters` on the scan and stay in the
post-scan Filter too.
- // This runs here because eligibility depends on `outputDataSchema`.
- val storageFilters =
- storageFiltersFor(normalizedFilters, fsRelation, readDataColumns,
outputDataSchema)
+ // This runs here because eligibility depends on the data columns the
reader reads.
+ val storageFilters = storageFiltersFor(normalizedFilters, fsRelation,
readDataColumns,
+ (readDataColumns ++ generatedMetadataColumns).toStructType)
+ // The column the reader marks the rows it checked in, which the
post-scan Filter reads. See
+ // `FileFormat.buildReaderWithStorageFilters`. It is left out where it
alone would push the
+ // scan past the whole-stage codegen field limit, which would cost the
scan its columnar
+ // output. The post-scan Filter then evaluates the offered conjuncts on
every row.
+ val storageFilterCheckedColumn = Option.when(storageFilters.nonEmpty) {
Review Comment:
**Where no row can be marked true, the column and the `Or` only add work.**
`rowsChecked` stays false or null for every row when:
- a row group has no offset index. `canNarrowRowGroup` is then false
(`LateMaterializationParquetRecordReader.java:561-563`), so phase 1 gives up at
the first survivor (L830-835). That covers files written by pyarrow's
`write_table` with its defaults, or by parquet-mr before 1.11, as the comment
at L555-557 there notes;
- `parquet.filter.columnindex.enabled` is false;
- the reader declines the file, or parquet-mr reads the scan.
Each row then pays the nullable-boolean load and the `Or` branches before
the same bloom evaluation as before, plus a fill per batch. That is a couple of
ns next to tens for the bloom, so it is small, but it buys nothing there.
@sunchao counted the per-conjunct branch as a non-blocking cost; the point here
is the scans where it can never pay off. The `columnindex` case is known at
planning time from the scan's Hadoop conf, so the planner could skip the column
there. Could the conf doc also say that files without a page index get no
benefit from the skip?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala:
##########
@@ -158,24 +158,30 @@ object FileSourceStrategy extends Strategy with
PredicateHelper with Logging {
* filters do, rather than the spelling the query used.
*
* They stay in the post-scan `Filter` as well, the way a pushed data filter
does. The reader is
- * offered them, not obliged to honor them, so the plan keeps the exact
check. What it costs is
- * evaluating the conjunct a second time for the rows the scan emits. What
it buys is a reader
- * free to give up on a file it cannot read in part, or on a stretch of rows
whose survivors cost
- * too much to keep track of, without the answer depending on it.
+ * offered them, not obliged to honor them, so the plan keeps the exact
check. What it buys is a
+ * reader free to give up on a file it cannot read in part, or on a stretch
of rows whose
+ * survivors cost too much to keep track of, without the answer depending on
it. The `Filter`
+ * skips the check on the rows the reader marks as checked, in a column the
scan adds for that
+ * (see [[FileFormat.buildReaderWithStorageFilters]]). So the `Filter`
evaluates a conjunct only
Review Comment:
**The contract is now restated in about nine places, and some copies
contradict the field-limit case.**
- Here: "So the `Filter` evaluates a conjunct only on the rows the reader
did not check." At exactly `spark.sql.codegen.maxFields`, the scan still gets
the filter but no column (L371-379), so the `Filter` evaluates it on every row.
The new test asserts that (`ParquetStorageFilterSuite.scala:2249-2272`), and
L367-370 and `FileFormat.scala:191-193` say so.
- `ParquetStorageFilter.scala:211-212` says the planner adds the column "to
every scan it offers storage filters to", and that a schema without it is one
"a test can build". The same case gives `create` a production schema without it
(`checkedColumnIndex == -1`), so tightening that into a `require` would fail
those queries. `create`'s doc (L172-174 there) also credits `storageFiltersFor`
with pre-checking every `require`, but the new `BooleanType` one holds only
because `apply` builds the attribute as `BooleanType`.
- The doc of the public `spark.sql.parquet.storageFilterPushdown.enabled`
(`SQLConf.scala:1985-1987`) now names `spark.sql.codegen.maxFields`, which is
`.internal()` and so absent from the published configuration page.
Could the contract live in `FileFormat.buildReaderWithStorageFilters` only,
with the other places pointing to it, and the conf doc describe the field-limit
case without naming an internal conf?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetStorageFilterSuite.scala:
##########
@@ -2010,6 +2182,188 @@ class ParquetStorageFilterSuite extends QueryTest with
SharedSparkSession
}
}
+ test("FileSourceStrategy skips an offered conjunct on the rows the reader
marks as checked") {
+ // The scan gets a column for the reader to mark the rows it checked in,
and the post-scan
+ // Filter evaluates the offered conjunct as `checked OR conjunct`. A
conjunct that is not
+ // offered stays as it is, and the column goes no further than the Filter.
The bloom names `K`
+ // where the relation says `k`, as the query can spell it, so the Filter's
conjunct and the one
+ // offered to the scan differ in name.
+ withTempDir { dir =>
+ val path = writeSingleParquetFile(dir,
+ spark.range(0, 50).selectExpr("id AS k", "CAST(id AS STRING) AS v"),
+ rowGroupSize = 64 * 1024L)
+ withSQLConf(SQLConf.PARQUET_STORAGE_FILTER_PUSHDOWN_ENABLED.key ->
"true") {
+ val relation = spark.read.parquet(path).queryExecution.optimizedPlan
+ def column(name: String): Attribute =
+ relation.output.find(_.name == name).getOrElse(fail(s"no $name in
the relation output"))
+ val bloom = BloomFilterMightContain(bloomLiteralOf(42L),
+ new XxHash64(Seq(column("k").withName("K"))))
+ val notOffered = EqualTo(column("v"), Literal("42"))
+ val logical = LogicalFilter(And(bloom, notOffered), relation)
+ val physical = FileSourceStrategy(logical).headOption
+ .getOrElse(fail(s"FileSourceStrategy did not plan $logical"))
+ val scan = scanOf(physical)
+ val checkedName = FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME
+ val checked = scan.output.find(_.name == checkedName)
+ .getOrElse(fail(s"the scan must output the checked column; got
${scan.output}"))
+ assert(scan.requiredSchema.fieldNames.toSeq == Seq("k", "v",
checkedName),
+ s"the reader must be asked for the column too; got
${scan.requiredSchema}")
+ val conjuncts =
+ physical.collect { case f: FilterExec =>
splitConjunctivePredicates(f.condition) }.flatten
+ assert(conjuncts.exists(_.semanticEquals(Or(checked, bloom))),
Review Comment:
**The rewrite is asserted only for a literal bloom, and the marks only
through `readChecked` and one columnar read, so the paths the feature runs on
in practice go unchecked.**
- This test uses `bloomLiteralOf(42L)`. A real `InjectRuntimeFilter` bloom
carries a `ScalarSubquery`, and the tests that plan one (around L1031, L1936
and L3235-3364) assert only `countBloomFiltersInPostScanFilters(plan) >= 1` and
join results. The former also counts a bloom inside the `Or`, and a wrong mark
cannot change the latter. If the `ExpressionSet` match stopped matching such a
conjunct, the feature would quietly do nothing and the suite would still pass.
- The row-mode late-materialization reader (a scan past `maxFields`, or
whole-stage codegen off, with the vectorized reader on) is never checked for
marks. The e2e row arm turns the vectorized reader off, so parquet-mr reads
that scan.
- `withStorageFilters` (L876-882) adds storage filters but no checked
column, so the 18 end-to-end reader tests that use it run without marks.
- `readChecked` reads row groups of 20 or 100 rows with capacity 4096, so
marks across the batches of one row group are not covered.
Could the e2e test also run a real runtime-bloom join, assert the rewritten
conjunct, and read the marks off the scan, both columnar and in row mode with
the vectorized reader on?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala:
##########
@@ -350,23 +360,36 @@ object FileSourceStrategy extends Strategy with
PredicateHelper with Logging {
}
}
- val outputDataSchema = (readDataColumns ++
generatedMetadataColumns).toStructType
-
// Offered conjuncts become `storageFilters` on the scan and stay in the
post-scan Filter too.
- // This runs here because eligibility depends on `outputDataSchema`.
- val storageFilters =
- storageFiltersFor(normalizedFilters, fsRelation, readDataColumns,
outputDataSchema)
+ // This runs here because eligibility depends on the data columns the
reader reads.
+ val storageFilters = storageFiltersFor(normalizedFilters, fsRelation,
readDataColumns,
+ (readDataColumns ++ generatedMetadataColumns).toStructType)
+ // The column the reader marks the rows it checked in, which the
post-scan Filter reads. See
+ // `FileFormat.buildReaderWithStorageFilters`. It is left out where it
alone would push the
+ // scan past the whole-stage codegen field limit, which would cost the
scan its columnar
+ // output. The post-scan Filter then evaluates the offered conjuncts on
every row.
+ val storageFilterCheckedColumn = Option.when(storageFilters.nonEmpty) {
+ AttributeReference(FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME,
BooleanType)()
+ }.filterNot { checked =>
+ val conf = fsRelation.sparkSession.sessionState.conf
+ val output =
Review Comment:
**The scan's output groups are now listed by hand in three places, and this
check reads as a double negative.** This list repeats `outputAttributes`
(L389-392) without the column, and the `_metadata` Project (L443-444) lists the
groups once more. If a later scan-internal column is added to
`outputDataColumns` only, this check undercounts by one. The checked column can
then push the scan past `maxFields` and cost it its columnar output, which is
the case this check exists to prevent. Leaving it out of the `_metadata`
Project breaks the `Filter`'s binding. The `storageFiltersFor` parameter
`outputDataSchema` (L200, without the column) also now means something
different from the local of the same name (L391).
Could this be one shared base list and a named boolean, e.g.
`Option.when(storageFilters.nonEmpty && !checkedAloneCrossesLimit)(...)`, with
the `_metadata` Project built from `scan.output` minus the flattened metadata
attributes?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/FileSourceStrategy.scala:
##########
@@ -418,11 +441,19 @@ object FileSourceStrategy extends Strategy with
PredicateHelper with Logging {
Alias(KnownNotNull(CreateStruct(structColumns.toImmutableArraySeq)),
FileFormat.METADATA_NAME)(exprId = metadataStruct.exprId)
execution.ProjectExec(
- readDataColumns ++ partitionColumns :+ metadataAlias, scan)
+ readDataColumns ++ storageFilterCheckedColumn ++ partitionColumns :+
metadataAlias, scan)
}.getOrElse(scan)
+ // The offered conjuncts are skipped on the rows the reader marks as
checked. They are
+ // matched by `ExpressionSet`, since `storageFilters` carry the
relation's column names.
+ val postScanFilters = storageFilterCheckedColumn match {
+ case Some(checked) =>
+ val offered = ExpressionSet(storageFilters)
+ afterScanFilters.toSeq.map(f => if (offered.contains(f)) Or(checked,
f) else f)
Review Comment:
**With two offered blooms on one key, whole-stage `FilterExec` CSE computes
the shared `xxhash64` before the first `Or`, so checked rows still pay the
hash.** With `spark.sql.subexpressionElimination.skipForShortcutExpr` at its
default, false, `EquivalentExpressions` recurses into both children of an `Or`
(`EquivalentExpressions.scala:160-173`). So an `xxhash64(k)` under two
`Or(checked, might_contain(...))` conjuncts is a common subexpression, and
`FilterExec.doConsume` emits its state before the first conjunct that
references it (`basicPhysicalOperators.scala:347-371`), ahead of the
short-circuit. Two blooms on one key are possible because
`InjectRuntimeFilter.hasBloomFilter` (L399-409 there) compares keys with
`fastEquals`, which includes the qualifier, e.g. `SELECT * FROM (SELECT f.*
FROM fact f JOIN d1 ON f.k = d1.k WHERE d1.x = 1) t JOIN d2 ON t.k = d2.k WHERE
d2.y = 2`. Results are not affected; only the probes are skipped.
This is narrow, but `If(checked, Literal(true), f)` would avoid it. `If` is
a `ConditionalExpression`, so CSE only looks at its predicate and at what both
branches share (`conditionalExpressions.scala:66-72`). It also filters a null
mark exactly as false, which is what @sunchao's P2 on this line asks for.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetStorageFilterSuite.scala:
##########
@@ -2010,6 +2182,188 @@ class ParquetStorageFilterSuite extends QueryTest with
SharedSparkSession
}
}
+ test("FileSourceStrategy skips an offered conjunct on the rows the reader
marks as checked") {
+ // The scan gets a column for the reader to mark the rows it checked in,
and the post-scan
+ // Filter evaluates the offered conjunct as `checked OR conjunct`. A
conjunct that is not
+ // offered stays as it is, and the column goes no further than the Filter.
The bloom names `K`
+ // where the relation says `k`, as the query can spell it, so the Filter's
conjunct and the one
+ // offered to the scan differ in name.
+ withTempDir { dir =>
+ val path = writeSingleParquetFile(dir,
+ spark.range(0, 50).selectExpr("id AS k", "CAST(id AS STRING) AS v"),
+ rowGroupSize = 64 * 1024L)
+ withSQLConf(SQLConf.PARQUET_STORAGE_FILTER_PUSHDOWN_ENABLED.key ->
"true") {
+ val relation = spark.read.parquet(path).queryExecution.optimizedPlan
+ def column(name: String): Attribute =
+ relation.output.find(_.name == name).getOrElse(fail(s"no $name in
the relation output"))
+ val bloom = BloomFilterMightContain(bloomLiteralOf(42L),
+ new XxHash64(Seq(column("k").withName("K"))))
+ val notOffered = EqualTo(column("v"), Literal("42"))
+ val logical = LogicalFilter(And(bloom, notOffered), relation)
+ val physical = FileSourceStrategy(logical).headOption
+ .getOrElse(fail(s"FileSourceStrategy did not plan $logical"))
+ val scan = scanOf(physical)
+ val checkedName = FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME
+ val checked = scan.output.find(_.name == checkedName)
+ .getOrElse(fail(s"the scan must output the checked column; got
${scan.output}"))
+ assert(scan.requiredSchema.fieldNames.toSeq == Seq("k", "v",
checkedName),
+ s"the reader must be asked for the column too; got
${scan.requiredSchema}")
+ val conjuncts =
+ physical.collect { case f: FilterExec =>
splitConjunctivePredicates(f.condition) }.flatten
+ assert(conjuncts.exists(_.semanticEquals(Or(checked, bloom))),
+ s"the offered conjunct must be skipped on checked rows; got
$conjuncts")
+ assert(conjuncts.exists(_.semanticEquals(notOffered)),
+ s"a conjunct not offered must stay as it is; got $conjuncts")
+ assert(!physical.output.exists(_.name == checkedName),
+ s"the column must not leave the scan's Filter; got
${physical.output}")
+ }
+ }
+ }
+
+ test("FileSourceStrategy offers nothing to a relation with a column named
like the checked one") {
+ // The scan adds a column of that name for the reader to fill, so a column
of the relation's own
+ // must not be taken for it, in any case. With nothing offered, the scan
gets no such column.
+ withTempDir { dir =>
+ val name =
FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME.toUpperCase(Locale.ROOT)
+ val path = writeSingleParquetFile(dir,
+ spark.range(0, 50).selectExpr("id AS k", "CAST(id AS STRING) AS v",
s"true AS $name"),
+ rowGroupSize = 64 * 1024L)
+ withSQLConf(SQLConf.PARQUET_STORAGE_FILTER_PUSHDOWN_ENABLED.key ->
"true") {
+ val relation = spark.read.parquet(path).queryExecution.optimizedPlan
+ val k = relation.output.find(_.name == "k").getOrElse(fail("no k in
the relation output"))
+ val logical = LogicalFilter(
+ BloomFilterMightContain(bloomLiteralOf(42L), new XxHash64(Seq(k))),
relation)
+ val physical = FileSourceStrategy(logical).headOption
+ .getOrElse(fail(s"FileSourceStrategy did not plan $logical"))
+ val scan = scanOf(physical)
+ assert(scan.storageFilters.isEmpty, s"nothing must be offered; got
${scan.storageFilters}")
+ assert(!scan.requiredSchema.fieldNames.contains(
+ FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME),
+ s"nor a checked column added; got ${scan.requiredSchema}")
+ }
+ }
+ }
+
+ test("FileSourceStrategy leaves the checked column out where it alone
crosses the field limit") {
+ // One more output column past `spark.sql.codegen.maxFields` would turn
the scan's columnar
+ // output off. So the scan still gets the filter but no checked column,
and the Filter evaluates
+ // the bloom on every row. A scan already past the limit reads rows
anyway, and gets the column.
+ // The limit counts the whole output, so a partition column counts too.
+ withTempDir { dir =>
+ val path = new File(dir, "parted").getAbsolutePath
+ spark.range(0, 50).selectExpr("id AS k", "CAST(id AS STRING) AS v", "id
% 3 AS p")
+ .repartition(1).write.partitionBy("p").parquet(path)
+ withSQLConf(SQLConf.PARQUET_STORAGE_FILTER_PUSHDOWN_ENABLED.key ->
"true") {
+ val relation = spark.read.parquet(path).queryExecution.optimizedPlan
+ val k = relation.output.find(_.name == "k").getOrElse(fail("no k in
the relation output"))
+ val logical = LogicalFilter(
+ BloomFilterMightContain(bloomLiteralOf(42L), new XxHash64(Seq(k))),
relation)
+ // `k`, `v` and the partition column `p` are 3 fields, and 4 with the
checked column.
+ Seq(4 -> true, 3 -> false, 2 -> true).foreach { case (maxFields,
withColumn) =>
+ withSQLConf(SQLConf.WHOLESTAGE_MAX_NUM_FIELDS.key ->
maxFields.toString) {
+ val scan = scanOf(FileSourceStrategy(logical).headOption
+ .getOrElse(fail(s"FileSourceStrategy did not plan $logical")))
+ assert(scan.storageFilters.size == 1,
+ s"a limit of $maxFields must still offer the bloom; got
${scan.storageFilters}")
+ assert(scan.requiredSchema.fieldNames.contains(
+ FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME) == withColumn,
+ s"a limit of $maxFields must ${if (withColumn) "add" else "leave
out"} the checked " +
+ s"column; got ${scan.requiredSchema}")
+ assert(scan.supportsColumnar == (maxFields >= 3),
+ s"the checked column must not cost a limit of $maxFields the
scan's columnar " +
+ s"output; got ${scan.output}")
+ }
+ }
+ }
+ }
+ }
+
+ Seq(false, true).foreach { withMetadata =>
+ test("the post-scan Filter still filters the rows the reader gave up on " +
+ s"(_metadata selected = $withMetadata)") {
+ // A runtime bloom is redundant with its join, which would drop such
rows anyway, so this
+ // filters on a bloom alone, against a plain read. The scan's metrics
show whether the filter
+ // was applied. A cap of 39 bytes gives the filter up in every row group
with a survivor, and
+ // the post-scan Filter must then filter those rows itself. So must it
when parquet-mr reads,
+ // which does not know the checked column and reads it as null. A
partition column puts the
+ // checked column between columns of the scan's output, and with
`_metadata` selected, the
+ // Project that builds it sits below the Filter and must keep the
checked column.
+ withTempDir { dir =>
+ val path = new File(dir, "parted").getAbsolutePath
+ rows400.toDF("k", "v").withColumn("p", col("k") % 3)
+ .repartition(1).write.partitionBy("p")
+ .option(ParquetOutputFormat.BLOCK_SIZE, 256L).parquet(path)
+ val columns = Seq(col("k"), col("v"), col("p")) ++ (if (withMetadata) {
+ Seq(col("_metadata.row_index").as("ri"),
col("_metadata.file_name").as("file"))
+ } else {
+ Nil
+ })
+ val relation = spark.read.parquet(path).select(columns:
_*).queryExecution.analyzed
+ val k = relation.output.find(_.name == "k").getOrElse(fail("no k in
the relation output"))
+ val bloom = BloomFilterMightContain(bloomLiteralOf(42L), new
XxHash64(Seq(k)))
+ // Plans under `planning` and reads under `reading`, and returns the
plan with its rows.
+ def run(
+ enabled: Boolean,
+ planning: Map[String, String] = Map.empty,
+ reading: Map[String, String] = Map.empty): (SparkPlan, Seq[Row]) =
{
+ val pushdown = SQLConf.PARQUET_STORAGE_FILTER_PUSHDOWN_ENABLED.key
-> enabled.toString
+ val df = withSQLConf((planning + pushdown).toSeq: _*) {
+ val df = classic.Dataset.ofRows(spark, LogicalFilter(bloom,
relation))
+ df.queryExecution.executedPlan
+ df
+ }
+ val rows = withSQLConf(reading.toSeq:
_*)(df.collect().toSeq.sortBy(_.getLong(0)))
+ (df.queryExecution.executedPlan, rows)
+ }
+ def excludedWithinRowGroups(plan: SparkPlan): Long = collect(plan) {
Review Comment:
**This is `rowsExcludedByStorageFilters` (L969-974) restricted to one
metric, and the new planner tests repeat the same setup.** The local copy is
needed only because the shared helper also sums `ROWS_EXCLUDED_BY_ROW_GROUP`,
and this test now sums the metrics both ways (L2331 and L2349, against L2360).
In the new tests:
- the attribute lookup `relation.output.find(_.name ==
...).getOrElse(fail(...))` appears at L2198, L2233, L2258 and L2302;
- the bloom `BloomFilterMightContain(bloomLiteralOf(42L), new
XxHash64(Seq(...)))` appears at L2199, L2235, L2260 and L2303;
- `FileSourceStrategy(logical).headOption.getOrElse(fail(...))` appears at
L2203, L2236 and L2264, on top of the existing copies;
- the fixture at L2192-2194 is what `writeKeyParquetFile(dir, 0L until 50L,
rowGroupSize = 64 * 1024L)` writes.
Could `rowsExcludedByStorageFilters` take the metric keys, and small helpers
cover the lookup, the bloom and the planning?
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetStorageFilterSuite.scala:
##########
@@ -1924,9 +1943,162 @@ class ParquetStorageFilterSuite extends QueryTest with
SharedSparkSession
}
}
+ // The `(k, v)` schema with the column the planner adds for the reader to
mark the rows it checked
+ // in. A filter created over it fills that column.
+ private def checkedSchema(valueType: DataType = StringType): StructType =
+ kvSchema(valueType =
valueType).add(FileFormat.STORAGE_FILTER_CHECKED_COLUMN_NAME, BooleanType)
+
+ // Reads `path` with `filter`, which was created over `schema`, and returns
each row's key, as
+ // `keyOf` reads it, with what the checked column says for the row. It goes
through the Hadoop
+ // initialize, since the test-only one projects only columns the file has.
+ private def readChecked(
Review Comment:
**`readChecked` and `hadoopSplit` add a third way to build the reader, while
`formatReader` with `drain` already covers what these tests need.** They go
through the Hadoop `initialize` and the test-only constructor, with a conf
holding 7 of the 13 keys that `ParquetFileFormat.setupHadoopConf` sets, and
`readAllWith`'s doc now carves out an exception for them (L2408-2410).
`formatReader` (L173) builds the production reader through
`buildReaderWithStorageFilters` with any required schema, so it can project the
missing checked column too. This test already reads the same file and schema
through it, via `readUnderCap` (L2009-2010). The cap comes from the session
conf, and off-heap from `COLUMN_VECTOR_OFFHEAP_ENABLED` plus `asTask` (as at
L805 and L1795). `OnKey` and the damaged-index retry with `withLogged` already
run through it too (L791, L1749).
As it is, the marks are read under a conf that differs from production (e.g.
`TIMESTAMP_NANOS_TYPES_ENABLED`, the session time zone, the rebase modes), and
the assertion at L2006 is backed by bytes from a different reader setup
(L2008-2012). Could `readChecked` be built on `formatReader` and `drain`?
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetStorageFilter.scala:
##########
@@ -203,12 +208,24 @@ object ParquetStorageFilter {
s"${ParquetFileFormat.ROW_INDEX_TEMPORARY_COLUMN_NAME}, because the
reader writes row " +
"indexes over that column, so what it reads back depends on how its
row group was read")
+ // The column the reader marks the rows it checked in, which the planner
adds to every scan it
+ // offers storage filters to. A schema without it, as a test can build,
gets no marks.
+ val checkedColumnIndex =
Review Comment:
**Could the reader look this slot up itself, as it does the row-index
column?** `checkedColumnIndex` is computed here and carried on the filter only
for `takeOverBatch` (`LateMaterializationParquetRecordReader.java:344`), which
already holds the same schema: `sparkRequestedSchema` comes from
`SPARK_ROW_REQUESTED_SCHEMA`, which is this same `requiredSchema`.
`VectorizedParquetRecordReader` finds the row-index column with
`ParquetRowIndexUtil.createGeneratorIfNeeded(sparkSchema)`, whose
`findRowIndexColumnIndexInSchema` checks the type too. A lookup in the reader
would drop the constructor field, the two `require`s here, and the test block
at `ParquetStorageFilterSuite.scala:545-557`. Neither `require` can fail on a
planner-built plan, since the attribute is always `BooleanType` and storage
filters reference only read data columns. It would also remove a second source
of truth. Today a filter created over `checkedSchema()` and handed to a reader
opened with the test-only `initialize
(path, columns)` throws `ArrayIndexOutOfBoundsException` at
`LateMaterializationParquetRecordReader.java:349`, or overwrites a partition
slot.
--
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]