Skip to content

Fix neuron per core multi runtime - #2263

Open
spanaik wants to merge 4 commits into
mainfrom
fix-neuron-per-core-multi-runtime
Open

Fix neuron per core multi runtime#2263
spanaik wants to merge 4 commits into
mainfrom
fix-neuron-per-core-multi-runtime

Conversation

@spanaik

@spanaik spanaik commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Description of the issue

On a node running more than one Neuron runtime, roughly half of every per-core Neuron
metric is silently replaced by zero. A NeuronCore pinned at 75% utilization reports 0.

transform/cw_k8s_ci_v0_neuron_promote runs in context: datapoint but writes
resource.attributes. Resource attributes are per-ResourceMetrics, so with N runtimes
in one RM the statement executes N times and the last write wins. It then deletes
runtime_tag from the datapoint — the only attribute distinguishing one runtime's real
reading for a core from another runtime's zero for that same core — so two datapoints
collapse onto one identity with different values.

A debug exporter on the pipeline shows the agent emitting four datapoints with only
two distinct attribute sets:

aws.neuron.core=0  Value: 75.154815   <- real
aws.neuron.core=1  Value:  0.000000
aws.neuron.core=0  Value:  0.000000   <- same identity, shadows the real one
aws.neuron.core=1  Value: 75.363968

Impact on a multi-runtime Neuron node: a saturated core renders as idle, a
low-utilization alarm fires falsely, and an idle-reclaim decision could reclaim a busy
core. Confirmed on neuroncore_utilization_ratio and all five
neuroncore_memory_usage_* families.

Single-runtime nodes are unaffected, which is why this went unnoticed — it needs ≥2
Neuron runtimes on one node to appear.

neuron-monitor is not at fault. Scraping its own /metrics endpoint at the same
instant shows it emitting the full (core × runtime_tag) cross-product correctly, with
only the matching pairs non-zero.

Description of changes

Aggregation is not needed — groupbyattrs already does the right thing, it just wasn't
given the runtime dimension.

  1. Add runtime_tag to the groupbyattrs/cw_k8s_ci_v0_neuron keys, so each runtime
    lands in its own ResourceMetrics and the datapoints can no longer collide.
  2. Reduce transform/cw_k8s_ci_v0_neuron_promote to a context: resource rename of
    runtime_tagaws.neuron.runtime.tag.

The six set/delete_key statements for k8s.pod.name, k8s.namespace.name and
k8s.container.name are removed as redundant: those keys are already in the
groupbyattrs keys, and groupbyattrs moves its grouping keys — see
processor/groupbyattrsprocessor/processor.go:

// These attributes are going to be moved from datapoint to resource level,
// so we can delete those on the datapoint
deleteAttributes(requiredAttributes, attributes)

so the promote was re-doing work already done one processor earlier, and its
datapoint-context write was the vehicle for the defect.

Net effect: 11 lines changed in neuron.yaml plus the four regenerated golden configs.

Trade-off

This publishes the full cross-product, so series count grows multiplicatively with
runtime count — 2 → 4 on a 2-core/2-runtime node; a 32-core trn1 with 4 runtimes goes
32 → 128 series per per-core metric. This is the cardinality neuron-monitor already
emits
; the previous behaviour reduced it only by discarding data.

The alternative is to aggregate the runtime dimension away in-agent (max per core),
which keeps the count flat but loses per-runtime attribution. Rejected here because the
PromQL/OTLP surface can carry the runtime dimension, and a runtime legitimately spans
multiple cores, so "which runtime is using this core" is a question this surface should
be able to answer.

Reviewers may want to weigh in on whether the cardinality growth is acceptable for large
trn1 topologies. It has not been measured beyond a 2-core node.

Scope

This does not affect the EMF / Container Insights path. neuron.yaml's pipeline
exports to the forward/opentelemetry connector, which is consumed only by
metrics/opentelemetryotlphttp/metrics. In the combined v1+v2 config the sole
pipeline touching an EMF exporter is logs/emf_logs (receiver udplog, one batch
processor, no Neuron processors). The EMF Neuron path is a separate pipeline —
awscontainerinsightreceivergpuattributesawsemf/containerinsights — and shares
no component instance with this one. Its own max-per-core reduction in
gpuattributes/internal/awsneuron_metric_modifier.go is unchanged and remains correct
for that surface, which does not publish runtime_tag as a dimension.

License

By submitting this pull request, I confirm that you can use, modify, copy, and
redistribute this contribution, under the terms of your choice.

Tests

