comphead commented on code in PR #6270:
URL: https://github.com/apache/datafusion-comet/pull/6270#discussion_r4265558207


##########
spark/src/main/scala/org/apache/spark/sql/comet/CometNativeScanExec.scala:
##########
@@ -285,14 +285,13 @@ case class CometNativeScanExec(
   }
 
   override def doCanonicalize(): CometNativeScanExec = {
-    // Canonicalize originalPlan but strip its DPP partition filters.
-    // originalPlan carries column selection and schema info needed for
-    // equals/hashCode. But its partitionFilters may have stale DPP
-    // expressions (e.g., SABs not yet converted to TrueLiteral) that
-    // would prevent exchange reuse between otherwise-identical scans.
+    // Canonicalize originalPlan without any of its DPP partition filters. 
originalPlan carries
+    // column selection and schema info needed for equals/hashCode, but its 
DPP filters are a stale
+    // copy that no DPP rule rewrites. The top-level partitionFilters below 
carry the scan's DPP
+    // identity.
     val canonOriginal = if (originalPlan != null) {
       val stripped = originalPlan.copy(partitionFilters =
-        
CometScanUtils.filterUnusedDynamicPruningExpressions(originalPlan.partitionFilters))
+        
originalPlan.partitionFilters.filterNot(_.isInstanceOf[DynamicPruningExpression]))

Review Comment:
   The same block (null check, `originalPlan.copy(partitionFilters = 
...filterNot(_.isInstanceOf[DynamicPruningExpression]))`, 
`stripped.doCanonicalize()`) is now in both 
`CometNativeScanExec.doCanonicalize` and 
`CometDeltaNativeScanExec.doCanonicalize`, with a comment explaining it in 
each, and the docs tell new scans to copy it. Could it live next to 
`filterUnusedDynamicPruningExpressions` in `CometScanUtils`, taking the 
nullable `FileSourceScanExec` and returning the canonical copy? Then both scans 
and the docs point at one place, and the two comments become one.



##########
spark/src/test/scala/org/apache/comet/exec/CometExecSuite.scala:
##########
@@ -1769,6 +1769,117 @@ class CometExecSuite extends CometTestBase {
     }
   }
 
