Skip to content

[Exporter.Prometheus] Avoid allocations for tags#7488

Open
martincostello wants to merge 15 commits into
open-telemetry:mainfrom
martincostello:prometheus-avoid-allocations
Open

[Exporter.Prometheus] Avoid allocations for tags#7488
martincostello wants to merge 15 commits into
open-telemetry:mainfrom
martincostello:prometheus-avoid-allocations

Conversation

@martincostello

@martincostello martincostello commented Jul 4, 2026

Copy link
Copy Markdown
Member

Changes

Use pooled buffers to write tags to avoid allocations when serializing metrics.

Builds on top of #7471 and #7487 - 1379395 are the interesting changes until this is rebased once changes those are merged.

Benchmarks comparing 1.15, 1.16, #7487 and then this PR are shown below:

Expand to view

Prometheus AspNetCore exporter — scrape performance across versions

End-to-end scrape benchmark across four points in history:

Label Commit / package
1.15.0-beta.1 022ae547028158df5718ba4b99ab809710831f6d
1.16.0-beta.1 c39666185fc7d8c7d5052eaec172b143af8041dc
orig latest 81a9cf49c5d319bca4fb91d9cf14c3d261ca6993
updated latest 1379395b15387c1f0097849ad9317d77fdb61341 ("Avoid allocations for tags")

Method

  • Identical harness for all four; only the exporter changes. 1.15/1.16 are the released NuGet packages; the two latest are project references to the respective commits.
  • MeterProvider built with Sdk.CreateMeterProviderBuilder(), exposed via the AspNetCore scrape middleware (UseOpenTelemetryPrometheusScrapingEndpoint), scraped over an in-memory TestServer. Response cache disabled, so every scrape does a full collect + serialize.
  • The benchmark reads only the HTTP response headers (HttpCompletionOption.ResponseHeadersRead) and does not materialize the body. The exporter serializes the entire payload into its buffer before the response headers are produced, so this measures all the server-side generation effort while excluding body transfer and client-side materialization. A full-body read is done once in setup to warm the buffer.
  • Workload: TimeSeries unique data points, ~70% counters / ~10% histograms / ~20% observable-gauge; cardinality limit raised to match.
  • BenchmarkDotNet, in-process toolchain, 5 warmup + 20 measured iterations, MemoryDiagnoser. net9.0, Windows, 13th Gen Intel i7-13700H. 1,000,000 series is only run for the two latest versions (1.15/1.16 cannot serve a scrape that large — ~83 MiB response-buffer ceiling).

Mean time per scrape (ms)

Series Format 1.15.0-beta.1 1.16.0-beta.1 orig latest updated latest
1k text/plain 0.64 1.66 1.40 1.35
1k OpenMetrics* 0.67 2.07 1.50 1.66
10k text/plain 6.86 13.20 9.05 9.06
10k OpenMetrics* 6.62 19.17 12.08 12.30
100k text/plain 100.1 141.6 113.5 104.6
100k OpenMetrics* 90.7 205.5 168.9 142.6
1M text/plain 947.8 970.7
1M OpenMetrics* 1,314.7 1,231.1

Allocated per scrape (MB)

Series Format 1.15.0-beta.1 1.16.0-beta.1 orig latest updated latest
1k text/plain 0.04 1.59 0.26 0.12
1k OpenMetrics* 0.04 2.68 0.35 0.13
10k text/plain 2.79 17.9 4.58 3.09
10k OpenMetrics* 2.82 30.3 6.87 4.71
100k text/plain 50.9 201.8 68.7 53.8
100k OpenMetrics* 51.2 325.5 91.6 70.1
1M text/plain 713 564
1M OpenMetrics* 943 727

Findings

  • 1.16 was a large allocation regression — 100k allocations rose 3–6× over 1.15 (otel_scope_* labels, _created series, and exemplars added in 1.16).

  • orig latest (81a9cf4) recovered most of that regression relative to 1.16.

  • updated latest (1379395, "Avoid allocations for tags") cuts a further ~21–23% off allocations across the board and improves time, with no regressions:

    100k series orig → updated Δ
    OpenMetrics allocated 91.6 → 70.1 MB −23%
    OpenMetrics time 168.9 → 142.6 ms −16%
    text/plain allocated 68.7 → 53.8 MB −22%
    text/plain time 113.5 → 104.6 ms −8%

    At 1,000,000 series the reduction holds: allocations −21% (text/plain), −23% (OpenMetrics). text/plain allocations are now within ~5% of 1.15 while emitting the richer modern output.

Caveats / notes

  • *1.15.0-beta.1 does not negotiate application/openmetrics-text; version=1.0.0 (content negotiation landed in 1.16), so its two format columns are the same text-style output — the 1.15 "OpenMetrics" rows are not a true OpenMetrics comparison.
  • Headers-only measurement captures collect + serialize (server generation), which completes before response headers are sent; body transfer and client materialization are intentionally excluded

Merge requirement checklist

  • CONTRIBUTING guidelines followed (license requirements, nullable enabled, static analysis, etc.)
  • Unit tests added/updated
  • Appropriate CHANGELOG.md files updated for non-trivial changes
  • Changes in public API reviewed (if applicable)

Add support for emitting resource attributes as metric labels based on a user-supplied predicate.

Resolves open-telemetry#7437.
Add missing comment.
- Allow the maximum buffer size to be configured to allow for scrape responses beyond 100,000 metrics.
- Return an HTTP 500 if the buffer is not large enough to contain the scrape response.
Use pooled buffers to write tags to avoid allocations when serializing metrics.
@github-actions github-actions Bot added pkg:OpenTelemetry.Exporter.Prometheus.AspNetCore Issues related to OpenTelemetry.Exporter.Prometheus.AspNetCore NuGet package pkg:OpenTelemetry.Exporter.Prometheus.HttpListener Issues related to OpenTelemetry.Exporter.Prometheus.HttpListener NuGet package labels Jul 4, 2026
@codecov

codecov Bot commented Jul 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.60440% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.29%. Comparing base (d84d2af) to head (33b9277).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...ernal/Shared/Serialization/TextFormatSerializer.cs 95.60% 4 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #7488      +/-   ##
==========================================
- Coverage   90.29%   90.29%   -0.01%     
==========================================
  Files         287      287              
  Lines       15617    15647      +30     
==========================================
+ Hits        14102    14128      +26     
- Misses       1515     1519       +4     
Flag Coverage Δ
unittests-Project-Experimental 90.31% <95.60%> (-0.02%) ⬇️
unittests-Project-Stable 90.24% <95.60%> (-0.03%) ⬇️
unittests-UnstableCoreLibraries-Experimental 51.05% <95.60%> (+0.09%) ⬆️

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

Files with missing lines Coverage Δ
...ernal/Shared/Serialization/TextFormatSerializer.cs 99.02% <95.60%> (-0.47%) ⬇️

... and 3 files with indirect coverage changes

@martincostello martincostello added this to the v1.17.0 milestone Jul 4, 2026
Increase the initial size of the buffer and remember the high-water mark for the allocation size to avoid try-catch to resize large tag buffers.
@martincostello

Copy link
Copy Markdown
Member Author

Prometheus AspNetCore exporter — scrape performance on .NET 10: 1.15 vs 1.16 vs latest

End-to-end scrape benchmark on .NET 10, comparing three points in history:

Label Commit / package
1.15.0-beta.1 022ae547028158df5718ba4b99ab809710831f6d
1.16.0-beta.1 c39666185fc7d8c7d5052eaec172b143af8041dc
latest c243345dc ("Avoid allocations for tags" + self-tuning tag buffer "Avoid resizing")

Method

  • Identical harness for all three; only the exporter changes. 1.15/1.16 are the released NuGet packages, latest is a project reference to the branch. All three target and run their net10.0 assets.
  • MeterProvider built with Sdk.CreateMeterProviderBuilder(), exposed via the AspNetCore scrape middleware (UseOpenTelemetryPrometheusScrapingEndpoint), scraped over an in-memory TestServer. Response cache disabled, so every scrape does a full collect + serialize.
  • The benchmark reads only the HTTP response headers (HttpCompletionOption.ResponseHeadersRead) — the client never materializes the body, but the exporter serializes the whole payload into its buffer before headers are produced, so all server-side generation is still measured. A full-body read is done once in setup to warm the buffer.
  • Workload: TimeSeries unique data points, ~70% counters / ~10% histograms / ~20% observable-gauge; cardinality limit raised to match.
  • BenchmarkDotNet, in-process toolchain, 5 warmup + 20 measured iterations, MemoryDiagnoser. net10.0, Windows, 13th Gen Intel i7-13700H. 1,000,000 series is only run for latest (1.15/1.16 cannot serve a scrape that large — ~83 MiB response-buffer ceiling).