New unit tests
translator/translate/otel/pipeline/opentelemetry/containerinsights/neuron_multiruntime_test.go.
Instantiates the real groupbyattrs and transform processors from the //go:embeded
neuron.yaml, chains them, and feeds synthetic metrics in the multi-runtime shape
(2 cores × 2 runtimes, non-owning pairs at zero, all four sharing one pod). No cluster or
Neuron hardware required; runs in ~25ms.

Test Asserts
KeepsEveryCorePerRuntime all four (core, tag, value) triples survive — both 75.x readings
SeparatesRuntimesIntoResources one ResourceMetrics per runtime
PromotesPodIdentity pod/namespace/container on the resource, absent from datapoints
PreFixConfigLosesData negative control — the pre-fix config collapses to one tag and two datapoints per core

Config is read from the embedded template rather than restated, so a revert fails the
test. Verified by mutation: removing - runtime_tag from the groupbyattrs keys in
neuron.yaml fails three of the four.

The negative control matters because it keeps the other three honest — if the collapse
ever stops reproducing, PreFixConfigLosesData starts passing and tells us the
assertions have gone vacuous.

Existing suitesgo test ./translator/tocwconfig/... and
./translator/translate/otel/pipeline/opentelemetry/containerinsights/... pass. The four
golden configs in this diff are the regenerated snapshots.

Live cluster — verified on a 2-core inf2.xlarge running two Neuron runtimes:

  • Before: 2 series on the PromQL surface, core 0 reading 0 while pinned at ~75%.
  • After: 4 correctly-labelled series, core 0 → 75.15 under its own runtime tag, core 1 →
    75.23 under the other.
  • Reverting the config on the live agent reproduced the loss (2 series, core 0 back to 0);
    re-applying restored it. So the fix is doing the work, not a caching artifact.
  • No attribute regression: 8 existing Neuron integration tests / 26 subtests pass,
    including TestNeuronPodName, TestNeuronNamespace, TestNeuronDeviceAttributes and
    TestNeuronNoPromotedDatapointKeys — the last of which specifically asserts
    pod/namespace/container are not left on the datapoint, covering the six statements
    removed here.

Integration test coverage for the multi-runtime case is a companion change to
amazon-cloudwatch-agent-test (adds a second co-located burn deployment so a node runs
two runtimes, plus 8 assertions on the resulting series shape). Worth noting that the
pre-existing integration tests could not catch this defect:
TestNeuronRuntimeTagInResourceScope passes on the broken code because the collapsed
resource still carries one tag, and TestNeuronNoDuplicateSeries passes because the
collision happens in-agent — the surface shows too few series rather than duplicated
ones. Cardinality is the signal, not duplication.

aws/amazon-cloudwatch-agent-test#749

Requirements

  • make fmt and make fmt-sh — no changes produced
  • make lint — 0 issues (license and import-order checks pass)

On a node with more than one Neuron runtime, roughly half of every per-core
Neuron metric is silently replaced by zero before it leaves the agent. A
NeuronCore pinned at 75% utilization reports 0.

Measured on an inf2.xlarge (2 NeuronCores, one process pinned per core).
neuron-monitor's own /metrics is correct -- it emits the full
(neuroncore x runtime_tag) cross-product with only the matching pairs non-zero:

  neuroncore_utilization_ratio{neuroncore="0",runtime_tag="...core0"} 0.7523  <- real
  neuroncore_utilization_ratio{neuroncore="1",runtime_tag="...core0"} 0.0
  neuroncore_utilization_ratio{neuroncore="0",runtime_tag="...core1"} 0.0
  neuroncore_utilization_ratio{neuroncore="1",runtime_tag="...core1"} 0.7517  <- real

A debug exporter on the live pipeline shows what the agent then produces: ONE
ResourceMetrics containing FOUR datapoints with only TWO distinct attribute
sets --

  aws.neuron.core=0  Value: 75.154815   <- real
  aws.neuron.core=1  Value:  0.000000
  aws.neuron.core=0  Value:  0.000000   <- same identity, shadows the real one
  aws.neuron.core=1  Value: 75.363968

Cause, in transform/cw_k8s_ci_v0_neuron_promote:

1. It runs in `context: datapoint` but writes `resource.attributes`. Resource
   attributes are per-ResourceMetrics, so with N runtimes in one RM the
   statement executes N times and the last write wins -- which is why a single
   arbitrary runtime tag ends up labelling the whole node.
2. It then deletes `runtime_tag` from the datapoint. That was the only
   attribute distinguishing the real reading from the other runtime's zero, so
   two datapoints collapse to one identity with different values. Nothing
   downstream reconciles them (awsattributelimit early-returns below 150
   attributes; batch only concatenates), so both go over the wire and one is
   arbitrarily dropped at ingest.

