Skip to content

perf: avoid per-observation array allocation in ObservableInstrument#128039

Open
unsafePtr wants to merge 9 commits into
dotnet:mainfrom
unsafePtr:perf/observable-instrument-skip-array
Open

perf: avoid per-observation array allocation in ObservableInstrument#128039
unsafePtr wants to merge 9 commits into
dotnet:mainfrom
unsafePtr:perf/observable-instrument-skip-array

Conversation

@unsafePtr
Copy link
Copy Markdown
Contributor

Description

Eliminate the per-observation Measurement<T>[1] allocation in the built-in observable instruments ObservableCounter<T>, ObservableGauge<T>, ObservableUpDownCounter<T>).

ObservableInstrument<T>.Observe(MeterListener) now pattern-matches on this to read the built-in's _callback directly and dispatches single-value callbacks straight to MeterListener.NotifyMeasurement, skipping the array wrapper and the foreach allocation. The Func<IEnumerable<Measurement<T>>> shape is unchanged in behavior — the user owns the enumerable.

Benchmark

BenchmarkDotNet on .NET 11 preview, one RecordObservableInstruments() call per benchmark:

Callback shape Baseline Patched
Func<T> 22.91 ns / 72 B 5.71 ns / 0 B
Func<Measurement<T>> 24.52 ns / 72 B 6.78 ns / 0 B
Func<IEnumerable<Measurement<T>>> 37.49 ns / 104 B 28.75 ns / 72 B

The IEnumerable row also improves (−32 B) because the simpler call chain lets the JIT elide the enumerator the baseline kept. The user-allocated array itself (72 B) is unchanged.

Compatibility

No public API change. The three built-ins are public sealed with internal constructors, so the this switch is exhaustive for instances that flow through this method.

Built-in observable instruments (ObservableCounter, ObservableGauge,
ObservableUpDownCounter) with a Func<T> or Func<Measurement<T>> callback
allocated a Measurement<T>[1] every observation, plus an enumerator from
the IEnumerable foreach. The fast path in ObservableInstrument<T>.Observe
now dispatches single-value callbacks directly to NotifyMeasurement,
skipping the array + enumerator allocations entirely.

The static Observe(object callback) helper is removed; each built-in's
Observe() override now only handles the Func<IEnumerable<Measurement<T>>>
shape with a direct cast.
@dotnet-policy-service dotnet-policy-service Bot added the community-contribution Indicates that the PR has been added by a community member label May 11, 2026
@dotnet-policy-service
Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @steveisok, @dotnet/area-system-diagnostics-tracing
See info in area-owners.md if you want to be subscribed.

@tarekgh
Copy link
Copy Markdown
Member

tarekgh commented May 11, 2026

CC @noahfalk

@tarekgh tarekgh added this to the 11.0.0 milestone May 11, 2026
Copy link
Copy Markdown
Member

@tarekgh tarekgh left a comment

Choose a reason for hiding this comment

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

Left minor comments. LGTM, otherwise.

@unsafePtr
Copy link
Copy Markdown
Contributor Author

@tarekgh , added Debug.Fail in inherited method for clarity.

@tarekgh
Copy link
Copy Markdown
Member

tarekgh commented May 13, 2026

@unsafePtr could you paste the benchmark numbers according to the latest changes?

@unsafePtr
Copy link
Copy Markdown
Contributor Author

@tarekgh, I've run them, but 3rd case went worse. Think I can hoist func() as it was before. Let me try it and I will post benchmarks afterwards.

@unsafePtr
Copy link
Copy Markdown
Contributor Author

unsafePtr commented May 13, 2026

Really strange how the initial one version got 72 B allocated. But I didn't had NoInlining back then. So I am not sure

Baseline


BenchmarkDotNet v0.16.0-nightly.20260513.530, Windows 11 (10.0.26200.8246/25H2/2025Update/HudsonValley2)
13th Gen Intel Core i7-13700KF 3.40GHz, 1 CPU, 24 logical and 16 physical cores
Memory: 63.72 GB Total, 23.33 GB Available
.NET SDK 11.0.100-preview.5.26227.104
  [Host]     : .NET 11.0.0 (11.0.0-preview.5.26227.104, 11.0.26.22804), X64 RyuJIT x86-64-v3
  DefaultJob : .NET 11.0.0 (11.0.0-preview.5.26227.104, 11.0.26.22804), X64 RyuJIT x86-64-v3


Method Mean Error StdDev Median Gen0 Allocated
Func<T> 17.31 ns 0.342 ns 0.634 ns 17.35 ns 0.0013 72 B
Func<Measurement<T>> 18.68 ns 0.369 ns 0.826 ns 18.73 ns 0.0013 72 B
Func<IEnumerable<Measurement<T>>> 25.27 ns 0.735 ns 2.156 ns 24.29 ns 0.0117 104 B

Patch


BenchmarkDotNet v0.16.0-nightly.20260513.530, Windows 11 (10.0.26200.8246/25H2/2025Update/HudsonValley2)
13th Gen Intel Core i7-13700KF 3.40GHz, 1 CPU, 24 logical and 16 physical cores
Memory: 63.72 GB Total, 23.31 GB Available
.NET SDK 11.0.100-preview.5.26227.104
  [Host]     : .NET 11.0.0 (11.0.0-preview.5.26227.104, 11.0.26.22804), X64 RyuJIT x86-64-v3
  DefaultJob : .NET 11.0.0 (11.0.0-preview.5.26227.104, 11.0.26.22804), X64 RyuJIT x86-64-v3


Method Mean Error StdDev Median Gen0 Allocated
Func<T> 4.284 ns 0.0112 ns 0.0100 ns 4.280 ns - -
Func<Measurement<T>> 4.568 ns 0.0229 ns 0.0191 ns 4.567 ns - -
Func<IEnumerable<Measurement<T>>> 25.112 ns 0.9667 ns 2.8503 ns 23.945 ns 0.0112 104 B

Benchmark code

[MemoryDiagnoser]
public class ObservableInstrumentBench
{
    private Meter _meter = null!;
    private MeterListener _funcValueListener = null!;
    private MeterListener _funcMeasurementListener = null!;
    private MeterListener _funcEnumerableListener = null!;

    private long _sink;

    [GlobalSetup]
    public void Setup()
    {
        _meter = new Meter("Bench");

        ObservableCounter<int> funcValue = _meter.CreateObservableCounter<int>(
            "counter-func-t", () => 42);

        ObservableGauge<int> funcMeasurement = _meter.CreateObservableGauge<int>(
            "gauge-func-measurement", () => new Measurement<int>(7));

        ObservableUpDownCounter<int> funcEnumerable = _meter.CreateObservableUpDownCounter<int>(
            "updown-func-enumerable",
            () => new[]
            {
                new Measurement<int>(1),
                new Measurement<int>(2),
                new Measurement<int>(3),
            });

        _funcValueListener = CreateListener(funcValue);
        _funcMeasurementListener = CreateListener(funcMeasurement);
        _funcEnumerableListener = CreateListener(funcEnumerable);
    }

    private MeterListener CreateListener(Instrument target)
    {
        MeterListener listener = new MeterListener
        {
            InstrumentPublished = (inst, l) =>
            {
                if (ReferenceEquals(inst, target))
                {
                    l.EnableMeasurementEvents(inst, null);
                }
            },
        };
        listener.SetMeasurementEventCallback<int>((inst, value, tags, state) => _sink += value);
        listener.Start();
        return listener;
    }

    [GlobalCleanup]
    public void Cleanup()
    {
        _funcValueListener.Dispose();
        _funcMeasurementListener.Dispose();
        _funcEnumerableListener.Dispose();
        _meter.Dispose();
    }

    [Benchmark(Description = "Func<T>")]
    [MethodImpl(MethodImplOptions.NoInlining)]
    public void Record_FuncValue() => _funcValueListener.RecordObservableInstruments();

    [Benchmark(Description = "Func<Measurement<T>>")]
    [MethodImpl(MethodImplOptions.NoInlining)]
    public void Record_FuncMeasurement() => _funcMeasurementListener.RecordObservableInstruments();

    [Benchmark(Description = "Func<IEnumerable<Measurement<T>>>")]
    [MethodImpl(MethodImplOptions.NoInlining)]
    public void Record_FuncEnumerable() => _funcEnumerableListener.RecordObservableInstruments();
}

@tarekgh
Copy link
Copy Markdown
Member

tarekgh commented May 14, 2026

The third case shows no improvement: same ~25 ns and 104 B allocation. This is expected because the allocation comes from the user's callback (creating the array/enumerable), which can't be avoided.

The Func<IEnumerable<Measurement>> branch inside ObservableInstrument.Observe(MeterListener) adds complexity without any perf benefit. It should be removed, and the concrete classes (ObservableCounter, ObservableGauge, ObservableUpDownCounter) should restore their Observe() overrides to handle the IEnumerable callback, instead of throwing InvalidOperationException.

Specifically:

  1. Remove the if (callback is Func<IEnumerable<Measurement>> enumerableFunc) block from ObservableInstrument.Observe(MeterListener), let IEnumerable fall through to the existing Observe() virtual call at the bottom.
  2. Restore each concrete class's Observe() to call the IEnumerable callback: return ((Func<IEnumerable<Measurement>>)_callback)(); instead of Debug.Assert(false); throw ....
  3. Remove the Arg_UnreachableException string resource (no longer needed).

This keeps the two big wins (Func and Func<Measurement> fast paths) while simplifying the code, the IEnumerable path naturally falls back to the virtual Observe() method, which is the same path user-defined subclasses use.

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

Labels

area-System.Diagnostics.Metric community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants