-
Notifications
You must be signed in to change notification settings - Fork 375
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
a34f2e6
573f5be
5d727c1
df15126
930cfc2
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,38 @@ 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. That holds only when the fallback scan registers its | ||
| * completion listener after this one, which a `CometSparkToColumnarExec` input always does and | ||
| * a coalesced Spark-scan partition computed first does not: `FileScanRDD`'s close then runs | ||
| * last and sets bytesRead from the value it snapshotted at construction. 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 +145,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 +175,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 +239,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`. | ||
| */ | ||
|
|
||
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.