+  // Tables for the #6264 tests. The fact table has 10 store partitions. 
Dimension group 'a' keeps
+  // store 1 and group 'b' keeps stores 2 to 4, so the two DPP scans prune to 
1 and 3 partitions.
+  private def withDppBranchTables(f: => Unit): Unit = {
+    withTempDir { dir =>
+      val path = dir.getAbsolutePath
+      spark
+        .range(1000)
+        .selectExpr(
+          "cast(id % 10 as int) as store_id",
+          "cast(id % 13 as int) as item",
+          "cast(id as int) as amount")
+        .write
+        .partitionBy("store_id")
+        .parquet(s"$path/fact")
+      spark
+        .range(10)
+        .selectExpr(
+          "cast(id as int) as store_id",
+          "case when id = 1 then 'a' when id in (2, 3, 4) then 'b' else 'c' 
end as grp")
+        .write
+        .parquet(s"$path/dim")
+      spark
+        .range(13)
+        .selectExpr("cast(id as int) as item", "cast(id * 10 as int) as w")
+        .write
+        .parquet(s"$path/other")
+      withTempView("dpp_branch_fact", "dpp_branch_dim", "dpp_branch_other") {
+        
spark.read.parquet(s"$path/fact").createOrReplaceTempView("dpp_branch_fact")
+        
spark.read.parquet(s"$path/dim").createOrReplaceTempView("dpp_branch_dim")
+        
spark.read.parquet(s"$path/other").createOrReplaceTempView("dpp_branch_other")
+        f
+      }
+    }
+  }
+
+  // Runs a UNION ALL of one CTE joined to dimension group 'a' and to group 
'b'. Coalescing is off:
+  // a coalesced shuffle read carries per-partition sizes that would keep the 
two branches' parent
+  // exchanges apart and hide a wrong reuse.
+  private def checkDppBranchesKeepTheirRows(query: String): Unit = {
+    withSQLConf(
+      SQLConf.USE_V1_SOURCE_LIST.key -> "parquet",
+      SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+      SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "true",
+      SQLConf.COALESCE_PARTITIONS_ENABLED.key -> "false") {
+      if (isSpark35Plus) {
+        val (_, cometPlan) = checkSparkAnswerAndOperator(sql(query))
+        def dppScans(plan: SparkPlan): Seq[CometNativeScanExec] = 
collect(plan) {
+          case s: CometNativeScanExec
+              if 
s.partitionFilters.exists(_.isInstanceOf[DynamicPruningExpression]) =>
+            s
+        }
+        assert(
+          dppScans(cometPlan).map(_.metrics("numPartitions").value).sorted == 
Seq(1L, 3L),
+          "Expected one DPP scan per branch, pruned to 1 and 3 partitions:\n" +
+            cometPlan.treeString)
+        val reusedDpp = collect(cometPlan) {

Review Comment:
   The `dppScans` collector plus the assertion that no `ReusedExchangeExec` 
sits over a DPP scan is written three times: here, in `CometIcebergNativeSuite` 
(the `reusedDpp` block) and in `CometDeltaNativeScanSuite`. Only the scan class 
and the way DPP is detected differ. All three suites extend `CometTestBase`, 
which already mixes in `AdaptiveSparkPlanHelper`, so a single 
`assertNoReusedExchangeOverDppScan(plan)(isDppScan: SparkPlan => Boolean)` 
there would hold the assertion and its message once.



##########
spark/src/test/scala/org/apache/comet/exec/CometExecSuite.scala:
##########
@@ -1769,6 +1769,117 @@ class CometExecSuite extends CometTestBase {
     }
   }
 
+  // Tables for the #6264 tests. The fact table has 10 store partitions. 
Dimension group 'a' keeps
+  // store 1 and group 'b' keeps stores 2 to 4, so the two DPP scans prune to 
1 and 3 partitions.
+  private def withDppBranchTables(f: => Unit): Unit = {
+    withTempDir { dir =>
+      val path = dir.getAbsolutePath
+      spark
+        .range(1000)
+        .selectExpr(
+          "cast(id % 10 as int) as store_id",
+          "cast(id % 13 as int) as item",
+          "cast(id as int) as amount")
+        .write
+        .partitionBy("store_id")
+        .parquet(s"$path/fact")
+      spark
+        .range(10)
+        .selectExpr(
+          "cast(id as int) as store_id",
+          "case when id = 1 then 'a' when id in (2, 3, 4) then 'b' else 'c' 
end as grp")
+        .write
+        .parquet(s"$path/dim")
+      spark
+        .range(13)
+        .selectExpr("cast(id as int) as item", "cast(id * 10 as int) as w")
+        .write
+        .parquet(s"$path/other")
+      withTempView("dpp_branch_fact", "dpp_branch_dim", "dpp_branch_other") {
+        
spark.read.parquet(s"$path/fact").createOrReplaceTempView("dpp_branch_fact")
+        
spark.read.parquet(s"$path/dim").createOrReplaceTempView("dpp_branch_dim")
+        
spark.read.parquet(s"$path/other").createOrReplaceTempView("dpp_branch_other")
+        f
+      }
+    }
+  }
+
+  // Runs a UNION ALL of one CTE joined to dimension group 'a' and to group 
'b'. Coalescing is off:
+  // a coalesced shuffle read carries per-partition sizes that would keep the 
two branches' parent
+  // exchanges apart and hide a wrong reuse.
+  private def checkDppBranchesKeepTheirRows(query: String): Unit = {
+    withSQLConf(
+      SQLConf.USE_V1_SOURCE_LIST.key -> "parquet",
+      SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+      SQLConf.DYNAMIC_PARTITION_PRUNING_REUSE_BROADCAST_ONLY.key -> "true",
+      SQLConf.COALESCE_PARTITIONS_ENABLED.key -> "false") {
+      if (isSpark35Plus) {
+        val (_, cometPlan) = checkSparkAnswerAndOperator(sql(query))
+        def dppScans(plan: SparkPlan): Seq[CometNativeScanExec] = 
collect(plan) {
+          case s: CometNativeScanExec
+              if 
s.partitionFilters.exists(_.isInstanceOf[DynamicPruningExpression]) =>
+            s
+        }
+        assert(
+          dppScans(cometPlan).map(_.metrics("numPartitions").value).sorted == 
Seq(1L, 3L),
+          "Expected one DPP scan per branch, pruned to 1 and 3 partitions:\n" +
+            cometPlan.treeString)
+        val reusedDpp = collect(cometPlan) {
+          case r: ReusedExchangeExec if dppScans(r.child).nonEmpty => r
+        }
+        assert(
+          reusedDpp.isEmpty,
+          s"An exchange over a DPP scan was reused by the other 
branch:\n${cometPlan.treeString}")
+      } else {
+        // On 3.4, AQE DPP falls back to Spark.
+        checkSparkAnswer(sql(query))
+      }
+    }
+  }
+
+  // TPC-DS q64 shape: a broadcast of (fact SMJ other), with the dimension 
join above it. The two
+  // broadcasts differ only in the DPP filter of the fact scan inside a child 
shuffle stage.
+  test("AQE DPP: broadcast over a join of DPP scans is not reused across 
branches (#6264)") {
+    withDppBranchTables {
+      checkDppBranchesKeepTheirRows("""WITH x AS (
+          |  SELECT /*+ MERGE(f, o) */ f.store_id, f.item, f.amount, o.w
+          |  FROM dpp_branch_fact f JOIN dpp_branch_other o ON f.item = o.item)
+          |SELECT /*+ BROADCAST(x) */ o2.item, x.store_id, x.amount, x.w
+          |FROM dpp_branch_other o2 JOIN x ON o2.item = x.item
+          |JOIN dpp_branch_dim d ON x.store_id = d.store_id WHERE d.grp = 'a'
+          |UNION ALL
+          |SELECT /*+ BROADCAST(x) */ o2.item, x.store_id, x.amount, x.w
+          |FROM dpp_branch_other o2 JOIN x ON o2.item = x.item
+          |JOIN dpp_branch_dim d ON x.store_id = d.store_id WHERE d.grp = 
'b'""".stripMargin)
+    }
+  }
+
+  test("AQE DPP: shuffle over a DPP scan stage is not reused across branches 
(#6264)") {
+    withDppBranchTables {
+      checkDppBranchesKeepTheirRows("""WITH x AS (
+          |  SELECT store_id, item, sum(amount) AS s FROM dpp_branch_fact 
GROUP BY store_id, item),
+          |y AS (SELECT store_id, s % 7 AS b, count(*) AS c FROM x GROUP BY 
store_id, s % 7)
+          |SELECT y.store_id, y.b, y.c FROM y JOIN dpp_branch_dim d ON 
y.store_id = d.store_id
+          |WHERE d.grp = 'a'
+          |UNION ALL
+          |SELECT y.store_id, y.b, y.c FROM y JOIN dpp_branch_dim d ON 
y.store_id = d.store_id
+          |WHERE d.grp = 'b'""".stripMargin)
+    }
+  }
+
+  // Guards the scan-stage path, which passes without keeping the DPP 
placeholder in the canonical
+  // form: the dimension join sits in the scan's own stage, and AQE keys each 
scan stage after its
+  // DPP filter is converted.
+  test("AQE DPP: DPP join in the scan's own stage keeps both branches 
(#6264)") {

Review Comment:
   By its own comment this test passes on main, so it does not pin this fix. 
Meanwhile nothing in the PR pins the other half of the rationale, that scans 
with identical DPP filters (the same dimension filter in both branches) still 
share a stage after the placeholder is kept. `checkDppBranchesKeepTheirRows` 
cannot cover it because it requires pruning to 1 and 3 partitions. Could this 
test become that case, with `grp = 'a'` in both branches and the Comet 
`ReusedExchangeExec` count compared with the Spark plan from 
`checkSparkAnswer`, as the Delta SPARK-32509 test does? I have not checked 
whether Spark reuses the stage there, but comparing against the Spark plan 
gives the right expectation either way (not run).



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