diff --git a/src/Core/src/Diagnostics/DiagnosticsManager.cs b/src/Core/src/Diagnostics/DiagnosticsManager.cs index 183c01e41d7b..2d32495ff55a 100644 --- a/src/Core/src/Diagnostics/DiagnosticsManager.cs +++ b/src/Core/src/Diagnostics/DiagnosticsManager.cs @@ -35,6 +35,8 @@ public DiagnosticsManager(IEnumerable metrics, IEnumerable ActivitySource.HasListeners(); + public Meter? Meter { get; } public void GetTags(object source, out TagList tagList) diff --git a/src/Core/src/Diagnostics/IDiagnosticsManager.cs b/src/Core/src/Diagnostics/IDiagnosticsManager.cs index a46dd82b7bd5..9166c60d43c4 100644 --- a/src/Core/src/Diagnostics/IDiagnosticsManager.cs +++ b/src/Core/src/Diagnostics/IDiagnosticsManager.cs @@ -7,6 +7,8 @@ internal interface IDiagnosticsManager { ActivitySource ActivitySource { get; } + bool HasActivityListeners { get; } + Meter? Meter { get; } void GetTags(object source, out TagList tagList); diff --git a/src/Core/src/Diagnostics/Instrumentation/DiagnosticInstrumentation.cs b/src/Core/src/Diagnostics/Instrumentation/DiagnosticInstrumentation.cs index cd1e74f1c528..3189d46296a3 100644 --- a/src/Core/src/Diagnostics/Instrumentation/DiagnosticInstrumentation.cs +++ b/src/Core/src/Diagnostics/Instrumentation/DiagnosticInstrumentation.cs @@ -10,18 +10,52 @@ internal static class DiagnosticInstrumentation /// /// The view to instrument. /// Returns an instance of if instrumentation is supported; otherwise, null. - public static LayoutMeasureInstrumentation? StartLayoutMeasure(IView view) => - RuntimeFeature.IsMeterSupported - ? new LayoutMeasureInstrumentation(view) - : null; + public static LayoutMeasureInstrumentation? StartLayoutMeasure(IView view) + { + if (!RuntimeFeature.IsMeterSupported) + { + return null; + } + + var diagnostics = view.GetMauiDiagnostics(); + if (diagnostics is null) + { + return null; + } + + var metrics = diagnostics.GetMetrics(); + if (!diagnostics.HasActivityListeners && metrics?.IsMeasureEnabled != true) + { + return null; + } + + return new LayoutMeasureInstrumentation(view, diagnostics, metrics); + } /// /// Starts layout arrange instrumentation for the specified view. /// /// The view to instrument. /// Returns an instance of if instrumentation is supported; otherwise, null. - public static LayoutArrangeInstrumentation? StartLayoutArrange(IView view) => - RuntimeFeature.IsMeterSupported - ? new LayoutArrangeInstrumentation(view) - : null; + public static LayoutArrangeInstrumentation? StartLayoutArrange(IView view) + { + if (!RuntimeFeature.IsMeterSupported) + { + return null; + } + + var diagnostics = view.GetMauiDiagnostics(); + if (diagnostics is null) + { + return null; + } + + var metrics = diagnostics.GetMetrics(); + if (!diagnostics.HasActivityListeners && metrics?.IsArrangeEnabled != true) + { + return null; + } + + return new LayoutArrangeInstrumentation(view, diagnostics, metrics); + } } diff --git a/src/Core/src/Diagnostics/Instrumentation/LayoutArrangeInstrumentation.cs b/src/Core/src/Diagnostics/Instrumentation/LayoutArrangeInstrumentation.cs index 895e7dd72dfd..8c24898840e0 100644 --- a/src/Core/src/Diagnostics/Instrumentation/LayoutArrangeInstrumentation.cs +++ b/src/Core/src/Diagnostics/Instrumentation/LayoutArrangeInstrumentation.cs @@ -5,21 +5,59 @@ namespace Microsoft.Maui.Diagnostics; /// /// Instrumentation for the layout arrange phase of a view. /// -readonly struct LayoutArrangeInstrumentation(IView view) : IDiagnosticInstrumentation +readonly struct LayoutArrangeInstrumentation : System.IDisposable { - readonly Activity? _activity = view.StartDiagnosticActivity("Arrange"); + readonly IView _view; + readonly IDiagnosticsManager _diagnostics; + readonly LayoutDiagnosticMetrics? _metrics; + readonly Activity? _activity; + readonly bool _metricsDurationStarted; + readonly long _metricsStartTimestamp; + + public LayoutArrangeInstrumentation(IView view, IDiagnosticsManager diagnostics, LayoutDiagnosticMetrics? metrics) + { + _view = view; + _diagnostics = diagnostics; + _metrics = metrics; + + if (diagnostics.HasActivityListeners) + { + diagnostics.GetTags(view, out var tagList); + _activity = diagnostics.ActivitySource.StartActivity( + ActivityKind.Internal, + name: $"Arrange {view.GetType().Name}", + tags: tagList); + } + else + { + _activity = null; + } + + _metricsDurationStarted = metrics?.IsArrangeDurationEnabled == true; + _metricsStartTimestamp = _metricsDurationStarted + ? Stopwatch.GetTimestamp() + : 0; + } /// /// Disposes the instrumentation and stops the diagnostic activity. /// - public void Dispose() => - view.StopDiagnostics(_activity, this); + public void Dispose() + { + var metrics = _metrics; + var recordDuration = _metricsDurationStarted && metrics?.IsArrangeDurationEnabled == true; + var duration = recordDuration + ? LayoutDiagnosticMetrics.GetElapsedNanoseconds(_metricsStartTimestamp) + : 0; - /// - /// Records the stopping of the instrumentation and publishes various metrics. - /// - /// The instance. - /// The tags associated with the instrumentation. - public void Stopped(IDiagnosticsManager diagnostics, in TagList tagList) => - diagnostics.GetMetrics()?.RecordArrange(_activity?.Duration, in tagList); + _activity?.Stop(); + + if (metrics?.IsArrangeEnabled == true) + { + _diagnostics.GetTags(_view, out var tagList); + metrics.RecordArrange(duration, recordDuration, in tagList); + } + + _activity?.Dispose(); + } } diff --git a/src/Core/src/Diagnostics/Instrumentation/LayoutDiagnosticMetrics.cs b/src/Core/src/Diagnostics/Instrumentation/LayoutDiagnosticMetrics.cs index 51531ed5584b..881d51346932 100644 --- a/src/Core/src/Diagnostics/Instrumentation/LayoutDiagnosticMetrics.cs +++ b/src/Core/src/Diagnostics/Instrumentation/LayoutDiagnosticMetrics.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics; using System.Diagnostics.Metrics; @@ -29,6 +28,18 @@ internal class LayoutDiagnosticMetrics : IDiagnosticMetrics /// internal Histogram? ArrangeHistogram { get; private set; } + internal bool IsMeasureEnabled => + MeasureCounter?.Enabled == true || + MeasureHistogram?.Enabled == true; + + internal bool IsMeasureDurationEnabled => MeasureHistogram?.Enabled == true; + + internal bool IsArrangeEnabled => + ArrangeCounter?.Enabled == true || + ArrangeHistogram?.Enabled == true; + + internal bool IsArrangeDurationEnabled => ArrangeHistogram?.Enabled == true; + /// public void Create(Meter meter) { @@ -42,38 +53,52 @@ public void Create(Meter meter) /// /// Records a measure operation with an optional duration and associated tags. /// - /// The duration of the measure operation. + /// The duration of the measure operation in nanoseconds. + /// Whether a duration should be recorded. /// The tags associated with the measure operation. - public void RecordMeasure(TimeSpan? duration, in TagList tagList) + public void RecordMeasure(int duration, bool recordDuration, in TagList tagList) { - MeasureCounter?.Add(1, tagList); + if (MeasureCounter?.Enabled == true) + { + MeasureCounter.Add(1, tagList); + } - if (duration is not null) + if (recordDuration && MeasureHistogram?.Enabled == true) { -#if NET9_0_OR_GREATER - MeasureHistogram?.Record((int)duration.Value.TotalNanoseconds, tagList); -#else - MeasureHistogram?.Record((int)(duration.Value.TotalMilliseconds * 1_000_000), tagList); -#endif + MeasureHistogram.Record(duration, tagList); } } /// /// Records an arrange operation with an optional duration and associated tags. /// - /// The duration of the arrange operation. + /// The duration of the arrange operation in nanoseconds. + /// Whether a duration should be recorded. /// The tags associated with the arrange operation. - public void RecordArrange(TimeSpan? duration, in TagList tagList) + public void RecordArrange(int duration, bool recordDuration, in TagList tagList) { - ArrangeCounter?.Add(1, tagList); + if (ArrangeCounter?.Enabled == true) + { + ArrangeCounter.Add(1, tagList); + } - if (duration is not null) + if (recordDuration && ArrangeHistogram?.Enabled == true) { -#if NET9_0_OR_GREATER - ArrangeHistogram?.Record((int)duration.Value.TotalNanoseconds, tagList); -#else - ArrangeHistogram?.Record((int)(duration.Value.TotalMilliseconds * 1_000_000), tagList); -#endif + ArrangeHistogram.Record(duration, tagList); } } + + internal static int GetElapsedNanoseconds(long startTimestamp) + { + var elapsedTimestamp = Stopwatch.GetTimestamp() - startTimestamp; + if (elapsedTimestamp <= 0) + { + return 0; + } + + var elapsedNanoseconds = elapsedTimestamp * (1_000_000_000.0 / Stopwatch.Frequency); + return elapsedNanoseconds >= int.MaxValue + ? int.MaxValue + : (int)elapsedNanoseconds; + } } diff --git a/src/Core/src/Diagnostics/Instrumentation/LayoutMeasureInstrumentation.cs b/src/Core/src/Diagnostics/Instrumentation/LayoutMeasureInstrumentation.cs index 66c76480eb99..8bd5fd7400bf 100644 --- a/src/Core/src/Diagnostics/Instrumentation/LayoutMeasureInstrumentation.cs +++ b/src/Core/src/Diagnostics/Instrumentation/LayoutMeasureInstrumentation.cs @@ -5,21 +5,59 @@ namespace Microsoft.Maui.Diagnostics; /// /// Instrumentation for measuring layout operations in a view. /// -readonly struct LayoutMeasureInstrumentation(IView view) : IDiagnosticInstrumentation +readonly struct LayoutMeasureInstrumentation : System.IDisposable { - readonly Activity? _activity = view.StartDiagnosticActivity("Measure"); + readonly IView _view; + readonly IDiagnosticsManager _diagnostics; + readonly LayoutDiagnosticMetrics? _metrics; + readonly Activity? _activity; + readonly bool _metricsDurationStarted; + readonly long _metricsStartTimestamp; + + public LayoutMeasureInstrumentation(IView view, IDiagnosticsManager diagnostics, LayoutDiagnosticMetrics? metrics) + { + _view = view; + _diagnostics = diagnostics; + _metrics = metrics; + + if (diagnostics.HasActivityListeners) + { + diagnostics.GetTags(view, out var tagList); + _activity = diagnostics.ActivitySource.StartActivity( + ActivityKind.Internal, + name: $"Measure {view.GetType().Name}", + tags: tagList); + } + else + { + _activity = null; + } + + _metricsDurationStarted = metrics?.IsMeasureDurationEnabled == true; + _metricsStartTimestamp = _metricsDurationStarted + ? Stopwatch.GetTimestamp() + : 0; + } /// /// Disposes the instrumentation and stops the diagnostic activity. /// - public void Dispose() => - view.StopDiagnostics(_activity, this); + public void Dispose() + { + var metrics = _metrics; + var recordDuration = _metricsDurationStarted && metrics?.IsMeasureDurationEnabled == true; + var duration = recordDuration + ? LayoutDiagnosticMetrics.GetElapsedNanoseconds(_metricsStartTimestamp) + : 0; - /// - /// Records the stopping of the instrumentation and publishes various metrics. - /// - /// The instance. - /// The tags associated with the instrumentation. - public void Stopped(IDiagnosticsManager diagnostics, in TagList tagList) => - diagnostics.GetMetrics()?.RecordMeasure(_activity?.Duration, in tagList); + _activity?.Stop(); + + if (metrics?.IsMeasureEnabled == true) + { + _diagnostics.GetTags(_view, out var tagList); + metrics.RecordMeasure(duration, recordDuration, in tagList); + } + + _activity?.Dispose(); + } } diff --git a/src/Core/tests/Benchmarks/Benchmarks/LayoutDiagnosticsBenchmarker.cs b/src/Core/tests/Benchmarks/Benchmarks/LayoutDiagnosticsBenchmarker.cs new file mode 100644 index 000000000000..6d4fc1019ec0 --- /dev/null +++ b/src/Core/tests/Benchmarks/Benchmarks/LayoutDiagnosticsBenchmarker.cs @@ -0,0 +1,148 @@ +using BenchmarkDotNet.Attributes; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Hosting; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Hosting; + +namespace Microsoft.Maui.Benchmarks +{ + [MemoryDiagnoser] + public class LayoutDiagnosticsBenchmarker + { + const int SingleIterations = 1024; + const int SingleOperationsPerInvoke = SingleIterations * 2; + const int TreeChildCount = 100; + const int TreeIterations = 16; + const int TreeOperationsPerInvoke = TreeChildCount * TreeIterations * 2; + + static readonly Rect ArrangeBounds = new(0, 0, 100, 100); + static readonly Rect TreeBounds = new(0, 0, 100, 2400); + + MauiApp _app; + MauiContext _context; + IView _view; + ILayout _layout; + + [GlobalSetup] + public void Setup() + { + var builder = MauiApp.CreateBuilder(); + builder.UseMauiApp(); + + _app = builder.Build(); + _context = new MauiContext(_app.Services); + _view = CreateProbeView(_context); + + var layout = new VerticalStackLayout + { + Spacing = 0, + }; + + for (var i = 0; i < TreeChildCount; i++) + { + layout.Add(CreateProbeView(_context)); + } + + _layout = layout; + } + + [GlobalCleanup] + public void Cleanup() + { + _app?.Dispose(); + } + + [Benchmark(OperationsPerInvoke = SingleOperationsPerInvoke)] + public double MeasureAndArrangeViewNoListener() + { + double checksum = 0; + + for (var i = 0; i < SingleIterations; i++) + { + var measured = _view.Measure(100, 100); + var arranged = _view.Arrange(ArrangeBounds); + checksum += measured.Width + arranged.Height; + } + + return checksum; + } + + [Benchmark(OperationsPerInvoke = TreeOperationsPerInvoke)] + public double MeasureAndArrangeTreeNoListener() + { + double checksum = 0; + + for (var i = 0; i < TreeIterations; i++) + { + var measured = _layout.CrossPlatformMeasure(100, 2400); + var arranged = _layout.CrossPlatformArrange(TreeBounds); + checksum += measured.Width + arranged.Height; + } + + return checksum; + } + + static ProbeView CreateProbeView(IMauiContext context) + { + var view = new ProbeView + { + Handler = new ProbeHandler(context), + }; + + return view; + } + + sealed class ProbeView : View + { + protected override Size MeasureOverride(double widthConstraint, double heightConstraint) => + new(42, 24); + + protected override Size ArrangeOverride(Rect bounds) + { + Frame = bounds; + return bounds.Size; + } + } + + sealed class ProbeHandler(IMauiContext context) : IViewHandler + { + public bool HasContainer { get; set; } + + public object ContainerView => null; + + public object PlatformView => null; + + public IView VirtualView { get; private set; } + + IElement IElementHandler.VirtualView => VirtualView; + + public IMauiContext MauiContext { get; private set; } = context; + + public void SetMauiContext(IMauiContext mauiContext) => MauiContext = mauiContext; + + public void SetVirtualView(IElement view) + { + VirtualView = (IView)view; + } + + public void UpdateValue(string property) + { + } + + public void Invoke(string command, object args = null) + { + } + + public void DisconnectHandler() + { + } + + public Size GetDesiredSize(double widthConstraint, double heightConstraint) => + new(42, 24); + + public void PlatformArrange(Rect frame) + { + } + } + } +} diff --git a/src/Core/tests/UnitTests/Diagnostics/LayoutDiagnosticsTests.cs b/src/Core/tests/UnitTests/Diagnostics/LayoutDiagnosticsTests.cs new file mode 100644 index 000000000000..3623157e922f --- /dev/null +++ b/src/Core/tests/UnitTests/Diagnostics/LayoutDiagnosticsTests.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Diagnostics; +using Microsoft.Maui.Graphics; +using Microsoft.Maui.Hosting; +using Xunit; + +namespace Microsoft.Maui.UnitTests.Diagnostics +{ + [Category(TestCategory.Core)] + public class LayoutDiagnosticsTests + { + const int AllocationIterations = 1024; + const string MeasureDurationInstrumentName = "maui.layout.measure_duration"; + const string ArrangeDurationInstrumentName = "maui.layout.arrange_duration"; + + static readonly Rect ArrangeBounds = new Rect(0, 0, 100, 100); + + [Fact] + public void MeasureAndArrangeDoNotAllocateWithoutListeners() + { + using var app = CreateMauiApp(); + var view = CreateProbeView(new MauiContext(app.Services)); + var iView = (IView)view; + + for (var i = 0; i < 64; i++) + { + iView.Measure(100, 100); + iView.Arrange(ArrangeBounds); + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + var allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < AllocationIterations; i++) + { + iView.Measure(100, 100); + iView.Arrange(ArrangeBounds); + } + var allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; + + Assert.Equal(0, allocated); + } + + [Theory] + [InlineData(MeasureDurationInstrumentName)] + [InlineData(ArrangeDurationInstrumentName)] + public void DurationMetricsRecordWithoutActivityListeners(string instrumentName) + { + var measurements = new List(); + + using var listener = new MeterListener(); + listener.InstrumentPublished = (instrument, meterListener) => + { + if (instrument.Meter.Name == "Microsoft.Maui" && instrument.Name == instrumentName) + { + meterListener.EnableMeasurementEvents(instrument); + } + }; + listener.SetMeasurementEventCallback((instrument, measurement, tags, state) => + { + if (instrument.Meter.Name == "Microsoft.Maui" && instrument.Name == instrumentName) + { + measurements.Add(measurement); + } + }); + listener.Start(); + + using var app = CreateMauiApp(); + var view = CreateProbeView(new MauiContext(app.Services)); + var iView = (IView)view; + + if (instrumentName == MeasureDurationInstrumentName) + { + iView.Measure(100, 100); + } + else + { + iView.Arrange(ArrangeBounds); + } + + var measurement = Assert.Single(measurements); + Assert.True(measurement >= 0); + } + + [Fact] + public void ElapsedNanosecondsClampsToIntMaxValue() + { + var startTimestamp = Stopwatch.GetTimestamp() - (Stopwatch.Frequency * 3); + + var elapsedNanoseconds = LayoutDiagnosticMetrics.GetElapsedNanoseconds(startTimestamp); + + Assert.Equal(int.MaxValue, elapsedNanoseconds); + } + + [Fact] + public void ElapsedNanosecondsReturnsZeroForNonPositiveElapsedTime() + { + var startTimestamp = Stopwatch.GetTimestamp() + Stopwatch.Frequency; + + var elapsedNanoseconds = LayoutDiagnosticMetrics.GetElapsedNanoseconds(startTimestamp); + + Assert.Equal(0, elapsedNanoseconds); + } + + static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder.Services.AddSingleton(); + + return builder.Build(); + } + + static ProbeView CreateProbeView(IMauiContext context) + { + var view = new ProbeView + { + Handler = new ProbeHandler(context), + }; + + return view; + } + + sealed class ProbeView : View + { + protected override Size MeasureOverride(double widthConstraint, double heightConstraint) => + new Size(42, 24); + + protected override Size ArrangeOverride(Rect bounds) => + bounds.Size; + } + + sealed class ProbeHandler : IViewHandler + { + public ProbeHandler(IMauiContext context) + { + MauiContext = context; + } + + public bool HasContainer { get; set; } + + public object ContainerView => null; + + public object PlatformView => null; + + public IView VirtualView { get; private set; } + + IElement IElementHandler.VirtualView => VirtualView; + + public IMauiContext MauiContext { get; private set; } + + public void SetMauiContext(IMauiContext mauiContext) => + MauiContext = mauiContext; + + public void SetVirtualView(IElement view) => + VirtualView = (IView)view; + + public void UpdateValue(string property) + { + } + + public void Invoke(string command, object args = null) + { + } + + public void DisconnectHandler() + { + } + + public Size GetDesiredSize(double widthConstraint, double heightConstraint) => + new Size(42, 24); + + public void PlatformArrange(Rect frame) + { + } + } + + sealed class TestMeterFactory : IMeterFactory + { + readonly List _meters = new List(); + + public Meter Create(MeterOptions options) + { + var meter = new Meter(options); + _meters.Add(meter); + + return meter; + } + + public void Dispose() + { + foreach (var meter in _meters) + { + meter.Dispose(); + } + } + } + } +}