andygrove commented on code in PR #5880:
URL: https://github.com/apache/datafusion-comet/pull/5880#discussion_r3996849011


##########
spark/src/main/scala/org/apache/spark/sql/comet/CometIcebergNativeScanExec.scala:
##########
@@ -235,9 +235,10 @@ case class CometIcebergNativeScanExec(
       nativeMetrics = nativeMetrics,
       subqueries = Seq.empty) {
       override def compute(split: Partition, context: TaskContext): 
Iterator[ColumnarBatch] = {
-        val res = super.compute(split, context)
+        // Register before super.compute creates the CometExecIterator, so 
this listener runs
+        // after the iterator's close has published the final scan metrics.

Review Comment:
   The Iceberg site gets the same ordering fix, but I do not think any existing 
test can fail without it. `"task-level inputMetrics.bytesRead is populated for 
Iceberg native scan"` is a plain `SELECT *` with no JVM input and no early 
stop, which is the batch-receiver shape where `update_metrics` fires per batch, 
so it passes either way. An Iceberg scan with a `LIMIT` at the default update 
interval would mirror your parquet test and give this line a guard.



##########
spark/src/main/scala/org/apache/spark/sql/comet/CometMetricNode.scala:
##########
@@ -105,24 +101,34 @@ case class CometMetricNode(metrics: Map[String, 
SQLMetric], children: Seq[CometM
       })
 
   /**
-   * Reports aggregated scan input metrics (bytesRead, recordsRead) to Spark's 
task metrics.
-   * Aggregates across all scan leaf nodes to handle plans with multiple scans 
(e.g., joins). Must
-   * be called in a TaskCompletionListener after the iterator is fully 
consumed.
+   * Reports the scan leaves' bytes and rows (summed across joins and unions) 
to Spark's task
+   * input metrics, which drive the Input column on the UI's Stages and 
Executors tabs.
+   *
+   * Must be registered on the task thread before 
[[org.apache.comet.CometExecIterator]] so its
+   * completion listener publishes final SQL metrics before this listener 
runs. A block with a JVM
+   * input only publishes on the metrics update interval, and a consumer that 
stops early, such as
+   * a limit, leaves the final publish to that close.
+   *
+   * Adds to the task's counters instead of replacing them, so bytes that a 
fallback Spark scan
+   * accumulated in the same task survive. Trees registered on one task may 
share accumulators
+   * (see [[reportSpillMetrics]]), so each accumulator is counted once per 
task.
    */
   def reportScanInputMetrics(ctx: TaskContext): Unit = {
+    val seenMetrics = CometMetricNode.taskSeenMetrics(ctx).scanInput
     ctx.addTaskCompletionListener[Unit] { _ =>
       val scanLeaves = leafNodes.filter(_.metrics.contains("bytes_scanned"))
-      if (scanLeaves.nonEmpty) {
-        val totalBytes = scanLeaves.map(_.metrics("bytes_scanned").value).sum
-        val totalRows = scanLeaves.map { leaf =>
-          val outputRows =
-            leaf.metrics.get("output_rows").map(_.value).getOrElse(0L)
-          val prunedRows =
-            leaf.metrics.get("pushdown_rows_pruned").map(_.value).getOrElse(0L)
-          outputRows + prunedRows
-        }.sum
-        ctx.taskMetrics().inputMetrics.setBytesRead(totalBytes)
-        ctx.taskMetrics().inputMetrics.setRecordsRead(totalRows)
+      def claimed(leaf: CometMetricNode, metricName: String): Long =
+        
leaf.metrics.get(metricName).fold(0L)(CometMetricNode.claimMetricValue(_, 
seenMetrics))
+
+      val totalBytes = scanLeaves.map(claimed(_, "bytes_scanned")).sum
+      val totalRows = scanLeaves.map { leaf =>
+        claimed(leaf, "output_rows") + claimed(leaf, "pushdown_rows_pruned")
+      }.sum
+      if (totalBytes > 0L) {
+        ctx.taskMetrics().inputMetrics.incBytesRead(totalBytes)

Review Comment:
   I do not think the `inc` change holds in the other arm order. `FileScanRDD` 
snapshots `existingBytesRead` when its iterator is constructed in `compute()` 
and its completion-time `close()` does `setBytesRead(existingBytesRead + 
getBytesReadCallback())`, so it replaces rather than adds. Comet's `inc` only 
survives when Spark's close listener runs first, which requires Spark's scan to 
have registered its listener after Comet's.
   
   In `"task input metrics keep bytes read by a fallback Spark scan in the same 
task"` the parquet arm comes first, so Comet registers first and Spark's close 
runs first. If I swap the arms so the JSON scan is the leading one, reading 
exactly the same two files, Comet's `bytesRead` drops by the parquet side's 
entire `bytes_scanned`:
   
   ```
   native-first:   sparkBytes=147770  cometBytes=166181
   fallback-first: sparkBytes=147770  cometBytes=146148
   ```
   
   `recordsRead` is correct in both orders, since `FileScanRDD` uses 
`incRecordsRead` there. The scope is narrow: a fallback scan reaching a native 
block through `CometSparkToColumnarExec` is always safe, because 
`CometExecRDD.compute` registers this listener before `resolveInputObjects` 
pulls the input RDDs. It takes a coalesce that merges a Spark-scan partition 
and a Comet-scan partition into one task with the Spark side first, which is 
the shape the new test uses.
   
   This is not a regression, both orders are wrong on main today, and I do not 
see a clean fix given `TaskContext` has no listener ordering control. Would you 
add the reversed-arm query as a test that pins the current behaviour, and 
mention it under "What this does not cover" next to #5265 and #5879? As written 
the description reads as though the fallback bytes always survive.



##########
spark/src/test/scala/org/apache/spark/sql/comet/CometTaskMetricsSuite.scala:
##########
@@ -1012,6 +1090,288 @@ class CometTaskMetricsSuite extends CometTestBase with 
AdaptiveSparkPlanHelper {
     }
   }
 
+  test("native scan left unconsumed by a limit still reports task input 
metrics") {
+    withTempPath { dir =>
+      spark
+        .createDataFrame((0 until 20000).map(i => (i, s"big_$i")))
+        .repartition(4)
+        .write
+        .parquet(dir.getAbsolutePath)
+      
spark.read.parquet(dir.getAbsolutePath).createOrReplaceTempView("limit_big")
+      spark
+        .createDataFrame((0 until 100).map(i => (i * 7, s"local_$i")))
+        .createOrReplaceTempView("limit_local")
+
+      // The broadcast side is a JVM input to the join block, so native 
execution polls and only
+      // publishes scan metrics on the update interval. The limit stops 
pulling before the scan
+      // is exhausted, leaving the final publish to the iterator's 
completion-time close.
+      val query = "SELECT /*+ BROADCAST(limit_local) */ * FROM limit_big JOIN 
limit_local " +
+        "ON limit_big._1 = limit_local._1 LIMIT 3"
+      val localBroadcast = Seq(
+        CometConf.COMET_SPARK_TO_ARROW_ENABLED.key -> "true",
+        CometConf.COMET_SPARK_TO_ARROW_SUPPORTED_OPERATOR_LIST.key -> 
"LocalTableScan")
+
+      Seq("-1", 
CometConf.COMET_METRICS_UPDATE_INTERVAL.defaultValueString).foreach { interval 
=>
+        val confs = localBroadcast :+ 
(CometConf.COMET_METRICS_UPDATE_INTERVAL.key -> interval)
+        val (cometBytes, cometRecords, cometPlan) =
+          collectInputMetrics(query, (CometConf.COMET_ENABLED.key -> "true") 
+: confs: _*)
+
+        val join = find(cometPlan)(_.isInstanceOf[CometBroadcastHashJoinExec])
+        assert(
+          join.isDefined,
+          s"Expected CometBroadcastHashJoinExec in 
plan:\n${cometPlan.treeString}")
+        val streamedNativeScan = join.get.children.exists { child =>
+          find(child)(_.isInstanceOf[CometBroadcastExchangeExec]).isEmpty &&
+          find(child)(_.isInstanceOf[CometNativeScanExec]).isDefined
+        }
+        assert(
+          streamedNativeScan,
+          s"Expected a native scan on the streamed side of the 
join:\n${cometPlan.treeString}")
+
+        assert(cometBytes > 0, s"bytesRead should be > 0 at interval 
$interval, got $cometBytes")
+        assert(
+          cometRecords >= 3 && cometRecords <= 20000,
+          s"recordsRead should cover at least the limit at interval $interval, 
got $cometRecords")
+      }
+    }
+  }
+
+  test("task input metrics keep bytes read by a fallback Spark scan in the 
same task") {
+    withTempPath { parquetDir =>
+      withTempPath { jsonDir =>
+        spark
+          .createDataFrame((0 until 5000).map(i => (i, s"parquet_$i")))
+          .repartition(1)
+          .write
+          .parquet(parquetDir.getAbsolutePath)
+        spark
+          .createDataFrame((5000 until 10000).map(i => (i, s"json_$i")))
+          .repartition(1)
+          .write
+          .json(jsonDir.getAbsolutePath)
+        
spark.read.parquet(parquetDir.getAbsolutePath).createOrReplaceTempView("mixed_parquet")
+        
spark.read.json(jsonDir.getAbsolutePath).createOrReplaceTempView("mixed_json")
+
+        // Coalescing the union to one partition computes the native scan and 
the fallback JSON
+        // scan inside the same task, so Spark's own input metrics and Comet's 
must add up.
+        val query = "SELECT /*+ COALESCE(1) */ * FROM (SELECT _1 FROM 
mixed_parquet " +
+          "UNION ALL SELECT CAST(_1 AS INT) FROM mixed_json)"
+        val convertJson = CometConf.COMET_CONVERT_FROM_JSON_ENABLED.key -> 
"true"
+
+        val (sparkBytes, sparkRecords, _) =
+          collectInputMetrics(query, CometConf.COMET_ENABLED.key -> "false", 
convertJson)
+        val (cometBytes, cometRecords, cometPlan) =
+          collectInputMetrics(query, CometConf.COMET_ENABLED.key -> "true", 
convertJson)
+
+        assert(
+          find(cometPlan)(_.isInstanceOf[CometNativeScanExec]).isDefined,
+          s"Expected CometNativeScanExec in plan:\n${cometPlan.treeString}")
+        assert(
+          find(cometPlan)(_.isInstanceOf[CometSparkToColumnarExec]).isDefined,
+          s"Expected CometSparkToColumnarExec in 
plan:\n${cometPlan.treeString}")
+
+        assert(sparkRecords > 0, s"Spark recordsRead should be > 0, got 
$sparkRecords")
+        assert(
+          cometRecords == sparkRecords,
+          s"recordsRead mismatch: comet=$cometRecords, spark=$sparkRecords")
+        assert(sparkBytes > 0, s"Spark bytesRead should be > 0, got 
$sparkBytes")
+        assertCometBytesReadInRange(cometBytes, sparkBytes)

Review Comment:
   When I reverted `inc` back to `set` to check what the new tests catch, all 
three integration failures were on `recordsRead` (`5000 did not equal 10000`), 
none on `bytesRead`. `assertCometBytesReadInRange` allows a 0.7 to 1.3 ratio on 
3.5 and 4.0, and merely `cometBytes >= sparkBytes` on 4.1+. In this fixture the 
parquet side is small enough relative to the JSON side that losing it entirely 
still lands around 0.88, inside the band. So the bytes half of the change is 
not pinned by anything here, or in the coalesced-union and cached-input tests.
   
   Could this measure the two sides separately first and then assert the 
coalesced run is at least their sum? That would make the bytes assertion fail 
on a `set` revert the same way the records one does.



##########
spark/src/test/scala/org/apache/spark/sql/comet/CometTaskMetricsSuite.scala:
##########
@@ -128,6 +128,84 @@ class CometTaskMetricsSuite extends CometTestBase with 
AdaptiveSparkPlanHelper {
     }
   }
 
+  test("scan input metrics add to the task once per accumulator and keep 
existing values") {
+    val nestedBytes = new SQLMetric("nestedBytes", -1L)
+    val nestedRows = new SQLMetric("nestedRows")
+    val nestedPruned = new SQLMetric("nestedPruned")
+    val siblingBytes = new SQLMetric("siblingBytes", -1L)
+    val siblingRows = new SQLMetric("siblingRows")
+    val nestedScan = CometMetricNode(
+      Map(
+        "bytes_scanned" -> nestedBytes,
+        "output_rows" -> nestedRows,
+        "pushdown_rows_pruned" -> nestedPruned))
+    // An operator above the scans contributes output_rows of its own, which 
is not input.
+    val outerRows = new SQLMetric("outerRows")
+    outerRows.set(99L)
+    val outerTree = CometMetricNode(
+      Map("output_rows" -> outerRows),
+      Seq(
+        nestedScan,
+        CometMetricNode(Map("bytes_scanned" -> siblingBytes, "output_rows" -> 
siblingRows))))
+
+    Seq(None, Some(new IllegalStateException("failed native stage"))).foreach 
{ failure =>
+      val ctx = TaskContext.empty()

Review Comment:
   Worth a line in the suite noting that these tests depend on 
`markTaskCompleted` running. Every `TaskContext.empty()` is constructed with 
`taskAttemptId == 0`, so they all share one `seenMetricsByTask` entry, and the 
cleanup listener is the only thing keeping them isolated from each other. A 
future test that registers a report and forgets `markTaskCompleted` would 
silently zero the next test's claims rather than failing.



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