Skip to content

[OTLP] Cache metric metadata bytes for protobuf serializer#7307

Open
unsafePtr wants to merge 2 commits into
open-telemetry:mainfrom
unsafePtr:perf/cache-metric-metadata
Open

[OTLP] Cache metric metadata bytes for protobuf serializer#7307
unsafePtr wants to merge 2 commits into
open-telemetry:mainfrom
unsafePtr:perf/cache-metric-metadata

Conversation

@unsafePtr
Copy link
Copy Markdown
Contributor

@unsafePtr unsafePtr commented May 18, 2026

Follow-up to #7303 which applied the same pattern to the resource bytes.

Changes

Caches the protobuf-encoded bytes of Metric.Name / Description / Unit keyed by Metric instance, replayed on every export via Buffer.BlockCopy. These three fields are set at instrument creation and immutable for the lifetime of a Metric, so the bytes never change after first computation.

Implementation mirrors ProtobufOtlpResourceSerializer:

  • ConcurrentDictionary<Metric, byte[]> static cache.
  • ArrayPool<byte>-backed scratch buffer with grow-on-overflow retry for the one-shot slow path.
  • Fast path: single GetOrAdd + Buffer.BlockCopy.

Merge requirement checklist

  • CONTRIBUTING guidelines followed (license requirements, nullable enabled, static analysis, etc.)
  • Unit tests added/updated — existing round-trip tests in OtlpMetricsExporterTests and ProtobufOtlpMetricSerializerTests cover the new path.
  • Appropriate CHANGELOG.md files updated for non-trivial changes
  • Changes in public API reviewed (if applicable) — no public API changes.

@unsafePtr unsafePtr requested a review from a team as a code owner May 18, 2026 16:16
@github-actions github-actions Bot added the pkg:OpenTelemetry.Exporter.OpenTelemetryProtocol Issues related to OpenTelemetry.Exporter.OpenTelemetryProtocol NuGet package label May 18, 2026
@martincostello
Copy link
Copy Markdown
Member

Would be good to see some before/after numbers before merge.

Copy link
Copy Markdown
Member

@cijothomas cijothomas left a comment

Choose a reason for hiding this comment

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

Need to see if this "leaks" Metric.

Unlike Resource (effectively a per-provider singleton), Metric instances come and go. A static ConcurrentDictionary<Metric, byte[]> pins every Metric it ever sees for the process lifetime, leaking memory for apps.

See if this makes sense and see if we can do alternates like ConditionalWeakTable<Metric, byte[]>, or an internal field on Metric itself.?

@unsafePtr
Copy link
Copy Markdown
Contributor Author

Benchmark results for the metric metadata cache.


BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8457/25H2/2025Update/HudsonValley2)
13th Gen Intel Core i7-13700KF 3.40GHz, 1 CPU, 24 logical and 16 physical cores
.NET SDK 10.0.300
  [Host]     : .NET 10.0.8 (10.0.8, 10.0.826.23019), X64 RyuJIT x86-64-v3
  DefaultJob : .NET 10.0.8 (10.0.8, 10.0.826.23019), X64 RyuJIT x86-64-v3


Before (no cache)

Method MetricCount Mean Error StdDev Allocated
WriteMetricsData 1 91.52 ns 0.395 ns 0.369 ns -
WriteMetricsData 4 237.82 ns 0.559 ns 0.467 ns -
WriteMetricsData 16 790.89 ns 5.556 ns 5.197 ns -
WriteMetricsData 64 3,034.48 ns 25.901 ns 22.960 ns -
WriteMetricsData 256 12,332.34 ns 198.683 ns 185.848 ns -

After (with cache)

Method MetricCount Mean Error StdDev Allocated
WriteMetricsData 1 73.79 ns 0.927 ns 0.822 ns -
WriteMetricsData 4 175.44 ns 3.279 ns 3.903 ns -
WriteMetricsData 16 596.32 ns 11.787 ns 18.351 ns -
WriteMetricsData 64 2,229.27 ns 44.045 ns 50.722 ns -
WriteMetricsData 256 9,260.80 ns 180.011 ns 274.896 ns -

Delta

MetricCount Δ Mean Speedup Per-metric saved
1 −17.73 ns 1.24× ~17.7 ns
4 −62.38 ns 1.36× ~15.6 ns
16 −194.57 ns 1.33× ~12.2 ns
64 −805.21 ns 1.36× ~12.6 ns
256 −3,071.54 ns 1.33× ~12.0 ns

Steady-state savings of ~12 ns per metric in the metadata write path. Zero allocations either way — the original serializer was already alloc-free; this PR cuts CPU work, not memory.

@github-actions github-actions Bot added the perf Performance related label May 19, 2026
@unsafePtr
Copy link
Copy Markdown
Contributor Author

@martincostello , @cijothomas generally I was expecting way smaller perf benefit here, comparing to Resource. If ~20ns win doesn't justify code changes, please feel free to close the PR.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 20, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.90%. Comparing base (b1dd8e8) to head (cb052c1).
⚠️ Report is 4 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #7307      +/-   ##
==========================================
- Coverage   89.97%   89.90%   -0.08%     
==========================================
  Files         276      276              
  Lines       14001    14027      +26     
==========================================
+ Hits        12598    12611      +13     
- Misses       1403     1416      +13     
Flag Coverage Δ
unittests-Project-Experimental 89.63% <100.00%> (-0.30%) ⬇️
unittests-Project-Stable 89.82% <100.00%> (-0.03%) ⬇️
unittests-Solution 89.84% <100.00%> (+0.08%) ⬆️
unittests-UnstableCoreLibraries-Experimental 47.21% <ø> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...ntation/Serializer/ProtobufOtlpMetricSerializer.cs 99.01% <100.00%> (+0.07%) ⬆️
...ol/Implementation/Serializer/ProtobufSerializer.cs 91.89% <100.00%> (+0.30%) ⬆️

... and 4 files with indirect coverage changes

Pre-serialize each Metric's name/description/unit once into a per-metric
byte cache keyed on the Metric instance. Hot-path emits the cached blob
with a single buffer copy instead of re-encoding strings on every export.

Cache is a ConditionalWeakTable<Metric, byte[]> so cache entries are
released with the metric. Stackalloc with an ArrayPool fallback covers
oversized metadata.

Adds polyfills (scoped via preprocessor) so the change builds on legacy
targets:
- ConditionalWeakTable.GetOrAdd (added in .NET 10) for non-net10 TFMs
- Encoding.GetBytes(ReadOnlySpan<char>, Span<byte>) for netstandard2.0 / net462
- DynamicallyAccessedMembersAttribute / DynamicallyAccessedMemberTypes
  for any non-.NET 5+ TFM (netstandard2.1 ships them internal-only)
@unsafePtr unsafePtr force-pushed the perf/cache-metric-metadata branch from 4fdb347 to 84389ad Compare May 20, 2026 13:12
@unsafePtr
Copy link
Copy Markdown
Contributor Author

Match dotnet/runtime convention (StackallocThreshold = 256) and use
separate constants for the stack and pool initial sizes.

Addresses open-telemetry#7307 (comment)
@martincostello
Copy link
Copy Markdown
Member

This looks good to me so far. I defer to @cijothomas being happy regarding the lifetimes before sticking a ✅ on this PR though.

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

Labels

perf Performance related pkg:OpenTelemetry.Exporter.OpenTelemetryProtocol Issues related to OpenTelemetry.Exporter.OpenTelemetryProtocol NuGet package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants