This is an automated email from the ASF dual-hosted git repository.

rui-mo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git


The following commit(s) were added to refs/heads/main by this push:
     new 5ecef3c0e1 [VL] Refactor Velox metrics transport to use JSON payloads 
(#12126)
5ecef3c0e1 is described below

commit 5ecef3c0e16255f691e7df84e8269098128e8dfe
Author: Rui Mo <[email protected]>
AuthorDate: Fri Jul 10 16:27:52 2026 +0100

    [VL] Refactor Velox metrics transport to use JSON payloads (#12126)
---
 .../java/org/apache/gluten/metrics/Metrics.java    | 200 +-----------
 .../org/apache/gluten/metrics/OperatorMetrics.java |   3 +
 .../metrics/HashAggregateMetricsUpdater.scala      |   2 +-
 .../apache/gluten/metrics/JoinMetricsUpdater.scala |   2 +-
 .../org/apache/gluten/metrics/MetricsUtil.scala    | 137 ++++++++-
 .../metrics/NestedLoopJoinMetricsUpdater.scala     |   2 +-
 .../gluten/metrics/UnionMetricsUpdater.scala       |   2 +-
 cpp/core/jni/JniWrapper.cc                         |  66 +---
 cpp/core/utils/Metrics.h                           |  89 +-----
 cpp/velox/compute/WholeStageResultIterator.cc      | 174 ++---------
 cpp/velox/compute/WholeStageResultIterator.h       |   6 -
 cpp/velox/tests/RuntimeTest.cc                     |   2 +-
 docs/developers/MetricsFramework.md                | 340 +++++++++++++++++++++
 mkdocs.yml                                         |   1 +
 14 files changed, 530 insertions(+), 496 deletions(-)

diff --git 
a/backends-velox/src/main/java/org/apache/gluten/metrics/Metrics.java 
b/backends-velox/src/main/java/org/apache/gluten/metrics/Metrics.java
index 12f38d84e5..ce5bec16a7 100644
--- a/backends-velox/src/main/java/org/apache/gluten/metrics/Metrics.java
+++ b/backends-velox/src/main/java/org/apache/gluten/metrics/Metrics.java
@@ -16,209 +16,21 @@
  */
 package org.apache.gluten.metrics;
 
-import org.apache.gluten.exception.GlutenException;
-
 public class Metrics implements IMetrics {
-  public long[] inputRows;
-  public long[] inputVectors;
-  public long[] inputBytes;
-  public long[] rawInputRows;
-  public long[] rawInputBytes;
-  public long[] outputRows;
-  public long[] outputVectors;
-  public long[] outputBytes;
-  public long[] cpuCount;
-  public long[] wallNanos;
-  public long[] scanTime;
-  public long[] peakMemoryBytes;
-  public long[] numMemoryAllocations;
-  public long[] spilledInputBytes;
-  public long[] spilledBytes;
-  public long[] spilledRows;
-  public long[] spilledPartitions;
-  public long[] spilledFiles;
-  public long[] numDynamicFiltersProduced;
-  public long[] numDynamicFiltersAccepted;
-  public long[] numReplacedWithDynamicFilterRows;
-  public long[] numDynamicFilterInputRows;
-  public long[] flushRowCount;
-  public long[] abandonedPartialAggregationRows;
-  public long[] loadedToValueHook;
-  public long[] bloomFilterBlocksByteSize;
-  public long[] skippedSplits;
-  public long[] processedSplits;
-  public long[] skippedStrides;
-  public long[] processedStrides;
-  public long[] remainingFilterTime;
-  public long[] ioWaitTime;
-  public long[] storageReadBytes;
-  public long[] storageReads;
-  public long[] localReadBytes;
-  public long[] ramReadBytes;
-  public long[] preloadSplits;
-  public long[] pageLoadTime;
-  public long[] dataSourceAddSplitTime;
-  public long[] dataSourceReadTime;
-
-  public long[] physicalWrittenBytes;
-  public long[] writeIOTime;
-  public long[] numWrittenFiles;
-
-  public long[] loadLazyVectorTime;
-
-  public SingleMetric singleMetric = new SingleMetric();
+  public final String metricsJson;
+  public final int numMetrics;
+  public final SingleMetric singleMetric = new SingleMetric();
 
   public String taskStats;
 
   /** Create an instance for native metrics. */
-  public Metrics(
-      long[] inputRows,
-      long[] inputVectors,
-      long[] inputBytes,
-      long[] rawInputRows,
-      long[] rawInputBytes,
-      long[] outputRows,
-      long[] outputVectors,
-      long[] outputBytes,
-      long[] cpuCount,
-      long[] wallNanos,
-      long veloxToArrow,
-      long[] peakMemoryBytes,
-      long[] numMemoryAllocations,
-      long[] spilledInputBytes,
-      long[] spilledBytes,
-      long[] spilledRows,
-      long[] spilledPartitions,
-      long[] spilledFiles,
-      long[] numDynamicFiltersProduced,
-      long[] numDynamicFiltersAccepted,
-      long[] numReplacedWithDynamicFilterRows,
-      long[] numDynamicFilterInputRows,
-      long[] flushRowCount,
-      long[] abandonedPartialAggregationRows,
-      long[] loadedToValueHook,
-      long[] bloomFilterBlocksByteSize,
-      long[] scanTime,
-      long[] skippedSplits,
-      long[] processedSplits,
-      long[] skippedStrides,
-      long[] processedStrides,
-      long[] remainingFilterTime,
-      long[] ioWaitTime,
-      long[] storageReadBytes,
-      long[] storageReads,
-      long[] localReadBytes,
-      long[] ramReadBytes,
-      long[] preloadSplits,
-      long[] pageLoadTime,
-      long[] dataSourceAddSplitTime,
-      long[] dataSourceReadTime,
-      long[] physicalWrittenBytes,
-      long[] writeIOTime,
-      long[] numWrittenFiles,
-      long[] loadLazyVectorTime,
-      String taskStats) {
-    this.inputRows = inputRows;
-    this.inputVectors = inputVectors;
-    this.inputBytes = inputBytes;
-    this.rawInputRows = rawInputRows;
-    this.rawInputBytes = rawInputBytes;
-    this.outputRows = outputRows;
-    this.outputVectors = outputVectors;
-    this.outputBytes = outputBytes;
-    this.cpuCount = cpuCount;
-    this.wallNanos = wallNanos;
-    this.scanTime = scanTime;
+  public Metrics(String metricsJson, int numMetrics, long veloxToArrow, String 
taskStats) {
+    this.metricsJson = metricsJson;
+    this.numMetrics = numMetrics;
     this.singleMetric.veloxToArrow = veloxToArrow;
-    this.peakMemoryBytes = peakMemoryBytes;
-    this.numMemoryAllocations = numMemoryAllocations;
-    this.spilledInputBytes = spilledInputBytes;
-    this.spilledBytes = spilledBytes;
-    this.spilledRows = spilledRows;
-    this.spilledPartitions = spilledPartitions;
-    this.spilledFiles = spilledFiles;
-    this.numDynamicFiltersProduced = numDynamicFiltersProduced;
-    this.numDynamicFiltersAccepted = numDynamicFiltersAccepted;
-    this.numReplacedWithDynamicFilterRows = numReplacedWithDynamicFilterRows;
-    this.numDynamicFilterInputRows = numDynamicFilterInputRows;
-    this.flushRowCount = flushRowCount;
-    this.abandonedPartialAggregationRows = abandonedPartialAggregationRows;
-    this.loadedToValueHook = loadedToValueHook;
-    this.bloomFilterBlocksByteSize = bloomFilterBlocksByteSize;
-    this.skippedSplits = skippedSplits;
-    this.processedSplits = processedSplits;
-    this.skippedStrides = skippedStrides;
-    this.processedStrides = processedStrides;
-    this.remainingFilterTime = remainingFilterTime;
-    this.ioWaitTime = ioWaitTime;
-    this.storageReadBytes = storageReadBytes;
-    this.storageReads = storageReads;
-    this.localReadBytes = localReadBytes;
-    this.ramReadBytes = ramReadBytes;
-    this.preloadSplits = preloadSplits;
-    this.pageLoadTime = pageLoadTime;
-    this.dataSourceAddSplitTime = dataSourceAddSplitTime;
-    this.dataSourceReadTime = dataSourceReadTime;
-
-    this.physicalWrittenBytes = physicalWrittenBytes;
-    this.writeIOTime = writeIOTime;
-    this.numWrittenFiles = numWrittenFiles;
-    this.loadLazyVectorTime = loadLazyVectorTime;
     this.taskStats = taskStats;
   }
 
-  public OperatorMetrics getOperatorMetrics(int index) {
-    if (index >= inputRows.length) {
-      throw new GlutenException("Invalid index.");
-    }
-
-    return new OperatorMetrics(
-        inputRows[index],
-        inputVectors[index],
-        inputBytes[index],
-        rawInputRows[index],
-        rawInputBytes[index],
-        outputRows[index],
-        outputVectors[index],
-        outputBytes[index],
-        cpuCount[index],
-        wallNanos[index],
-        peakMemoryBytes[index],
-        numMemoryAllocations[index],
-        spilledInputBytes[index],
-        spilledBytes[index],
-        spilledRows[index],
-        spilledPartitions[index],
-        spilledFiles[index],
-        numDynamicFiltersProduced[index],
-        numDynamicFiltersAccepted[index],
-        numReplacedWithDynamicFilterRows[index],
-        numDynamicFilterInputRows[index],
-        flushRowCount[index],
-        abandonedPartialAggregationRows[index],
-        loadedToValueHook[index],
-        bloomFilterBlocksByteSize[index],
-        scanTime[index],
-        skippedSplits[index],
-        processedSplits[index],
-        skippedStrides[index],
-        processedStrides[index],
-        remainingFilterTime[index],
-        ioWaitTime[index],
-        storageReadBytes[index],
-        storageReads[index],
-        localReadBytes[index],
-        ramReadBytes[index],
-        preloadSplits[index],
-        pageLoadTime[index],
-        dataSourceAddSplitTime[index],
-        dataSourceReadTime[index],
-        physicalWrittenBytes[index],
-        writeIOTime[index],
-        numWrittenFiles[index],
-        loadLazyVectorTime[index]);
-  }
-
   public SingleMetric getSingleMetrics() {
     return singleMetric;
   }
diff --git 
a/backends-velox/src/main/java/org/apache/gluten/metrics/OperatorMetrics.java 
b/backends-velox/src/main/java/org/apache/gluten/metrics/OperatorMetrics.java
index 18feed0b9e..bf54167711 100644
--- 
a/backends-velox/src/main/java/org/apache/gluten/metrics/OperatorMetrics.java
+++ 
b/backends-velox/src/main/java/org/apache/gluten/metrics/OperatorMetrics.java
@@ -64,6 +64,9 @@ public class OperatorMetrics implements IOperatorMetrics {
 
   public long loadLazyVectorTime;
 
+  /** Create an empty instance for operator metrics. */
+  public OperatorMetrics() {}
+
   /** Create an instance for operator metrics. */
   public OperatorMetrics(
       long inputRows,
diff --git 
a/backends-velox/src/main/scala/org/apache/gluten/metrics/HashAggregateMetricsUpdater.scala
 
b/backends-velox/src/main/scala/org/apache/gluten/metrics/HashAggregateMetricsUpdater.scala
index dcfa0d7cf3..246879f8ed 100644
--- 
a/backends-velox/src/main/scala/org/apache/gluten/metrics/HashAggregateMetricsUpdater.scala
+++ 
b/backends-velox/src/main/scala/org/apache/gluten/metrics/HashAggregateMetricsUpdater.scala
@@ -92,7 +92,7 @@ class HashAggregateMetricsUpdaterImpl(val metrics: 
Map[String, SQLMetric])
       idx += 1
     }
 
-    loadLazyVectorTime += aggregationMetrics.asScala.last.loadLazyVectorTime
+    loadLazyVectorTime += 
aggregationMetrics.asScala.map(_.loadLazyVectorTime).sum
 
     if (TaskResources.inSparkTask()) {
       SparkMetricsUtil.incMemoryBytesSpilled(
diff --git 
a/backends-velox/src/main/scala/org/apache/gluten/metrics/JoinMetricsUpdater.scala
 
b/backends-velox/src/main/scala/org/apache/gluten/metrics/JoinMetricsUpdater.scala
index b7d32f94bd..3bccc1d753 100644
--- 
a/backends-velox/src/main/scala/org/apache/gluten/metrics/JoinMetricsUpdater.scala
+++ 
b/backends-velox/src/main/scala/org/apache/gluten/metrics/JoinMetricsUpdater.scala
@@ -181,7 +181,7 @@ class HashJoinMetricsUpdater(override val metrics: 
Map[String, SQLMetric])
         hashBuildMetrics.spilledBytes)
     }
 
-    loadLazyVectorTime += joinMetrics.asScala.last.loadLazyVectorTime
+    loadLazyVectorTime += joinMetrics.asScala.map(_.loadLazyVectorTime).sum
   }
 }
 
diff --git 
a/backends-velox/src/main/scala/org/apache/gluten/metrics/MetricsUtil.scala 
b/backends-velox/src/main/scala/org/apache/gluten/metrics/MetricsUtil.scala
index 878eb2dcf1..0c40b6e56e 100644
--- a/backends-velox/src/main/scala/org/apache/gluten/metrics/MetricsUtil.scala
+++ b/backends-velox/src/main/scala/org/apache/gluten/metrics/MetricsUtil.scala
@@ -16,6 +16,7 @@
  */
 package org.apache.gluten.metrics
 
+import org.apache.gluten.exception.GlutenException
 import org.apache.gluten.execution._
 import org.apache.gluten.substrait.{AggregationParams, JoinParams}
 
@@ -23,10 +24,124 @@ import org.apache.spark.internal.Logging
 import org.apache.spark.metrics.TaskStatsAccumulator
 import org.apache.spark.sql.execution.SparkPlan
 
+import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
+
 import java.lang.{Long => JLong}
 import java.util.{ArrayList => JArrayList, List => JList, Map => JMap}
 
+import scala.collection.JavaConverters._
+
 object MetricsUtil extends Logging {
+  private val objectMapper = new ObjectMapper()
+
+  private def value(node: JsonNode, fieldName: String): Long =
+    Option(node.get(fieldName)).map(_.asLong()).getOrElse(0L)
+
+  private def customMetricSum(node: JsonNode, metricName: String): Long =
+    Option(node.get("customStats"))
+      .flatMap(customStats => Option(customStats.get(metricName)))
+      .flatMap(metric => Option(metric.get("sum")))
+      .map(_.asLong())
+      .getOrElse(0L)
+
+  private def customMetricCount(node: JsonNode, metricName: String): Long =
+    Option(node.get("customStats"))
+      .flatMap(customStats => Option(customStats.get(metricName)))
+      .flatMap(metric => Option(metric.get("count")))
+      .map(_.asLong())
+      .getOrElse(0L)
+
+  private def operatorMetricFromJson(node: JsonNode): OperatorMetrics = {
+    val metrics = new OperatorMetrics()
+    metrics.inputRows = value(node, "inputRows")
+    metrics.inputVectors = value(node, "inputVectors")
+    metrics.inputBytes = value(node, "inputBytes")
+    metrics.rawInputRows = value(node, "rawInputRows")
+    metrics.rawInputBytes = value(node, "rawInputBytes")
+    metrics.outputRows = value(node, "outputRows")
+    metrics.outputVectors = value(node, "outputVectors")
+    metrics.outputBytes = value(node, "outputBytes")
+    metrics.cpuCount = value(node, "cpuCount")
+    metrics.wallNanos = value(node, "wallNanos")
+    metrics.peakMemoryBytes = value(node, "peakMemoryBytes")
+    metrics.numMemoryAllocations = value(node, "numMemoryAllocations")
+    metrics.spilledInputBytes = value(node, "spilledInputBytes")
+    metrics.spilledBytes = value(node, "spilledBytes")
+    metrics.spilledRows = value(node, "spilledRows")
+    metrics.spilledPartitions = value(node, "spilledPartitions")
+    metrics.spilledFiles = value(node, "spilledFiles")
+    metrics.numDynamicFiltersProduced = customMetricSum(node, 
"dynamicFiltersProduced")
+    metrics.numDynamicFiltersAccepted = customMetricSum(node, 
"dynamicFiltersAccepted")
+    metrics.numReplacedWithDynamicFilterRows =
+      customMetricSum(node, "replacedWithDynamicFilterRows")
+    metrics.numDynamicFilterInputRows = customMetricSum(node, 
"dynamicFilterInputRows")
+    metrics.flushRowCount = customMetricSum(node, "flushRowCount")
+    metrics.abandonedPartialAggregationRows =
+      customMetricSum(node, "abandonedPartialAggregationRows")
+    metrics.loadedToValueHook = customMetricSum(node, "loadedToValueHook")
+    metrics.bloomFilterBlocksByteSize = customMetricSum(node, 
"bloomFilterSize")
+    metrics.scanTime = customMetricSum(node, "totalScanTime")
+    metrics.skippedSplits = customMetricSum(node, "skippedSplits")
+    metrics.processedSplits = customMetricSum(node, "processedSplits")
+    metrics.skippedStrides = customMetricSum(node, "skippedStrides")
+    metrics.processedStrides = customMetricSum(node, "processedStrides")
+    metrics.remainingFilterTime = customMetricSum(node, 
"totalRemainingFilterWallNanos")
+    metrics.ioWaitTime = customMetricSum(node, "ioWaitWallNanos")
+    metrics.storageReadBytes = customMetricSum(node, "storageReadBytes")
+    metrics.storageReads = customMetricCount(node, "storageReadBytes")
+    metrics.localReadBytes = customMetricSum(node, "localReadBytes")
+    metrics.ramReadBytes = customMetricSum(node, "ramReadBytes")
+    metrics.preloadSplits = customMetricSum(node, "readyPreloadedSplits")
+    metrics.pageLoadTime = customMetricSum(node, "pageLoadTimeNs")
+    metrics.dataSourceAddSplitTime = customMetricSum(node, 
"dataSourceAddSplitWallNanos") +
+      customMetricSum(node, "waitForPreloadSplitNanos")
+    metrics.dataSourceReadTime = customMetricSum(node, 
"dataSourceReadWallNanos")
+    metrics.physicalWrittenBytes = value(node, "physicalWrittenBytes")
+    metrics.writeIOTime = customMetricSum(node, "writeIOWallNanos")
+    metrics.numWrittenFiles = customMetricSum(node, "numWrittenFiles")
+    metrics
+  }
+
+  private def parseNativeOperatorMetrics(metrics: Metrics): 
JArrayList[OperatorMetrics] = {
+    val operatorMetrics = new JArrayList[OperatorMetrics]()
+    if (metrics.numMetrics == 0 || metrics.metricsJson == null || 
metrics.metricsJson.isEmpty) {
+      return operatorMetrics
+    }
+
+    val root = objectMapper.readTree(metrics.metricsJson)
+    val omittedNodeIds = 
root.path("omittedNodeIds").elements().asScala.map(_.asText()).toSet
+    val nodeStats = root.path("nodeStats")
+    val orderedNodeIds = root.path("orderedNodeIds")
+    orderedNodeIds.elements().asScala.foreach {
+      nodeIdNode =>
+        val nodeId = nodeIdNode.asText()
+        val node = nodeStats.get(nodeId)
+        if (node == null || node.isNull || node.isMissingNode) {
+          if (omittedNodeIds.contains(nodeId)) {
+            operatorMetrics.add(new OperatorMetrics())
+          } else {
+            throw new GlutenException(s"Node id cannot be found in native 
metrics: $nodeId.")
+          }
+        } else {
+          node.path("operatorStats").elements().asScala.foreach {
+            opStats => operatorMetrics.add(operatorMetricFromJson(opStats))
+          }
+        }
+    }
+
+    val loadLazyVectorMetricsIdx = operatorMetrics.size() - 1
+    if (loadLazyVectorMetricsIdx >= 0) {
+      operatorMetrics.get(loadLazyVectorMetricsIdx).loadLazyVectorTime =
+        root.path("loadLazyVectorTime").asLong(0L)
+    }
+
+    if (operatorMetrics.size() != metrics.numMetrics) {
+      throw new GlutenException(
+        s"Unexpected native metrics size. Expected ${metrics.numMetrics}, " +
+          s"got ${operatorMetrics.size()}.")
+    }
+    operatorMetrics
+  }
 
   /**
    * Generate the function which updates metrics fetched from certain iterator 
to transformers.
@@ -240,7 +355,8 @@ object MetricsUtil extends Logging {
       mutNode: MetricsUpdaterTree,
       relMap: JMap[JLong, JList[JLong]],
       operatorIdx: JLong,
-      metrics: Metrics,
+      nativeMetrics: JList[OperatorMetrics],
+      singleMetrics: Metrics.SingleMetric,
       metricsIdx: Int,
       joinParamsMap: JMap[JLong, JoinParams],
       aggParamsMap: JMap[JLong, AggregationParams]): (JLong, Int) = {
@@ -253,7 +369,7 @@ object MetricsUtil extends Logging {
       .get(operatorIdx)
       .forEach(
         _ => {
-          operatorMetrics.add(metrics.getOperatorMetrics(curMetricsIdx))
+          operatorMetrics.add(nativeMetrics.get(curMetricsIdx))
           curMetricsIdx -= 1
         })
 
@@ -264,22 +380,22 @@ object MetricsUtil extends Logging {
           p.postProjectionNeeded = false
           p
         }
-        smj.updateJoinMetrics(operatorMetrics, metrics.getSingleMetrics, 
joinParams)
+        smj.updateJoinMetrics(operatorMetrics, singleMetrics, joinParams)
       case ju: JoinMetricsUpdaterBase =>
         // JoinRel and CrossRel output two suites of metrics respectively for 
build and probe.
         // Therefore, fetch one more suite of metrics here.
-        operatorMetrics.add(metrics.getOperatorMetrics(curMetricsIdx))
+        operatorMetrics.add(nativeMetrics.get(curMetricsIdx))
         curMetricsIdx -= 1
         val joinParams = Option(joinParamsMap.get(operatorIdx)).getOrElse {
           val p = JoinParams()
           p.postProjectionNeeded = false
           p
         }
-        ju.updateJoinMetrics(operatorMetrics, metrics.getSingleMetrics, 
joinParams)
+        ju.updateJoinMetrics(operatorMetrics, singleMetrics, joinParams)
       case u: UnionMetricsUpdater =>
         // Union outputs two suites of metrics respectively.
         // Therefore, fetch one more suite of metrics here.
-        operatorMetrics.add(metrics.getOperatorMetrics(curMetricsIdx))
+        operatorMetrics.add(nativeMetrics.get(curMetricsIdx))
         curMetricsIdx -= 1
         u.updateUnionMetrics(operatorMetrics)
       case hau: HashAggregateMetricsUpdater =>
@@ -315,7 +431,8 @@ object MetricsUtil extends Logging {
           child,
           relMap,
           newOperatorIdx,
-          metrics,
+          nativeMetrics,
+          singleMetrics,
           newMetricsIdx,
           joinParamsMap,
           aggParamsMap)
@@ -354,7 +471,8 @@ object MetricsUtil extends Logging {
       taskStatsAccumulator: TaskStatsAccumulator): IMetrics => Unit = {
     imetrics =>
       val metrics = imetrics.asInstanceOf[Metrics]
-      val numNativeMetrics = metrics.inputRows.length
+      val nativeMetrics = parseNativeOperatorMetrics(metrics)
+      val numNativeMetrics = nativeMetrics.size()
       if (numNativeMetrics == 0) {
         ()
       } else {
@@ -362,7 +480,8 @@ object MetricsUtil extends Logging {
           mutNode,
           relMap,
           operatorIdx,
-          metrics,
+          nativeMetrics,
+          metrics.getSingleMetrics,
           numNativeMetrics - 1,
           joinParamsMap,
           aggParamsMap)
diff --git 
a/backends-velox/src/main/scala/org/apache/gluten/metrics/NestedLoopJoinMetricsUpdater.scala
 
b/backends-velox/src/main/scala/org/apache/gluten/metrics/NestedLoopJoinMetricsUpdater.scala
index f2a4dfff36..0e414c1d80 100644
--- 
a/backends-velox/src/main/scala/org/apache/gluten/metrics/NestedLoopJoinMetricsUpdater.scala
+++ 
b/backends-velox/src/main/scala/org/apache/gluten/metrics/NestedLoopJoinMetricsUpdater.scala
@@ -74,6 +74,6 @@ class NestedLoopJoinMetricsUpdater(override val metrics: 
Map[String, SQLMetric])
     nestedLoopJoinBuildPeakMemoryBytes += 
nestedLoopJoinBuildMetrics.peakMemoryBytes
     nestedLoopJoinBuildNumMemoryAllocations += 
nestedLoopJoinBuildMetrics.numMemoryAllocations
 
-    loadLazyVectorTime += joinMetrics.asScala.last.loadLazyVectorTime
+    loadLazyVectorTime += joinMetrics.asScala.map(_.loadLazyVectorTime).sum
   }
 }
diff --git 
a/backends-velox/src/main/scala/org/apache/gluten/metrics/UnionMetricsUpdater.scala
 
b/backends-velox/src/main/scala/org/apache/gluten/metrics/UnionMetricsUpdater.scala
index 4a3fc961eb..95d70b775c 100644
--- 
a/backends-velox/src/main/scala/org/apache/gluten/metrics/UnionMetricsUpdater.scala
+++ 
b/backends-velox/src/main/scala/org/apache/gluten/metrics/UnionMetricsUpdater.scala
@@ -33,6 +33,6 @@ class UnionMetricsUpdater(val metrics: Map[String, 
SQLMetric]) extends MetricsUp
     metrics("inputBytes") += localExchangeMetrics.inputBytes
     metrics("cpuCount") += localExchangeMetrics.cpuCount
     metrics("wallNanos") += localExchangeMetrics.wallNanos
-    metrics("loadLazyVectorTime") += 
unionMetrics.asScala.last.loadLazyVectorTime
+    metrics("loadLazyVectorTime") += 
unionMetrics.asScala.map(_.loadLazyVectorTime).sum
   }
 }
diff --git a/cpp/core/jni/JniWrapper.cc b/cpp/core/jni/JniWrapper.cc
index ea397e6527..29036132a4 100644
--- a/cpp/core/jni/JniWrapper.cc
+++ b/cpp/core/jni/JniWrapper.cc
@@ -308,11 +308,8 @@ jint JNI_OnLoad(JavaVM* vm, void* reserved) {
 
   metricsBuilderClass = createGlobalClassReferenceOrError(env, 
"Lorg/apache/gluten/metrics/Metrics;");
 
-  metricsBuilderConstructor = getMethodIdOrError(
-      env,
-      metricsBuilderClass,
-      "<init>",
-      
"([J[J[J[J[J[J[J[J[J[JJ[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[J[JLjava/lang/String;)V");
+  metricsBuilderConstructor =
+      getMethodIdOrError(env, metricsBuilderClass, "<init>", 
"(Ljava/lang/String;IJLjava/lang/String;)V");
 
   nativeColumnarToRowInfoClass =
       createGlobalClassReferenceOrError(env, 
"Lorg/apache/gluten/vectorized/NativeColumnarToRowInfo;");
@@ -340,6 +337,7 @@ void JNI_OnUnload(JavaVM* vm, void* reserved) {
   env->DeleteGlobalRef(splitResultClass);
   env->DeleteGlobalRef(nativeColumnarToRowInfoClass);
   env->DeleteGlobalRef(byteArrayClass);
+  env->DeleteGlobalRef(metricsBuilderClass);
   env->DeleteGlobalRef(jniUnsafeByteBufferClass);
   env->DeleteGlobalRef(shuffleReaderMetricsClass);
 
@@ -625,63 +623,15 @@ JNIEXPORT jobject JNICALL 
Java_org_apache_gluten_metrics_IteratorMetricsJniWrapp
     numMetrics = metrics->numMetrics;
   }
 
-  jlongArray longArray[Metrics::kNum];
-  for (auto i = static_cast<int>(Metrics::kBegin); i != 
static_cast<int>(Metrics::kEnd); ++i) {
-    longArray[i] = env->NewLongArray(numMetrics);
-    if (metrics) {
-      env->SetLongArrayRegion(longArray[i], 0, numMetrics, 
metrics->get((Metrics::TYPE)i));
-    }
-  }
-
+  jstring metricsJson = env->NewStringUTF(metrics ? metrics->json.c_str() : 
"");
+  jstring taskStats = metrics && metrics->stats.has_value() ? 
env->NewStringUTF(metrics->stats->c_str()) : nullptr;
   return env->NewObject(
       metricsBuilderClass,
       metricsBuilderConstructor,
-      longArray[Metrics::kInputRows],
-      longArray[Metrics::kInputVectors],
-      longArray[Metrics::kInputBytes],
-      longArray[Metrics::kRawInputRows],
-      longArray[Metrics::kRawInputBytes],
-      longArray[Metrics::kOutputRows],
-      longArray[Metrics::kOutputVectors],
-      longArray[Metrics::kOutputBytes],
-      longArray[Metrics::kCpuCount],
-      longArray[Metrics::kWallNanos],
+      metricsJson,
+      static_cast<jint>(numMetrics),
       metrics ? metrics->veloxToArrow : -1,
-      longArray[Metrics::kPeakMemoryBytes],
-      longArray[Metrics::kNumMemoryAllocations],
-      longArray[Metrics::kSpilledInputBytes],
-      longArray[Metrics::kSpilledBytes],
-      longArray[Metrics::kSpilledRows],
-      longArray[Metrics::kSpilledPartitions],
-      longArray[Metrics::kSpilledFiles],
-      longArray[Metrics::kNumDynamicFiltersProduced],
-      longArray[Metrics::kNumDynamicFiltersAccepted],
-      longArray[Metrics::kNumReplacedWithDynamicFilterRows],
-      longArray[Metrics::kNumDynamicFilterInputRows],
-      longArray[Metrics::kFlushRowCount],
-      longArray[Metrics::kAbandonedPartialAggregationRows],
-      longArray[Metrics::kLoadedToValueHook],
-      longArray[Metrics::kBloomFilterBlocksByteSize],
-      longArray[Metrics::kScanTime],
-      longArray[Metrics::kSkippedSplits],
-      longArray[Metrics::kProcessedSplits],
-      longArray[Metrics::kSkippedStrides],
-      longArray[Metrics::kProcessedStrides],
-      longArray[Metrics::kRemainingFilterTime],
-      longArray[Metrics::kIoWaitTime],
-      longArray[Metrics::kStorageReadBytes],
-      longArray[Metrics::kStorageReads],
-      longArray[Metrics::kLocalReadBytes],
-      longArray[Metrics::kRamReadBytes],
-      longArray[Metrics::kPreloadSplits],
-      longArray[Metrics::kPageLoadTime],
-      longArray[Metrics::kDataSourceAddSplitWallNanos],
-      longArray[Metrics::kDataSourceReadWallNanos],
-      longArray[Metrics::kPhysicalWrittenBytes],
-      longArray[Metrics::kWriteIOTime],
-      longArray[Metrics::kNumWrittenFiles],
-      longArray[Metrics::kLoadLazyVectorTime],
-      metrics && metrics->stats.has_value() ? 
env->NewStringUTF(metrics->stats->c_str()) : nullptr);
+      taskStats);
 
   JNI_METHOD_END(nullptr)
 }
diff --git a/cpp/core/utils/Metrics.h b/cpp/core/utils/Metrics.h
index 67c0b485c7..f6e0365f97 100644
--- a/cpp/core/utils/Metrics.h
+++ b/cpp/core/utils/Metrics.h
@@ -17,7 +17,9 @@
 
 #pragma once
 
-#include <memory>
+#include <optional>
+#include <string>
+#include <utility>
 
 namespace gluten {
 
@@ -25,96 +27,17 @@ struct Metrics {
   unsigned int numMetrics = 0;
   long veloxToArrow = 0;
 
-  // The underlying memory buffer.
-  std::unique_ptr<long[]> array;
-
-  // Point to array.get() after the above unique_ptr created.
-  long* arrayRawPtr = nullptr;
-
+  // Structured metrics payload produced by the backend and decoded on JVM 
side.
+  std::string json;
   // Optional stats string.
   std::optional<std::string> stats = std::nullopt;
 
-  enum TYPE {
-    // Begin from 0.
-    kBegin = 0,
-
-    kInputRows = kBegin,
-    kInputVectors,
-    kInputBytes,
-
-    kRawInputRows,
-    kRawInputBytes,
-
-    kOutputRows,
-    kOutputVectors,
-    kOutputBytes,
-
-    // CpuWallTiming.
-    kCpuCount,
-    kWallNanos,
-
-    kPeakMemoryBytes,
-    kNumMemoryAllocations,
-
-    // Spill.
-    kSpilledInputBytes,
-    kSpilledBytes,
-    kSpilledRows,
-    kSpilledPartitions,
-    kSpilledFiles,
-
-    // Runtime metrics.
-    kNumDynamicFiltersProduced,
-    kNumDynamicFiltersAccepted,
-    kNumReplacedWithDynamicFilterRows,
-    kNumDynamicFilterInputRows,
-    kFlushRowCount,
-    kAbandonedPartialAggregationRows,
-    kLoadedToValueHook,
-    kBloomFilterBlocksByteSize,
-    kScanTime,
-    kSkippedSplits,
-    kProcessedSplits,
-    kSkippedStrides,
-    kProcessedStrides,
-    kRemainingFilterTime,
-    kIoWaitTime,
-    kStorageReadBytes,
-    kStorageReads,
-    kLocalReadBytes,
-    kRamReadBytes,
-    kPreloadSplits,
-    kPageLoadTime,
-    kDataSourceAddSplitWallNanos,
-    kDataSourceReadWallNanos,
-
-    // Write metrics.
-    kPhysicalWrittenBytes,
-    kWriteIOTime,
-    kNumWrittenFiles,
-
-    // Load lazy vector.
-    kLoadLazyVectorTime,
-
-    // The end of enum items.
-    kEnd,
-    kNum = kEnd - kBegin
-  };
-
-  Metrics(unsigned int numMetrics) : numMetrics(numMetrics), array(new 
long[numMetrics * kNum]) {
-    arrayRawPtr = array.get();
-  }
+  Metrics(unsigned int numMetrics, std::string json) : numMetrics(numMetrics), 
json(std::move(json)) {}
 
   Metrics(const Metrics&) = delete;
   Metrics(Metrics&&) = delete;
   Metrics& operator=(const Metrics&) = delete;
   Metrics& operator=(Metrics&&) = delete;
-
-  long* get(TYPE type) {
-    assert(static_cast<int>(type) >= static_cast<int>(kBegin) && 
static_cast<int>(type) < static_cast<int>(kEnd));
-    auto offset = (static_cast<int>(type) - static_cast<int>(kBegin)) * 
numMetrics;
-    return &arrayRawPtr[offset];
-  }
 };
 
 } // namespace gluten
diff --git a/cpp/velox/compute/WholeStageResultIterator.cc 
b/cpp/velox/compute/WholeStageResultIterator.cc
index 29213c2e0e..0cb840c7b5 100644
--- a/cpp/velox/compute/WholeStageResultIterator.cc
+++ b/cpp/velox/compute/WholeStageResultIterator.cc
@@ -17,6 +17,7 @@
 #include "WholeStageResultIterator.h"
 #include <folly/executors/CPUThreadPoolExecutor.h>
 #include <folly/executors/thread_factory/NamedThreadFactory.h>
+#include <folly/json.h>
 #include <folly/system/ThreadName.h>
 #include <optional>
 #include "VeloxBackend.h"
@@ -47,33 +48,6 @@ namespace gluten {
 
 namespace {
 
-// metrics
-const std::string kDynamicFiltersProduced = "dynamicFiltersProduced";
-const std::string kDynamicFiltersAccepted = "dynamicFiltersAccepted";
-const std::string kReplacedWithDynamicFilterRows = 
"replacedWithDynamicFilterRows";
-const std::string kDynamicFilterInputRows = "dynamicFilterInputRows";
-const std::string kFlushRowCount = "flushRowCount";
-const std::string kAbandonedPartialAggregationRows = 
"abandonedPartialAggregationRows";
-const std::string kLoadedToValueHook = "loadedToValueHook";
-const std::string kBloomFilterBlocksByteSize = "bloomFilterSize";
-const std::string kTotalScanTime = "totalScanTime";
-const std::string kSkippedSplits = "skippedSplits";
-const std::string kProcessedSplits = "processedSplits";
-const std::string kSkippedStrides = "skippedStrides";
-const std::string kProcessedStrides = "processedStrides";
-const std::string kRemainingFilterTime = "totalRemainingFilterWallNanos";
-const std::string kIoWaitTime = "ioWaitWallNanos";
-const std::string kStorageReadBytes = "storageReadBytes";
-const std::string kLocalReadBytes = "localReadBytes";
-const std::string kRamReadBytes = "ramReadBytes";
-const std::string kPreloadSplits = "readyPreloadedSplits";
-const std::string kPageLoadTime = "pageLoadTimeNs";
-const std::string kDataSourceAddSplitWallNanos = "dataSourceAddSplitWallNanos";
-const std::string kWaitForPreloadSplitNanos = "waitForPreloadSplitNanos";
-const std::string kDataSourceReadWallNanos = "dataSourceReadWallNanos";
-const std::string kNumWrittenFiles = "numWrittenFiles";
-const std::string kWriteIOTime = "writeIOWallNanos";
-
 // others
 const std::string kHiveDefaultPartition = "__HIVE_DEFAULT_PARTITION__";
 const std::string kDeltaTableFormat = "delta";
@@ -487,116 +461,55 @@ void WholeStageResultIterator::collectMetrics() {
   }
 
   auto planStats = velox::exec::toPlanStats(taskStats);
-  // Calculate the total number of metrics.
-  int statsNum = 0;
+  folly::dynamic orderedNodeIds = folly::dynamic::array();
+  folly::dynamic omittedNodeIds = folly::dynamic::array();
+  folly::dynamic nodeStats = folly::dynamic::object();
+  unsigned int statsNum = 0;
+
   for (int idx = 0; idx < orderedNodeIds_.size(); idx++) {
     const auto& nodeId = orderedNodeIds_[idx];
+    orderedNodeIds.push_back(nodeId);
+
     if (planStats.find(nodeId) == planStats.end()) {
       if (omittedNodeIds_.find(nodeId) == omittedNodeIds_.end()) {
         LOG(WARNING) << "Not found node id: " << nodeId;
         LOG(WARNING) << "Plan Node: " << std::endl << 
veloxPlan_->toString(true, true);
         throw std::runtime_error("Node id cannot be found in plan status.");
       }
-      // Special handing for Filter over Project case. Filter metrics are
-      // omitted.
+      omittedNodeIds.push_back(nodeId);
       statsNum += 1;
       continue;
     }
-    statsNum += planStats.at(nodeId).operatorStats.size();
-  }
-
-  metrics_ = std::make_unique<Metrics>(statsNum);
-
-  int metricIndex = 0;
-  for (int idx = 0; idx < orderedNodeIds_.size(); idx++) {
-    metrics_->get(Metrics::kLoadLazyVectorTime)[metricIndex] = 0;
-
-    const auto& nodeId = orderedNodeIds_[idx];
-    if (planStats.find(nodeId) == planStats.end()) {
-      // Special handing for Filter over Project case. Filter metrics are
-      // omitted.
-      metrics_->get(Metrics::kOutputRows)[metricIndex] = 0;
-      metrics_->get(Metrics::kOutputVectors)[metricIndex] = 0;
-      metrics_->get(Metrics::kOutputBytes)[metricIndex] = 0;
-      metrics_->get(Metrics::kCpuCount)[metricIndex] = 0;
-      metrics_->get(Metrics::kWallNanos)[metricIndex] = 0;
-      metrics_->get(Metrics::kPeakMemoryBytes)[metricIndex] = 0;
-      metrics_->get(Metrics::kNumMemoryAllocations)[metricIndex] = 0;
-      metricIndex += 1;
-      continue;
-    }
 
     const auto& stats = planStats.at(nodeId);
-    // Add each operator stats into metrics.
+    folly::dynamic operatorStats = folly::dynamic::array();
     for (const auto& entry : stats.operatorStats) {
-      const auto& second = entry.second;
-      metrics_->get(Metrics::kInputRows)[metricIndex] = second->inputRows;
-      metrics_->get(Metrics::kInputVectors)[metricIndex] = 
second->inputVectors;
-      metrics_->get(Metrics::kInputBytes)[metricIndex] = second->inputBytes;
-      metrics_->get(Metrics::kRawInputRows)[metricIndex] = 
second->rawInputRows;
-      metrics_->get(Metrics::kRawInputBytes)[metricIndex] = 
second->rawInputBytes;
-      metrics_->get(Metrics::kOutputRows)[metricIndex] = second->outputRows;
-      metrics_->get(Metrics::kOutputVectors)[metricIndex] = 
second->outputVectors;
-      metrics_->get(Metrics::kOutputBytes)[metricIndex] = second->outputBytes;
-      metrics_->get(Metrics::kCpuCount)[metricIndex] = 
second->cpuWallTiming.count;
-      metrics_->get(Metrics::kWallNanos)[metricIndex] = 
second->cpuWallTiming.wallNanos;
-      metrics_->get(Metrics::kPeakMemoryBytes)[metricIndex] = 
second->peakMemoryBytes;
-      metrics_->get(Metrics::kNumMemoryAllocations)[metricIndex] = 
second->numMemoryAllocations;
-      metrics_->get(Metrics::kSpilledInputBytes)[metricIndex] = 
second->spilledInputBytes;
-      metrics_->get(Metrics::kSpilledBytes)[metricIndex] = 
second->spilledBytes;
-      metrics_->get(Metrics::kSpilledRows)[metricIndex] = second->spilledRows;
-      metrics_->get(Metrics::kSpilledPartitions)[metricIndex] = 
second->spilledPartitions;
-      metrics_->get(Metrics::kSpilledFiles)[metricIndex] = 
second->spilledFiles;
-      metrics_->get(Metrics::kNumDynamicFiltersProduced)[metricIndex] =
-          runtimeMetric("sum", second->customStats, kDynamicFiltersProduced);
-      metrics_->get(Metrics::kNumDynamicFiltersAccepted)[metricIndex] =
-          runtimeMetric("sum", second->customStats, kDynamicFiltersAccepted);
-      metrics_->get(Metrics::kNumReplacedWithDynamicFilterRows)[metricIndex] =
-          runtimeMetric("sum", second->customStats, 
kReplacedWithDynamicFilterRows);
-      metrics_->get(Metrics::kNumDynamicFilterInputRows)[metricIndex] =
-          runtimeMetric("sum", second->customStats, kDynamicFilterInputRows);
-      metrics_->get(Metrics::kFlushRowCount)[metricIndex] = 
runtimeMetric("sum", second->customStats, kFlushRowCount);
-      metrics_->get(Metrics::kAbandonedPartialAggregationRows)[metricIndex] =
-          runtimeMetric("sum", second->customStats, 
kAbandonedPartialAggregationRows);
-      metrics_->get(Metrics::kLoadedToValueHook)[metricIndex] =
-          runtimeMetric("sum", second->customStats, kLoadedToValueHook);
-      metrics_->get(Metrics::kBloomFilterBlocksByteSize)[metricIndex] =
-          runtimeMetric("sum", second->customStats, 
kBloomFilterBlocksByteSize);
-      metrics_->get(Metrics::kScanTime)[metricIndex] = runtimeMetric("sum", 
second->customStats, kTotalScanTime);
-      metrics_->get(Metrics::kSkippedSplits)[metricIndex] = 
runtimeMetric("sum", second->customStats, kSkippedSplits);
-      metrics_->get(Metrics::kProcessedSplits)[metricIndex] =
-          runtimeMetric("sum", second->customStats, kProcessedSplits);
-      metrics_->get(Metrics::kSkippedStrides)[metricIndex] = 
runtimeMetric("sum", second->customStats, kSkippedStrides);
-      metrics_->get(Metrics::kProcessedStrides)[metricIndex] =
-          runtimeMetric("sum", second->customStats, kProcessedStrides);
-      metrics_->get(Metrics::kRemainingFilterTime)[metricIndex] =
-          runtimeMetric("sum", second->customStats, kRemainingFilterTime);
-      metrics_->get(Metrics::kIoWaitTime)[metricIndex] = runtimeMetric("sum", 
second->customStats, kIoWaitTime);
-      metrics_->get(Metrics::kStorageReadBytes)[metricIndex] =
-          runtimeMetric("sum", second->customStats, kStorageReadBytes);
-      metrics_->get(Metrics::kStorageReads)[metricIndex] =
-          runtimeMetric("count", second->customStats, kStorageReadBytes);
-      metrics_->get(Metrics::kLocalReadBytes)[metricIndex] = 
runtimeMetric("sum", second->customStats, kLocalReadBytes);
-      metrics_->get(Metrics::kRamReadBytes)[metricIndex] = 
runtimeMetric("sum", second->customStats, kRamReadBytes);
-      metrics_->get(Metrics::kPreloadSplits)[metricIndex] =
-          runtimeMetric("sum", entry.second->customStats, kPreloadSplits);
-      metrics_->get(Metrics::kPageLoadTime)[metricIndex] = 
runtimeMetric("sum", second->customStats, kPageLoadTime);
-      metrics_->get(Metrics::kDataSourceAddSplitWallNanos)[metricIndex] =
-          runtimeMetric("sum", second->customStats, 
kDataSourceAddSplitWallNanos) +
-          runtimeMetric("sum", second->customStats, kWaitForPreloadSplitNanos);
-      metrics_->get(Metrics::kDataSourceReadWallNanos)[metricIndex] =
-          runtimeMetric("sum", second->customStats, kDataSourceReadWallNanos);
-      metrics_->get(Metrics::kNumWrittenFiles)[metricIndex] =
-          runtimeMetric("sum", entry.second->customStats, kNumWrittenFiles);
-      metrics_->get(Metrics::kPhysicalWrittenBytes)[metricIndex] = 
second->physicalWrittenBytes;
-      metrics_->get(Metrics::kWriteIOTime)[metricIndex] = runtimeMetric("sum", 
second->customStats, kWriteIOTime);
-
-      metricIndex += 1;
+      const auto& opStats = entry.second;
+      folly::dynamic customStats = folly::dynamic::object();
+      for (const auto& customMetric : opStats->customStats) {
+        customStats[customMetric.first] = folly::dynamic::object("sum", 
customMetric.second.sum)(
+            "count", customMetric.second.count)("min", 
customMetric.second.min)("max", customMetric.second.max);
+      }
+
+      operatorStats.push_back(folly::dynamic::object("inputRows", 
opStats->inputRows)(
+          "inputVectors", opStats->inputVectors)("inputBytes", 
opStats->inputBytes)(
+          "rawInputRows", opStats->rawInputRows)("rawInputBytes", 
opStats->rawInputBytes)(
+          "outputRows", opStats->outputRows)("outputVectors", 
opStats->outputVectors)(
+          "outputBytes", opStats->outputBytes)("cpuCount", 
opStats->cpuWallTiming.count)(
+          "wallNanos", opStats->cpuWallTiming.wallNanos)("peakMemoryBytes", 
opStats->peakMemoryBytes)(
+          "numMemoryAllocations", 
opStats->numMemoryAllocations)("spilledInputBytes", opStats->spilledInputBytes)(
+          "spilledBytes", opStats->spilledBytes)("spilledRows", 
opStats->spilledRows)(
+          "spilledPartitions", opStats->spilledPartitions)("spilledFiles", 
opStats->spilledFiles)(
+          "physicalWrittenBytes", 
opStats->physicalWrittenBytes)("customStats", customStats));
     }
+
+    statsNum += static_cast<unsigned int>(operatorStats.size());
+    nodeStats[nodeId] = folly::dynamic::object("operatorStats", operatorStats);
   }
 
-  // Put the loadLazyVector time into the metrics of the last operator.
-  metrics_->get(Metrics::kLoadLazyVectorTime)[orderedNodeIds_.size() - 1] = 
loadLazyVectorTime_;
+  folly::dynamic payload = folly::dynamic::object("orderedNodeIds", 
orderedNodeIds)("omittedNodeIds", omittedNodeIds)(
+      "loadLazyVectorTime", loadLazyVectorTime_)("nodeStats", nodeStats);
+  metrics_ = std::make_unique<Metrics>(statsNum, folly::toJson(payload));
 
   // Populate the metrics with task stats for long running tasks.
   if (const int64_t collectTaskStatsThreshold =
@@ -609,27 +522,6 @@ void WholeStageResultIterator::collectMetrics() {
   }
 }
 
-int64_t WholeStageResultIterator::runtimeMetric(
-    const std::string& type,
-    const std::unordered_map<std::string, velox::RuntimeMetric>& runtimeStats,
-    const std::string& metricId) {
-  if (runtimeStats.find(metricId) == runtimeStats.end()) {
-    return 0;
-  }
-
-  if (type == "sum") {
-    return runtimeStats.at(metricId).sum;
-  } else if (type == "count") {
-    return runtimeStats.at(metricId).count;
-  } else if (type == "min") {
-    return runtimeStats.at(metricId).min;
-  } else if (type == "max") {
-    return runtimeStats.at(metricId).max;
-  } else {
-    return 0;
-  }
-}
-
 std::unordered_map<std::string, std::string> 
WholeStageResultIterator::getQueryContextConf() {
   std::unordered_map<std::string, std::string> configs = {};
   // Find batch size from Spark confs. If found, set the preferred and max 
batch size.
diff --git a/cpp/velox/compute/WholeStageResultIterator.h 
b/cpp/velox/compute/WholeStageResultIterator.h
index ebb13d342f..fb2c9229ce 100644
--- a/cpp/velox/compute/WholeStageResultIterator.h
+++ b/cpp/velox/compute/WholeStageResultIterator.h
@@ -123,12 +123,6 @@ class WholeStageResultIterator : public 
SplitAwareColumnarBatchIterator {
   /// Collect Velox metrics.
   void collectMetrics();
 
-  /// Return a certain type of runtime metric. Supported metric types are: 
sum, count, min, max.
-  static int64_t runtimeMetric(
-      const std::string& type,
-      const std::unordered_map<std::string, facebook::velox::RuntimeMetric>& 
runtimeStats,
-      const std::string& metricId);
-
   /// Memory.
   VeloxMemoryManager* memoryManager_;
 
diff --git a/cpp/velox/tests/RuntimeTest.cc b/cpp/velox/tests/RuntimeTest.cc
index f743692539..df36ceaed1 100644
--- a/cpp/velox/tests/RuntimeTest.cc
+++ b/cpp/velox/tests/RuntimeTest.cc
@@ -103,7 +103,7 @@ class DummyRuntime final : public Runtime {
     throw GlutenException("Not yet implemented");
   }
   Metrics* getMetrics(ColumnarBatchIterator* rawIter, int64_t exportNanos) 
override {
-    static Metrics m(1);
+    static Metrics m(0, 
R"({"orderedNodeIds":[],"omittedNodeIds":[],"loadLazyVectorTime":0,"nodeStats":{}})");
     return &m;
   }
   std::shared_ptr<ShuffleReader> createShuffleReader(
diff --git a/docs/developers/MetricsFramework.md 
b/docs/developers/MetricsFramework.md
new file mode 100644
index 0000000000..de6568b1d9
--- /dev/null
+++ b/docs/developers/MetricsFramework.md
@@ -0,0 +1,340 @@
+---
+layout: page
+title: Metrics Framework
+nav_order: 15
+parent: Developer Overview
+---
+
+# Metrics Framework
+
+This document explains how Velox operator metrics are mapped back to Gluten
+Spark SQL metrics. The mapping has three ordered steps:
+
+1. Native code treefies the Velox plan into `orderedNodeIds`.
+2. Scala parses the JSON payload into a flat `JList[OperatorMetrics]`.
+3. Scala treefies the Spark plan into `MetricsUpdaterTree` and consumes the
+   flattened native metrics in that order.
+
+The JSON transport keeps the JNI boundary small and stable. C++ reports named
+Velox stats in a deterministic order; Scala owns the mapping from those stats 
to
+Gluten operator metrics.
+
+## Mapping Overview
+
+The metrics mapping joins three views of the same execution:
+
+- Velox plan node ids and task stats from native execution.
+- Substrait rel ids recorded in `operatorToRelsMap`.
+- Spark physical operators, each with a `MetricsUpdater`.
+
+During planning, Gluten assigns an operator id to each transform operator and
+records the Substrait rel ids generated for that operator:
+
+```text
+operatorToRelsMap: Spark operator id -> Substrait rel ids
+```
+
+After execution, native code serializes Velox stats in `orderedNodeIds` order.
+Scala parses that JSON into:
+
+```text
+Velox JSON node stats -> JList[OperatorMetrics]
+```
+
+Finally, `MetricsUtil.updateTransformerMetricsInternal` walks the
+`MetricsUpdaterTree`, `operatorToRelsMap`, and native metric list together. The
+current implementation consumes both Spark operator ids and native metric 
indexes
+from the end:
+
+```text
+operatorIdx = relMap.size() - 1
+metricsIdx  = nativeMetrics.size() - 1
+```
+
+Each Spark operator consumes the native metric suites that correspond to its
+Substrait rel ids, merges or interprets them, and writes the final values into
+Spark `SQLMetric`s.
+
+## Step 1: Native Treefy to orderedNodeIds
+
+`orderedNodeIds` is produced by `WholeStageResultIterator::getOrderedNodeIds`.
+This is a native treefy process over the Velox plan. It converts the Velox plan
+tree into a deterministic list of node ids that Scala can later flatten into
+`OperatorMetrics`.
+
+This step is required because Velox task stats are keyed by plan node id. A map
+of stats does not preserve the traversal order needed by Gluten's metrics
+updater tree. Native code must provide that order explicitly.
+
+For ordinary Velox nodes, `getOrderedNodeIds` performs post-order traversal:
+
+```text
+visit all sources first
+then append current node id
+```
+
+This gives Scala a list that matches Substrait rel order and can be consumed in
+reverse by decrementing `metricsIdx`.
+
+`getOrderedNodeIds` also encodes Velox-specific plan-shape adjustments that
+Scala cannot reliably infer from task stats alone:
+
+- For Project nodes, it visits the source first and then the Project node.
+- If the Project source is a Filter node, Velox has mapped Filter over Project
+  into a FilterProject operator. The Filter node has no independent stats, so
+  native code records the Filter id in `omittedNodeIds`.
+- For `LocalPartitionNode`, Velox may insert local exchange/partition nodes and
+  optional projected children. Native code walks through projected children 
when
+  present, otherwise through the source directly. When the node has two 
sources,
+  it records the `LocalPartitionNode` id as the concrete Spark native union
+  transformer.
+
+The native treefy result becomes the ordering contract for the rest of the
+framework:
+
+```text
+Velox plan tree
+  -> getOrderedNodeIds
+  -> orderedNodeIds + omittedNodeIds
+```
+
+Without `orderedNodeIds`, Scala would have to depend on the iteration order of
+Velox's `planStats` map or reconstruct Velox-specific plan rewrites after the
+fact. Either option would make metrics assignment fragile, especially for
+operators that are fused, omitted, or expanded into multiple Velox nodes.
+
+## Step 2: JSON to OperatorMetrics
+
+After the Velox iterator finishes, C++ reads Velox task stats and serializes a
+JSON payload with these fields:
+
+- `orderedNodeIds`: Velox plan node ids in the native treefy order.
+- `omittedNodeIds`: expected nodes that do not have Velox stats.
+- `nodeStats`: per-node Velox operator stats.
+- `loadLazyVectorTime`: Gluten lazy vector loading time.
+
+Scala parses the payload in `MetricsUtil.parseNativeOperatorMetrics`:
+
+1. Iterate through `orderedNodeIds`.
+2. Look up each node id in `nodeStats`.
+3. Convert every Velox operator stat for the node into `OperatorMetrics`.
+4. If the node id is in `omittedNodeIds` and has no stat, insert an empty
+   `OperatorMetrics` placeholder.
+5. Attach `loadLazyVectorTime` to the last flattened native metric suite.
+6. Validate that the parsed count matches `Metrics.numMetrics`.
+
+This produces the flat `JList[OperatorMetrics]` that the Spark updater tree
+will consume.
+
+```text
+orderedNodeIds: [n0, n1, n2]
+nodeStats:
+  n0 -> [stat0]
+  n1 -> [stat1, stat2]
+  n2 -> [stat3]
+
+flattened nativeMetrics:
+  [OperatorMetrics(stat0),
+   OperatorMetrics(stat1),
+   OperatorMetrics(stat2),
+   OperatorMetrics(stat3)]
+```
+
+If a node is omitted, Scala still inserts a zero-value placeholder so native
+metric indexes continue to line up with the updater traversal.
+
+## Step 3: Spark Plan to MetricsUpdaterTree
+
+`MetricsUtil.treeifyMetricsUpdaters(plan)` converts the Spark physical plan 
into
+a tree of metrics updaters. This is the Spark-side treefy process. The 
resulting
+tree describes which Spark operators should receive native metrics and in what
+child order they should be traversed.
+
+The important cases are:
+
+- `HashJoinLikeExecTransformer`: creates a join updater node with children in
+  `(buildPlan, streamedPlan)` order.
+- `SortMergeJoinExecTransformer`: creates a join updater node with children in
+  `(bufferedPlan, streamedPlan)` order.
+- `TransformSupport` with `MetricsUpdater.None`: skips the current node and
+  treeifies its child. This is used when a Spark node exists for planning shape
+  but should not receive native metrics itself.
+- Other `TransformSupport`: creates an updater node and treeifies
+  `children.reverse`.
+- Non-transform Spark nodes: become `MetricsUpdater.Terminate`, which stops
+  native metric propagation for that branch.
+
+The child reversal is intentional. Native metrics are later consumed from the
+end of the flattened list, so the updater tree must mirror the order produced 
by
+Substrait planning and native `orderedNodeIds`.
+
+Conceptually:
+
+```text
+SparkPlan
+  -> treeifyMetricsUpdaters
+  -> MetricsUpdaterTree(updater, children)
+```
+
+The tree does not contain metric values. It only contains the updater topology
+needed to replay native metrics onto Spark operators.
+
+## Step 4: Consume and Map Suites
+
+In the Scala mapping code, one `OperatorMetrics` object represents one native
+metric suite. A suite usually corresponds to one Velox operator stat. Some 
Spark
+operators consume one suite; others consume multiple suites because the Spark
+operator expands to multiple Substrait/Velox operators.
+
+The number of suites initially assigned to a Spark operator is:
+
+```text
+relMap(operatorIdx).size()
+```
+
+`updateTransformerMetricsInternal` performs the operator-level mapping. For 
each
+updater node, it:
+
+1. Reads the Substrait rel ids for the current `operatorIdx`.
+2. Consumes one native `OperatorMetrics` suite per rel id.
+3. Applies operator-specific handling.
+4. Recurses into child updater nodes with decremented indexes.
+
+For a normal unary operator, the consumed suites are merged and passed to:
+
+```text
+u.updateNativeMetrics(mergedOperatorMetrics)
+```
+
+The merge behavior is designed around Velox pipeline shape:
+
+- Input-side counters are taken from the last consumed suite.
+- Output-side and write counters are taken from the first consumed suite.
+- CPU, wall time, spill, allocation, and most custom counters are accumulated.
+- `loadLazyVectorTime` is attached to the final flattened suite and accumulated
+  across consumed suites.
+- Peak memory uses the maximum value.
+
+This gives the Spark operator one coherent metric row even when it was
+implemented by multiple native rels.
+
+The alignment can be summarized as:
+
+```text
+native orderedNodeIds treefy
+  -> flattened OperatorMetrics
+  -> Spark MetricsUpdaterTree traversal
+  -> Spark SQLMetric updates
+```
+
+## Operator-Specific Mapping
+
+Some operators do not follow the simple "consume rel count, merge, update" 
rule.
+
+### Joins
+
+Join updaters consume the suites assigned by `relMap`, then consume one
+additional suite for the build/probe side metrics. The updater also receives 
join
+parameters from planning, so it can map Velox join-side values to the correct
+Spark SQL metrics.
+
+`HashJoinLikeExecTransformer` and `SortMergeJoinExecTransformer` also use 
custom
+tree child ordering during Spark-side treefy, because build/buffered and
+streamed sides must line up with the native traversal.
+
+### Union
+
+`UnionMetricsUpdater` consumes one extra suite and updates union-specific 
metrics
+from the combined native values.
+
+### Hash Aggregate
+
+`HashAggregateMetricsUpdater` uses aggregation parameters recorded during
+planning. The native suites still come from `relMap`, but the updater needs
+those parameters to decide how aggregation metrics map to Spark metrics.
+
+### Limit Over Sort
+
+Velox may implement `Limit` over `Sort` as a TopN-style native operator. In 
that
+case, the native metric suite belongs to the sort updater. The limit updater 
does
+not update metrics and does not consume a suite, so the downstream indexes
+remain aligned.
+
+## End-to-End Example
+
+For a simple transformed plan:
+
+```text
+Project
+  Filter
+    Scan
+```
+
+native treefy produces:
+
+```text
+orderedNodeIds:
+  [scan node, filter node, project node]
+```
+
+Scala parses the JSON into:
+
+```text
+nativeMetrics:
+  0 -> scan suite
+  1 -> filter suite
+  2 -> project suite
+```
+
+Spark-side treefy builds:
+
+```text
+ProjectUpdater
+  FilterUpdater
+    ScanUpdater
+```
+
+Suppose planning recorded:
+
+```text
+operatorToRelsMap:
+  0 -> [scan rel]
+  1 -> [filter rel]
+  2 -> [project rel]
+```
+
+The updater starts from the end:
+
+```text
+operatorIdx = 2, metricsIdx = 2
+```
+
+It updates project first, then filter, then scan. Each step consumes the suite
+for the current operator and decrements both indexes. More complex operators 
use
+the same traversal but may consume more than one suite.
+
+## Adding or Debugging a Mapping
+
+When adding a metric or debugging a wrong value, follow the same path as 
runtime:
+
+1. Check native `orderedNodeIds` and `omittedNodeIds` if the wrong Velox node 
is
+   being flattened or a fused node is missing.
+2. Check that `parseNativeOperatorMetrics` produces the expected number and
+   order of `OperatorMetrics` suites.
+3. Check the Spark metric key in `VeloxMetricsApi`.
+4. Check the target `MetricsUpdater` to see how the `OperatorMetrics` suite is
+   written to Spark SQL metrics.
+5. Check whether the operator consumes a normal number of suites or has special
+   handling in `updateTransformerMetricsInternal`.
+6. Check Spark-side treefy child ordering if the wrong side of a join or
+   multi-child operator is being updated.
+
+The most useful invariant is:
+
+```text
+parsed native metric count == Metrics.numMetrics
+```
+
+If that holds but values are assigned to the wrong Spark operator, inspect the
+native `orderedNodeIds`, the `MetricsUpdaterTree` shape, `operatorToRelsMap`,
+and any operator-specific extra suite consumption.
diff --git a/mkdocs.yml b/mkdocs.yml
index 69aa0efcd3..b5f6fbd963 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -62,6 +62,7 @@ nav:
   - Velox Backend CI: developers/velox-backend-CI.md
   - Build in Docker: developers/velox-backend-build-in-docker.md
   - Query Trace: developers/QueryTrace.md
+  - Metrics Framework: developers/MetricsFramework.md
   - Memory Profiling: developers/ProfileMemoryOfGlutenWithVelox.md
   - Dynamic Off-Heap Sizing: developers/VeloxDynamicSizingOffheap.md
   - Partial Project: developers/PartialProject.md


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to