Skip to content

perf(encoding): cache encoded labels for text histogram encoding - #380

Open
herin049 wants to merge 2 commits into
prometheus:masterfrom
herin049:perf/cache-histogram-labels
Open

herin049 wants to merge 2 commits into
prometheus:masterfrom
herin049:perf/cache-histogram-labels

Conversation

@herin049

Copy link
Copy Markdown
Contributor

Summary

This PR optimizes Histogram encoding for both text formats by encoding cost and family labels into a reusable scratch buffer, reusing the results for encoding each line.

Changes

  • ScratchBuf (src/encoding/scratch.rs): a reusable string buffer used as a stack.
  • LabelList::encode_raw: splices an already encoded k="v",... sequence into a label list with the same separator and brace handling as encode. An empty sequence is a no-op.
  • Prometheus text and OpenMetrics text encoders:
    • One ScratchBuf per encode call is passed down through DescriptorEncoder and MetricEncoder.
    • encode_histogram caches the const and family labels and writes every line via write_labels_raw.
    • OpenMetrics text now writes labels as {const, family, additional}, matching the Prometheus text encoder.

Benchmarks

cargo bench --bench textApple M5 Pro, rustc 1.94.1. Baseline is master

Format Benchmark Before After Change
OpenMetrics encode 8.97 ms 5.51 ms −38.6%
OpenMetrics encode_realistic_string_labels 9.67 ms 5.60 ms −42.1%
OpenMetrics encode_escaped_label_values 10.19 ms 5.87 ms −42.4%
Prometheus text prometheus_text_encode 8.15 ms 4.89 ms −40.0%
Prometheus text prometheus_text_encode_realistic_string_labels 8.74 ms 4.99 ms −42.9%
Prometheus text prometheus_text_encode_escaped_label_values 9.22 ms 5.08 ms −44.9%

Signed-off-by: Lukas Hering <lukasheringb@gmail.com>
@herin049
herin049 force-pushed the perf/cache-histogram-labels branch from 4f03c73 to db0c97c Compare November 11, 2026 04:33

@krisztianfekete krisztianfekete 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.

Thanks, the perf diff looks great here!

Since only encode_histogram uses the cache and never nests, could we replace ScratchBuf, Offset, Span with a reused String for the duration of the histogram? See my inline comments/questions around this.

Comment thread src/encoding/scratch.rs
Comment on lines +1 to +7
//! A LIFO string scratch buffer for caching intermediate encoder output.
//!
//! Encoders write pieces that repeat across many lines, such as a metric name
//! or a rendered label set, into a [`ScratchBuf`] once. They then refer to
//! those pieces by [`Span`] and copy them into the output as often as needed.
//! The buffer is used as a stack: record an [`Offset`] before pushing and
//! [`ScratchBuf::rewind`] to it once the pushed pieces are no longer needed.

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 only caller pushes once and rewinds once per histogram and never nests. What's the stack semantics for?

Comment thread src/encoding/scratch.rs
Comment on lines +13 to +14
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct Offset(usize);

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.

Is anything comparing Offsets, or can we remove PartialOrd/Ord?

Comment thread src/encoding/scratch.rs
Comment on lines +25 to +30
pub(crate) struct ScratchBuf {
buf: String,
/// Staging buffer for [`ScratchBuf::encode_text`]. It is always empty
/// between calls and only kept around to reuse its allocation.
staging: String,
}

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.

Could the caller own the staging string, so ScratchBuf doesn't need to know about TextBuf?

Comment thread src/encoding/scratch.rs
Comment on lines +44 to +48
/// Pushes `s` onto the top of the buffer.
#[inline]
pub(crate) fn push_str(&mut self, s: &str) {
self.buf.push_str(s);
}

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.

Apart from tests, this only runs through the fmt::Write impl, and that impl is only used by tests, right? Do we need this?

Comment thread src/encoding/scratch.rs
Comment on lines +65 to +72
/// Borrows the text covered by `span`.
///
/// Returns `None` if the span is out of bounds or does not lie on `char`
/// boundaries, e.g. because the buffer was rewound below its end.
#[inline]
pub(crate) fn get(&self, span: Span) -> Option<&str> {
self.buf.get(span.start..span.end)
}

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.

When would this return None in a correct program?

Comment thread src/encoding/text.rs
Comment on lines +506 to +513
// Encode the const and family labels once and reuse them for every
// line.
let start = self.scratch.offset();
let result = self
.encode_cached_labels()
.and_then(|labels| self.write_histogram_lines(labels, sum, count, buckets, exemplars));
self.scratch.rewind(start);
result

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.

Since we never nest, would clear() at the start be enough instead of offset() and rewind()?

Comment thread src/encoding/text.rs
Comment on lines +518 to +525
fn encode_cached_labels(&mut self) -> Result<Span, std::fmt::Error> {
let mut seq = LabelSeqEncoder::new(self.scratch);
seq.encode(&self.const_labels)?;
if let Some(labels) = self.family_labels {
seq.encode(labels)?;
}
Ok(seq.finish())
}

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.

Could this return a borrowed &str, or fill a &mut String the caller passes?

Comment thread src/encoding/text.rs
Comment on lines +649 to +655
if let Some(labels) = self.family_labels {
list.encode(self.writer.reborrow(), labels)?;
}
if let Some(labels) = additional_labels {
list.encode(self.writer.reborrow(), labels)?;
}
if let Some(labels) = self.family_labels {
list.finish(self.writer.reborrow(), "}")

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.

This changes OpenMetrics output for Info and histogram le, which will break tests I guess. Can it go in its own commit, or at least the PR title? I agree with the new order, as it matches the Go and Python clients.

Comment thread src/encoding/text.rs
cached: Span,
additional_labels: Option<&S>,
) -> Result<(), std::fmt::Error> {
let cached = self.scratch.get(cached).ok_or(std::fmt::Error)?;

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.

If this get() ever fails, it's our bug, but users only see a fmt::Error. It's also an extra check on every line. Could we pass a &str here so it can't fail?

Comment thread src/encoding/text.rs
Comment on lines +780 to +786
/// Append `encoded`, a `k="v",...` sequence previously produced by a
/// `LabelList::new("")`, e.g. via [`LabelSeqEncoder`].
pub(crate) fn encode_raw(
&mut self,
mut writer: TextBuf<'_>,
encoded: &str,
) -> Result<(), std::fmt::Error> {

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.

Wdyt about a newtype that only the encoder can construct to make abusing this impossible?

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.

2 participants