dongjoon-hyun commented on code in PR #60030:
URL: https://github.com/apache/spark/pull/60030#discussion_r4448632048
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/BatchScanExec.scala:
##########
@@ -46,6 +46,12 @@ import org.apache.spark.util.ArrayImplicits._
* post-scan `Filter` too.
* @param appendsCheckedColumn Whether the readers mark the rows they checked
in a boolean column,
* the last of `output`, see
`StorageFilterPlanning.checkedColumn`.
+ * @param plannedInputPartitions Keeps the input partitions once the node
plans them. The copies
+ * later rules make of the node share it, so
they report the layout
Review Comment:
**1. [Medium] A copy still re-derives the keyed layout from the live
bucketing conf**
The holder shares the input partitions, but each copy still builds its own
`reportedKeyedPartitioning` on first use, and that is gated on the live
`conf.v2BucketingEnabled` (`DataSourceV2ScanExecBase.scala` L103-L106;
`SparkPlan.conf` is the session conf, `SparkPlan.scala` L70-L72). The copies
made after `EnsureRequirements` read it for the first time at execution, so
they can still report a layout other than the one the plan was built for.
With the tables and confs of the second new test, AQE off:
```scala
val df = sql(query) // the nested-DPP query of the second new
test
df.queryExecution.executedPlan // planned with bucketing on: no shuffle
withSQLConf(SQLConf.V2_BUCKETING_ENABLED.key -> "false") {
df.collect()
}
```
- `ReuseExchangeAndSubquery` runs at the end of the preparations
(`QueryExecution.scala` L886). The `purchases` scan it rebuilds, and the
`GroupPartitionsExec` above it, have not computed their partitioning yet.
- At execution the copy sees bucketing off, gets `None` for
`reportedKeyedPartitioning`, and reports `UnknownPartitioning(0)`.
- `checkChildStillMatches` then throws `[INTERNAL_ERROR]
GroupPartitionsExec's child no longer reports the partitioning it was planned
over` (`GroupPartitionsExec.scala` L354-L357).
- With AQE on, `ReuseAdaptiveSubquery` makes the copies inside `collect()`,
after the conf change, and the same happens.
- Without a `GroupPartitionsExec`, `filteredPartitions` skips the
None-padding and the key sort (`PushDownUtils.scala` L339-L341 and L354). The
join then fails with "Can't zip RDDs", or can zip partitions of different keys
when the source prunes nothing.
This is the recipe of the SPARK-59252 test
(`KeyGroupedPartitioningSuite.scala` L2183-L2198), which has no subquery and so
never builds a copy. The mechanism predates this PR, but this `@param` and the
new sentence at `GroupPartitionsExec.scala` L130-L132 now say that the copies
report the planned layout.
Keeping the first node's bucketing decision in the holder, next to the
partitions, would make that true, the same way `StampUnionDecisions` freezes a
`UnionExec` decision under the conf the exchanges were planned against
(`QueryExecution.scala` L849-L851). Storing the `KeyedPartitioning` itself
would be harder, because `doCanonicalize` shares the holder through `copy`, so
the canonicalized node would pick up expressions that were not normalized. I
traced this from the code and did not run it.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/BatchScanExec.scala:
##########
@@ -109,7 +116,7 @@ case class BatchScanExec(
keyGroupedPartitioning.map(_.filter(_.references.subsetOf(outputSet)))
@transient override lazy val inputPartitions: Seq[InputPartition] =
- batch.planInputPartitions().toImmutableArraySeq
+
plannedInputPartitions.getOrPlan(scan)(batch.planInputPartitions().toImmutableArraySeq)
Review Comment:
**2. [Low] The executed node reads partitions that another `Batch` planned**
The holder is also shared with the node that runs, because `executedPlan` is
built from `sparkPlan.clone()` (`QueryExecution.scala` L393). For the default
`ColumnarSupportMode.PARTITION_DEFINED` (`Scan.java` L157-L159),
`DataSourceV2Strategy` already asks the planner's node for `supportsColumnar`
(`DataSourceV2Strategy.scala` L242), which plans the partitions with that
node's `Batch` (`DataSourceV2ScanExecBase.scala` L195-L200). Every node after
it, including the executed one, takes those partitions from the holder, while
its `readerFactory` comes from its own `batch` (L132), whose
`planInputPartitions()` is never called.
Before this PR, a scan without a pushed runtime filter planned and read
through the same `Batch`. Spark mixed `Batch` instances only on the
runtime-filter path (`PushDownUtils.scala` L299). Now it does so for every scan
in the default mode, not only for the copies that later rules make.
- `Scan.toBatch` may be called more than once (`Scan.java` L73-L75), and
nothing in the DSv2 API pairs the partitions with the reader factory of the
same `Batch`, so this breaks no written contract.
- A connector whose `toBatch` returns a new `Batch`, which opens a read
session in `planInputPartitions()` and passes it to `createReaderFactory()`,
would now get a factory without a session, or with another one.
- No connector in this repo depends on that: the file scans, the in-memory
test tables and `StateScan` return `this`, `ColumnarScan` caches its `Batch`,
and the Kafka and Python sources are `UNSUPPORTED`.
Keeping the first node's `Batch` in the holder too, and building
`readerFactory` from it when the `Scan` matches, would keep the partitions and
the factory together. Otherwise a sentence in the description or in the
`Scan.toBatch` Javadoc would tell connector authors.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/BatchScanExec.scala:
##########
@@ -181,3 +188,24 @@ case class BatchScanExec(
s"BatchScan ${table.name()}".trim
}
}
+
+/**
+ * The input partitions of a [[BatchScanExec]], planned once, by the first
node that asks for them.
+ * Each newly built node gets its own instance, and the copies later rules
make of the node share
+ * that instance. Only the nodes over the `Scan` that planned the partitions
use them. A copy given
+ * another `Scan` plans its own.
+ */
+class PlannedInputPartitions {
+ private var planned: Option[(Scan, Seq[InputPartition])] = None
+
+ def getOrPlan(scan: Scan)(plan: => Seq[InputPartition]): Seq[InputPartition]
= synchronized {
+ planned match {
+ case Some((plannedScan, partitions)) if plannedScan eq scan => partitions
+ case Some(_) => plan
Review Comment:
**3. [Low] A copy given another `Scan` drops the sharing for its own copies**
The holder keeps one `(Scan, partitions)` entry, and this branch plans
without storing the result. So:
- after a rule makes `b.copy(scan = s2)`, every later copy of that node,
with the same holder and the same `s2`, plans again from the live `Scan`;
- if the `s2` node asks first, it takes the slot, and the copies of the
original node plan again.
Either way the protection of this PR is off for that chain. For example, an
extension rule that wraps the scan after `EnsureRequirements`, such as
`b.copy(scan = DelegatingScan(b.scan))` from an injected columnar rule,
followed by `ReuseExchangeAndSubquery`, gives a copy that plans after the DPP
build side pruned the shared `Scan`. The join then fails with `Can't zip RDDs
... List(7, 1)` as before.
Nothing in this repo replaces `scan` on a `BatchScanExec` (only the new test
at `KeyGroupedPartitioningSuite.scala` L479 does), so only extensions are
affected. The class comment covers the first copy ("A copy given another `Scan`
plans its own"), but the `@param` at L49-L51 says "The copies later rules make
of the node share it" without that limit.
Keeping one entry per `Scan` instance, for example in a
`java.util.IdentityHashMap`, would make such a chain behave like the original
one. At least the `@param` could say "the copies over the same `Scan`". This
branch also runs the connector's planning under the holder's monitor, which is
not needed, since the result is not kept.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/BatchScanExec.scala:
##########
@@ -55,7 +61,8 @@ case class BatchScanExec(
@transient table: Table,
keyGroupedPartitioning: Option[Seq[Expression]] = None,
storageFilters: Seq[Expression] = Nil,
- appendsCheckedColumn: Boolean = false
+ appendsCheckedColumn: Boolean = false,
+ @transient plannedInputPartitions: PlannedInputPartitions = new
PlannedInputPartitions
Review Comment:
**5. [Low] A 9th case-class field changes the signatures of `BatchScanExec`**
Adding the holder to the first parameter list changes the constructor,
`apply`, `copy` and `unapply`. On master that costs the 13 positional patterns
in 8 test files that this PR widens, a week after SPARK-60465 widened the same
13 from 6 to 8 fields. SPARK-42454 bundled the SPJ parameters to avoid exactly
this.
It matters more for the backports:
- branch-4.2, branch-4.3 and branch-4.x have a 6-field `BatchScanExec` and
no SPARK-60465, so this diff does not apply there.
- A 7th field in 4.2.x or 4.3.x would break plugins compiled against the
released class. Their constructor, `apply` and `copy` calls fail with
`NoSuchMethodError`, and their positional patterns stop compiling.
- MiMa does not flag it, because `org.apache.spark.sql.execution.*` is
excluded (`MimaExcludes.scala` L187). The field count of `BatchScanExec` has
not changed within a maintenance line from 3.3.0 to 4.3.0.
A second parameter list with `otherCopyArgs`, as SPARK-58265 did for
`SubqueryAdaptiveBroadcastExec.broadcastValueProjection`
(`SubqueryAdaptiveBroadcastExec.scala` L41-L45), keeps the extractor and
`productArity`, but not the JVM signatures, since the lists are flattened in
bytecode. Keeping every signature needs a `@transient private var` that
`makeCopy` and `clone` overrides hand over, as `InMemoryRelation` does for
`statsOfPlanToCache` (`InMemoryRelation.scala` L671 and L734-L745). A plain
case-class `copy` would then start a new holder, so the direct test would copy
through a transform instead. That shape may be worth it, at least for the
maintenance branches.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -389,6 +389,95 @@ trait KeyGroupedPartitioningRuntimeFilterTests extends
KeyGroupedPartitioningSui
checkAnswer(df, Seq(Row(1L, 42.0f), Row(1L, 44.0f)))
}
}
+
+ test("SPARK-60752: a DPP subquery's build side does not change the layout of
the scan it " +
+ "shares with the join") {
+ // A DPP subquery that does not reuse a broadcast plans its build side
again, over the same
+ // `Scan` as the join's `purchases` scan. A scalar subquery filter on
`item_id` is a runtime
+ // filter of both, and the subquery runs it first, which prunes the shared
`Scan`. The join's
+ // scan is a copy made after planning, when the scalar subquery is
replaced by a reused one. Had
+ // it planned its partitions again, it would report fewer of them than
`items`, and the join
+ // would fail with "Can't zip RDDs". Of the two suites that run this, only
the Catalyst one
+ // prunes on these predicates.
+ withSQLConf(
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "true",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "false",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_FALLBACK_FILTER_RATIO.key -> "10") {
+ createTable(items, itemsColumns, Array(identity("id")))
+ sql(s"INSERT INTO testcat.ns.$items VALUES " + (1 to 7).map(i =>
+ s"($i, 'x', 10.0, cast('2020-01-01' as timestamp))").mkString(", "))
+ createTable(purchases, purchasesColumns, Array(identity("item_id")))
+ sql(s"INSERT INTO testcat.ns.$purchases VALUES " + (1 to 7).map(i =>
+ s"($i, $i.0, cast('2020-01-01' as timestamp))").mkString(", "))
+
+ def bound(agg: String): String =
+ s"(SELECT $agg(item_id) FROM testcat.ns.$purchases WHERE price > 4.0)"
+ Seq(true, false).foreach { aqe =>
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString) {
+ Seq(s"i.id = ${bound("max")}" -> Seq(7), s"i.id >= ${bound("min")}"
-> (5 to 7)).foreach {
+ case (filter, ids) =>
+ val df = sql(s"SELECT i.id, p.price FROM testcat.ns.$items i " +
+ s"JOIN testcat.ns.$purchases p ON i.id = p.item_id WHERE
$filter")
+ checkAnswer(df, ids.map(i => Row(i.toLong, i.toFloat)))
+ val plan = df.queryExecution.executedPlan
+ assert(collectShuffles(plan).isEmpty,
+ s"aqe=$aqe, $filter: the join should stay
storage-partitioned:\n$plan")
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-60752: a nested DPP filter on a DPP subquery's build side does
not change the " +
Review Comment:
**4. [Medium] branch-4.1 and branch-4.0 fail too, and the affected releases
are not listed**
The description says that branch-4.1 and branch-4.0 are not affected,
because their `BatchScanExec` pads to the partition values in `spjParams`. That
holds only when `EnsureRequirements` pushed those values and the copy still has
partitions. With AQE off, this test's query fails on branch-4.1 in two cases
(line numbers at d4521911899):
- With `spark.sql.sources.v2.bucketing.pushPartValues.enabled=false`, a
public conf that the 4.1 DPP tests also run with, `EnsureRequirements` only
checks `isCompatibleWith` and never calls `populateCommonPartitionInfo`
(`EnsureRequirements.scala` L509-L512 and L662-L666). The copy then reports its
own re-planned keys (`KeyGroupedPartitionedScan.scala` L57), and the join fails
with `Can't zip RDDs with unequal numbers of partitions: List(7, 3)`.
- With the default confs but no `dim` row with `flag = 1`, the build side
prunes every split, and the copy plans no partitions. `groupPartitions` returns
`None` (`DataSourceV2ScanExecBase.scala` L140), so `outputPartitioning` is not
a `KeyGroupedPartitioning` and `inputRDD` skips the padding
(`BatchScanExec.scala` L125-L130 and L139-L142). The query fails with `List(7,
0)` instead of returning no rows.
branch-4.0 behaves the same for row-based scans once
`spark.sql.sources.v2.bucketing.enabled` is on. In the other direction, 4.2.0,
4.2.1 and 4.3.0 (branch-4.2, branch-4.3 and branch-4.x) look affected as
described, since SPARK-55535 removed `spjParams` before 4.2.0 and their copies
plan `inputPartitions` again, but the description does not list them. The
`GroupPartitionsExec` INTERNAL_ERROR form needs SPARK-59289, which only master
and branch-4.x have.
Could the description list the affected versions, so that the backports can
be planned? A third case in this test, with no `dim` row matching, would also
pin the fully pruned build side on master, which this PR already handles. I
traced the branch-4.1 and branch-4.0 paths from the code and did not run them.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -389,6 +389,95 @@ trait KeyGroupedPartitioningRuntimeFilterTests extends
KeyGroupedPartitioningSui
checkAnswer(df, Seq(Row(1L, 42.0f), Row(1L, 44.0f)))
}
}
+
+ test("SPARK-60752: a DPP subquery's build side does not change the layout of
the scan it " +
+ "shares with the join") {
+ // A DPP subquery that does not reuse a broadcast plans its build side
again, over the same
+ // `Scan` as the join's `purchases` scan. A scalar subquery filter on
`item_id` is a runtime
+ // filter of both, and the subquery runs it first, which prunes the shared
`Scan`. The join's
+ // scan is a copy made after planning, when the scalar subquery is
replaced by a reused one. Had
+ // it planned its partitions again, it would report fewer of them than
`items`, and the join
+ // would fail with "Can't zip RDDs". Of the two suites that run this, only
the Catalyst one
+ // prunes on these predicates.
+ withSQLConf(
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "true",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "false",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_FALLBACK_FILTER_RATIO.key -> "10") {
+ createTable(items, itemsColumns, Array(identity("id")))
+ sql(s"INSERT INTO testcat.ns.$items VALUES " + (1 to 7).map(i =>
+ s"($i, 'x', 10.0, cast('2020-01-01' as timestamp))").mkString(", "))
+ createTable(purchases, purchasesColumns, Array(identity("item_id")))
+ sql(s"INSERT INTO testcat.ns.$purchases VALUES " + (1 to 7).map(i =>
+ s"($i, $i.0, cast('2020-01-01' as timestamp))").mkString(", "))
+
+ def bound(agg: String): String =
+ s"(SELECT $agg(item_id) FROM testcat.ns.$purchases WHERE price > 4.0)"
+ Seq(true, false).foreach { aqe =>
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString) {
+ Seq(s"i.id = ${bound("max")}" -> Seq(7), s"i.id >= ${bound("min")}"
-> (5 to 7)).foreach {
+ case (filter, ids) =>
+ val df = sql(s"SELECT i.id, p.price FROM testcat.ns.$items i " +
+ s"JOIN testcat.ns.$purchases p ON i.id = p.item_id WHERE
$filter")
+ checkAnswer(df, ids.map(i => Row(i.toLong, i.toFloat)))
+ val plan = df.queryExecution.executedPlan
+ assert(collectShuffles(plan).isEmpty,
+ s"aqe=$aqe, $filter: the join should stay
storage-partitioned:\n$plan")
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-60752: a nested DPP filter on a DPP subquery's build side does
not change the " +
+ "layout of the scan it shares with the join") {
+ // As above, with a DPP filter from a broadcast dimension as the build
side's own runtime
+ // filter, which every fixture prunes on. Two rows per key give
`purchases` two splits per key,
+ // so a GroupPartitionsExec groups that side and checks the layout of the
copied scan.
+ val dim = "dim"
+ withSQLConf(
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "true",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "false",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_FALLBACK_FILTER_RATIO.key -> "10") {
+ createTable(items, itemsColumns, Array(identity("id")))
+ sql(s"INSERT INTO testcat.ns.$items VALUES " + (1 to 7).map(i =>
+ s"($i, 'x', 10.0, cast('2020-01-01' as timestamp))").mkString(", "))
+ createTable(purchases, purchasesColumns, Array(identity("item_id")))
+ sql(s"INSERT INTO testcat.ns.$purchases VALUES " + (1 to 7).flatMap(i =>
+ Seq.fill(2)(s"($i, $i.0, cast('2020-01-01' as
timestamp))")).mkString(", "))
+ createTable(dim, Array(Column.create("k", LongType),
Column.create("flag", IntegerType)),
+ Array.empty)
+ sql(s"INSERT INTO testcat.ns.$dim VALUES " +
+ (1 to 7).map(i => s"($i, ${if (i >= 5) 1 else 0})").mkString(", "))
+
+ Seq(true, false).foreach { aqe =>
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString) {
+ val df = sql(s"SELECT i.id, p.price FROM testcat.ns.$items i JOIN " +
+ s"(SELECT /*+ BROADCAST(d) */ p.item_id, p.price FROM
testcat.ns.$purchases p " +
+ s"JOIN testcat.ns.$dim d ON p.item_id = d.k WHERE d.flag = 1) p ON
i.id = p.item_id")
+ checkAnswer(df, (5 to 7).flatMap(i => Seq.fill(2)(Row(i.toLong,
i.toFloat))))
+ val plan = df.queryExecution.executedPlan
+ assert(collectShuffles(plan).isEmpty,
+ s"aqe=$aqe: the join should stay storage-partitioned:\n$plan")
+ }
+ }
+ }
+ }
+
+ test("SPARK-60752: copies of a BatchScanExec share the input partitions it
planned") {
Review Comment:
**8. [Nit] The copy test runs in both runtime-filter suites**
This test has no join and no runtime filter. The trait is scoped to "runtime
filtering under a storage-partitioned join, whose outcome depends on how the
scan takes runtime filters" (L125-L128), and both
`KeyGroupedPartitioningRuntimeFilterSuite` and
`KeyGroupedPartitioningCatalystRuntimeFilterSuite` mix it in (L11164 and
L11178), so the test runs twice with the same outcome.
`KeyGroupedPartitioningSuite` (L484) has the same helpers and the same
`clearTables` cleanup, and would run it once.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -389,6 +389,95 @@ trait KeyGroupedPartitioningRuntimeFilterTests extends
KeyGroupedPartitioningSui
checkAnswer(df, Seq(Row(1L, 42.0f), Row(1L, 44.0f)))
}
}
+
+ test("SPARK-60752: a DPP subquery's build side does not change the layout of
the scan it " +
+ "shares with the join") {
+ // A DPP subquery that does not reuse a broadcast plans its build side
again, over the same
+ // `Scan` as the join's `purchases` scan. A scalar subquery filter on
`item_id` is a runtime
+ // filter of both, and the subquery runs it first, which prunes the shared
`Scan`. The join's
+ // scan is a copy made after planning, when the scalar subquery is
replaced by a reused one. Had
+ // it planned its partitions again, it would report fewer of them than
`items`, and the join
+ // would fail with "Can't zip RDDs". Of the two suites that run this, only
the Catalyst one
+ // prunes on these predicates.
+ withSQLConf(
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "true",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "false",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_FALLBACK_FILTER_RATIO.key -> "10") {
+ createTable(items, itemsColumns, Array(identity("id")))
+ sql(s"INSERT INTO testcat.ns.$items VALUES " + (1 to 7).map(i =>
+ s"($i, 'x', 10.0, cast('2020-01-01' as timestamp))").mkString(", "))
+ createTable(purchases, purchasesColumns, Array(identity("item_id")))
+ sql(s"INSERT INTO testcat.ns.$purchases VALUES " + (1 to 7).map(i =>
+ s"($i, $i.0, cast('2020-01-01' as timestamp))").mkString(", "))
+
+ def bound(agg: String): String =
+ s"(SELECT $agg(item_id) FROM testcat.ns.$purchases WHERE price > 4.0)"
+ Seq(true, false).foreach { aqe =>
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString) {
+ Seq(s"i.id = ${bound("max")}" -> Seq(7), s"i.id >= ${bound("min")}"
-> (5 to 7)).foreach {
+ case (filter, ids) =>
+ val df = sql(s"SELECT i.id, p.price FROM testcat.ns.$items i " +
+ s"JOIN testcat.ns.$purchases p ON i.id = p.item_id WHERE
$filter")
+ checkAnswer(df, ids.map(i => Row(i.toLong, i.toFloat)))
+ val plan = df.queryExecution.executedPlan
+ assert(collectShuffles(plan).isEmpty,
+ s"aqe=$aqe, $filter: the join should stay
storage-partitioned:\n$plan")
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-60752: a nested DPP filter on a DPP subquery's build side does
not change the " +
+ "layout of the scan it shares with the join") {
+ // As above, with a DPP filter from a broadcast dimension as the build
side's own runtime
+ // filter, which every fixture prunes on. Two rows per key give
`purchases` two splits per key,
+ // so a GroupPartitionsExec groups that side and checks the layout of the
copied scan.
+ val dim = "dim"
+ withSQLConf(
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "true",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "false",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_FALLBACK_FILTER_RATIO.key -> "10") {
+ createTable(items, itemsColumns, Array(identity("id")))
+ sql(s"INSERT INTO testcat.ns.$items VALUES " + (1 to 7).map(i =>
+ s"($i, 'x', 10.0, cast('2020-01-01' as timestamp))").mkString(", "))
+ createTable(purchases, purchasesColumns, Array(identity("item_id")))
+ sql(s"INSERT INTO testcat.ns.$purchases VALUES " + (1 to 7).flatMap(i =>
+ Seq.fill(2)(s"($i, $i.0, cast('2020-01-01' as
timestamp))")).mkString(", "))
+ createTable(dim, Array(Column.create("k", LongType),
Column.create("flag", IntegerType)),
+ Array.empty)
+ sql(s"INSERT INTO testcat.ns.$dim VALUES " +
+ (1 to 7).map(i => s"($i, ${if (i >= 5) 1 else 0})").mkString(", "))
+
+ Seq(true, false).foreach { aqe =>
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString) {
+ val df = sql(s"SELECT i.id, p.price FROM testcat.ns.$items i JOIN " +
+ s"(SELECT /*+ BROADCAST(d) */ p.item_id, p.price FROM
testcat.ns.$purchases p " +
+ s"JOIN testcat.ns.$dim d ON p.item_id = d.k WHERE d.flag = 1) p ON
i.id = p.item_id")
+ checkAnswer(df, (5 to 7).flatMap(i => Seq.fill(2)(Row(i.toLong,
i.toFloat))))
+ val plan = df.queryExecution.executedPlan
+ assert(collectShuffles(plan).isEmpty,
+ s"aqe=$aqe: the join should stay storage-partitioned:\n$plan")
+ }
+ }
+ }
+ }
+
+ test("SPARK-60752: copies of a BatchScanExec share the input partitions it
planned") {
+ createTable(items, itemsColumns, Array(identity("id")))
+ sql(s"INSERT INTO testcat.ns.$items VALUES " +
+ "(1, 'aa', 40.0, cast('2020-01-01' as timestamp)), " +
+ "(2, 'bb', 10.0, cast('2020-01-01' as timestamp))")
+ def plannedScan(): BatchScanExec =
+ collectScans(sql(s"SELECT * FROM
testcat.ns.$items").queryExecution.executedPlan).head
+ val scan = plannedScan()
+ // A copy, like the ones runtime filter rewrites make, uses the partitions
the node planned.
+ assert(scan.copy(runtimeFilters = Nil).inputPartitions eq
scan.inputPartitions)
+ // A copy over another `Scan` plans its own.
+ assert(!(scan.copy(scan = plannedScan().scan).inputPartitions eq
scan.inputPartitions))
Review Comment:
**6. [Low] This assertion cannot catch an other-`Scan` copy that takes over
the holder**
`toImmutableArraySeq` wraps the array in a new `ArraySeq` on every call, so
this assertion holds with or without the holder. It would also hold if `case
Some(_) => plan` stored its result (`planned = Some((scan, p))`), which would
turn the sharing off for every later copy over the original `Scan`. None of the
three new tests would fail then, since in the first two every copy keeps the
same `Scan`. Repeating the assertion of L477 after this line would catch it:
```scala
assert(scan.copy(runtimeFilters = Nil).inputPartitions eq
scan.inputPartitions)
```
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -389,6 +389,95 @@ trait KeyGroupedPartitioningRuntimeFilterTests extends
KeyGroupedPartitioningSui
checkAnswer(df, Seq(Row(1L, 42.0f), Row(1L, 44.0f)))
}
}
+
+ test("SPARK-60752: a DPP subquery's build side does not change the layout of
the scan it " +
+ "shares with the join") {
+ // A DPP subquery that does not reuse a broadcast plans its build side
again, over the same
+ // `Scan` as the join's `purchases` scan. A scalar subquery filter on
`item_id` is a runtime
+ // filter of both, and the subquery runs it first, which prunes the shared
`Scan`. The join's
+ // scan is a copy made after planning, when the scalar subquery is
replaced by a reused one. Had
+ // it planned its partitions again, it would report fewer of them than
`items`, and the join
+ // would fail with "Can't zip RDDs". Of the two suites that run this, only
the Catalyst one
+ // prunes on these predicates.
+ withSQLConf(
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "true",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "false",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_FALLBACK_FILTER_RATIO.key -> "10") {
+ createTable(items, itemsColumns, Array(identity("id")))
+ sql(s"INSERT INTO testcat.ns.$items VALUES " + (1 to 7).map(i =>
+ s"($i, 'x', 10.0, cast('2020-01-01' as timestamp))").mkString(", "))
+ createTable(purchases, purchasesColumns, Array(identity("item_id")))
+ sql(s"INSERT INTO testcat.ns.$purchases VALUES " + (1 to 7).map(i =>
+ s"($i, $i.0, cast('2020-01-01' as timestamp))").mkString(", "))
+
+ def bound(agg: String): String =
+ s"(SELECT $agg(item_id) FROM testcat.ns.$purchases WHERE price > 4.0)"
+ Seq(true, false).foreach { aqe =>
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString) {
+ Seq(s"i.id = ${bound("max")}" -> Seq(7), s"i.id >= ${bound("min")}"
-> (5 to 7)).foreach {
+ case (filter, ids) =>
+ val df = sql(s"SELECT i.id, p.price FROM testcat.ns.$items i " +
+ s"JOIN testcat.ns.$purchases p ON i.id = p.item_id WHERE
$filter")
+ checkAnswer(df, ids.map(i => Row(i.toLong, i.toFloat)))
+ val plan = df.queryExecution.executedPlan
+ assert(collectShuffles(plan).isEmpty,
+ s"aqe=$aqe, $filter: the join should stay
storage-partitioned:\n$plan")
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-60752: a nested DPP filter on a DPP subquery's build side does
not change the " +
+ "layout of the scan it shares with the join") {
+ // As above, with a DPP filter from a broadcast dimension as the build
side's own runtime
+ // filter, which every fixture prunes on. Two rows per key give
`purchases` two splits per key,
+ // so a GroupPartitionsExec groups that side and checks the layout of the
copied scan.
+ val dim = "dim"
+ withSQLConf(
Review Comment:
**9. [Nit] The two end-to-end tests repeat their setup**
This test and the previous one repeat the four-conf `withSQLConf`, the
`items` table, the `purchases` table with one or two rows per key, the AQE loop
and the no-shuffle check, about 16 lines. A private helper that takes the rows
per key would remove about 10 lines net. The older tests in this trait inline
the same DPP confs too, so this is only a suggestion.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/BatchScanExec.scala:
##########
@@ -181,3 +188,24 @@ case class BatchScanExec(
s"BatchScan ${table.name()}".trim
}
}
+
+/**
+ * The input partitions of a [[BatchScanExec]], planned once, by the first
node that asks for them.
+ * Each newly built node gets its own instance, and the copies later rules
make of the node share
Review Comment:
**12. [Nit] Newly planned nodes over the same `Scan` still plan it again**
As this comment says, each newly built node gets its own holder, so the
nodes planned again over the same `Scan` still call `planInputPartitions()`
once each. Those are the DPP build side (`PlanDynamicPruningFilters.scala`
L60-L61 and L128-L129, where the first plan only checks broadcast reuse and is
dropped), its AQE counterparts (`PlanAdaptiveDynamicPruningFilters.scala` L124
and `InsertAdaptiveSparkPlan.compileSubquery`), and `reOptimize`.
For the first new test, this PR brings the calls on the shared `purchases`
scan from 8 to 5 without AQE and from 10 to 5 with it, and 2 of the remaining 5
come from the rebuilt build side. So "runs less often" in the description is
right, and this is only a note. A holder per `Scan` on the logical relation
would cover these nodes, but it needs `otherCopyArgs`-style plumbing on
`DataSourceV2ScanRelation`, which does not look worth it.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/BatchScanExec.scala:
##########
@@ -181,3 +188,24 @@ case class BatchScanExec(
s"BatchScan ${table.name()}".trim
}
}
+
+/**
Review Comment:
**10. [Nit] The contract of the holder is described twice**
The `@param` at L49-L54 and this comment both say that the copies later
rules make of the node share the holder. Keeping the contract here, and only
the reason (a shared `Scan` that a DPP build side prunes) in the `@param` with
a link, would leave one place to update when the contract changes, for example
for item 3.
##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -389,6 +389,95 @@ trait KeyGroupedPartitioningRuntimeFilterTests extends
KeyGroupedPartitioningSui
checkAnswer(df, Seq(Row(1L, 42.0f), Row(1L, 44.0f)))
}
}
+
+ test("SPARK-60752: a DPP subquery's build side does not change the layout of
the scan it " +
+ "shares with the join") {
+ // A DPP subquery that does not reuse a broadcast plans its build side
again, over the same
+ // `Scan` as the join's `purchases` scan. A scalar subquery filter on
`item_id` is a runtime
+ // filter of both, and the subquery runs it first, which prunes the shared
`Scan`. The join's
+ // scan is a copy made after planning, when the scalar subquery is
replaced by a reused one. Had
+ // it planned its partitions again, it would report fewer of them than
`items`, and the join
+ // would fail with "Can't zip RDDs". Of the two suites that run this, only
the Catalyst one
+ // prunes on these predicates.
+ withSQLConf(
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_ENABLED.key -> "true",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "false",
+ SQLConf.DYNAMIC_PARTITION_PRUNING_FALLBACK_FILTER_RATIO.key -> "10") {
+ createTable(items, itemsColumns, Array(identity("id")))
+ sql(s"INSERT INTO testcat.ns.$items VALUES " + (1 to 7).map(i =>
+ s"($i, 'x', 10.0, cast('2020-01-01' as timestamp))").mkString(", "))
+ createTable(purchases, purchasesColumns, Array(identity("item_id")))
+ sql(s"INSERT INTO testcat.ns.$purchases VALUES " + (1 to 7).map(i =>
+ s"($i, $i.0, cast('2020-01-01' as timestamp))").mkString(", "))
+
+ def bound(agg: String): String =
+ s"(SELECT $agg(item_id) FROM testcat.ns.$purchases WHERE price > 4.0)"
+ Seq(true, false).foreach { aqe =>
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString) {
+ Seq(s"i.id = ${bound("max")}" -> Seq(7), s"i.id >= ${bound("min")}"
-> (5 to 7)).foreach {
Review Comment:
**7. [Low] The `>=` iteration repeats the path of the `=` one**
Both predicates become runtime filters through the same `SCALAR_SUBQUERY`
condition (`DataSourceV2Strategy.scala` L207-L213), the Catalyst fixture prunes
on both through the same interpreted predicate, and the fix does not depend on
the operator. Without the fix, the `=` iteration already fails first. The three
keys that `>=` keeps exercise the padding over several keys, which the existing
tests of this trait already cover.
The test runs in two suites, in two AQE modes and with two predicates, and
`checkAnswer` executes each query twice, so it makes 16 executions. The 8 in
`KeyGroupedPartitioningRuntimeFilterSuite` pass with or without the fix
(L400-L401). Dropping `>=` halves the count.
From reading the code, the AQE-on iteration may also pass without the fix.
There the copy plans its partitions when the final stage is created (through
its `supportsColumnar`), before the subquery runs, so it is the AQE-off
iteration that catches the regression. I did not run it.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -126,9 +126,10 @@ case class GroupPartitionsExec(
* The comparison is by reference for a child that reports the same object,
which the columnar and
* codegen wrappers do. A rule that rewrites a subquery in the runtime or
storage filters of a
* `BatchScanExec` after `EnsureRequirements` rebuilds it, so there it
compares the partition keys
- * by value, and reads equal only while the connector returns the same input
partitions for the
- * same scan. Such rules are `PlanAdaptiveDynamicPruningFilters` and
`ReuseAdaptiveSubquery` with
- * AQE, and `ReuseExchangeAndSubquery` without it.
+ * by value. Such rules are `PlanAdaptiveDynamicPruningFilters` and
`ReuseAdaptiveSubquery` with
+ * AQE, and `ReuseExchangeAndSubquery` without it. The rebuilt node shares
the input partitions
+ * the first one planned (`BatchScanExec.plannedInputPartitions`), so its
keys come from the same
Review Comment:
**11. [Nit] Each copy still sorts the partition keys again**
The rebuilt node shares the partitions, but it still builds its own
`reportedKeyedPartitioning` from them: one key extraction per split, a sort
with the interpreted grouped-key ordering, a wrapper per key and a `distinct`
(`DataSourceV2ScanExecBase.scala` L111-L113). This node then compares the
result with `childPartitioning` by value (L139, and again in
`checkChildStillMatches`). For N splits that is about N log N comparisons and N
wrappers on the driver per copy. This predates the PR and is small next to the
planning itself. If the holder keeps the bucketing decision for item 1, it
could keep the derived partitioning too, keyed by the node's
`keyGroupedPartitioning` and `output` so that the canonicalized node does not
reuse it.
--
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]