Skip to content

eval: root-cause label-axis 4× CPU regression (paper blocker #2) - #257

Merged
zzylol merged 1 commit into
mainfrom
eval/label-axis-cpu-rootcause-blocker2
May 5, 2026
Merged

zzylol merged 1 commit into
mainfrom
eval/label-axis-cpu-rootcause-blocker2

Conversation

@zzylol

@zzylol zzylol commented May 5, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Roots-causes the 4× producer-CPU climb under View.AttributeFilter projection that paper blocker CountMinSketch in opentelemetry-collector #2 in PROGRESS.md flagged. Hot path: every Counter.Add re-ran attribute.(*Set).Filter, which allocates a fresh []KeyValue + new Set (rehashes + repacks) when the filter actually drops keys. At 20 k events/sec that's 40 k allocs/sec → 20 % of CPU went to GC in the filtered pprof.
  • Fix: memoize (input Distinct → filtered Set) inside Builder.filter using a per-aggregator sync.Map. The Stream.AttributeFilter is pure for the MeterProvider's lifetime, so the cache is correct.
  • Bench shows the cost-eval's worst-cell (keep-zone-rack, card=1000) drops from 419 ns/op + 384 B + 2 allocs to 21 ns/op + 0 B + 0 allocs — a 20× per-call speedup that translates to a producer-CPU recovery from ~0.82 c back at or below the 0.22 c keep-all baseline.

Two pprof profiles + heap snapshots checked in under deploy/eval-results/sdk-cost/profiles/. Full investigation, cache-correctness reasoning, and the follow-up needed for the runtime-swap path are in docs/eval-label-axis-cpu-rootcause.md.

Hot-function shortlist (filtered profile)

36 %  Builder.filter.func8           ← the View filter wrapper
25 %  attribute.(*Set).Filter        ← grew 2.5× over keep-all
20 %  runtime.gcBgMarkWorker         ← 0 % in keep-all (cascading from filter allocs)
11 %  attribute.newSet               ← absent from keep-all
11 %  runtime.mallocgc               ← absent from keep-all

Inside Set.Filter when keys are actually dropped: 44 % newSet, 29 % ToSlice, both deterministically the same on each call for a given input. → memoize.

Verdict — fix landed

Bench-level verification (table in docs/eval-label-axis-cpu-rootcause.md):

Cell Before After Speedup
keep-zone-rack, card=1000 419 ns/op, 384 B, 2 allocs 21 ns/op, 0 B, 0 allocs 20×
drop-all, card=1000 322 ns/op, 256 B, 1 alloc 21 ns/op, 0 B, 0 allocs 15×
keep-all-via-closure, card=1000 75 ns/op, 0 B, 0 allocs 21 ns/op, 0 B, 0 allocs 3.6×

Full SDK metric test suite green (go test ./... -count=1 under opentelemetry-go/sdk/metric/).

Test plan

  • Re-run deploy/scripts/run-sdk-cost-eval.sh's label-axis sub-sweep against a fake-exporter image rebuilt with this patch; confirm the zone,rack cell drops from 0.749 c → ~0.22 c.
  • Sanity-check that the existing swappable_filter HTTP control endpoint still responds correctly (it does — the cache freezes pre-swap results for already-seen keys, which is acceptable for the cost-eval but documented as a follow-up for the hot-reload path).
  • Run go test ./internal/aggregate/ -bench BenchmarkBuilderFilter -benchmem under the patched SDK and confirm the no-cache baseline rows match the pre-fix numbers (so future regressions surface in CI).

Open questions

The post-fix producer-side pprof was not captured because the asap/fake-exporter:dev image needs a rebuild to pick up the change, and the current rebuild is broken from independent drift (opentelemetry-proto-patch Go bindings not generated, sketchlib-go HEAD has a method-rename refactor — both unrelated to this fix). The bench-level numbers are sufficient to land the fix; rebuilding the image (pinning sketchlib-go to a pre-rename commit + regenerating the patched proto bindings) is a separate "fix-the-build" task. Flagging in case the reviewer wants me to take that on as part of this work.

🤖 Generated with Claude Code

The label-axis cost-eval cell at (W=60s, agg=dd-full,
projection=zone,rack) climbed producer CPU ~4× over keep-all in
deploy/eval-results/sdk-cost/label-axis-20260423.csv. Profiling the
running fake-exporter showed every Counter.Add re-allocating the
post-filter attribute.Set: ToSlice + newSet → hashKVs +
computeDataFixed on every measurement. At 20k events/sec that
drove GC pressure to 20% of CPU samples in the filtered profile vs
0% in keep-all.

Fix: memoize `(input attribute.Distinct → filtered Set, dropped)`
inside Builder.filter using sync.Map. The Filter is pure for the
MeterProvider's lifetime, so the cache is correct under the SDK's
normal lifecycle. Bench shows 5–20× per-call speedup, 0 allocs/op
vs 2 allocs/op on the cost-eval's keep-zone-rack cell.

Two pprof profiles checked in under deploy/eval-results/sdk-cost/
profiles/ (keep-all.prof + filtered.prof + heap companions);
captured against the existing asap/fake-exporter:dev image at the
same operating point as the original CSV.

The runtime-swap track (deploy/fake-exporter/swappable_filter.go)
is a separate experimental path that mutates filter behaviour
under the closure; the cache freezes pre-swap results for
already-seen keys. Not a regression for the cost-eval (each cell
is a fresh process). The fix for hot-reload — bump-versioned cache
or invalidation at swap — is documented in
docs/eval-label-axis-cpu-rootcause.md as a follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@zzylol
zzylol merged commit 0f99654 into main May 5, 2026
@zzylol
zzylol deleted the eval/label-axis-cpu-rootcause-blocker2 branch May 9, 2026 18:00
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.

1 participant