Skip to content

perf: optimize classic-only histogram observe path - #2252

Open
arnabnandy7 wants to merge 13 commits into
prometheus:mainfrom
arnabnandy7:perf/synchronized-histogram
Open

perf: optimize classic-only histogram observe path#2252
arnabnandy7 wants to merge 13 commits into
prometheus:mainfrom
arnabnandy7:perf/synchronized-histogram

Conversation

@arnabnandy7

@arnabnandy7arnabnandy7 commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Optimizes Histogram.classicOnly() observations by adding a dedicated classic-only fast path that uses synchronized plain fields instead of the native/hybrid LongAdder/DoubleAdder plus buffer path.

Fixes#1915

Changes

  • Added classic-only storage for bucket counts, total count, and sum in Histogram.DataPoint.
  • Routed observe() and observeWithExemplar() through the classic-only fast path when nativeInitialSchema == CLASSIC_HISTOGRAM.
  • Kept native-only and hybrid histograms on the existing buffer/aduder implementation.
  • Added multithreaded coverage for classic-only observe/collect behavior.

Testing

  • .\mvnw compile -pl prometheus-metrics-core -Dcoverage.skip=true -Dcheckstyle.skip=true
  • git diff --check

Signed-off-by: Arnab Nandy <arnab_nandy7@yahoo.com>
@arnabnandy7
arnabnandy7force-pushed the perf/synchronized-histogram branch from 8f7d299 to 591d6adCompareJune 25, 2026 21:07
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ API changes detected — maintainer review required

This PR modifies the published API diff for the following module(s):

  • prometheus-metrics-core

Please review the changes in docs/apidiffs/current_vs_latest/ carefully before approving.

Signed-off-by: Gregor Zeitlinger <gregor.zeitlinger@grafana.com>
@zeitlinger
zeitlingerforce-pushed the perf/synchronized-histogram branch from 2dea810 to e539344CompareJuly 2, 2026 14:42
zeitlingerand others added 2 commits July 3, 2026 07:35
zeitlinger added a commit that referenced this pull request Jul 7, 2026
## Summary
Extract the PR benchmark workflows from #2252 into a standalone PR so
they can land on `main` first.
## Why
The benchmark job uploads a Markdown artifact with the PR comment
contents, but the actual PR comment is posted by a separate
`workflow_run` workflow. GitHub only triggers `workflow_run` workflows
when the workflow file already exists on the default branch, so keeping
the reporter in the same PR prevents it from commenting on that PR's
benchmark run.
Splitting these workflows out first lets subsequent benchmark PRs use
the comment-reporting flow as intended.
## Changes
- add `PR Benchmarks` workflow triggered by the `benchmark` label on
pull requests
- add `PR Benchmark Report` workflow triggered by `workflow_run`
- keep the benchmark summary as an uploaded artifact consumed by the
reporter workflow
## Testing
- `mise run lint:fix`
zeitlinger added a commit that referenced this pull request Jul 8, 2026
## Summary
The `PR Benchmark Report` workflow failed to comment on
[#2252](#2252) after a
successful benchmark run ([run
28865892835](https://github.com/prometheus/client_java/actions/runs/28865892835)):
```
gh: Not Found (HTTP 404)
parse "https://api.github.com/repos/prometheus/client_java/issues/comments/{\r": net/url: invalid control character in URL
```
Root cause: `workflow_run.pull_requests` is empty when the triggering
run's head branch is not on the default branch (or the PR is from a
fork). With `PR_NUMBER` empty, the subsequent
`repos/${REPO}/issues//comments` API call 404s and the paginator emits a
malformed URL, so no comment gets posted.
## Changes
- Fall back to `repos/{owner}/{repo}/commits/{sha}/pulls` to resolve the
PR from the head SHA when `workflow_run.pull_requests` is empty.
- Fail fast with an explicit error if the PR still can't be resolved, so
future regressions don't cascade into cryptic URL-parse errors.
## Test plan
- [ ] Re-run the report workflow against run 28861406560 (or a fresh
push to #2252) and confirm a benchmark comment lands on the PR.
Signed-off-by: Gregor Zeitlinger <gregor.zeitlinger@grafana.com>
zeitlinger added a commit that referenced this pull request Jul 9, 2026
## Summary
Fix the `PR Benchmark Report` workflow for forked PR benchmark runs like
#2252, where `workflow_run.pull_requests` and `commits/{sha}/pulls` can
both be empty for the upstream repository.
## Changes
- Resolve the PR number from the uploaded benchmark artifact names
first.
- Fall back to the workflow run head repository owner and branch via
`repos/{owner}/{repo}/pulls`.
- Keep the existing commit-SHA lookup as a final fallback.
- Remove the artifact ZIP download `Accept: application/octet-stream`
header because `gh api` rejected it during local replay.
## Validation
- Replayed the resolution logic against benchmark run `28928458470` and
confirmed it resolves PR #2252 and downloads artifact `8164318312`.
- Ran `mise run lint:fix`.
- Ran `git diff --check`.
@github-actions

github-actionsBot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Benchmark results

Benchmark run succeeded for 1c1f76907c1318938f88d4a207dfeb27ac30c971.

Prometheus Java Client Benchmarks

Run Information

  • Date: 2026-07-20T13:53:43Z
  • Commit:1c1f769
  • JDK: 25.0.3 (OpenJDK 64-Bit Server VM)
  • Benchmark config: 3 fork(s), 3 warmup, 5 measurement, 4 threads
  • Hardware: AMD EPYC 7763 64-Core Processor, 4 cores, 16 GB RAM
  • OS: Linux 6.17.0-1020-azure

Comparison with base

  • Head:1c1f769
  • Base:0a91771
  • Change: positive means the PR is faster than base.
  • Note: Base and head are compared on the same runner within each topic.
BenchmarkPRBaseChangeResult
CounterBenchmark.codahaleIncNoLabels49.66K47.08K+5.5%within noise
CounterBenchmark.openTelemetryAdd3.50K3.56K-1.6%within noise
CounterBenchmark.openTelemetryInc3.12K3.09K+0.9%within noise
CounterBenchmark.openTelemetryIncNoLabels3.20K3.16K+1.2%within noise
CounterBenchmark.prometheusAdd51.11K51.32K-0.4%within noise
CounterBenchmark.prometheusInc66.09K66.46K-0.6%within noise
CounterBenchmark.prometheusNoLabelsInc56.79K56.95K-0.3%within noise
CounterBenchmark.simpleclientAdd6.11K6.39K-4.4%within noise
CounterBenchmark.simpleclientInc6.55K6.55K-0.1%within noise
CounterBenchmark.simpleclientNoLabelsInc6.34K6.33K+0.3%within noise
HistogramBenchmark.openTelemetryClassic707.83712.84-0.7%within noise
HistogramBenchmark.openTelemetryExponential524.92556.18-5.6%slower
HistogramBenchmark.prometheusClassic935.757.89K-88.1%slower
HistogramBenchmark.prometheusClassicPerThread19.20K14.48K+32.6%faster
HistogramBenchmark.prometheusClassicSingleThread9.39K5.94K+58.1%faster
HistogramBenchmark.prometheusNative2.97K2.80K+5.9%within noise
HistogramBenchmark.simpleclient4.62K4.51K+2.3%within noise
HistogramTextFormatBenchmark.openMetricsWriteToNull23.51K23.69K-0.8%within noise
HistogramTextFormatBenchmark.prometheusWriteToNull23.70K24.05K-1.4%within noise
TextFormatUtilBenchmark.openMetricsWriteToByteArray487.19K482.95K+0.9%within noise
TextFormatUtilBenchmark.openMetricsWriteToNull491.70K493.02K-0.3%within noise
TextFormatUtilBenchmark.prometheusWriteToByteArray505.02K509.99K-1.0%within noise
TextFormatUtilBenchmark.prometheusWriteToNull513.34K512.86K+0.1%within noise

Results for PR head

CounterBenchmark

BenchmarkScoreErrorUnitsWithin run
prometheusInc66.09K± 537.02ops/sfastest
prometheusNoLabelsInc56.79K± 299.70ops/s1.2x slower
prometheusAdd51.11K± 269.88ops/s1.3x slower
codahaleIncNoLabels49.66K± 886.22ops/s1.3x slower
simpleclientInc6.55K± 147.47ops/s10x slower
simpleclientNoLabelsInc6.34K± 9.84ops/s10x slower
simpleclientAdd6.11K± 306.29ops/s11x slower
openTelemetryAdd3.50K± 166.71ops/s19x slower
openTelemetryIncNoLabels3.20K± 243.16ops/s21x slower
openTelemetryInc3.12K± 339.34ops/s21x slower

HistogramBenchmark

BenchmarkScoreErrorUnitsWithin run
prometheusClassicPerThread19.20K± 11.10ops/sfastest
prometheusClassicSingleThread9.39K± 43.52ops/s2.0x slower
simpleclient4.62K± 91.85ops/s4.2x slower
prometheusNative2.97K± 228.32ops/s6.5x slower
prometheusClassic935.75± 108.72ops/s21x slower
openTelemetryClassic707.83± 9.35ops/s27x slower
openTelemetryExponential524.92± 8.95ops/s37x slower

HistogramTextFormatBenchmark

BenchmarkScoreErrorUnitsWithin run
prometheusWriteToNull23.70K± 603.04ops/sfastest
openMetricsWriteToNull23.51K± 606.26ops/s1.0x slower

TextFormatUtilBenchmark

BenchmarkScoreErrorUnitsWithin run
prometheusWriteToNull513.34K± 3.50Kops/sfastest
prometheusWriteToByteArray505.02K± 8.91Kops/s1.0x slower
openMetricsWriteToNull491.70K± 2.39Kops/s1.0x slower
openMetricsWriteToByteArray487.19K± 1.41Kops/s1.1x slower

Raw Results

Benchmark Mode Cnt Score Error Units
CounterBenchmark.codahaleIncNoLabels thrpt 15 49657.370 ± 886.225 ops/s
CounterBenchmark.openTelemetryAdd thrpt 15 3499.269 ± 166.707 ops/s
CounterBenchmark.openTelemetryInc thrpt 15 3119.853 ± 339.336 ops/s
CounterBenchmark.openTelemetryIncNoLabels thrpt 15 3200.141 ± 243.162 ops/s
CounterBenchmark.prometheusAdd thrpt 15 51107.850 ± 269.875 ops/s
CounterBenchmark.prometheusInc thrpt 15 66087.397 ± 537.025 ops/s
CounterBenchmark.prometheusNoLabelsInc thrpt 15 56785.283 ± 299.705 ops/s
CounterBenchmark.simpleclientAdd thrpt 15 6110.421 ± 306.287 ops/s
CounterBenchmark.simpleclientInc thrpt 15 6546.736 ± 147.473 ops/s
CounterBenchmark.simpleclientNoLabelsInc thrpt 15 6342.087 ± 9.837 ops/s
HistogramBenchmark.openTelemetryClassic thrpt 15 707.826 ± 9.351 ops/s
HistogramBenchmark.openTelemetryExponential thrpt 15 524.921 ± 8.946 ops/s
HistogramBenchmark.prometheusClassic thrpt 15 935.747 ± 108.715 ops/s
HistogramBenchmark.prometheusClassicPerThread thrpt 15 19195.302 ± 11.102 ops/s
HistogramBenchmark.prometheusClassicSingleThread thrpt 15 9386.316 ± 43.521 ops/s
HistogramBenchmark.prometheusNative thrpt 15 2968.843 ± 228.324 ops/s
HistogramBenchmark.simpleclient thrpt 15 4616.714 ± 91.854 ops/s
HistogramTextFormatBenchmark.openMetricsWriteToNull thrpt 15 23510.366 ± 606.262 ops/s
HistogramTextFormatBenchmark.prometheusWriteToNull thrpt 15 23699.025 ± 603.043 ops/s
TextFormatUtilBenchmark.openMetricsWriteToByteArray thrpt 15 487186.586 ± 1405.248 ops/s
TextFormatUtilBenchmark.openMetricsWriteToNull thrpt 15 491698.956 ± 2390.927 ops/s
TextFormatUtilBenchmark.prometheusWriteToByteArray thrpt 15 505020.646 ± 8905.491 ops/s
TextFormatUtilBenchmark.prometheusWriteToNull thrpt 15 513341.626 ± 3496.829 ops/s

Notes

  • Score = Throughput in operations per second (higher is better)
  • Error = 99.9% confidence interval
  • Comparison with base uses JMH confidence intervals when available; overlapping intervals are marked "within noise".
  • Within run compares benchmarks in the same result set, not against the base commit.

Benchmark Descriptions

BenchmarkDescription
CounterBenchmarkCounter increment performance: Prometheus, OpenTelemetry, simpleclient, Codahale
HistogramBenchmarkHistogram observation performance (classic vs native/exponential)
TextFormatUtilBenchmarkMetric exposition format writing speed

zeitlinger added a commit that referenced this pull request Jul 10, 2026
## Summary
Make PR benchmark comments answer whether the PR is faster or slower
than its base commit, while keeping wall-clock time close to a single
benchmark run.
## Changes
- Run base and head benchmarks in parallel matrix jobs.
- Add a lightweight summary job that downloads both raw JMH artifacts
and generates the PR comment.
- Add a `Comparison with base` table to the generated `README.md` / PR
comment.
- Mark each benchmark as `faster`, `slower`, or `within noise` using JMH
confidence intervals when available.
- Capture and show runner hardware for both base and head jobs,
including a note that parallel jobs may run on different hardware.
- Rename the existing per-run relative column to `Within run` so it is
clear that it is not the base comparison.
- Store `baseline-results.json` in the uploaded benchmark artifact for
inspection.
## Validation
- Ran `mise run lint:fix`.
- Ran `python3 -m py_compile .mise/tasks/generate_benchmark_summary.py`.
- Ran `git diff --check`.
- Replayed summary generation with the latest #2252 benchmark artifact
and the published benchmark baseline; the output now shows
`HistogramBenchmark.prometheusClassic` as `-78.6%` / `slower`.
- Verified no-baseline summary generation still omits the comparison
section.
## Tradeoff
Parallel base/head jobs are faster wall-clock than measuring both
commits sequentially on one runner, but runner hardware can differ. The
comment now prints both runner descriptions and calls out that caveat.
@zeitlinger

Copy link
Copy Markdown
Member

the bench is not comparing to main - I'll fix that

@arnabnandy7

Copy link
Copy Markdown
ContributorAuthor

the bench is not comparing to main - I'll fix that

thanks

zeitlinger added a commit that referenced this pull request Jul 20, 2026
## Summary
Replace the parallel base/head PR benchmark comparison with topic-split
benchmark jobs. Each topic compares base and head sequentially on the
same runner, while topics still run in parallel for better wall-clock
time.
## Changes
- Split PR benchmarks into topic jobs: counter, histogram, and
exposition.
- Run base and head sequentially inside each topic job so each
comparison uses the same runner.
- Merge per-topic JMH JSON results in the summary job before generating
the PR comment.
- Add a comparison note clarifying that base/head are compared on the
same runner within each topic.
- Add histogram benchmark variants for classic-only histograms:
- shared contended 4-thread benchmark remains as `prometheusClassic`
- single-thread benchmark: `prometheusClassicSingleThread`
- per-thread 4-thread benchmark: `prometheusClassicPerThread`
## Why
Running base and head in separate jobs was faster but not reliable
enough because GitHub Actions can schedule them on different CPU models.
Topic splitting keeps the reliable same-runner comparison within each
topic while still allowing unrelated benchmark topics to run in
parallel.
## Validation
- Ran `mise run lint:fix`.
- Ran `mise run build`.
- Ran `python3 -m py_compile .mise/tasks/generate_benchmark_summary.py`.
- Ran `git diff --check`.
- Verified the JMH include patterns select the intended benchmarks with
`java -jar benchmarks/target/benchmarks.jar -l ...`.
- Smoke-tested the topic result merge and summary generation using the
latest #2252 benchmark artifact.

@zeitlingerzeitlinger left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The shared-observer benchmark regression is blocking for this implementation: serializing every classic-only observation on the DataPoint monitor removes the contention-avoiding behavior we need on this path.

I opened #2329 as an experimental alternate candidate so we can compare an epoch-striped/double-buffered per-thread accumulator against main on the same benchmark runner. Its local four-thread JMH smoke result was 40,944.515 ± 302.666 ops/s, but that is not a controlled cross-branch result and should not be treated as proof. The full benchmark workflow for #2329 is queued at https://github.com/prometheus/client_java/actions/runs/29990012023.

The alternate is not production-ready yet. Before it could be considered for merge, it still needs:

  • Java Memory Model review and preferably dedicated jcstress coverage for epoch handoff races;
  • a decision on bounding/reclaiming per-thread cells under high thread churn;
  • scrape latency and stalled-observer spin-wait measurements;
  • evaluation of the epoch-flip/cell-scan cost in getCount() and getSum();
  • the full CI benchmark comparison and broader supported-JDK/architecture coverage.

The benchmark can be rerun by removing and re-adding the benchmark label on the alternate PR.

I recommend keeping #2252 blocked while we collect the alternate benchmark and decide whether the striped design can be hardened, rather than merging the global-monitor regression.

@zeitlinger

Copy link
Copy Markdown
Member

Follow-up: the alternate draft's current head is 6fe299560f57865c3ca33d399163a0639f84b47e; the benchmark label has been toggled after the API-diff metadata commit. The current benchmark run is https://github.com/prometheus/client_java/actions/runs/29990237603 (queued/running). The blocking review and remaining-hardening caveats above are unchanged.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Classic-only histogram: consider synchronized block instead of multi-LongAdder for observe() hot path

2 participants

@arnabnandy7@zeitlinger