Blast radius is wider than utilization: neuron_runtime_memory_used_bytes has 4
series upstream (2 memory_location x 2 runtime_tag) and arrives as 2, so one
runtime's memory is dropped as well.

Fix: make `runtime_tag` a groupbyattrs grouping key so each runtime gets its own
ResourceMetrics. That removes the key from the datapoint as a side effect, so
each datapoint identity stays unique and the last-write-wins clobber becomes a
no-op. The promote then moves to `context: resource`, which is where the
attribute now lives -- and is how the DCGM pipeline already does it
(containerinsights/dcgm.yaml:66-83).

The six pod-identity statements are dropped because they are dead code:
groupbyattrs has already deleted those keys from the datapoint by then, so
`attributes["k8s.pod.name"]` is always nil and they never fire.

Verified on the live cluster: core 0 goes from 0 to 75.18, and CloudWatch now
carries 4 correctly-labelled series instead of 2.

Trade-off: this publishes the full cross-product, so series count grows
multiplicatively with runtime count (2 -> 4 here; a 32-core trn1 with 4 runtimes
goes from 32 to 128 series per metric). The alternative is to aggregate the
runtime dimension away in-agent, which keeps the count flat but loses per-runtime
attribution; that trade-off is rejected here because the PromQL surface can carry
the runtime dimension and a runtime legitimately spans multiple cores.

Note: the legacy EMF path already handles this correctly, via
gpuattributes/internal/awsneuron_metric_modifier.go -- it flattens runtime_tag
to "DEFAULT" then aggregates per core with max (commit dc672ff). The OTel
rewrite reimplemented synthesis and scaling but not the aggregation. There is no
multi-runtime test case in plugins/processors/awsneuron/processor_test.go, which
is why this shipped; adding pipeline-level coverage is recommended follow-up.
… loss

Builds the real groupbyattrs + promote processors from the embedded neuron.yaml
and feeds them the shape neuron-monitor emits with two runtimes on one node
(2 cores x 2 runtimes, non-owning pairs at zero, all four sharing one pod).

Four tests:
  KeepsEveryCorePerRuntime      all four (core, tag, value) triples survive
  SeparatesRuntimesIntoResources one ResourceMetrics per runtime
  PromotesPodIdentity            pod/ns/container on resource, off datapoints --
                                 pins the six promote statements the fix removed,
                                 which groupbyattrs already performs
  PreFixConfigLosesData          negative control: the datapoint-context promote
                                 collapses to one tag and two datapoints per core

Config is read from the embedded template, not restated, so a revert fails the
test. Verified by mutation: removing `- runtime_tag` from the groupbyattrs keys in
neuron.yaml fails three of the four. Runs in ~25ms with no cluster or Neuron
hardware, which is the gap that let the original defect ship -- it needed >=2
runtimes on one node to appear and no test environment had that.
// collapsed onto one identity and a core pinned at 75% reported 0.
//
// TestNeuronMultiRuntimePreFixConfigLosesData pins that failure mode, so the
// assertions below are known to discriminate rather than merely pass.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads like an artifact from the fix you've done rather than future-understandable preface for a set of tests... LEts refactor it to focus the intention of the tests rather than the historical bug

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair — rewritten to lead with the invariant the tests protect rather than the history of how it broke. The context: datapoint/last-write-wins mechanism moved onto the negative control, which is the one test genuinely about that configuration shape, so it's local to where it's load-bearing.

Also renamed PreFixConfigLosesData → CollapsedConfigLosesData, since "pre-fix" ages the same way the comment did, and gave PromotesPodIdentity the same treatment — it described itself as guarding "the six promote statements removed by the fix" instead of stating that pod identity must land on the resource and not stay on the datapoint.

