-
Notifications
You must be signed in to change notification settings - Fork 373
fix: report task input metrics after the native iterator closes and add to Spark's counters #5880
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -77,11 +77,7 @@ case class CometMetricNode(metrics: Map[String, SQLMetric], children: Seq[CometM | |
| seenMetrics: IdentityHashMap[SQLMetric, java.lang.Boolean]): Long = { | ||
| def sumFromNode(metricNode: CometMetricNode): Long = { | ||
| val nodeValue = metricNode.metrics.get(metricName).fold(0L) { metric => | ||
| if (seenMetrics.put(metric, java.lang.Boolean.TRUE) == null) { | ||
| math.max(metric.value, 0L) | ||
| } else { | ||
| 0L | ||
| } | ||
| CometMetricNode.claimMetricValue(metric, seenMetrics) | ||
| } | ||
| nodeValue + metricNode.children.iterator.map(sumFromNode).sum | ||
| } | ||
|
|
@@ -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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: the reversed-arm test and the description bullet cover this now, but the scaladoc here still has the unconditional version, "so bytes that a fallback Spark scan accumulated in the same task survive". That is the claim the reversed arm disproves, and it is the sentence someone reading this file will find rather than the PR description. Could you carry a short version of the caveat up here? Something like: this survives only when the fallback scan registers its listener after Comet's, which a |
||
| * 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do not think the In
This is not a regression, both orders are wrong on main today, and I do not see a clean fix given
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Added both. The test now runs the union in both arm orders. Native first asserts bytesRead covers the sum of the two sides measured on their own. Fallback first asserts records still add up and bytesRead stays below that sum, with a comment on FileScanRDD's close setting the value it snapshotted at construction. The description has a bullet for it next to the other two. |
||
| } | ||
| if (totalRows > 0L) { | ||
| ctx.taskMetrics().inputMetrics.incRecordsRead(totalRows) | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -135,6 +141,10 @@ case class CometMetricNode(metrics: Map[String, SQLMetric], children: Seq[CometM | |
| * Must be registered on the task thread before [[org.apache.comet.CometExecIterator]] so | ||
| * Spark's completion listener stack invokes the iterator `close` (final SQL metric update) | ||
| * before this listener runs. | ||
| * | ||
| * The native writer is the root of its task's plan and runs once per task, so its values | ||
| * replace the task's output counters without the per-task registry that the scan input and | ||
| * spill reports share. | ||
| */ | ||
| def reportNativeWriteOutputMetrics(ctx: TaskContext): Unit = { | ||
| ctx.addTaskCompletionListener[Unit] { _ => | ||
|
|
@@ -161,7 +171,7 @@ case class CometMetricNode(metrics: Map[String, SQLMetric], children: Seq[CometM | |
| * per-task registry, so each accumulator is counted once while disjoint trees still all report. | ||
| */ | ||
| def reportSpillMetrics(ctx: TaskContext): Unit = { | ||
| val seenMetrics = CometMetricNode.taskSeenSpillMetrics(ctx) | ||
| val seenMetrics = CometMetricNode.taskSeenMetrics(ctx) | ||
| ctx.addTaskCompletionListener[Unit] { _ => | ||
| val diskBytesSpilled = sumMetricValues("spilled_bytes", seenMetrics.disk) | ||
| if (diskBytesSpilled > 0L) { | ||
|
|
@@ -225,30 +235,39 @@ object CometMetricNode { | |
| private val aggregateMetricNames = | ||
| Set("spill_count", "spilled_bytes", "spilled_rows", "peak_mem_used") | ||
|
|
||
| private case class SeenSpillMetrics( | ||
| disk: IdentityHashMap[SQLMetric, java.lang.Boolean], | ||
| memory: IdentityHashMap[SQLMetric, java.lang.Boolean]) | ||
| private type SeenMetricSet = IdentityHashMap[SQLMetric, java.lang.Boolean] | ||
|
|
||
| private case class SeenMetrics( | ||
| disk: SeenMetricSet, | ||
| memory: SeenMetricSet, | ||
| scanInput: SeenMetricSet) | ||
|
|
||
| // Per running task attempt: the spill accumulators already claimed by a reporting listener, | ||
| // one identity set per metric name (see reportSpillMetrics). The first registration installs | ||
| // a cleanup listener ahead of every reporting listener, so it runs last (reverse registration | ||
| // order) and removes the entry. | ||
| private val seenSpillMetricsByTask = new ConcurrentHashMap[Long, SeenSpillMetrics]() | ||
| // Per running task attempt: the accumulators already claimed by a reporting listener, one | ||
| // identity set per reported task metric (see reportSpillMetrics and reportScanInputMetrics). | ||
| // The first registration installs a cleanup listener ahead of every reporting listener, so it | ||
| // runs last (reverse registration order) and removes the entry. | ||
| private val seenMetricsByTask = new ConcurrentHashMap[Long, SeenMetrics]() | ||
|
|
||
| private def taskSeenSpillMetrics(ctx: TaskContext): SeenSpillMetrics = { | ||
| private def taskSeenMetrics(ctx: TaskContext): SeenMetrics = { | ||
| val attemptId = ctx.taskAttemptId() | ||
| val existing = seenSpillMetricsByTask.get(attemptId) | ||
| val existing = seenMetricsByTask.get(attemptId) | ||
| if (existing != null) { | ||
| existing | ||
| } else { | ||
| // The task thread is the only registrant for its attempt id, so there is no put race. | ||
| val created = SeenSpillMetrics(new IdentityHashMap(), new IdentityHashMap()) | ||
| seenSpillMetricsByTask.put(attemptId, created) | ||
| ctx.addTaskCompletionListener[Unit](_ => seenSpillMetricsByTask.remove(attemptId)) | ||
| val created = | ||
| SeenMetrics(new IdentityHashMap(), new IdentityHashMap(), new IdentityHashMap()) | ||
| seenMetricsByTask.put(attemptId, created) | ||
| ctx.addTaskCompletionListener[Unit](_ => seenMetricsByTask.remove(attemptId)) | ||
| created | ||
| } | ||
| } | ||
|
|
||
| /** The metric's value the first time it is claimed for a task, zero afterwards. */ | ||
| private def claimMetricValue(metric: SQLMetric, seenMetrics: SeenMetricSet): Long = | ||
| if (seenMetrics.put(metric, java.lang.Boolean.TRUE) == null) math.max(metric.value, 0L) | ||
| else 0L | ||
|
|
||
| /** | ||
| * The baseline SQL metrics for DataFusion `BaselineMetrics`. | ||
| */ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3738,6 +3738,81 @@ class CometIcebergNativeSuite | |
| } | ||
| } | ||
|
|
||
| test("Iceberg native scan left unconsumed by a limit still reports task input metrics") { | ||
| assume(icebergAvailable, "Iceberg not available in classpath") | ||
|
|
||
| withTempIcebergDir { warehouseDir => | ||
| withSQLConf( | ||
| "spark.sql.catalog.test_cat" -> "org.apache.iceberg.spark.SparkCatalog", | ||
| "spark.sql.catalog.test_cat.type" -> "hadoop", | ||
| "spark.sql.catalog.test_cat.warehouse" -> warehouseDir.getAbsolutePath, | ||
| CometConf.COMET_ENABLED.key -> "true", | ||
| CometConf.COMET_EXEC_ENABLED.key -> "true", | ||
| CometConf.COMET_ICEBERG_NATIVE_ENABLED.key -> "true") { | ||
|
|
||
| spark.sql(""" | ||
| CREATE TABLE test_cat.db.task_metrics_limit_test ( | ||
| id INT, | ||
| value DOUBLE | ||
| ) USING iceberg | ||
| """) | ||
| spark | ||
| .range(20000) | ||
| .selectExpr("CAST(id AS INT)", "CAST(id * 1.5 AS DOUBLE) as value") | ||
| .repartition(4) | ||
| .write | ||
| .format("iceberg") | ||
| .mode("append") | ||
| .saveAsTable("test_cat.db.task_metrics_limit_test") | ||
|
|
||
| val bytesReadValues = mutable.ArrayBuffer.empty[Long] | ||
| val recordsReadValues = mutable.ArrayBuffer.empty[Long] | ||
| val listener = new SparkListener { | ||
| override def onTaskEnd(taskEnd: SparkListenerTaskEnd): Unit = { | ||
| val im = taskEnd.taskMetrics.inputMetrics | ||
| bytesReadValues.synchronized { | ||
| bytesReadValues += im.bytesRead | ||
| recordsReadValues += im.recordsRead | ||
| } | ||
| } | ||
| } | ||
| spark.sparkContext.addSparkListener(listener) | ||
|
|
||
| try { | ||
| // The limit stops pulling before the scan is exhausted, so the final metric publish | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: I ran the ordering revert against both suites to see what this test buys. It passes with the ordering reverted, as you said it would, and so does Both tests earn their keep as coverage for their sites. Could this comment say what the test actually does though? Right now it reads "the final metric publish happens in the iterator's completion-time close", which is the broadcast-join test's mechanism rather than this one. Something like: this Iceberg scan is its own block with no JVM input, so metrics publish per batch and this covers the site rather than the order, which stays unguarded until #5265. |
||
| // happens in the iterator's completion-time close. | ||
| val query = "SELECT * FROM test_cat.db.task_metrics_limit_test LIMIT 3" | ||
| Seq("-1", CometConf.COMET_METRICS_UPDATE_INTERVAL.defaultValueString).foreach { | ||
| interval => | ||
| withSQLConf(CometConf.COMET_METRICS_UPDATE_INTERVAL.key -> interval) { | ||
| CometListenerBusUtils.waitUntilEmpty(spark.sparkContext) | ||
| bytesReadValues.clear() | ||
| recordsReadValues.clear() | ||
| val df = spark.sql(query) | ||
| assert( | ||
| collectIcebergNativeScans(df.queryExecution.executedPlan).nonEmpty, | ||
| "Expected CometIcebergNativeScanExec in plan") | ||
| df.collect() | ||
| CometListenerBusUtils.waitUntilEmpty(spark.sparkContext) | ||
|
|
||
| val cometBytes = bytesReadValues.sum | ||
| val cometRecords = recordsReadValues.sum | ||
| 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") | ||
| } | ||
| } | ||
| } finally { | ||
| spark.sparkContext.removeSparkListener(listener) | ||
| spark.sql("DROP TABLE test_cat.db.task_metrics_limit_test") | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| test("task-level inputMetrics.bytesRead is populated for Iceberg native scan") { | ||
| assume(icebergAvailable, "Iceberg not available in classpath") | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 plainSELECT *with no JVM input and no early stop, which is the batch-receiver shape whereupdate_metricsfires per batch, so it passes either way. An Iceberg scan with aLIMITat the default update interval would mirror your parquet test and give this line a guard.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added in CometIcebergNativeSuite at the default interval and at -1. One caveat: the Iceberg scan is always its own block with no JVM input, so it takes the batch-receiver path where every returned batch publishes metrics, and the test passes with or without the reorder. The shape that would fail, an Iceberg scan fused under a join with a broadcast input, registers no report at all until #5265 widens the gate. So this test covers the site rather than proving the order.