blob: de6568b1d925a46b9ea630da1554672760f29e24 [file] [view]
---
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.