// renderedNeuronProcessors returns the processors block of the shipped neuron.yaml.
// Read from the embedded template rather than restated, so reverting the config
// fails this test.
func renderedNeuronProcessors(t *testing.T) map[string]any {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I wonder if this is really a substitute for the integration tests? Do we have other tests like this? Or what are you trying to cover here that you can't cover with an integration test?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a substitute — complementary, and there is precedent for the pattern: translator/translate/otel/processor/metricsdecorator/translator_unix_test.go:52 (TestMetricDecoration) does the same thing with the same imports, and its own comment describes it as "using a test TransformProcessor yaml configuration, starting the processor and having it consume test metrics." One deliberate difference: it loads a testdata yaml, whereas this renders the shipped neuron.yaml off the //go:embeded template, so reverting the real config fails this test.

Three things the integration test structurally can't do:

  1. The negative control can only live here. CollapsedConfigLosesData runs the broken config to prove the assertions discriminate rather than merely pass. You can't deploy a deliberately broken agent in CI.

  2. The integration test can't distinguish a collapse from a single-runtime node. I hit this when I reverted the agent on a live cluster to check the integration tests actually fail: the collapse destroys runtime_tag itself, so exactly one tag survives per node — structurally identical to a node that genuinely runs one runtime. The integration test has to bail out with a "check both of these causes" message rather than assert. Here the input is fixed, so it asserts an exact four-series expectation.

  3. It fails on a config revert, in this repo's build. The integration test only ever sees whatever config happens to be deployed, and only if the cluster fixture is right.

The integration side is in a companion PR against amazon-cloudwatch-agent-test — that's the layer that proves it on real inf2 silicon.

… the bug

Review feedback on #2263: the file's preface read as a post-mortem of the fix
rather than something a future reader could use. Lead with the invariant every
(core, runtime) reading must satisfy; keep the collapsed-config mechanism on the
negative control, which is the one test that is genuinely about that shape.

Rename PreFixConfigLosesData -> CollapsedConfigLosesData so the name describes the
configuration rather than when it existed. No assertions changed.

Also drop an internal cluster name from a comment.
@github-actions

Copy link
Copy Markdown
Contributor

Binary Size Report

linux/amd64

Binary PR vs main (0b21e7a) vs v1.300072.0
amazon-cloudwatch-agent 194.8 MB +0 B ${\color{red}▲}$ +1.5 MB (+0.8%)
amazon-cloudwatch-agent-config-wizard 2.0 MB +0 B +0 B
config-downloader 2.0 MB +0 B +0 B
config-translator 2.0 MB +0 B +0 B
start-amazon-cloudwatch-agent 2.5 MB +0 B +0 B
workload-discovery 3.0 MB +0 B +0 B
Total 206.4 MB +0 B ${\color{red}▲}$ +1.5 MB (+0.7%)
linux/amd64 amazon-cloudwatch-agent (last 9 main commits + this PR)

 195 ┤                                        
     ┤                        ███ ███ ███ ███ 
     ┤            ███ ███ ███ ███ ███ ███ ███ 
     ┤    ▄▄▄ ▄▄▄ ███ ███ ███ ███ ███ ███ ███ 
     ┤    ███ ███ ███ ███ ███ ███ ███ ███ ███ 
     ┤    ███ ███ ███ ███ ███ ███ ███ ███ ███ 
     ┤    ███ ███ ███ ███ ███ ███ ███ ███ ███ 
     ┤    ███ ███ ███ ███ ███ ███ ███ ███ ███ 
 195 ┤▄▄▄ ███ ███ ███ ███ ███ ███ ███ ███ ███ 
 MB  └────────────────────────────────────────
      4235e66                             PR
linux/arm64
Binary PR vs main (0b21e7a) vs v1.300072.0
amazon-cloudwatch-agent 178.6 MB +0 B ${\color{red}▲}$ +1.5 MB (+0.9%)
amazon-cloudwatch-agent-config-wizard 2.0 MB +0 B +0 B
config-downloader 2.0 MB +0 B +0 B
config-translator 2.0 MB +0 B +0 B
start-amazon-cloudwatch-agent 2.4 MB +0 B +0 B
workload-discovery 2.9 MB +0 B +0 B
Total 189.9 MB +0 B ${\color{red}▲}$ +1.5 MB (+0.8%)
windows/amd64
Binary PR vs main (0b21e7a) vs v1.300072.0
amazon-cloudwatch-agent.exe 197.6 MB ${\color{red}▲}$ +5.6 KB (+0.0%) ${\color{red}▲}$ +1.5 MB (+0.8%)
amazon-cloudwatch-agent-config-wizard.exe 2.1 MB +0 B +0 B
config-downloader.exe 2.1 MB +0 B +0 B
config-translator.exe 2.1 MB +0 B +0 B
start-amazon-cloudwatch-agent.exe 2.3 MB +0 B +0 B
workload-discovery.exe 2.9 MB +0 B ${\color{red}▲}$ +512 B (+0.0%)
Total 209.3 MB ${\color{red}▲}$ +5.6 KB (+0.0%) ${\color{red}▲}$ +1.5 MB (+0.7%)
Investigating size changes

Use go-size-analyzer to compare binaries:

GOEXPERIMENT=jsonv2 go install github.com/Zxilly/go-size-analyzer/cmd/gsa@latest
gsa diff --old <baseline-binary> --new <new-binary>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants