Skip to content

perf: avoid allocating a mapping lambda on every labelValues() call - #2442

Open
david-mollitor-db wants to merge 1 commit into
prometheus:mainfrom
david-mollitor-db:labelvalues-fast-path
Open

perf: avoid allocating a mapping lambda on every labelValues() call#2442
david-mollitor-db wants to merge 1 commit into
prometheus:mainfrom
david-mollitor-db:labelvalues-fast-path

Conversation

@david-mollitor-db

Copy link
Copy Markdown

What

StatefulMetric.labelValues(...) — the path every counter.labelValues(...).inc() /
histogram.labelValues(...).observe(...) goes through — called
data.computeIfAbsent(key, l -> newDataPoint()) on every invocation. The mapping function
captures this (via metadata/labelNames), so it is not a cached singleton: a new lambda
instance is allocated on every call, including the overwhelmingly common case where the data point
already exists (the lambda argument is constructed before computeIfAbsent runs, even though it is
only invoked on a miss).

This adds a data.get(key) fast path that returns the existing data point without constructing the
lambda:

List<String> key = Arrays.asList(labelValues);
T dataPoint = data.get(key);
if (dataPoint != null) {
  return dataPoint;
}
// miss: validate the raw array outside the CHM lock, then create at-most-once
for (int i = 0; i < labelValues.length; i++) {
  if (labelValues[i] == null) {
    throw new IllegalArgumentException(
        "null label value for metric " + metadata.getName() + " and label " + labelNames[i]);
  }
}
return data.computeIfAbsent(key, l -> newDataPoint());

Two secondary points folded in on the miss path:

  • the null-value validation now runs before computeIfAbsent, i.e. outside the
    ConcurrentHashMap bin lock, and operates on the raw String[] (no List.get indirection);
  • creation stays inside computeIfAbsent on purpose — newDataPoint() has a side effect (a native
    histogram may schedule a reset task via Scheduler.schedule), so at-most-once creation must be
    preserved; putIfAbsent with a pre-built value would leak the loser's scheduled task on a race.

Why

labelValues(...) is the hottest path in the library (every metric update). In a JMH benchmark of a
histogram-heavy workload, the get() fast path cut record-path allocation by ~18% — exactly the
16-byte captured lambda per observation.

Correctness

Behavior is unchanged (a null label value still throws on first use, since a null-containing key is
never inserted and so always reaches the miss branch). Verified by the core tests, including
StatefulMetricTest.

This pull request and its description were written by Isaac.

labelValues() went straight to data.computeIfAbsent(key, l -> ...). The mapping
function captures 'this', so a new lambda instance was allocated on every call -
including the common case where the data point already exists, since the lambda
argument is constructed before computeIfAbsent runs.

Add a data.get(key) fast path that returns the existing data point without
constructing the lambda. In a JMH benchmark of a histogram-heavy workload this
cut record-path allocation by ~18% (exactly the 16-byte captured lambda per
observation).

On the miss path, validate the label values on the raw array before
computeIfAbsent, so the null check runs outside the ConcurrentHashMap bin lock
and without List indirection. Creation stays inside computeIfAbsent: newDataPoint()
has side effects (a native histogram may schedule a reset task), so at-most-once
creation must be preserved.

Behavior is unchanged (verified by the core tests, including StatefulMetricTest).

Signed-off-by: David Mollitor <david.mollitor@databricks.com>
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