From 7a77cf90fa5fdcb70fd4154fbaf9027768d30d03 Mon Sep 17 00:00:00 2001 From: redth Date: Wed, 11 Mar 2026 10:11:24 -0400 Subject: [PATCH] Clarify profiler native memory semantics Add nativeMemoryKind to profiler samples so clients can interpret nativeMemoryBytes consistently across providers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 + .../Profiling/INativeFrameStatsProvider.cs | 1 + .../Profiling/ProfilerContracts.cs | 1 + .../Profiling/RuntimeProfilerCollector.cs | 18 +++-- .../NativeFrameStatsProviderFactory.cs | 12 ++++ src/MauiDevFlow.Driver/AgentClient.cs | 2 + .../ProfilerAgentClientTests.cs | 2 + tests/MauiDevFlow.Tests/ProfilerCoreTests.cs | 66 +++++++++++++++++++ 8 files changed, 98 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 0f44adf..8164719 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,8 @@ auto-assigned by the broker (range 10223–10899), or configurable via `.mauidev | `/api/profiler/span` | POST | Publish manual span `{"kind":"ui.operation","name":"...","startTsUtc":"...","endTsUtc":"..."}` | | `/api/profiler/hotspots?kind=ui.operation&minDurationMs=16&limit=20` | GET | Aggregated slow-operation hotspots ordered by P95 duration | +Profiler sample payloads also include `nativeMemoryKind` to disambiguate what `nativeMemoryBytes` means for that sample. Current values include `apple.phys-footprint`, `android.native-heap-allocated`, `windows.working-set`, and `process.working-set-minus-managed` when the collector falls back to process working set minus managed memory. + ## Project Structure ``` diff --git a/src/MauiDevFlow.Agent.Core/Profiling/INativeFrameStatsProvider.cs b/src/MauiDevFlow.Agent.Core/Profiling/INativeFrameStatsProvider.cs index 676c6e8..5d08c06 100644 --- a/src/MauiDevFlow.Agent.Core/Profiling/INativeFrameStatsProvider.cs +++ b/src/MauiDevFlow.Agent.Core/Profiling/INativeFrameStatsProvider.cs @@ -11,6 +11,7 @@ public sealed class NativeFrameStatsSnapshot public int JankFrameCount { get; set; } public int UiThreadStallCount { get; set; } public long? NativeMemoryBytes { get; set; } + public string? NativeMemoryKind { get; set; } } public interface INativeFrameStatsProvider : IDisposable diff --git a/src/MauiDevFlow.Agent.Core/Profiling/ProfilerContracts.cs b/src/MauiDevFlow.Agent.Core/Profiling/ProfilerContracts.cs index 82a5db9..27dfedf 100644 --- a/src/MauiDevFlow.Agent.Core/Profiling/ProfilerContracts.cs +++ b/src/MauiDevFlow.Agent.Core/Profiling/ProfilerContracts.cs @@ -20,6 +20,7 @@ public class ProfilerSample public int Gc1 { get; set; } public int Gc2 { get; set; } public long? NativeMemoryBytes { get; set; } + public string? NativeMemoryKind { get; set; } public double? CpuPercent { get; set; } public int? ThreadCount { get; set; } public int JankFrameCount { get; set; } diff --git a/src/MauiDevFlow.Agent.Core/Profiling/RuntimeProfilerCollector.cs b/src/MauiDevFlow.Agent.Core/Profiling/RuntimeProfilerCollector.cs index 854280a..cb28d2f 100644 --- a/src/MauiDevFlow.Agent.Core/Profiling/RuntimeProfilerCollector.cs +++ b/src/MauiDevFlow.Agent.Core/Profiling/RuntimeProfilerCollector.cs @@ -133,7 +133,12 @@ public bool TryCollect(out ProfilerSample sample) sample.Gc0 = GC.CollectionCount(0); sample.Gc1 = GC.CollectionCount(1); sample.Gc2 = GC.CollectionCount(2); - sample.NativeMemoryBytes ??= TryReadNativeMemoryBytes(processSnapshotAvailable, sample.ManagedBytes); + if (!sample.NativeMemoryBytes.HasValue) + { + var nativeMemory = TryReadNativeMemory(processSnapshotAvailable, sample.ManagedBytes); + sample.NativeMemoryBytes = nativeMemory.Bytes; + sample.NativeMemoryKind = nativeMemory.Kind; + } sample.CpuPercent = cpuPercent; sample.ThreadCount = threadCount; @@ -158,6 +163,7 @@ private ProfilerSample BuildFrameSample(DateTime now) JankFrameCount = nativeSnapshot.JankFrameCount, UiThreadStallCount = nativeSnapshot.UiThreadStallCount, NativeMemoryBytes = nativeSnapshot.NativeMemoryBytes, + NativeMemoryKind = nativeSnapshot.NativeMemoryKind, FrameSource = nativeSnapshot.Source, FrameQuality = _nativeFrameStatsProvider.ProvidesExactFrameTimings ? "native.exact" @@ -255,18 +261,18 @@ ex is InvalidOperationException } } - private long? TryReadNativeMemoryBytes(bool processSnapshotAvailable, long managedBytes) + private (long? Bytes, string? Kind) TryReadNativeMemory(bool processSnapshotAvailable, long managedBytes) { if (!_capabilities.NativeMemorySupported || !processSnapshotAvailable) - return null; + return (null, null); try { var workingSetBytes = _process.WorkingSet64; if (workingSetBytes <= 0) - return null; + return (null, null); - return Math.Max(0L, workingSetBytes - managedBytes); + return (Math.Max(0L, workingSetBytes - managedBytes), "process.working-set-minus-managed"); } catch (Exception ex) when ( ex is InvalidOperationException @@ -274,7 +280,7 @@ ex is InvalidOperationException || ex is PlatformNotSupportedException) { _capabilities.NativeMemorySupported = false; - return null; + return (null, null); } } diff --git a/src/MauiDevFlow.Agent/Profiling/NativeFrameStatsProviderFactory.cs b/src/MauiDevFlow.Agent/Profiling/NativeFrameStatsProviderFactory.cs index 488d491..72cad86 100644 --- a/src/MauiDevFlow.Agent/Profiling/NativeFrameStatsProviderFactory.cs +++ b/src/MauiDevFlow.Agent/Profiling/NativeFrameStatsProviderFactory.cs @@ -182,6 +182,9 @@ public bool TryCollect(out NativeFrameStatsSnapshot snapshot) return false; snapshot.NativeMemoryBytes = TryReadAndroidNativeMemoryBytes(); + snapshot.NativeMemoryKind = snapshot.NativeMemoryBytes.HasValue + ? "android.native-heap-allocated" + : null; return true; } @@ -278,6 +281,9 @@ public bool TryCollect(out NativeFrameStatsSnapshot snapshot) return false; snapshot.NativeMemoryBytes = TryReadAndroidNativeMemoryBytes(); + snapshot.NativeMemoryKind = snapshot.NativeMemoryBytes.HasValue + ? "android.native-heap-allocated" + : null; return true; } @@ -386,6 +392,9 @@ public bool TryCollect(out NativeFrameStatsSnapshot snapshot) return false; snapshot.NativeMemoryBytes = TryReadPhysFootprint(); + snapshot.NativeMemoryKind = snapshot.NativeMemoryBytes.HasValue + ? "apple.phys-footprint" + : null; return true; } @@ -527,6 +536,9 @@ public bool TryCollect(out NativeFrameStatsSnapshot snapshot) return false; snapshot.NativeMemoryBytes = TryReadResidentMemoryBytes(); + snapshot.NativeMemoryKind = snapshot.NativeMemoryBytes.HasValue + ? "windows.working-set" + : null; return true; } diff --git a/src/MauiDevFlow.Driver/AgentClient.cs b/src/MauiDevFlow.Driver/AgentClient.cs index b22ff1a..c21399c 100644 --- a/src/MauiDevFlow.Driver/AgentClient.cs +++ b/src/MauiDevFlow.Driver/AgentClient.cs @@ -464,6 +464,8 @@ public class ProfilerSample public int Gc2 { get; set; } [System.Text.Json.Serialization.JsonPropertyName("nativeMemoryBytes")] public long? NativeMemoryBytes { get; set; } + [System.Text.Json.Serialization.JsonPropertyName("nativeMemoryKind")] + public string? NativeMemoryKind { get; set; } [System.Text.Json.Serialization.JsonPropertyName("cpuPercent")] public double? CpuPercent { get; set; } [System.Text.Json.Serialization.JsonPropertyName("threadCount")] diff --git a/tests/MauiDevFlow.Tests/ProfilerAgentClientTests.cs b/tests/MauiDevFlow.Tests/ProfilerAgentClientTests.cs index 1f4a6f3..bcc9683 100644 --- a/tests/MauiDevFlow.Tests/ProfilerAgentClientTests.cs +++ b/tests/MauiDevFlow.Tests/ProfilerAgentClientTests.cs @@ -54,6 +54,7 @@ public async Task Profiler_StartStopAndPollFlow_WorksThroughAgentClient() "worstFrameTimeMs": 48.2, "managedBytes": 2048, "nativeMemoryBytes": 8192, + "nativeMemoryKind": "android.native-heap-allocated", "gc0": 1, "gc1": 0, "gc2": 0, @@ -130,6 +131,7 @@ public async Task Profiler_StartStopAndPollFlow_WorksThroughAgentClient() Assert.Equal("native.android.choreographer", batch.Samples[0].FrameSource); Assert.Equal(3, batch.Samples[0].JankFrameCount); Assert.Equal(8192, batch.Samples[0].NativeMemoryBytes); + Assert.Equal("android.native-heap-allocated", batch.Samples[0].NativeMemoryKind); Assert.Equal(1, batch.SampleCursor); Assert.Equal(1, batch.MarkerCursor); Assert.Equal(1, batch.SpanCursor); diff --git a/tests/MauiDevFlow.Tests/ProfilerCoreTests.cs b/tests/MauiDevFlow.Tests/ProfilerCoreTests.cs index 5f13364..92fc171 100644 --- a/tests/MauiDevFlow.Tests/ProfilerCoreTests.cs +++ b/tests/MauiDevFlow.Tests/ProfilerCoreTests.cs @@ -30,6 +30,7 @@ public void ProfilerBatch_SerializesAndDeserializes() WorstFrameTimeMs = 31.2, ManagedBytes = 123_456, NativeMemoryBytes = 654_321, + NativeMemoryKind = "android.native-heap-allocated", Gc0 = 10, Gc1 = 4, Gc2 = 1, @@ -79,6 +80,7 @@ public void ProfilerBatch_SerializesAndDeserializes() Assert.Equal(4, parsed.SpanCursor); Assert.Equal(123_456, parsed.Samples[0].ManagedBytes); Assert.Equal(654_321, parsed.Samples[0].NativeMemoryBytes); + Assert.Equal("android.native-heap-allocated", parsed.Samples[0].NativeMemoryKind); Assert.Equal("native.android.choreographer", parsed.Samples[0].FrameSource); Assert.Equal(2, parsed.Samples[0].JankFrameCount); } @@ -175,6 +177,10 @@ public void RuntimeProfilerCollector_CollectsRuntimeMetrics() Assert.StartsWith("estimated", sample1.FrameQuality); Assert.True(sample1.Fps > 0); Assert.True(sample1.FrameTimeMsP95 > 0); + if (sample1.NativeMemoryBytes.HasValue) + Assert.Equal("process.working-set-minus-managed", sample1.NativeMemoryKind); + else + Assert.Null(sample1.NativeMemoryKind); Assert.True(sample2.TsUtc > sample1.TsUtc); } @@ -213,6 +219,30 @@ public void RuntimeProfilerCollector_WhenNativeProviderStartFails_CleansUpAndFal Assert.False(capabilities.UiThreadStallSupported); } + [Fact] + public void RuntimeProfilerCollector_PropagatesNativeMemoryKindFromProvider() + { + var provider = new SnapshotNativeProvider(new NativeFrameStatsSnapshot + { + Source = "native.test", + Fps = 60, + FrameTimeMsP50 = 16.7, + FrameTimeMsP95 = 20.5, + WorstFrameTimeMs = 24.1, + NativeMemoryBytes = 42_000, + NativeMemoryKind = "apple.phys-footprint" + }); + var collector = new RuntimeProfilerCollector(provider); + + collector.Start(100); + var collected = collector.TryCollect(out var sample); + collector.Stop(); + + Assert.True(collected); + Assert.Equal(42_000, sample.NativeMemoryBytes); + Assert.Equal("apple.phys-footprint", sample.NativeMemoryKind); + } + [Fact] public void ProfilerContractModels_StayAlignedWithDriverModels() { @@ -351,4 +381,40 @@ public void Dispose() { } } + + private sealed class SnapshotNativeProvider(NativeFrameStatsSnapshot snapshotToReturn) : INativeFrameStatsProvider + { + public bool IsSupported => true; + public bool ProvidesExactFrameTimings => true; + public string Source => snapshotToReturn.Source; + + public void Start() + { + } + + public void Stop() + { + } + + public bool TryCollect(out NativeFrameStatsSnapshot snapshot) + { + snapshot = new NativeFrameStatsSnapshot + { + Source = snapshotToReturn.Source, + Fps = snapshotToReturn.Fps, + FrameTimeMsP50 = snapshotToReturn.FrameTimeMsP50, + FrameTimeMsP95 = snapshotToReturn.FrameTimeMsP95, + WorstFrameTimeMs = snapshotToReturn.WorstFrameTimeMs, + JankFrameCount = snapshotToReturn.JankFrameCount, + UiThreadStallCount = snapshotToReturn.UiThreadStallCount, + NativeMemoryBytes = snapshotToReturn.NativeMemoryBytes, + NativeMemoryKind = snapshotToReturn.NativeMemoryKind + }; + return true; + } + + public void Dispose() + { + } + } }