Mean time per scrape (ms)

Series Format 1.15.0-beta.1 1.16.0-beta.1 latest
1k text/plain 0.58 1.91 1.03
1k OpenMetrics* 0.61 2.07 1.02
10k text/plain 5.82 10.06 4.76
10k OpenMetrics* 6.52 15.55 8.14
100k text/plain 86.2 124.8 74.3
100k OpenMetrics* 85.4 323.0 105.8
1M text/plain 540.2
1M OpenMetrics* 849.8

Allocated per scrape (MB)

Series Format 1.15.0-beta.1 1.16.0-beta.1 latest
1k text/plain 0.04 1.58 0.06
1k OpenMetrics* 0.04 2.67 0.07
10k text/plain 2.79 17.8 2.51
10k OpenMetrics* 2.82 30.2 4.03
100k text/plain 50.9 200.3 47.9
100k OpenMetrics* 51.2 324.0 63.2
1M text/plain 504.7
1M OpenMetrics* 658.8

Summary of the differences (100k series)

  • 1.16 was a large regression vs 1.15 — OpenMetrics ~3.8× slower and ~6.4× more allocations; text/plain ~1.4× slower and ~3.9× more allocations (from the otel_scope_* labels, _created series, and exemplars introduced in 1.16).
  • Latest recovers dramatically over 1.16:
    • OpenMetrics: 323 → 106 ms (−67%), 324 → 63 MB (−80%).
    • text/plain: 125 → 74 ms (−40%), 200 → 48 MB (−76%).
  • Latest beats even 1.15 for text/plain — faster (74.3 vs 86.2 ms) and leaner (47.9 vs 50.9 MB) while emitting the richer modern output.
  • Latest OpenMetrics (real OpenMetrics with exemplars + _created) is now only ~24% slower and ~23% larger than 1.15's text-fallback "OpenMetrics", a large closing of the gap.
  • Latest also serves scales the older versions cannot (1M rows) — impossible on 1.15/1.16 due to their fixed ~83 MiB response-buffer ceiling.

Caveat

  • *1.15.0-beta.1 does not negotiate application/openmetrics-text; version=1.0.0 (content negotiation landed in 1.16), so its two format columns are the same text-style output — the 1.15 "OpenMetrics" rows are not a true OpenMetrics comparison and are cheaper because the output is smaller.

@github-actions github-actions Bot removed the pkg:OpenTelemetry.Exporter.Prometheus.AspNetCore Issues related to OpenTelemetry.Exporter.Prometheus.AspNetCore NuGet package label Jul 7, 2026
@martincostello martincostello marked this pull request as ready for review July 7, 2026 10:22
@martincostello martincostello requested a review from a team as a code owner July 7, 2026 10:22
Copilot AI review requested due to automatic review settings July 7, 2026 10:22
@martincostello martincostello enabled auto-merge July 7, 2026 10:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR improves Prometheus HttpListener exporter scrape-time performance by reducing allocations during metric serialization, specifically by reusing pooled buffers when serializing tag sets (to avoid repeated per-bucket allocations for histograms).

Changes:

  • Replaces per-call tag serialization allocations with ArrayPool<byte>-backed buffers for histogram tag serialization.
  • Optimizes duplicate-label detection in the tag-writing fast path by replacing a List<(int,int)> with a pooled (int,int)[].
  • Introduces a serializedTagsBufferHint to reduce re-rent/grow churn when serializing tags repeatedly.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Cap the maximum size of the scratch buffer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pkg:OpenTelemetry.Exporter.Prometheus.HttpListener Issues related to OpenTelemetry.Exporter.Prometheus.HttpListener NuGet package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants