perf: avoid per-observation array allocation in ObservableInstrument#128039
perf: avoid per-observation array allocation in ObservableInstrument#128039unsafePtr wants to merge 9 commits into
Conversation
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.
|
Tagging subscribers to this area: @steveisok, @dotnet/area-system-diagnostics-tracing |
|
CC @noahfalk |
tarekgh
left a comment
There was a problem hiding this comment.
Left minor comments. LGTM, otherwise.
|
@tarekgh , added Debug.Fail in inherited method for clarity. |
|
@unsafePtr could you paste the benchmark numbers according to the latest changes? |
|
@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. |
|
Really strange how the initial one version got 72 B allocated. But I didn't had Baseline
Patch
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();
} |
|
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:
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. |
Description
Eliminate the per-observation
Measurement<T>[1]allocation in the built-in observable instrumentsObservableCounter<T>,ObservableGauge<T>,ObservableUpDownCounter<T>).ObservableInstrument<T>.Observe(MeterListener)now pattern-matches onthisto read the built-in's_callbackdirectly and dispatches single-value callbacks straight toMeterListener.NotifyMeasurement, skipping the array wrapper and the foreach allocation. TheFunc<IEnumerable<Measurement<T>>>shape is unchanged in behavior — the user owns the enumerable.Benchmark
BenchmarkDotNet on .NET 11 preview, one
RecordObservableInstruments()call per benchmark:Func<T>Func<Measurement<T>>Func<IEnumerable<Measurement<T>>>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 sealedwithinternalconstructors, so thethis switchis exhaustive for instances that flow through this method.