Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reduce allocations of TextSerializer #410

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Prometheus/Histogram.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,13 @@ internal Child(Histogram parent, LabelSequence instanceLabels, LabelSequence fla
_upperBounds = Parent._buckets;
_bucketCounts = new ThreadSafeLong[_upperBounds.Length];
_leLabels = new CanonicalLabel[_upperBounds.Length];

// create a reusable buffer outside of the loop to avoid double-to-bytes intermediary string allocations
Span<byte> buffer = stackalloc byte[32];

for (var i = 0; i < Parent._buckets.Length; i++)
{
_leLabels[i] = TextSerializer.EncodeValueAsCanonicalLabel(LeLabelName, Parent._buckets[i]);
_leLabels[i] = TextSerializer.EncodeValueAsCanonicalLabel(LeLabelName, Parent._buckets[i], buffer);
}
_exemplars = new ObservedExemplar[_upperBounds.Length];
for (var i = 0; i < _upperBounds.Length; i++)
Expand Down
6 changes: 5 additions & 1 deletion Prometheus/Summary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,15 @@ internal Child(Summary parent, LabelSequence instanceLabels, LabelSequence flatt
_headStream = _streams[0];

_quantileLabels = new CanonicalLabel[_objectives.Count];

// create a reusable buffer outside of the loop to avoid double-to-bytes intermediary string allocations
Span<byte> buffer = stackalloc byte[32];

for (var i = 0; i < _objectives.Count; i++)
{
_sortedObjectives[i] = _objectives[i].Quantile;
_quantileLabels[i] = TextSerializer.EncodeValueAsCanonicalLabel(
QuantileLabelName, _objectives[i].Quantile);
QuantileLabelName, _objectives[i].Quantile, buffer);
}

Array.Sort(_sortedObjectives);
Expand Down
71 changes: 62 additions & 9 deletions Prometheus/TextSerializer.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using System.Globalization;
using System.Buffers;
using System.Buffers.Text;
using System.Globalization;
using System.Runtime.CompilerServices;

namespace Prometheus;

Expand Down Expand Up @@ -33,6 +36,10 @@ internal sealed class TextSerializer : IMetricsSerializer

private static readonly char[] DotEChar = { '.', 'e' };

// Avoid paying the field initializer cost:
// https://endjin.com/blog/2023/02/dotnet-csharp-11-utf8-string-literals
private static ReadOnlySpan<byte> DotEBytes => ".e"u8;

public TextSerializer(Stream stream, ExpositionFormat fmt = ExpositionFormat.PrometheusText)
{
_expositionFormat = fmt;
Expand Down Expand Up @@ -175,13 +182,19 @@ private async Task WriteValue(double value, CancellationToken cancel)
}
}

var valueAsString = value.ToString("g", CultureInfo.InvariantCulture);
// Utf8Formatter.TryFormat always uses invariant culture
// This saves the intermediary string allocation.
if (!Utf8Formatter.TryFormat(value, _stringBytesBuffer, out var numBytes, new StandardFormat('g')))
{
// something went wrong. Fall back to manually creating the string.
var valueAsString = value.ToString("g", CultureInfo.InvariantCulture);
numBytes = PrometheusConstants.ExportEncoding.GetBytes(valueAsString, 0, valueAsString.Length, _stringBytesBuffer, 0);
}

var numBytes = PrometheusConstants.ExportEncoding.GetBytes(valueAsString, 0, valueAsString.Length, _stringBytesBuffer, 0);
await _stream.Value.WriteAsync(_stringBytesBuffer, 0, numBytes, cancel);

// In certain places (e.g. "le" label) we need floating point values to actually have the decimal point in them for OpenMetrics.
if (_expositionFormat == ExpositionFormat.OpenMetricsText && valueAsString.IndexOfAny(DotEChar) == -1 /* did not contain .|e */)
if (_expositionFormat == ExpositionFormat.OpenMetricsText && _stringBytesBuffer.AsSpan(0, numBytes).IndexOfAny(DotEBytes) == -1 /* did not contain .|e */)
await _stream.Value.WriteAsync(DotZero, 0, DotZero.Length, cancel);
}

Expand All @@ -203,9 +216,15 @@ private async Task WriteValue(long value, CancellationToken cancel)
}
}

var valueAsString = value.ToString("D", CultureInfo.InvariantCulture);

var numBytes = PrometheusConstants.ExportEncoding.GetBytes(valueAsString, 0, valueAsString.Length, _stringBytesBuffer, 0);
// Utf8Formatter.TryFormat always uses invariant culture
// This saves the intermediary string allocation.
if (!Utf8Formatter.TryFormat(value, _stringBytesBuffer, out var numBytes, new StandardFormat('D')))
{
// something went wrong. Fall back to manually creating the string.
var valueAsString = value.ToString("D", CultureInfo.InvariantCulture);
numBytes = PrometheusConstants.ExportEncoding.GetBytes(valueAsString, 0, valueAsString.Length, _stringBytesBuffer, 0);
}

await _stream.Value.WriteAsync(_stringBytesBuffer, 0, numBytes, cancel);
}

Expand Down Expand Up @@ -272,14 +291,48 @@ await _stream.Value.WriteAsync(
/// the same.
/// see: https://github.com/OpenObservability/OpenMetrics/blob/main/specification/OpenMetrics.md#considerations-canonical-numbers
/// </summary>
internal static CanonicalLabel EncodeValueAsCanonicalLabel(byte[] name, double value)
internal static CanonicalLabel EncodeValueAsCanonicalLabel(byte[] name, double value, Span<byte> buffer)
{
if (double.IsPositiveInfinity(value))
return new CanonicalLabel(name, PositiveInfinity, PositiveInfinity);

// Utf8Formatter.TryFormat always uses invariant culture
// This saves the intermediary string allocation.
if (Utf8Formatter.TryFormat(value, buffer, out var numBytes, new StandardFormat('g')))
{
// Copy the byte buffer into a new array for passing to CanonicalLabel
var prometheusBytes = buffer.Slice(0, numBytes).ToArray();
var openMetricsBytes = prometheusBytes;

// Identify whether the original value is floating-point, by checking for presence of the 'e' or '.' characters.
if (prometheusBytes.AsSpan().IndexOfAny(DotEBytes) == -1)
{
var targetLength = prometheusBytes.Length + 2;

openMetricsBytes = new byte[targetLength];
Array.Copy(prometheusBytes, openMetricsBytes, numBytes);

// OpenMetrics requires labels containing numeric values to be expressed in floating point format.
// If all we find is an integer, we add a ".0" to the end to make it a floating point value.
openMetricsBytes[numBytes] = DotZero[0];
openMetricsBytes[numBytes + 1] = DotZero[1];
}

return new CanonicalLabel(name, prometheusBytes, openMetricsBytes);
}
else
{
return EncodeValueAsCanonicalLabelSlow(name, value);
}
}

// Avoid inlining the unlikely branch;
[MethodImpl(MethodImplOptions.NoInlining)]
private static CanonicalLabel EncodeValueAsCanonicalLabelSlow(byte[] name, double value)
{
// something went wrong. Fall back to manually creating the string.
var valueAsString = value.ToString("g", CultureInfo.InvariantCulture);
var prometheusBytes = PrometheusConstants.ExportEncoding.GetBytes(valueAsString);

var openMetricsBytes = prometheusBytes;

// Identify whether the original value is floating-point, by checking for presence of the 'e' or '.' characters.
Expand Down