From 0dc8e3c7a587a2fadf1b059640d83818cebe6cf9 Mon Sep 17 00:00:00 2001 From: Tristan Gibeau <41077386+trgibeau@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:46:39 -0700 Subject: [PATCH 1/3] Refactor profiling engine: Migrate aggregation and call tree processing to FunctionProfiler - Moved call tree processing logic from CallTreeProcessor to ProfileData.ComputeProfile. - Removed FunctionProfileProcessor and integrated its functionality into ProfileData. - Introduced FunctionProfiler for handling resolved input and aggregation. - Updated ProfileData to utilize concurrent collections for thread-safe operations. - Added tests for new aggregation logic and ensured parity with previous implementations. - Created new tests for ProfileReportMapper and RawProfileLibraryAdapter to validate functionality. --- ...ggregatorSemanticsCharacterizationTests.cs | 148 +++++++++ .../Unit/FunctionProfilerInjectionTests.cs | 141 +++++++++ .../Abstractions/ResolvedFrame.cs | 22 ++ .../Abstractions/ResolvedSampleProjector.cs | 17 ++ .../FunctionProfiler.cs | 168 ++++++++++- .../ProfileExplorer.Profiling.csproj | 2 + .../Profiling/CallTreeBuilder.cs | 103 ++++++- .../Profiling/IpResolver.cs | 42 +++ .../Profiling/SampleAggregator.cs | 63 +++- .../Profile/Adapters/ProfileReportMapper.cs | 71 +++++ .../Adapters/RawProfileLibraryAdapters.cs | 124 ++++++++ .../Profile/Data/ProfileData.cs | 155 ++++++++-- .../Profile/Processing/CallTreeProcessor.cs | 91 ------ .../Processing/FunctionProfileProcessor.cs | 248 --------------- .../ETWUnmappedFrameResolutionTests.cs | 14 +- .../EngineAggregationParityTests.cs | 284 ++++++++++++++++++ .../ProfileReportMapperTests.cs | 94 ++++++ .../RawProfileLibraryAdapterTests.cs | 84 ++++++ 18 files changed, 1497 insertions(+), 374 deletions(-) create mode 100644 src/ProfileExplorer.Profiling.Tests/Unit/AggregatorSemanticsCharacterizationTests.cs create mode 100644 src/ProfileExplorer.Profiling.Tests/Unit/FunctionProfilerInjectionTests.cs create mode 100644 src/ProfileExplorer.Profiling/Abstractions/ResolvedFrame.cs create mode 100644 src/ProfileExplorer.Profiling/Abstractions/ResolvedSampleProjector.cs create mode 100644 src/ProfileExplorerCore/Profile/Adapters/ProfileReportMapper.cs create mode 100644 src/ProfileExplorerCore/Profile/Adapters/RawProfileLibraryAdapters.cs delete mode 100644 src/ProfileExplorerCore/Profile/Processing/CallTreeProcessor.cs delete mode 100644 src/ProfileExplorerCore/Profile/Processing/FunctionProfileProcessor.cs create mode 100644 src/ProfileExplorerCoreTests/EngineAggregationParityTests.cs create mode 100644 src/ProfileExplorerCoreTests/ProfileReportMapperTests.cs create mode 100644 src/ProfileExplorerCoreTests/RawProfileLibraryAdapterTests.cs diff --git a/src/ProfileExplorer.Profiling.Tests/Unit/AggregatorSemanticsCharacterizationTests.cs b/src/ProfileExplorer.Profiling.Tests/Unit/AggregatorSemanticsCharacterizationTests.cs new file mode 100644 index 00000000..b1a91b11 --- /dev/null +++ b/src/ProfileExplorer.Profiling.Tests/Unit/AggregatorSemanticsCharacterizationTests.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ProfileExplorer.Core.Binary; // FunctionDebugInfo +using ProfileExplorer.Core.Profile; // ProfileFunctionId +using ProfileExplorer.Core.Profile.Data; // FunctionProfileData +using ProfileExplorer.Profiling.Profiling; // IpResolver, SampleAggregator (InternalsVisibleTo) +using ProfileExplorer.Profiling.Tests.Helpers; // SyntheticSample + +namespace ProfileExplorer.Profiling.Tests.Unit; + +/// +/// Verifies the library aggregator reproduces Core's per-instruction attribution, so Core can +/// delegate to the library without changing observable behavior (library = single source of truth). +/// +/// Scenario: one CPU sample taken inside Leaf (offset 0x10), whose stack shows it was called +/// from Caller (return address at offset 0x20). Both engines credit a per-instruction sample +/// at the caller's call-site offset (0x20) for EVERY unique function on the stack — this is what +/// makes a `call` instruction show the inclusive time of what it invoked. +/// +/// * Core (FunctionProfileProcessor.ProcessSample): credits AddInstructionSample per unique frame. +/// * Library (SampleAggregator.AddSamples): reconciled to do the same for caller frames. +/// +/// The library side below runs the REAL . The Core side is a +/// line-for-line transcription of FunctionProfileProcessor.ProcessSample +/// (src/ProfileExplorerCore/Profile/Processing/FunctionProfileProcessor.cs, the per-frame loop), +/// using the REAL type and the same AddInstructionSample/Weight/ +/// ExclusiveWeight operations, so the comparison is faithful without standing up the full ETW +/// resolved-stack machinery. +/// +[TestClass] +public class AggregatorSemanticsCharacterizationTests { + private const string Module = "app.dll"; + private const long ModuleBase = 0x10000; + private const int ModuleSize = 0x10000; + private const long LeafRva = 0x1000; + private const long CallerRva = 0x2000; + private const long LeafOffset = 0x10; // sample IP within Leaf + private const long CallerOffset = 0x20; // return address within Caller + + public TestContext TestContext { get; set; } + + [TestMethod] + [TestCategory("Characterization")] + public void CallerInstructionAttribution_LibraryVsCore_Matches() { + var weight = TimeSpan.FromMilliseconds(1); + long leafIp = ModuleBase + LeafRva + LeafOffset; // 0x11010 + long callerReturnIp = ModuleBase + CallerRva + CallerOffset; // 0x12020 + + var leafId = new ProfileFunctionId(Module, "Leaf"); + var callerId = new ProfileFunctionId(Module, "Caller"); + + // ---- LIBRARY: real SampleAggregator over a leaf-first stack [leaf, caller-return] ---- + var ipResolver = new IpResolver(); + ipResolver.AddImage(Module, ModuleBase, ModuleSize); + ipResolver.SetFunctions(Module, new List { + new FunctionDebugInfo("Leaf", LeafRva, 0x100), + new FunctionDebugInfo("Caller", CallerRva, 0x100) + }); + + var aggregator = new SampleAggregator(ipResolver); + var sample = new SyntheticSample(leafIp, weight, 1, 1, Module, ModuleBase, + new long[] { leafIp, callerReturnIp }); + aggregator.AddSamples(new[] { sample }); + var lib = aggregator.Build(); + var libLeaf = lib[leafId]; + var libCaller = lib[callerId]; + + // ---- CORE: faithful transcription of FunctionProfileProcessor.ProcessSample ---- + var core = CoreProcessSampleTranscription(weight, new[] { + // leaf-first: (funcId, funcRva, frameRva=module-relative RVA of the frame's IP, debugInfo) + (leafId, LeafRva, LeafRva + LeafOffset, new FunctionDebugInfo("Leaf", LeafRva, 0x100)), + (callerId, CallerRva, CallerRva + CallerOffset, new FunctionDebugInfo("Caller", CallerRva, 0x100)) + }); + var coreLeaf = core[leafId]; + var coreCaller = core[callerId]; + + TestContext.WriteLine(Describe("LIBRARY", libLeaf, libCaller)); + TestContext.WriteLine(Describe("CORE ", coreLeaf, coreCaller)); + + // ---- Agreements: exclusive + inclusive weight, and leaf per-instruction attribution ---- + Assert.AreEqual(coreLeaf.ExclusiveWeight, libLeaf.ExclusiveWeight, "leaf exclusive weight should match"); + Assert.AreEqual(coreLeaf.Weight, libLeaf.Weight, "leaf inclusive weight should match"); + Assert.AreEqual(coreCaller.ExclusiveWeight, libCaller.ExclusiveWeight, "caller exclusive weight should match (both 0)"); + Assert.AreEqual(coreCaller.Weight, libCaller.Weight, "caller inclusive weight should match"); + CollectionAssert.AreEquivalent(coreLeaf.InstructionWeight.Keys.ToList(), + libLeaf.InstructionWeight.Keys.ToList(), + "leaf per-instruction offsets should match"); + + // ---- Caller per-instruction attribution now matches (reconciled) ---- + Assert.AreEqual(weight, coreCaller.InstructionWeight[CallerOffset], + "CORE attributes a per-instruction sample at the caller call-site offset 0x20"); + Assert.AreEqual(weight, libCaller.InstructionWeight[CallerOffset], + "LIBRARY now attributes the same caller call-site sample (behavior-preserving)"); + CollectionAssert.AreEquivalent(coreCaller.InstructionWeight.Keys.ToList(), + libCaller.InstructionWeight.Keys.ToList(), + "caller per-instruction offsets should match"); + } + + /// + /// Line-for-line transcription of the per-frame loop in + /// FunctionProfileProcessor.ProcessSample (Core). Uses the real FunctionProfileData type and the + /// identical AddInstructionSample / Weight / ExclusiveWeight operations. Frames are leaf-first; + /// the first frame is the top (leaf) frame. + /// + private static Dictionary CoreProcessSampleTranscription( + TimeSpan weight, + (ProfileFunctionId funcId, long funcRva, long frameRva, FunctionDebugInfo debugInfo)[] framesLeafFirst) { + var functionProfiles = new Dictionary(); + var stackFunctions = new HashSet(); + bool isTopFrame = true; + + foreach (var frame in framesLeafFirst) { + if (!functionProfiles.TryGetValue(frame.funcId, out var funcProfile)) { + funcProfile = new FunctionProfileData(frame.debugInfo); + functionProfiles[frame.funcId] = funcProfile; + } + + long offset = frame.frameRva - frame.funcRva; + + // Don't count the inclusive time / instruction sample for recursive functions multiple times. + if (stackFunctions.Add(frame.funcId)) { + funcProfile.AddInstructionSample(offset, weight); + funcProfile.Weight += weight; + } + + // Exclusive time only for the top (leaf) frame. + if (isTopFrame) { + funcProfile.ExclusiveWeight += weight; + } + + isTopFrame = false; + } + + return functionProfiles; + } + + private static string Describe(string label, FunctionProfileData leaf, FunctionProfileData caller) { + string InstrMap(FunctionProfileData d) => + d.InstructionWeight.Count == 0 + ? "{}" + : "{" + string.Join(", ", d.InstructionWeight.OrderBy(p => p.Key) + .Select(p => $"0x{p.Key:X}:{p.Value.TotalMilliseconds}ms")) + "}"; + + return $"{label} | Leaf excl={leaf.ExclusiveWeight.TotalMilliseconds}ms incl={leaf.Weight.TotalMilliseconds}ms instr={InstrMap(leaf)}" + + $"\n{new string(' ', label.Length)} | Caller excl={caller.ExclusiveWeight.TotalMilliseconds}ms incl={caller.Weight.TotalMilliseconds}ms instr={InstrMap(caller)}"; + } +} diff --git a/src/ProfileExplorer.Profiling.Tests/Unit/FunctionProfilerInjectionTests.cs b/src/ProfileExplorer.Profiling.Tests/Unit/FunctionProfilerInjectionTests.cs new file mode 100644 index 00000000..7847cfa6 --- /dev/null +++ b/src/ProfileExplorer.Profiling.Tests/Unit/FunctionProfilerInjectionTests.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ProfileExplorer.Core.Binary; // FunctionDebugInfo +using ProfileExplorer.Core.Profile; // ProfileFunctionId +using ProfileExplorer.Profiling; // FunctionProfiler, ProfilerOptions, IProfileSample +using ProfileExplorer.Profiling.Symbols; // ISymbolFileLocator +using ProfileExplorer.Profiling.Tests.Helpers; + +namespace ProfileExplorer.Profiling.Tests.Unit; + +/// +/// Verifies the host-symbol-injection path (Stage 3): when the host (e.g. Profile Explorer, which +/// owns symbol acquisition via TraceEvent) provides pre-read function lists via +/// , the engine resolves and aggregates against +/// them with no symbol download of its own. +/// +[TestClass] +public class FunctionProfilerInjectionTests { + /// Symbol locator that never resolves anything — proves no download path is taken. + private sealed class NoSymbolLocator : ISymbolFileLocator { + public Task FindSymbolFileAsync(string pdbName, Guid guid, int age, CancellationToken ct = default) => + Task.FromResult(null); + + public Task FindBinaryFileAsync(string binaryName, int timeDateStamp, long imageSize, + CancellationToken ct = default) => + Task.FromResult(null); + } + + [TestMethod] + public void AddResolvedFunctions_ResolvesAndAggregates_WithoutSymbolDownload() { + const string module = "app.dll"; + const long baseAddr = 0x140000000; + const int size = 0x100000; + + var options = new ProfilerOptions { + SymbolPaths = new[] { "srv*https://symbols.invalid" }, // only needs to be non-empty for Validate() + IncludeManagedCode = false, + IncludePerformanceCounters = false + }; + + using var profiler = new FunctionProfiler(options, new NoSymbolLocator()); + + profiler.AddImages(SyntheticSampleBuilder.CreateImages((module, baseAddr, size))); + profiler.AddResolvedFunctions(module, new List { + new("Main", 0x1000, 0x800), + new("Foo", 0x2000, 0x800), + new("Bar", 0x3000, 0x800) + }); + + // One 10ms sample, stack leaf-first: Bar(@0x40) -> Foo(@0x80) -> Main(@0x100). + long leafIp = baseAddr + 0x3000 + 0x40; + long fooIp = baseAddr + 0x2000 + 0x80; + long mainIp = baseAddr + 0x1000 + 0x100; + var sample = new SyntheticSample(leafIp, TimeSpan.FromMilliseconds(10), 1, 1, module, baseAddr, + new long[] { leafIp, fooIp, mainIp }); + profiler.AddSamples(new IProfileSample[] { sample }); + + var report = profiler.GetReport(); + + var bar = report.Functions[new ProfileFunctionId(module, "Bar")]; + var foo = report.Functions[new ProfileFunctionId(module, "Foo")]; + var main = report.Functions[new ProfileFunctionId(module, "Main")]; + + // Leaf gets exclusive + inclusive; callers get inclusive; per-instruction attributed at call sites. + Assert.AreEqual(TimeSpan.FromMilliseconds(10), bar.ExclusiveWeight, "Bar exclusive (leaf)"); + Assert.AreEqual(TimeSpan.FromMilliseconds(10), bar.Weight, "Bar inclusive"); + Assert.AreEqual(TimeSpan.Zero, foo.ExclusiveWeight, "Foo exclusive (caller)"); + Assert.AreEqual(TimeSpan.FromMilliseconds(10), foo.Weight, "Foo inclusive"); + Assert.AreEqual(TimeSpan.Zero, main.ExclusiveWeight, "Main exclusive (caller)"); + Assert.AreEqual(TimeSpan.FromMilliseconds(10), main.Weight, "Main inclusive"); + Assert.AreEqual(TimeSpan.FromMilliseconds(10), report.TotalWeight, "total sampled weight"); + + Assert.AreEqual(TimeSpan.FromMilliseconds(10), bar.InstructionWeight[0x40], "Bar leaf @0x40"); + Assert.AreEqual(TimeSpan.FromMilliseconds(10), foo.InstructionWeight[0x80], "Foo call-site @0x80"); + Assert.AreEqual(TimeSpan.FromMilliseconds(10), main.InstructionWeight[0x100], "Main call-site @0x100"); + } + + [TestMethod] + public void AddResolvedFunctions_NullArguments_Throw() { + var options = new ProfilerOptions { SymbolPaths = new[] { "srv*https://symbols.invalid" } }; + using var profiler = new FunctionProfiler(options, new NoSymbolLocator()); + + Assert.ThrowsException(() => + profiler.AddResolvedFunctions("", new List())); + Assert.ThrowsException(() => + profiler.AddResolvedFunctions("m.dll", null!)); + } + + [TestMethod] + public void AddSamples_WithInstancePath_FocusesOnMatchingStacks() { + const string module = "app.dll"; + const long baseAddr = 0x140000000; + const int size = 0x100000; + + var options = new ProfilerOptions { + SymbolPaths = new[] { "srv*https://symbols.invalid" }, + IncludeManagedCode = false, + IncludePerformanceCounters = false + }; + + using var profiler = new FunctionProfiler(options, new NoSymbolLocator()); + profiler.AddImages(SyntheticSampleBuilder.CreateImages((module, baseAddr, size))); + profiler.AddResolvedFunctions(module, new List { + new("Main", 0x1000, 0x800), + new("Foo", 0x2000, 0x800), + new("Bar", 0x3000, 0x800), + new("Baz", 0x4000, 0x800) + }); + + long Ip(long rva, long off) => baseAddr + rva + off; + + // Stack 1 (10ms): Bar <- Foo <- Main → matches instance path Main -> Foo. + var s1 = new SyntheticSample(Ip(0x3000, 0x40), TimeSpan.FromMilliseconds(10), 1, 1, module, baseAddr, + new long[] { Ip(0x3000, 0x40), Ip(0x2000, 0x80), Ip(0x1000, 0x100) }); + // Stack 2 (20ms): Baz <- Main → does NOT match (Main then Baz, not Foo). + var s2 = new SyntheticSample(Ip(0x4000, 0x50), TimeSpan.FromMilliseconds(20), 1, 1, module, baseAddr, + new long[] { Ip(0x4000, 0x50), Ip(0x1000, 0x104) }); + + // Root-first instance path: focus on Main -> Foo. + var instancePath = new List { + new(module, "Main"), + new(module, "Foo") + }; + + profiler.AddSamples(new IProfileSample[] { s1, s2 }, instancePath); + var report = profiler.GetReport(); + + Assert.AreEqual(TimeSpan.FromMilliseconds(10), report.TotalWeight, "only the matching stack is counted"); + Assert.IsTrue(report.Functions.ContainsKey(new ProfileFunctionId(module, "Bar")), "Bar (in focused path)"); + Assert.IsTrue(report.Functions.ContainsKey(new ProfileFunctionId(module, "Foo")), "Foo (in focused path)"); + Assert.IsTrue(report.Functions.ContainsKey(new ProfileFunctionId(module, "Main")), "Main (in focused path)"); + Assert.IsFalse(report.Functions.ContainsKey(new ProfileFunctionId(module, "Baz")), "Baz stack filtered out"); + Assert.AreEqual(TimeSpan.FromMilliseconds(10), + report.Functions[new ProfileFunctionId(module, "Bar")].ExclusiveWeight, "Bar exclusive from matching stack"); + + // Call tree is focused the same way: single root Main -> Foo -> Bar. + Assert.AreEqual(1, report.CallTree.RootNodes.Count, "single focused root"); + Assert.AreEqual("Main", report.CallTree.RootNodes[0].FunctionName); + } +} diff --git a/src/ProfileExplorer.Profiling/Abstractions/ResolvedFrame.cs b/src/ProfileExplorer.Profiling/Abstractions/ResolvedFrame.cs new file mode 100644 index 00000000..1ecde3e9 --- /dev/null +++ b/src/ProfileExplorer.Profiling/Abstractions/ResolvedFrame.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.Profile; + +namespace ProfileExplorer.Profiling; + +/// +/// A single pre-resolved stack frame (leaf-first order) supplied by a host that owns symbol +/// resolution (e.g. Profile Explorer). Lets the library aggregate over already-resolved stacks +/// without re-resolving IPs, so host-side managed / JIT / unknown-frame resolution flows through +/// unchanged. Frames the host could not resolve should be omitted before aggregation. +/// +public readonly record struct ResolvedFrame( + ProfileFunctionId FunctionId, + FunctionDebugInfo DebugInfo, + long FrameRva, + bool IsKernel = false, + bool IsManaged = false) { + /// Instruction offset within the owning function (frame RVA minus function RVA). + public long InstructionOffset => FrameRva - (DebugInfo?.RVA ?? 0); +} diff --git a/src/ProfileExplorer.Profiling/Abstractions/ResolvedSampleProjector.cs b/src/ProfileExplorer.Profiling/Abstractions/ResolvedSampleProjector.cs new file mode 100644 index 00000000..98ec842a --- /dev/null +++ b/src/ProfileExplorer.Profiling/Abstractions/ResolvedSampleProjector.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +namespace ProfileExplorer.Profiling; + +/// +/// Projects the host's sample at into the library's neutral resolved form. +/// Invoked CONCURRENTLY by worker threads, +/// so implementations MUST be thread-safe. Write the sample's resolved frames (leaf-first, with the +/// host omitting any frame it could not resolve) into , which is supplied +/// already cleared. Return false to skip the sample (filtered out, or nothing resolvable). +/// +/// Sample index in the host's collection. +/// Destination for the leaf-first resolved frames (pre-cleared, reused per worker). +/// The sample's CPU weight. +/// The sample's thread id (used for per-thread call-tree weights). +public delegate bool ResolvedSampleProjector(int index, List frames, + out TimeSpan weight, out int threadId); diff --git a/src/ProfileExplorer.Profiling/FunctionProfiler.cs b/src/ProfileExplorer.Profiling/FunctionProfiler.cs index b1e7198f..574ca7c4 100644 --- a/src/ProfileExplorer.Profiling/FunctionProfiler.cs +++ b/src/ProfileExplorer.Profiling/FunctionProfiler.cs @@ -43,8 +43,29 @@ public FunctionProfiler(ProfilerOptions options) /// is null, the library's vendored is used (and owned/disposed /// by this instance). When a resolver is supplied, the caller retains ownership of its lifetime. /// - public FunctionProfiler(ProfilerOptions options, ISymbolFileLocator? symbolResolver) { - options.Validate(); + public FunctionProfiler(ProfilerOptions options, ISymbolFileLocator? symbolResolver) + : this(options, symbolResolver, validateOptions: true) { + } + + /// + /// Create a profiler for PRE-RESOLVED input only (the host owns symbol resolution): no symbol + /// paths, download, or option validation are required. Use with + + /// . This is how Profile Explorer's ETW load path drives the library. + /// + public static FunctionProfiler CreateForResolvedInput() { + var options = new ProfilerOptions { + MinSelfPercent = 0, + IncludeManagedCode = false, + IncludePerformanceCounters = false + }; + return new FunctionProfiler(options, NullSymbolFileLocator.Instance, validateOptions: false); + } + + private FunctionProfiler(ProfilerOptions options, ISymbolFileLocator? symbolResolver, bool validateOptions) { + if (validateOptions) { + options.Validate(); + } + options_ = options; if (symbolResolver != null) { @@ -77,12 +98,135 @@ public void AddImages(IEnumerable images) { } /// - /// Add CPU samples. Can be called multiple times (e.g., per-processor batches). + /// Register pre-resolved, RVA-sorted function debug info for a module, bypassing the profiler's + /// own PDB download and reading. Use this when the host has already acquired and read the module's + /// symbols (e.g. Profile Explorer, which owns symbol acquisition via TraceEvent): the engine then + /// resolves and aggregates against these functions instead of loading its own. + /// + /// Must be called BEFORE , because samples are resolved as they are added. + /// Providing functions this way makes a no-op. + /// /// - public void AddSamples(IEnumerable samples) { + /// Module/image name, matching . + /// The module's functions, sorted by ascending RVA. + public void AddResolvedFunctions(string moduleName, IReadOnlyList sortedFunctions) { + if (string.IsNullOrEmpty(moduleName)) { + throw new ArgumentException("Module name is required.", nameof(moduleName)); + } + + ArgumentNullException.ThrowIfNull(sortedFunctions); + + ipResolver_.SetFunctions(moduleName, + sortedFunctions as List ?? new List(sortedFunctions)); + symbolsLoaded_ = true; // Host owns symbol acquisition; suppress the profiler's own download path. + InvalidateReport(); + } + + /// + /// Add CPU samples. Can be called multiple times (e.g., per-processor batches). When + /// (a root-first call-tree instance path) is provided, only samples + /// whose stack begins with that path from the root are aggregated (call-tree "focus" filtering). + /// + public void AddSamples(IEnumerable samples, + IReadOnlyList? instancePath = null) { var sampleList = samples as IReadOnlyList ?? samples.ToList(); - sampleAggregator_.AddSamples(sampleList); - callTreeBuilder_.AddSamples(sampleList); + sampleAggregator_.AddSamples(sampleList, instancePath); + callTreeBuilder_.AddSamples(sampleList, instancePath); + InvalidateReport(); + } + + /// + /// Aggregate a single PRE-RESOLVED sample (the host owns symbol resolution). Frames are leaf-first + /// and already resolved; the host must omit frames it could not resolve. Feeds both the per-function + /// profile and the call tree. Call after all samples are added. + /// + public void AddResolvedSample(TimeSpan weight, int threadId, IReadOnlyList framesLeafFirst, + IReadOnlyList? instancePath = null) { + sampleAggregator_.AddResolvedStack(weight, framesLeafFirst, instancePath); + callTreeBuilder_.AddResolvedStack(weight, threadId, framesLeafFirst, instancePath); + InvalidateReport(); + } + + /// + /// Aggregate a range of PRE-RESOLVED samples IN PARALLEL (the host owns symbol resolution). The + /// library partitions [startIndex, endIndex) across up to + /// worker threads; each worker projects its samples via (invoked + /// concurrently, so it must be thread-safe), aggregates per-function profiles into the shared + /// thread-safe map, and builds an isolated per-worker call tree. The per-worker trees are then + /// merged into one — reproducing the parallelism of the former Core FunctionProfileProcessor / + /// CallTreeProcessor while keeping aggregation in the library. + /// + /// Any host-side filtering (thread, time range, call-tree instance) should be applied inside + /// by returning false to skip a sample. Call + /// once this returns. + /// + /// + /// Inclusive first sample index. + /// Exclusive last sample index. + /// Thread-safe projection of a sample into leaf-first s. + /// When false, skips call-tree construction entirely (only profiles). + /// Max worker threads; <= 0 uses . + public void AddResolvedSamplesParallel(int startIndex, int endIndex, ResolvedSampleProjector project, + bool buildCallTree = true, int maxWorkers = 0) { + ArgumentNullException.ThrowIfNull(project); + int count = endIndex - startIndex; + if (count <= 0) return; + + int workers = maxWorkers > 0 ? maxWorkers : Environment.ProcessorCount; + workers = Math.Max(1, Math.Min(workers, count)); + int chunkSize = (count + workers - 1) / workers; // ceil + + var chunkTrees = buildCallTree ? new ProfileCallTree[workers] : null; + var tasks = new Task[workers]; + + for (int w = 0; w < workers; w++) { + int worker = w; + int chunkStart = startIndex + worker * chunkSize; + int chunkEnd = Math.Min(chunkStart + chunkSize, endIndex); + + if (chunkStart >= chunkEnd) { + tasks[worker] = Task.CompletedTask; + continue; + } + + tasks[worker] = Task.Run(() => { + var frames = new List(); + var chunkTree = buildCallTree ? callTreeBuilder_.CreateChunkTree(worker, workers) : null; + + for (int i = chunkStart; i < chunkEnd; i++) { + frames.Clear(); + + if (!project(i, frames, out var weight, out int threadId) || frames.Count == 0) { + continue; + } + + sampleAggregator_.AddResolvedStack(weight, frames); + + if (chunkTree != null) { + callTreeBuilder_.AddResolvedStackToChunk(chunkTree, weight, threadId, frames); + } + } + + if (chunkTrees != null) { + chunkTrees[worker] = chunkTree; + } + }); + } + + Task.WaitAll(tasks); + + if (chunkTrees != null) { + var built = new List(workers); + + foreach (var tree in chunkTrees) { + if (tree != null) { + built.Add(tree); + } + } + + callTreeBuilder_.MergeChunkTrees(built); + } + InvalidateReport(); } @@ -292,6 +436,18 @@ public void Dispose() { // Forward a diagnostic message to the consumer-provided sink (no-op when none is configured). private void Log(string message) => options_.LogCallback?.Invoke(message); + // Null-object symbol locator for CreateForResolvedInput (no download path is exercised). + private sealed class NullSymbolFileLocator : ISymbolFileLocator { + public static readonly NullSymbolFileLocator Instance = new(); + + public Task FindSymbolFileAsync(string pdbName, Guid guid, int age, CancellationToken ct = default) => + Task.FromResult(null); + + public Task FindBinaryFileAsync(string binaryName, int timeDateStamp, long imageSize, + CancellationToken ct = default) => + Task.FromResult(null); + } + /// /// Generate hot lines from instruction weights only, without requiring /// the binary for Capstone disassembly. Avoids DIA COM calls to prevent diff --git a/src/ProfileExplorer.Profiling/ProfileExplorer.Profiling.csproj b/src/ProfileExplorer.Profiling/ProfileExplorer.Profiling.csproj index c8a99832..c30bc365 100644 --- a/src/ProfileExplorer.Profiling/ProfileExplorer.Profiling.csproj +++ b/src/ProfileExplorer.Profiling/ProfileExplorer.Profiling.csproj @@ -19,6 +19,8 @@ + + diff --git a/src/ProfileExplorer.Profiling/Profiling/CallTreeBuilder.cs b/src/ProfileExplorer.Profiling/Profiling/CallTreeBuilder.cs index 92c60667..15e3a24c 100644 --- a/src/ProfileExplorer.Profiling/Profiling/CallTreeBuilder.cs +++ b/src/ProfileExplorer.Profiling/Profiling/CallTreeBuilder.cs @@ -10,7 +10,7 @@ namespace ProfileExplorer.Profiling.Profiling; /// Stacks are expected to be leaf-first (index 0 = leaf, last index = root). /// internal class CallTreeBuilder { - private readonly ProfileCallTree callTree_ = new(); + private ProfileCallTree callTree_ = new(); private readonly IpResolver ipResolver_; private readonly object lock_ = new(); @@ -22,10 +22,18 @@ public CallTreeBuilder(IpResolver ipResolver) { /// Add samples with stack frames to the call tree. /// Stacks are expected to be leaf-first (index 0 = leaf, last index = root). /// - public void AddSamples(IEnumerable samples) { + public void AddSamples(IEnumerable samples, + IReadOnlyList? instancePath = null) { foreach (var sample in samples) { if (sample.StackFrames is not { Count: > 0 }) continue; + // Call-tree instance filter: include only samples whose stack begins (from the root) with the + // instance path, so the focused call tree matches the focused per-function profile. + if (instancePath is { Count: > 0 } && + !ipResolver_.StackHasInstancePrefix(sample.StackFrames, instancePath)) { + continue; + } + // Resolve frames, preserving leaf-first order expected by ProfileCallTree.UpdateCallTree. var frames = new List(sample.StackFrames.Count); @@ -49,6 +57,85 @@ public void AddSamples(IEnumerable samples) { } } + /// + /// Add one PRE-RESOLVED sample stack (leaf-first) to the call tree. Frames already carry their + /// resolved identity, so no IP resolution happens. Thread-safe. + /// + public void AddResolvedStack(TimeSpan weight, int threadId, IReadOnlyList framesLeafFirst, + IReadOnlyList? instancePath = null) { + if (framesLeafFirst.Count == 0) return; + + if (instancePath is { Count: > 0 } && + !IpResolver.StackHasInstancePrefixResolved(framesLeafFirst, instancePath)) { + return; + } + + var stack = new ResolvedSampleStack(BuildFrames(framesLeafFirst), threadId); + + lock (lock_) { + callTree_.UpdateCallTree(weight, stack); + } + } + + /// + /// Create an isolated call tree for a single parallel chunk. Its node-ID namespace is partitioned + /// by so the per-chunk trees can be merged later without ID + /// collisions (same scheme as the former Core CallTreeProcessor). + /// + public ProfileCallTree CreateChunkTree(int chunkIndex, int chunkCount) { + int startNodeId = chunkIndex * (int.MaxValue / (chunkCount + 1)); + return new ProfileCallTree(startNodeId); + } + + /// + /// Add one PRE-RESOLVED stack (leaf-first) to a chunk-local tree. No locking ΓÇö each chunk tree is + /// owned by a single worker thread; call once all workers finish. + /// + public void AddResolvedStackToChunk(ProfileCallTree chunkTree, TimeSpan weight, int threadId, + IReadOnlyList framesLeafFirst) { + if (framesLeafFirst.Count == 0) return; + var stack = new ResolvedSampleStack(BuildFrames(framesLeafFirst), threadId); + chunkTree.UpdateCallTree(weight, stack); + } + + /// + /// Merge the per-chunk trees produced by parallel workers into this builder's tree, using a + /// log-depth parallel pairwise merge (faithful to the former CallTreeProcessor.Complete). After + /// this returns, yields the merged tree. + /// + public void MergeChunkTrees(IReadOnlyList chunkTrees) { + if (chunkTrees.Count == 0) return; + + var chunks = new List(chunkTrees); + + while (chunks.Count > 1) { + const int step = 2; + int pairCount = chunks.Count / step; + var tasks = new Task[pairCount]; + var newChunks = new List(pairCount + 1); + + for (int i = 0; i < pairCount; i++) { + int baseIndex = i * step; + newChunks.Add(chunks[baseIndex]); + tasks[i] = Task.Run(() => chunks[baseIndex].MergeWith(chunks[baseIndex + 1])); + } + + Task.WaitAll(tasks); + + // Odd trailing tree (only possible in the first round with step 2): fold into the first. + for (int i = pairCount * step; i < chunks.Count; i++) { + chunks[0].MergeWith(chunks[i]); + } + + chunks = newChunks; + } + + callTree_ = chunks[0]; +#if DEBUG + callTree_.VerifyCycles(); +#endif + } + /// /// Returns the built call tree (a forest of root nodes). /// @@ -56,6 +143,18 @@ public ProfileCallTree Build() { return callTree_; } + // Project neutral resolved frames (leaf-first) into the call-tree frame type. + private static List BuildFrames(IReadOnlyList framesLeafFirst) { + var frames = new List(framesLeafFirst.Count); + + for (int i = 0; i < framesLeafFirst.Count; i++) { + var f = framesLeafFirst[i]; + frames.Add(new ResolvedCallStackFrame(f.FrameRva, f.DebugInfo, f.FunctionId, f.IsKernel, f.IsManaged)); + } + + return frames; + } + // Adapter exposing resolved frames (leaf-first) to ProfileCallTree.UpdateCallTree. private sealed class ResolvedSampleStack : IResolvedCallStack { private readonly List frames_; diff --git a/src/ProfileExplorer.Profiling/Profiling/IpResolver.cs b/src/ProfileExplorer.Profiling/Profiling/IpResolver.cs index 169142da..5155fa1e 100644 --- a/src/ProfileExplorer.Profiling/Profiling/IpResolver.cs +++ b/src/ProfileExplorer.Profiling/Profiling/IpResolver.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.Profile; using ProfileExplorer.Profiling.Symbols; namespace ProfileExplorer.Profiling.Profiling; @@ -67,6 +68,47 @@ public void SetFunctions(string moduleName, List sortedFuncti return new ResolvedIp(image.Name, moduleRva, null, ip, new FunctionDebugInfo(null, moduleRva, 0)); } + /// + /// True when (root-first function identities) is a prefix of the + /// sample stack read from the root. is leaf-first, so the root + /// is the last frame. Used for call-tree instance ("focus on this path") filtering — the neutral + /// equivalent of Core FunctionProfileProcessor's stack-prefix instance filter. + /// + public bool StackHasInstancePrefix(IReadOnlyList? framesLeafFirst, + IReadOnlyList instancePath) { + if (instancePath.Count == 0) return true; + if (framesLeafFirst is not { Count: > 0 } || framesLeafFirst.Count < instancePath.Count) return false; + + for (int i = 0; i < instancePath.Count; i++) { + long ip = framesLeafFirst[framesLeafFirst.Count - 1 - i]; + var resolved = Resolve(ip); + var id = resolved != null + ? new ProfileFunctionId(resolved.ModuleName, resolved.FunctionName ?? $"") + : default; + if (!id.Equals(instancePath[i])) return false; + } + + return true; + } + + /// + /// Instance-prefix check for PRE-RESOLVED frames (leaf-first) whose + /// is already known — no IP resolution needed. is root-first. + /// + public static bool StackHasInstancePrefixResolved(IReadOnlyList framesLeafFirst, + IReadOnlyList instancePath) { + if (instancePath.Count == 0) return true; + if (framesLeafFirst.Count < instancePath.Count) return false; + + for (int i = 0; i < instancePath.Count; i++) { + if (!framesLeafFirst[framesLeafFirst.Count - 1 - i].FunctionId.Equals(instancePath[i])) { + return false; + } + } + + return true; + } + private ImageInfo? FindImage(long ip) { // Binary search for the image with the largest base address <= ip. var keys = imagesByBaseAddress_.Keys; diff --git a/src/ProfileExplorer.Profiling/Profiling/SampleAggregator.cs b/src/ProfileExplorer.Profiling/Profiling/SampleAggregator.cs index 0a447fc1..0aba74c7 100644 --- a/src/ProfileExplorer.Profiling/Profiling/SampleAggregator.cs +++ b/src/ProfileExplorer.Profiling/Profiling/SampleAggregator.cs @@ -19,6 +19,10 @@ internal class SampleAggregator { private TimeSpan totalWeight_; private readonly object totalWeightLock_ = new(); + // Per-thread scratch set reused across AddResolvedStack calls (each parallel chunk thread gets its + // own) so recursive functions are credited inclusive time once per stack without per-call alloc. + [ThreadStatic] private static HashSet? resolvedCredited_; + public SampleAggregator(IpResolver ipResolver) { ipResolver_ = ipResolver; } @@ -26,7 +30,8 @@ public SampleAggregator(IpResolver ipResolver) { /// /// Add a batch of samples. Thread-safe. /// - public void AddSamples(IEnumerable samples) { + public void AddSamples(IEnumerable samples, + IReadOnlyList? instancePath = null) { TimeSpan batchWeight = TimeSpan.Zero; // Reused across samples to track which functions already received inclusive weight for the @@ -37,6 +42,13 @@ public void AddSamples(IEnumerable samples) { foreach (var sample in samples) { if (string.IsNullOrEmpty(sample.ImageName)) continue; + // Call-tree instance filter: include only samples whose stack (read from the root) begins with + // the instance path. Matches Core FunctionProfileProcessor's instance-path prefix filtering. + if (instancePath is { Count: > 0 } && + !ipResolver_.StackHasInstancePrefix(sample.StackFrames, instancePath)) { + continue; + } + var resolved = ipResolver_.Resolve(sample.InstructionPointer); if (resolved == null) continue; @@ -54,7 +66,10 @@ public void AddSamples(IEnumerable samples) { batchWeight += sample.Weight; - // Caller frames contribute inclusive weight only. + // Caller frames contribute inclusive weight plus a per-instruction sample at the call-site + // (the return address offset within the caller). This mirrors Core's + // FunctionProfileProcessor.ProcessSample, which credits AddInstructionSample for every unique + // function on the stack — so a `call` instruction shows the inclusive time of what it invoked. // Stack is leaf-first; skip index 0 (leaf — already counted above). if (sample.StackFrames is { Count: > 1 }) { for (int i = 1; i < sample.StackFrames.Count; i++) { @@ -68,6 +83,7 @@ public void AddSamples(IEnumerable samples) { lock (caller) { caller.Weight += sample.Weight; + caller.AddInstructionSample(callerResolved.InstructionOffset, sample.Weight); } } } @@ -78,6 +94,49 @@ public void AddSamples(IEnumerable samples) { } } + /// + /// Aggregate one PRE-RESOLVED sample stack (leaf-first). Frames already carry their function + /// identity and offset, so no IP resolution happens — used by hosts (e.g. Profile Explorer) that + /// own symbol resolution. Mirrors the per-frame exclusive/inclusive/instruction math of the raw + /// path and of Core's FunctionProfileProcessor. Thread-safe. + /// + public void AddResolvedStack(TimeSpan weight, IReadOnlyList framesLeafFirst, + IReadOnlyList? instancePath = null) { + if (framesLeafFirst.Count == 0) return; + + if (instancePath is { Count: > 0 } && + !IpResolver.StackHasInstancePrefixResolved(framesLeafFirst, instancePath)) { + return; + } + + var credited = resolvedCredited_ ??= new HashSet(); + credited.Clear(); + bool isTop = true; + + for (int i = 0; i < framesLeafFirst.Count; i++) { + var frame = framesLeafFirst[i]; + var id = frame.FunctionId; + var fp = functions_.GetOrAdd(id, static (_, dbg) => new FunctionProfileData(dbg), frame.DebugInfo); + + lock (fp) { + if (credited.Add(id)) { + fp.Weight += weight; + fp.AddInstructionSample(frame.InstructionOffset, weight); + } + + if (isTop) { + fp.ExclusiveWeight += weight; + } + } + + isTop = false; + } + + lock (totalWeightLock_) { + totalWeight_ += weight; + } + } + /// /// Build the final per-function profile map (snapshot). /// diff --git a/src/ProfileExplorerCore/Profile/Adapters/ProfileReportMapper.cs b/src/ProfileExplorerCore/Profile/Adapters/ProfileReportMapper.cs new file mode 100644 index 00000000..c5e6cf75 --- /dev/null +++ b/src/ProfileExplorerCore/Profile/Adapters/ProfileReportMapper.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System; +using System.Collections.Generic; +using ProfileExplorer.Core.IR; +using ProfileExplorer.Core.Profile.Data; +using ProfileExplorer.Profiling; + +namespace ProfileExplorer.Core.Profile.Adapters; + +/// +/// Projects a library (the neutral output of the ProfileExplorer.Profiling +/// engine) onto Core's shape used by the UI. This is the return leg of the +/// engine deduplication: the library owns aggregation and produces per-function profiles keyed by +/// ; Core supplies the mapping (for document +/// navigation) and module identity (for per-module weight display). +/// +public static class ProfileReportMapper { + /// + /// Populate from . + /// + /// The ProfileData to fill (typically a freshly constructed instance). + /// The library aggregation result. + /// + /// Maps a neutral function identity to its (Core owns the per-module + /// IRTextSummary registry). May return null for functions with no IR representation + /// (e.g. unresolved/JIT frames), in which case no resolver entry is added. + /// + /// + /// Maps a module name to the representative used to key + /// . Module exclusive weight is summed from the per-function + /// exclusive weights (the leaf-attributed time), consistent with Core's module-weight semantics. + /// + public static void ApplyReport(ProfileData target, ProfileReport report, + Func resolveFunction, + Func moduleIdByName) { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(resolveFunction); + ArgumentNullException.ThrowIfNull(moduleIdByName); + + var functions = new Dictionary(report.Functions.Count); + var resolver = new Dictionary(report.Functions.Count); + var moduleWeights = new Dictionary(); + + foreach (var pair in report.Functions) { + var id = pair.Key; + var data = pair.Value; + functions[id] = data; + + var irFunction = resolveFunction(id); + + if (irFunction != null) { + resolver[id] = irFunction; + } + + // Module time is the leaf-attributed (exclusive) time, aggregated per module — matching Core's + // FunctionProfileProcessor, which accumulates module weight from the top (leaf) stack frame. + int moduleId = moduleIdByName(id.ModuleName); + moduleWeights.TryGetValue(moduleId, out var existing); + moduleWeights[moduleId] = existing + data.ExclusiveWeight; + } + + target.FunctionProfiles = functions; + target.FunctionResolver = resolver; + target.ModuleWeights = moduleWeights; + target.CallTree = report.CallTree; + target.TotalWeight = report.TotalWeight; + target.ProfileWeight = report.TotalWeight; + } +} diff --git a/src/ProfileExplorerCore/Profile/Adapters/RawProfileLibraryAdapters.cs b/src/ProfileExplorerCore/Profile/Adapters/RawProfileLibraryAdapters.cs new file mode 100644 index 00000000..47bb3b5a --- /dev/null +++ b/src/ProfileExplorerCore/Profile/Adapters/RawProfileLibraryAdapters.cs @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System; +using System.Collections.Generic; +using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.Profile.Data; +using ProfileExplorer.Profiling; + +namespace ProfileExplorer.Core.Profile.Adapters; + +/// +/// Bridges Core's ETW-derived to the library's neutral input +/// abstractions ( / ) so the +/// ProfileExplorer.Profiling engine (FunctionProfiler / SampleAggregator) can consume ETW samples +/// directly — letting Core delegate sample aggregation, IP resolution, and symbol reading to the +/// library instead of maintaining its own parallel implementation. +/// +public static class RawProfileLibraryAdapter { + /// + /// Build library image descriptors for the given processes, attaching the per-image PDB identity + /// (GUID / Age / name) that Core captured from the trace's ImageID_DBG (RSDS) events. + /// + public static IReadOnlyList CreateImages(RawProfileData profile, IReadOnlyList processIds) { + var images = new List(); + var seen = new HashSet<(int ProcessId, long BaseAddress)>(); + + foreach (int processId in processIds) { + var process = profile.GetOrCreateProcess(processId); + + foreach (var image in process.Images(profile)) { + if (image == null || image.Size <= 0) { + continue; + } + + if (!seen.Add((processId, image.BaseAddress))) { + continue; + } + + var symbolFile = profile.GetDebugFileForImage(image, processId); + images.Add(new RawProfileImageAdapter(image, processId, symbolFile)); + } + } + + return images; + } + + /// + /// Enumerate library samples for the given processes. Lazy — one adapter is yielded per sample. + /// Stack frames are leaf-first (Core's ), which matches + /// the contract. + /// + public static IEnumerable CreateSamples(RawProfileData profile, IReadOnlyList processIds) { + var wanted = new HashSet(processIds); + + foreach (var sample in profile.Samples) { + var context = sample.GetContext(profile); + + if (!wanted.Contains(context.ProcessId)) { + continue; + } + + var image = profile.FindImageForIP(sample.IP, context.ProcessId); + var stack = sample.GetStack(profile); + long[] frames = stack.IsUnknown ? null : stack.FramePointers; + + yield return new RawProfileSampleAdapter( + sample.IP, sample.Weight, context.ProcessId, context.ThreadId, + image?.ModuleName, image?.BaseAddress ?? 0, frames); + } + } +} + +/// +/// view over a Core plus the PDB identity +/// resolved from the trace's ImageID_DBG events (may be null when the trace lacks RSDS data). +/// +public sealed class RawProfileImageAdapter : IProfileImage { + public RawProfileImageAdapter(ProfileImage image, int processId, SymbolFileDescriptor symbolFile) { + ImageName = image.ModuleName ?? string.Empty; + BaseAddress = image.BaseAddress; + Size = image.Size; + TimeDateStamp = image.TimeStamp; + PdbGuid = symbolFile?.Id ?? Guid.Empty; + PdbAge = symbolFile?.Age ?? 0; + PdbName = symbolFile?.FileName ?? string.Empty; + ProcessId = processId; + } + + public string ImageName { get; } + public long BaseAddress { get; } + public int Size { get; } + public int TimeDateStamp { get; } + public Guid PdbGuid { get; } + public int PdbAge { get; } + public string PdbName { get; } + public int ProcessId { get; } +} + +/// +/// view over a Core . Stack frames are the +/// sample's leaf-first (frame 0 is the leaf / sample IP). +/// +public sealed class RawProfileSampleAdapter : IProfileSample { + private readonly long[] stackFrames_; + + public RawProfileSampleAdapter(long ip, TimeSpan weight, int processId, int threadId, + string? imageName, long imageBaseAddress, long[] stackFrames) { + InstructionPointer = ip; + Weight = weight; + ProcessId = processId; + ThreadId = threadId; + ImageName = imageName; + ImageBaseAddress = imageBaseAddress; + stackFrames_ = stackFrames; + } + + public long InstructionPointer { get; } + public TimeSpan Weight { get; } + public int ProcessId { get; } + public int ThreadId { get; } + public string? ImageName { get; } + public long ImageBaseAddress { get; } + public IReadOnlyList? StackFrames => stackFrames_; +} diff --git a/src/ProfileExplorerCore/Profile/Data/ProfileData.cs b/src/ProfileExplorerCore/Profile/Data/ProfileData.cs index 1e88778e..13a7825f 100644 --- a/src/ProfileExplorerCore/Profile/Data/ProfileData.cs +++ b/src/ProfileExplorerCore/Profile/Data/ProfileData.cs @@ -1,13 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT license. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Threading.Tasks; using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.Profile.Adapters; using ProfileExplorer.Core.Profile.CallTree; using ProfileExplorer.Core.Profile.Processing; using ProfileExplorer.Core.Utilities; +using ProfileExplorer.Profiling; namespace ProfileExplorer.Core.Profile.Data; @@ -298,32 +301,148 @@ public ProcessingResult RestorePreviousProfile(ProcessingResult previousProfile) public ProfileData ComputeProfile(ProfileData baseProfile, ProfileSampleFilter filter, bool computeCallTree = true, int maxChunks = int.MaxValue) { - // Compute the call tree in parallel with the per-function profiles. - var tasks = new List(); + // The ProfileExplorer.Profiling library owns aggregation + call-tree building; it runs over the + // already-resolved sample stacks (Core owns symbol resolution). Managed/JIT/unknown frames flow + // through because Core resolved them before they landed in ResolvedProfileStack. + // + // Aggregation runs in parallel: the library partitions the sample range across workers, + // aggregates per-function profiles into a shared thread-safe map, and builds per-worker call + // trees that are merged — matching the parallelism of the former FunctionProfileProcessor / + // CallTreeProcessor. Core supplies a thread-safe projection of each resolved stack. + using var profiler = FunctionProfiler.CreateForResolvedInput(); + + // Neutral function identity -> IRTextFunction, for UI/document navigation. Populated concurrently + // from worker threads during projection, so it must be a concurrent map. + var functionResolver = new ConcurrentDictionary(); + var instancePaths = BuildInstanceFilterPaths(filter); + var samples = baseProfile.Samples; + + int startIndex = filter.TimeRange?.StartSampleIndex ?? 0; + int endIndex = filter.TimeRange?.EndSampleIndex ?? samples.Count; + int workerCount = maxChunks == int.MaxValue + ? Math.Max(1, CoreSettingsProvider.GeneralSettings.CurrentCpuCoreLimit) + : Math.Max(1, maxChunks); + + // Thread-safe projection of one resolved stack into the library's neutral ResolvedFrame form, + // applying the same thread/instance filtering the sequential path did. Called concurrently. + bool Project(int index, List frames, out TimeSpan weight, out int threadId) { + var entry = samples[index]; + var stack = entry.Stack; + weight = entry.Sample.Weight; + threadId = stack.Context.ThreadId; + + if (filter.HasThreadFilter && !filter.ThreadIds.Contains(threadId)) { + return false; + } - if (maxChunks == int.MaxValue) { - // Use half the threads for each task. - maxChunks = Math.Max(1, CoreSettingsProvider.GeneralSettings.CurrentCpuCoreLimit / 2); - } + if (instancePaths != null && !StackMatchesAnyInstance(stack, instancePaths)) { + return false; + } + + foreach (var frame in stack.StackFrames) { + if (frame.IsUnknown) { + continue; + } + + var details = frame.FrameDetails; + frames.Add(new ResolvedFrame(details.FunctionId, details.DebugInfo, frame.FrameRVA, + details.IsKernelCode, details.IsManagedCode)); - var callTreeTask = Task.Run(() => { - if (computeCallTree) { - return CallTreeProcessor.Compute(baseProfile, filter, maxChunks); + if (details.Function != null) { + functionResolver.TryAdd(details.FunctionId, details.Function); + } } + return frames.Count > 0; + } + + profiler.AddResolvedSamplesParallel(startIndex, endIndex, Project, computeCallTree, workerCount); + + var report = profiler.GetReport(); + var moduleIdByName = BuildModuleIdMap(baseProfile); + var result = new ProfileData(); + + ProfileReportMapper.ApplyReport(result, report, + id => functionResolver.TryGetValue(id, out var func) ? func : null, + name => moduleIdByName.GetValueOrDefault(name, 0)); + + if (!computeCallTree) { + result.CallTree = null; + } + + return result; + } + + // Builds the per-instance root-first ProfileFunctionId paths used to focus the profile on specific + // call-tree instances (mirrors the former FunctionProfileProcessor instance filter). + private static List> BuildInstanceFilterPaths(ProfileSampleFilter filter) { + if (filter.FunctionInstances is not { Count: > 0 }) { return null; - }); + } + + var paths = new List>(); + + void AddInstance(ProfileCallTreeNode node) { + var path = new List(); + + while (node != null) { + path.Add(node.FunctionId); + node = node.Caller; + } - var funcProfileTask = Task.Run(() => { - return FunctionProfileProcessor.Compute(baseProfile, filter, maxChunks); - }); + path.Reverse(); // root-first + paths.Add(path); + } + + foreach (var instance in filter.FunctionInstances) { + if (instance is ProfileCallTreeGroupNode groupNode) { + foreach (var node in groupNode.Nodes) { + AddInstance(node); + } + } + else { + AddInstance(instance); + } + } + + return paths; + } + + // True if the stack (leaf-first) begins from the root with any instance path (root-first). + private static bool StackMatchesAnyInstance(ResolvedProfileStack stack, + List> instancePaths) { + foreach (var path in instancePaths) { + if (stack.FrameCount < path.Count) { + continue; + } + + bool isMatch = true; - tasks.Add(callTreeTask); - Task.WhenAll(tasks.ToArray()).Wait(); + for (int i = 0; i < path.Count; i++) { + if (path[i] != stack.StackFrames[stack.FrameCount - i - 1].FrameDetails.FunctionId) { + isMatch = false; + break; + } + } + + if (isMatch) { + return true; + } + } + + return false; + } + + // Maps a module name to a representative ProfileImage.Id for ModuleWeights keying. + private static Dictionary BuildModuleIdMap(ProfileData baseProfile) { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var pair in baseProfile.Modules) { + string name = pair.Value.ModuleName ?? string.Empty; + map.TryAdd(name, pair.Key); + } - var profile = funcProfileTask.Result; - profile.CallTree = callTreeTask.Result; - return profile; + return map; } //? TODO: Port to ProfileSampleProcessor diff --git a/src/ProfileExplorerCore/Profile/Processing/CallTreeProcessor.cs b/src/ProfileExplorerCore/Profile/Processing/CallTreeProcessor.cs deleted file mode 100644 index e4f753ca..00000000 --- a/src/ProfileExplorerCore/Profile/Processing/CallTreeProcessor.cs +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using ProfileExplorer.Core.Profile.CallTree; -using ProfileExplorer.Core.Profile.Data; - -namespace ProfileExplorer.Core.Profile.Processing; - -public sealed class CallTreeProcessor : ProfileSampleProcessor { - private List chunks_; - private int maxChunks_; - - public CallTreeProcessor(int maxChunks) { - chunks_ = new List(); - maxChunks_ = maxChunks; - } - - public ProfileCallTree CallTree { get; set; } = new(); - - public static ProfileCallTree Compute(ProfileData profile, ProfileSampleFilter filter, - int maxChunks = int.MaxValue) { - var funcProcessor = new CallTreeProcessor(maxChunks); - funcProcessor.ProcessSampleChunk(profile, filter, maxChunks); - return funcProcessor.CallTree; - } - - protected override void ProcessSample(ref ProfileSample sample, ResolvedProfileStack stack, - int sampleIndex, object chunkData) { - var callTree = (ProfileCallTree)chunkData; - callTree.UpdateCallTree(sample.Weight, stack); - } - - protected override object InitializeChunk(int k, int samplesPerChunk) { - // Partition the node IDs into namespaces based on the chunk - // of samples they are created from - this ensures that each - // call tree will usue unique node IDs when compared to other call trees. - int startNodeId = k * (int.MaxValue / (maxChunks_ + 1)); - var chunk = new ProfileCallTree(startNodeId); - - lock (chunks_) { - chunks_.Add(chunk); - } - - return chunk; - } - - protected override void Complete() { - lock (chunks_) { - // Multi-threaded merging of partial call trees. - while (chunks_.Count > 1) { - int step = Math.Min(chunks_.Count, 2); - // Trace.WriteLine($"=> Merging {chunks_.Count} chunks, step {step}"); - - var tasks = new Task[chunks_.Count / step]; - var newChunks = new List(chunks_.Count / step); - - for (int i = 0; i < chunks_.Count / step; i++) { - int iCopy = i; - newChunks.Add(chunks_[iCopy * step]); - - tasks[i] = Task.Run(() => { - for (int k = 1; k < step; k++) { - chunks_[iCopy * step].MergeWith(chunks_[iCopy * step + k]); - } - }); - } - - Task.WaitAll(tasks); - - // Handle any chunks that were not paired during the parallel phase. - // With a step of 2 this can happen only in the first round. - if (chunks_.Count % step != 0) { - int lastHandledIndex = chunks_.Count / step * step; - - for (int i = lastHandledIndex; i < chunks_.Count; i++) { - chunks_[0].MergeWith(chunks_[i]); - } - } - - chunks_ = newChunks; - } - - CallTree = chunks_[0]; -#if DEBUG - CallTree.VerifyCycles(); -#endif - } - } -} \ No newline at end of file diff --git a/src/ProfileExplorerCore/Profile/Processing/FunctionProfileProcessor.cs b/src/ProfileExplorerCore/Profile/Processing/FunctionProfileProcessor.cs deleted file mode 100644 index 7e253094..00000000 --- a/src/ProfileExplorerCore/Profile/Processing/FunctionProfileProcessor.cs +++ /dev/null @@ -1,248 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -using System; -using System.Collections.Generic; -using System.Runtime.InteropServices; -using System.Threading.Tasks; -using ProfileExplorer.Core.Profile.CallTree; -using ProfileExplorer.Core.Profile.Data; -using ProfileExplorer.Core.Utilities; - -namespace ProfileExplorer.Core.Profile.Processing; - -public sealed class FunctionProfileProcessor : ProfileSampleProcessor { - private ProfileSampleFilter filter_; - private List> filterStackFuncts_; - private List chunks_; - - private FunctionProfileProcessor(ProfileSampleFilter filter) { - filter_ = filter; - chunks_ = new List(); - - if (filter_ != null && filter_.FunctionInstances is {Count: > 0}) { - // Compute once the list of functions on the path - // from call tree root to the function instance node. - filterStackFuncts_ = new List>(); - - foreach (var instance in filter_.FunctionInstances) { - if (instance is ProfileCallTreeGroupNode groupNode) { - foreach (var node in groupNode.Nodes) { - AddInstanceFilter(node); - } - } - else { - AddInstanceFilter(instance); - } - } - } - } - - public ProfileData Profile { get; } = new(); - - private void AddInstanceFilter(ProfileCallTreeNode node) { - var stackFuncts = new List(); - - while (node != null) { - stackFuncts.Add(node.FunctionId); - node = node.Caller; - } - - stackFuncts.Reverse(); - filterStackFuncts_.Add(stackFuncts); - } - - public static ProfileData Compute(ProfileData profile, ProfileSampleFilter filter, - int maxChunks = int.MaxValue) { - var funcProcessor = new FunctionProfileProcessor(filter); - funcProcessor.ProcessSampleChunk(profile, filter, maxChunks); - return funcProcessor.Profile; - } - - protected override object InitializeChunk(int k, int samplesPerChunk) { - var chunk = new ChunkData(); - - lock (chunks_) { - chunks_.Add(chunk); - } - - return chunk; - } - - protected override void ProcessSample(ref ProfileSample sample, ResolvedProfileStack stack, - int sampleIndex, object chunkData) { - if (filterStackFuncts_ != null) { - // Filtering of functions to a single instance is enabled, - // accept only samples that have the instance path nodes - // as a prefix of the call stack, this accounts for total weight. - if (stack.FrameCount < filterStackFuncts_.Count) { - return; - } - - bool foundMatch = false; - - foreach (var stackFuncts in filterStackFuncts_) { - // Check if instance path nodes are a prefix of the call stack. - if (stack.FrameCount < stackFuncts.Count) { - continue; - } - - bool isMatch = true; - - for (int i = 0; i < stackFuncts.Count; i++) { - if (stackFuncts[i] != - stack.StackFrames[stack.FrameCount - i - 1].FrameDetails.Function.ToProfileId()) { - isMatch = false; - break; - } - } - - if (isMatch) { - foundMatch = true; - break; - } - } - - if (!foundMatch) { - return; - } - } - - var data = (ChunkData)chunkData; - data.TotalWeight += sample.Weight; - data.ProfileWeight += sample.Weight; - - bool isTopFrame = true; - data.StackModules.Clear(); - data.StackFunctions.Clear(); - - foreach (var resolvedFrame in stack.StackFrames) { - if (resolvedFrame.IsUnknown) { - continue; - } - - var frameDetails = resolvedFrame.FrameDetails; - - if (isTopFrame && data.StackModules.Add(frameDetails.Image.Id)) { - data.ModuleWeights.AccumulateValue(frameDetails.Image.Id, sample.Weight); - } - - long funcRva = frameDetails.DebugInfo.RVA; - long frameRva = resolvedFrame.FrameRVA; - var textFunction = frameDetails.Function; - var funcId = new ProfileFunctionId(textFunction?.ModuleName, textFunction?.Name); - ref var funcProfile = - ref CollectionsMarshal.GetValueRefOrAddDefault(data.FunctionProfiles, funcId, out bool exists); - - if (!exists) { - funcProfile = new FunctionProfileData(frameDetails.DebugInfo); - data.FunctionResolver[funcId] = textFunction; - } - - long offset = frameRva - funcRva; - - // Don't count the inclusive time for recursive functions multiple times. - if (data.StackFunctions.Add(funcId)) { - funcProfile.AddInstructionSample(offset, sample.Weight); - funcProfile.Weight += sample.Weight; - - // Set sample range covered by function. - funcProfile.SampleStartIndex = Math.Min(funcProfile.SampleStartIndex, sampleIndex); - funcProfile.SampleEndIndex = Math.Max(funcProfile.SampleEndIndex, sampleIndex); - } - - // Count the exclusive time for the top frame function. - if (isTopFrame) { - funcProfile.ExclusiveWeight += sample.Weight; - } - - isTopFrame = false; - } - } - - protected override void CompleteChunk(int k, object chunkData) { - var data = (ChunkData)chunkData; - - lock (Profile) { - Profile.TotalWeight += data.TotalWeight; - Profile.ProfileWeight += data.ProfileWeight; - - foreach ((int moduleId, var weight) in data.ModuleWeights) { - Profile.AddModuleSample(moduleId, weight); - } - } - } - - protected override void Complete() { - lock (chunks_) { - while (chunks_.Count > 1) { - int step = Math.Min(chunks_.Count, 2); - // Trace.WriteLine($"=> Merging {chunks_.Count} chunks, step {step}"); - - var tasks = new Task[chunks_.Count / step]; - var newChunks = new List(chunks_.Count / step); - - for (int i = 0; i < chunks_.Count / step; i++) { - int iCopy = i; - newChunks.Add(chunks_[iCopy * step]); - - tasks[i] = Task.Run(() => { - for (int k = 1; k < step; k++) { - var destChunk = chunks_[iCopy * step]; - var sourceChunk = chunks_[iCopy * step + k]; - MergeChuncks(destChunk, sourceChunk); - } - }); - } - - Task.WaitAll(tasks); - - // Handle any chuncks that were not paired during the parallel phase. - // With a step of 2 this can happen only in the first round. - if (chunks_.Count % step != 0) { - int lastHandledIndex = chunks_.Count / step * step; - - for (int i = lastHandledIndex; i < chunks_.Count; i++) { - MergeChuncks(chunks_[0], chunks_[i]); - } - } - - chunks_ = newChunks; - } - - lock (Profile) { - Profile.FunctionProfiles = chunks_[0].FunctionProfiles; - Profile.FunctionResolver = chunks_[0].FunctionResolver; - } - } - } - - private static void MergeChuncks(ChunkData destChunk, ChunkData sourceChunk) { - foreach (var pair in sourceChunk.FunctionProfiles) { - ref var existingValue = - ref CollectionsMarshal.GetValueRefOrAddDefault(destChunk.FunctionProfiles, pair.Key, - out bool exists); - - if (exists) { - existingValue.MergeWith(pair.Value); - } - else { - // Copy over func. profile if missing. - existingValue = pair.Value; - } - } - - foreach (var pair in sourceChunk.FunctionResolver) { - destChunk.FunctionResolver[pair.Key] = pair.Value; - } - } - - private class ChunkData { - public HashSet StackModules = new(); - public HashSet StackFunctions = new(); - public Dictionary ModuleWeights = new(); - public Dictionary FunctionProfiles = new(); - public Dictionary FunctionResolver = new(); - public TimeSpan TotalWeight = TimeSpan.Zero; - public TimeSpan ProfileWeight = TimeSpan.Zero; - } -} \ No newline at end of file diff --git a/src/ProfileExplorerCoreTests/ETWUnmappedFrameResolutionTests.cs b/src/ProfileExplorerCoreTests/ETWUnmappedFrameResolutionTests.cs index 59f22b89..68ad041e 100644 --- a/src/ProfileExplorerCoreTests/ETWUnmappedFrameResolutionTests.cs +++ b/src/ProfileExplorerCoreTests/ETWUnmappedFrameResolutionTests.cs @@ -24,8 +24,8 @@ namespace ProfileExplorer.CoreTests; /// properly attributed rather than silently dropped. /// /// Integration tests exercise the actual ETWProfileDataProvider code path. -/// Processor tests verify FunctionProfileProcessor and FunctionsForSamplesProcessor -/// handle the resulting frames correctly (these processors are not covered by +/// Processor tests verify the library ComputeProfile path and FunctionsForSamplesProcessor +/// handle the resulting frames correctly (these are not covered by /// the existing SyntheticProfileTests in the UI test project). /// [TestClass] @@ -189,10 +189,10 @@ public void GetOrCreateThreadFunction_PerThreadIsolation() { #endregion - #region Processor tests — FunctionProfileProcessor / FunctionsForSamplesProcessor + #region Processor tests — ComputeProfile (library) / FunctionsForSamplesProcessor [TestMethod] - public void FunctionProfileProcessor_ExclusiveWeightGoesToLeafNotCaller() { + public void ComputeProfile_ExclusiveWeightGoesToLeafNotCaller() { // Core bug: before the fix, Unknown leaf frames were skipped and // ExclusiveWeight was mis-attributed to the deepest known caller. var profileData = new ProfileData(); @@ -218,7 +218,7 @@ public void FunctionProfileProcessor_ExclusiveWeightGoesToLeafNotCaller() { } profileData.ComputeThreadSampleRanges(); - var result = FunctionProfileProcessor.Compute(profileData, new ProfileSampleFilter()); + var result = profileData.ComputeProfile(profileData, new ProfileSampleFilter()); Assert.AreEqual(TimeSpan.FromMilliseconds(20), result.GetFunctionProfile(unknownFunc).ExclusiveWeight, @@ -232,7 +232,7 @@ public void FunctionProfileProcessor_ExclusiveWeightGoesToLeafNotCaller() { } [TestMethod] - public void FunctionProfileProcessor_AllUnmappedStack_WeightPreserved() { + public void ComputeProfile_AllUnmappedStack_WeightPreserved() { var profileData = new ProfileData(); var unknownImage = CreateUnknownModuleImage(); profileData.Modules[unknownImage.Id] = unknownImage; @@ -249,7 +249,7 @@ public void FunctionProfileProcessor_AllUnmappedStack_WeightPreserved() { resolved)); profileData.ComputeThreadSampleRanges(); - var result = FunctionProfileProcessor.Compute(profileData, new ProfileSampleFilter()); + var result = profileData.ComputeProfile(profileData, new ProfileSampleFilter()); Assert.AreEqual(TimeSpan.FromMilliseconds(5), result.ProfileWeight, "Profile weight must include unmapped-code-only samples"); diff --git a/src/ProfileExplorerCoreTests/EngineAggregationParityTests.cs b/src/ProfileExplorerCoreTests/EngineAggregationParityTests.cs new file mode 100644 index 00000000..591ea047 --- /dev/null +++ b/src/ProfileExplorerCoreTests/EngineAggregationParityTests.cs @@ -0,0 +1,284 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ProfileExplorer.Core; +using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.IR; +using ProfileExplorer.Core.Profile; +using ProfileExplorer.Core.Profile.Adapters; +using ProfileExplorer.Core.Profile.Data; +using ProfileExplorer.Core.Profile.Processing; // ProfileSampleFilter +using ProfileExplorer.Profiling; // IProfileSample, FunctionProfiler, ResolvedFrame +using ProfileExplorer.Profiling.Profiling; // IpResolver, SampleAggregator (InternalsVisibleTo) +using ProfileExplorer.Profiling.Symbols; // ISymbolFileLocator + +namespace ProfileExplorer.CoreTests; + +/// +/// Regression test for the profiling-engine deduplication: proves the library's +/// (fed either through the ETW->library +/// or the resolved-stack cutover path) reproduces the hand-computed per-function profile — exclusive +/// weight, inclusive weight, and per-instruction (hot-line) attribution — across multi-function, +/// multi-thread, and recursive stacks. Originally the Stage-2 parity gate against Core's +/// FunctionProfileProcessor; now that Core delegates aggregation to the library, the golden values +/// are asserted directly. +/// +[TestClass] +public class EngineAggregationParityTests { + private const string Module = "TestModule.dll"; + private const long Base = 0x140000000; + private const int ModuleSize = 0x100000; + + private sealed record Fn(string Name, long Rva, uint Size); + + private static readonly Fn Main = new("Main", 0x1000, 0x800); + private static readonly Fn Foo = new("Foo", 0x2000, 0x800); + private static readonly Fn Bar = new("Bar", 0x3000, 0x800); + private static readonly Fn Baz = new("Baz", 0x4000, 0x800); + + [TestMethod] + [TestCategory("Aggregation")] + public void LibraryAggregation_MatchesGolden_OnSyntheticStacks() { + // Static frame-interning caches are shared across the process; reset for deterministic isolation. + ResolvedProfileStack.ResetCaches(); + + // Each stack is leaf-first: frame 0 is the sampled (leaf) instruction; the rest are callers, + // each at its call-site (return-address) offset within the caller function. + var stacks = new (int ThreadId, int WeightMs, (Fn Func, long Offset)[] Frames)[] { + (200, 10, new[] { (Bar, 0x40L), (Foo, 0x80L), (Main, 0x100L) }), + (200, 10, new[] { (Baz, 0x50L), (Foo, 0x84L), (Main, 0x100L) }), + (201, 5, new[] { (Bar, 0x44L), (Main, 0x104L) }), + (201, 7, new[] { (Bar, 0x40L), (Foo, 0x88L), (Foo, 0x8CL), (Main, 0x100L) }), // recursion in Foo + }; + + // Raw path: ETW samples -> RawProfileSampleAdapter -> SampleAggregator (the library's own resolver). + AssertMatchesGolden(ComputeWithLibrary(stacks)); + } + + [TestMethod] + [TestCategory("Aggregation")] + public void ResolvedAggregation_MatchesGolden_OnSyntheticStacks() { + // The library aggregates over the SAME ResolvedProfileStacks Core builds — this is exactly the + // ETWProfileDataProvider (option B) cutover path: host resolves; library aggregates the result. + ResolvedProfileStack.ResetCaches(); + + var stacks = new (int ThreadId, int WeightMs, (Fn Func, long Offset)[] Frames)[] { + (200, 10, new[] { (Bar, 0x40L), (Foo, 0x80L), (Main, 0x100L) }), + (200, 10, new[] { (Baz, 0x50L), (Foo, 0x84L), (Main, 0x100L) }), + (201, 5, new[] { (Bar, 0x44L), (Main, 0x104L) }), + (201, 7, new[] { (Bar, 0x40L), (Foo, 0x88L), (Foo, 0x8CL), (Main, 0x100L) }), + }; + + // Resolved path: project ResolvedProfileStacks -> ResolvedFrame -> FunctionProfiler.AddResolvedSample. + AssertMatchesGolden(ComputeWithLibraryResolved(BuildCoreInput(stacks))); + } + + [TestMethod] + [TestCategory("Aggregation")] + public void ComputeProfile_ParallelMatchesSequential_OnManyStacks() { + // Guards the parallel ComputeProfile path: many-worker aggregation + per-worker call-tree merge + // must produce byte-for-byte the same result as a single-worker (sequential) run. ComputeProfile + // does not mutate baseProfile, so the same input drives both runs. + ResolvedProfileStack.ResetCaches(); + + var stacks = GenerateStacks(240); + var input = BuildCoreInput(stacks); + + var sequential = input.ComputeProfile(input, new ProfileSampleFilter(), computeCallTree: true, maxChunks: 1); + var parallel = input.ComputeProfile(input, new ProfileSampleFilter(), computeCallTree: true, maxChunks: int.MaxValue); + + // Per-function parity: exclusive, inclusive, and per-instruction (hot-line) weights. + CollectionAssert.AreEquivalent(sequential.FunctionProfiles.Keys.ToList(), + parallel.FunctionProfiles.Keys.ToList(), "function set"); + + foreach (var id in sequential.FunctionProfiles.Keys) { + var seq = sequential.FunctionProfiles[id]; + var par = parallel.FunctionProfiles[id]; + Assert.AreEqual(seq.ExclusiveWeight, par.ExclusiveWeight, $"{id.FunctionName} exclusive"); + Assert.AreEqual(seq.Weight, par.Weight, $"{id.FunctionName} inclusive"); + CollectionAssert.AreEquivalent(seq.InstructionWeight, par.InstructionWeight, + $"{id.FunctionName} per-instruction"); + } + + // Totals + call-tree parity (root set, per-root inclusive weight, and total root weight). + Assert.AreEqual(sequential.TotalWeight, parallel.TotalWeight, "total weight"); + Assert.AreEqual(sequential.CallTree.TotalRootNodesWeight, parallel.CallTree.TotalRootNodesWeight, + "call-tree root weight"); + + var seqRoots = sequential.CallTree.RootNodes.ToDictionary(n => n.FunctionId, n => n.Weight); + var parRoots = parallel.CallTree.RootNodes.ToDictionary(n => n.FunctionId, n => n.Weight); + CollectionAssert.AreEquivalent(seqRoots.Keys.ToList(), parRoots.Keys.ToList(), "call-tree root set"); + + foreach (var pair in seqRoots) { + Assert.AreEqual(pair.Value, parRoots[pair.Key], $"root {pair.Key.FunctionName} weight"); + } + + // Sanity: the total equals the summed sample weight (nothing dropped by chunk boundaries). + var expectedTotal = TimeSpan.FromMilliseconds(stacks.Sum(s => s.WeightMs)); + Assert.AreEqual(expectedTotal, parallel.TotalWeight, "parallel total equals summed sample weight"); + } + + // Deterministic synthetic stacks across several threads with varied depth, offsets, and Foo + // recursion, sized to span multiple parallel workers. + private static (int ThreadId, int WeightMs, (Fn Func, long Offset)[] Frames)[] GenerateStacks(int count) { + var result = new (int ThreadId, int WeightMs, (Fn Func, long Offset)[] Frames)[count]; + + for (int i = 0; i < count; i++) { + int threadId = 200 + (i % 4); + int weightMs = 1 + (i % 7); + var frames = new List<(Fn, long)> { + ((i % 2 == 0) ? Bar : Baz, 0x40 + (i % 16)) // leaf + }; + + if (i % 3 == 0) { + frames.Add((Foo, 0x88 + (i % 4))); // extra Foo frame -> recursion when combined with the next + } + + frames.Add((Foo, 0x80 + (i % 8))); + frames.Add((Main, 0x100 + (i % 4))); // root + result[i] = (threadId, weightMs, frames.ToArray()); + } + + return result; + } + + // Projects Core's ResolvedProfileStacks (the exact cutover input) through the library's + // resolved-aggregation path, skipping unknown frames exactly as Core's aggregation does. + private static Dictionary ComputeWithLibraryResolved(ProfileData input) { + var options = new ProfilerOptions { + SymbolPaths = new[] { "srv*https://symbols.invalid" }, + IncludeManagedCode = false, + IncludePerformanceCounters = false + }; + + using var profiler = new FunctionProfiler(options, new NoLocator()); + var frames = new List(); + + foreach (var (sample, stack) in input.Samples) { + frames.Clear(); + + foreach (var f in stack.StackFrames) { + if (f.IsUnknown) continue; + var d = f.FrameDetails; + frames.Add(new ResolvedFrame(d.FunctionId, d.DebugInfo, f.FrameRVA, d.IsKernelCode, d.IsManagedCode)); + } + + profiler.AddResolvedSample(sample.Weight, stack.Context.ThreadId, frames); + } + + return new Dictionary(profiler.GetReport().Functions); + } + + private sealed class NoLocator : ISymbolFileLocator { + public Task FindSymbolFileAsync(string pdbName, Guid guid, int age, CancellationToken ct = default) => + Task.FromResult(null); + + public Task FindBinaryFileAsync(string binaryName, int timeDateStamp, long imageSize, + CancellationToken ct = default) => + Task.FromResult(null); + } + + // Hand-computed golden truth for the shared 4-stack fixture (independent of any engine). + // Weights: S1=10 S2=10 S3=5 S4=7 (total 32ms). + private static void AssertMatchesGolden(Dictionary result) { + var mainId = new ProfileFunctionId(Module, "Main"); + var fooId = new ProfileFunctionId(Module, "Foo"); + var barId = new ProfileFunctionId(Module, "Bar"); + var bazId = new ProfileFunctionId(Module, "Baz"); + + CollectionAssert.AreEquivalent(new[] { mainId, fooId, barId, bazId }, result.Keys.ToList(), "function set"); + + Assert.AreEqual(TimeSpan.FromMilliseconds(22), result[barId].ExclusiveWeight, "Bar exclusive (leaf 10+5+7)"); + Assert.AreEqual(TimeSpan.FromMilliseconds(10), result[bazId].ExclusiveWeight, "Baz exclusive (leaf 10)"); + Assert.AreEqual(TimeSpan.Zero, result[fooId].ExclusiveWeight, "Foo exclusive (never leaf)"); + Assert.AreEqual(TimeSpan.FromMilliseconds(27), result[fooId].Weight, "Foo inclusive (10+10+7)"); + Assert.AreEqual(TimeSpan.FromMilliseconds(32), result[mainId].Weight, "Main inclusive (on every stack)"); + Assert.AreEqual(TimeSpan.Zero, result[mainId].ExclusiveWeight, "Main exclusive (never leaf)"); + + // Per-instruction (hot-line) attribution — the reconciled caller call-site behavior. + Assert.AreEqual(TimeSpan.FromMilliseconds(17), result[barId].InstructionWeight[0x40], "Bar leaf @0x40 (S1 10 + S4 7)"); + Assert.AreEqual(TimeSpan.FromMilliseconds(27), result[mainId].InstructionWeight[0x100], "Main call-site @0x100 (S1 10 + S2 10 + S4 7)"); + Assert.AreEqual(TimeSpan.FromMilliseconds(7), result[fooId].InstructionWeight[0x88], "Foo first recursive call-site @0x88 (S4 7)"); + Assert.IsFalse(result[fooId].InstructionWeight.ContainsKey(0x8C), "Foo second recursive frame @0x8C not double-counted"); + } + + private static ProfileData BuildCoreInput( + (int ThreadId, int WeightMs, (Fn Func, long Offset)[] Frames)[] stacks) { + var image = new ProfileImage(Module, Module, Base, Base, ModuleSize, timeStamp: 0, checksum: 0); + var summary = new IRTextSummary(Module); + var irByName = new Dictionary(); + var dbgByName = new Dictionary(); + + IRTextFunction Ir(Fn f) { + if (!irByName.TryGetValue(f.Name, out var ir)) { + ir = new IRTextFunction(f.Name); + summary.AddFunction(ir); + irByName[f.Name] = ir; + } + return ir; + } + + FunctionDebugInfo Dbg(Fn f) { + if (!dbgByName.TryGetValue(f.Name, out var dbg)) { + dbg = new FunctionDebugInfo(f.Name, f.Rva, f.Size); + dbgByName[f.Name] = dbg; + } + return dbg; + } + + var input = new ProfileData(); + const int processId = 100; + + foreach (var s in stacks) { + var context = new ProfileContext(processId, s.ThreadId, 0); + var rawStack = new ProfileStack(contextId: 0, framePtrs: new long[s.Frames.Length]); + var resolved = new ResolvedProfileStack(s.Frames.Length, context); + + for (int i = 0; i < s.Frames.Length; i++) { + var (fn, offset) = s.Frames[i]; + long frameRva = fn.Rva + offset; + long frameIp = Base + frameRva; + var key = new ResolvedProfileStackFrameKey(Dbg(fn), image, isManagedCode: false); + resolved.AddFrame(Ir(fn), frameIp, frameRva, frameIndex: i, key, rawStack, pointerSize: 8); + } + + long leafIp = Base + s.Frames[0].Func.Rva + s.Frames[0].Offset; + var sample = new ProfileSample(leafIp, TimeSpan.Zero, TimeSpan.FromMilliseconds(s.WeightMs), + isKernelCode: false, contextId: 0); + input.Samples.Add((sample, resolved)); + } + + input.ComputeThreadSampleRanges(); + return input; + } + + private static Dictionary ComputeWithLibrary( + (int ThreadId, int WeightMs, (Fn Func, long Offset)[] Frames)[] stacks) { + var ipResolver = new IpResolver(); + ipResolver.AddImage(Module, Base, ModuleSize); + ipResolver.SetFunctions(Module, new List { + new(Main.Name, Main.Rva, Main.Size), + new(Foo.Name, Foo.Rva, Foo.Size), + new(Bar.Name, Bar.Rva, Bar.Size), + new(Baz.Name, Baz.Rva, Baz.Size) + }); + + var aggregator = new SampleAggregator(ipResolver); + var samples = new List(); + + foreach (var s in stacks) { + long[] frames = s.Frames.Select(fr => Base + fr.Func.Rva + fr.Offset).ToArray(); + samples.Add(new RawProfileSampleAdapter( + ip: frames[0], weight: TimeSpan.FromMilliseconds(s.WeightMs), + processId: 100, threadId: s.ThreadId, imageName: Module, imageBaseAddress: Base, stackFrames: frames)); + } + + aggregator.AddSamples(samples); + return aggregator.Build(); + } +} diff --git a/src/ProfileExplorerCoreTests/ProfileReportMapperTests.cs b/src/ProfileExplorerCoreTests/ProfileReportMapperTests.cs new file mode 100644 index 00000000..eb2a0918 --- /dev/null +++ b/src/ProfileExplorerCoreTests/ProfileReportMapperTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System; +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ProfileExplorer.Core; +using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.Profile; +using ProfileExplorer.Core.Profile.Adapters; +using ProfileExplorer.Core.Profile.CallTree; +using ProfileExplorer.Core.Profile.Data; +using ProfileExplorer.Profiling; + +namespace ProfileExplorer.CoreTests; + +/// +/// Verifies the return-leg mapping of the engine deduplication: a library +/// projected onto Core's (function profiles, IRTextFunction resolver, +/// per-module weights, call tree, and totals). +/// +[TestClass] +public class ProfileReportMapperTests { + private const string Module = "M.dll"; + private const int ModuleId = 42; + + [TestMethod] + public void ApplyReport_PopulatesProfiles_Resolver_ModuleWeights_Totals() { + var idA = new ProfileFunctionId(Module, "A"); + var idB = new ProfileFunctionId(Module, "B"); + + var dataA = new FunctionProfileData(new FunctionDebugInfo("A", 0x1000, 0x100)) { + ExclusiveWeight = TimeSpan.FromMilliseconds(10), Weight = TimeSpan.FromMilliseconds(10) + }; + var dataB = new FunctionProfileData(new FunctionDebugInfo("B", 0x2000, 0x100)) { + ExclusiveWeight = TimeSpan.Zero, Weight = TimeSpan.FromMilliseconds(20) + }; + + var report = new ProfileReport( + new Dictionary { [idA] = dataA, [idB] = dataB }, + new ProfileCallTree(), + TimeSpan.FromMilliseconds(30)); + + // Core-owned IRTextFunction registry. + var summary = new IRTextSummary(Module); + var funcA = new IRTextFunction("A"); summary.AddFunction(funcA); + var funcB = new IRTextFunction("B"); summary.AddFunction(funcB); + var irById = new Dictionary { [idA] = funcA, [idB] = funcB }; + + var target = new ProfileData(); + ProfileReportMapper.ApplyReport(target, report, + resolveFunction: id => irById.GetValueOrDefault(id), + moduleIdByName: _ => ModuleId); + + // Function profiles carried through unchanged. + Assert.AreEqual(2, target.FunctionProfiles.Count); + Assert.AreSame(dataA, target.FunctionProfiles[idA]); + Assert.AreSame(dataB, target.FunctionProfiles[idB]); + + // IRTextFunction resolver populated for navigation. + Assert.AreSame(funcA, target.FunctionResolver[idA]); + Assert.AreSame(funcB, target.FunctionResolver[idB]); + Assert.AreSame(funcA, target.ResolveFunction(idA)); + + // Module weight = sum of exclusive (leaf) weights per module. + Assert.AreEqual(TimeSpan.FromMilliseconds(10), target.ModuleWeights[ModuleId]); + + // Call tree + totals. + Assert.AreSame(report.CallTree, target.CallTree); + Assert.AreEqual(TimeSpan.FromMilliseconds(30), target.TotalWeight); + Assert.AreEqual(TimeSpan.FromMilliseconds(30), target.ProfileWeight); + } + + [TestMethod] + public void ApplyReport_UnresolvedFunction_SkipsResolverButKeepsProfileAndWeight() { + var id = new ProfileFunctionId(Module, "Jit_0x1234"); + var data = new FunctionProfileData(new FunctionDebugInfo("Jit_0x1234", 0x0, 0x10)) { + ExclusiveWeight = TimeSpan.FromMilliseconds(5), Weight = TimeSpan.FromMilliseconds(5) + }; + + var report = new ProfileReport( + new Dictionary { [id] = data }, + new ProfileCallTree(), + TimeSpan.FromMilliseconds(5)); + + var target = new ProfileData(); + ProfileReportMapper.ApplyReport(target, report, + resolveFunction: _ => null, // no IR representation + moduleIdByName: _ => ModuleId); + + Assert.IsTrue(target.FunctionProfiles.ContainsKey(id), "profile retained even without IR function"); + Assert.IsFalse(target.FunctionResolver.ContainsKey(id), "no resolver entry when IR function is null"); + Assert.AreEqual(TimeSpan.FromMilliseconds(5), target.ModuleWeights[ModuleId], "weight still counted"); + } +} diff --git a/src/ProfileExplorerCoreTests/RawProfileLibraryAdapterTests.cs b/src/ProfileExplorerCoreTests/RawProfileLibraryAdapterTests.cs new file mode 100644 index 00000000..e47492ec --- /dev/null +++ b/src/ProfileExplorerCoreTests/RawProfileLibraryAdapterTests.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.Profile.Adapters; +using ProfileExplorer.Core.Profile.Data; +using ProfileExplorer.Profiling; + +namespace ProfileExplorer.CoreTests; + +/// +/// Verifies the ETW->library input adapters (Stage 1 of the profiling-engine deduplication) map +/// Core's / onto the library's +/// / abstractions correctly — in particular +/// the per-image PDB identity and the leaf-first stack order the library engine relies on. +/// +[TestClass] +public class RawProfileLibraryAdapterTests { + [TestMethod] + public void ImageAdapter_MapsPdbIdentityAndModuleFields() { + var pdbGuid = Guid.NewGuid(); + var image = new ProfileImage(@"C:\Windows\System32\ntdll.dll", "ntdll.dll", + baseAddress: 0x7FF800000000, defaultBaseAddress: 0, + size: 0x1F0000, timeStamp: 0x12345678, checksum: 0); + var symbolFile = new SymbolFileDescriptor("ntdll.pdb", pdbGuid, age: 7); + + IProfileImage adapter = new RawProfileImageAdapter(image, processId: 4321, symbolFile); + + Assert.AreEqual("ntdll.dll", adapter.ImageName); + Assert.AreEqual(0x7FF800000000L, adapter.BaseAddress); + Assert.AreEqual(0x1F0000, adapter.Size); + Assert.AreEqual(0x12345678, adapter.TimeDateStamp); + Assert.AreEqual(pdbGuid, adapter.PdbGuid); + Assert.AreEqual(7, adapter.PdbAge); + Assert.AreEqual("ntdll.pdb", adapter.PdbName); + Assert.AreEqual(4321, adapter.ProcessId); + } + + [TestMethod] + public void ImageAdapter_NullSymbolFile_YieldsEmptyPdbIdentity() { + var image = new ProfileImage(@"C:\app\app.exe", "app.exe", + baseAddress: 0x140000000, defaultBaseAddress: 0, + size: 0x10000, timeStamp: 0, checksum: 0); + + IProfileImage adapter = new RawProfileImageAdapter(image, processId: 1, symbolFile: null); + + Assert.AreEqual(Guid.Empty, adapter.PdbGuid); + Assert.AreEqual(0, adapter.PdbAge); + Assert.AreEqual(string.Empty, adapter.PdbName); + Assert.AreEqual("app.exe", adapter.ImageName); + } + + [TestMethod] + public void SampleAdapter_PassesThroughFields_LeafFirstStack() { + var frames = new long[] { 0x1000, 0x2000, 0x3000 }; // leaf-first: [leaf, caller, caller-of-caller] + IProfileSample adapter = new RawProfileSampleAdapter( + ip: 0x1000, weight: TimeSpan.FromMilliseconds(1), processId: 42, threadId: 7, + imageName: "app.dll", imageBaseAddress: 0x140000000, stackFrames: frames); + + Assert.AreEqual(0x1000L, adapter.InstructionPointer); + Assert.AreEqual(TimeSpan.FromMilliseconds(1), adapter.Weight); + Assert.AreEqual(42, adapter.ProcessId); + Assert.AreEqual(7, adapter.ThreadId); + Assert.AreEqual("app.dll", adapter.ImageName); + Assert.AreEqual(0x140000000L, adapter.ImageBaseAddress); + + Assert.IsNotNull(adapter.StackFrames); + Assert.AreEqual(3, adapter.StackFrames.Count); + Assert.AreEqual(0x1000L, adapter.StackFrames[0]); // leaf at index 0 (== sample IP) + Assert.AreEqual(0x2000L, adapter.StackFrames[1]); + Assert.AreEqual(0x3000L, adapter.StackFrames[2]); + } + + [TestMethod] + public void SampleAdapter_NoStack_ExposesNullStackFrames() { + IProfileSample adapter = new RawProfileSampleAdapter( + ip: 0x5000, weight: TimeSpan.FromMilliseconds(1), processId: 1, threadId: 1, + imageName: "app.dll", imageBaseAddress: 0x140000000, stackFrames: null); + + Assert.IsNull(adapter.StackFrames); + Assert.AreEqual(0x5000L, adapter.InstructionPointer); + } +} From 3be9da334bc71bc86a7955bdd5996382f3dcb2ca Mon Sep 17 00:00:00 2001 From: Tristan Gibeau <41077386+trgibeau@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:46:58 -0700 Subject: [PATCH 2/3] Enhance FunctionProfiler and SampleAggregator for improved sample aggregation and filtering --- .../FunctionProfiler.cs | 25 +++++++++-- .../Profiling/SampleAggregator.cs | 41 +++++++++++++++++-- .../Profile/Data/ProfileData.cs | 7 +++- 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/ProfileExplorer.Profiling/FunctionProfiler.cs b/src/ProfileExplorer.Profiling/FunctionProfiler.cs index 574ca7c4..32dc4015 100644 --- a/src/ProfileExplorer.Profiling/FunctionProfiler.cs +++ b/src/ProfileExplorer.Profiling/FunctionProfiler.cs @@ -157,7 +157,11 @@ public void AddResolvedSample(TimeSpan weight, int threadId, IReadOnlyList /// Any host-side filtering (thread, time range, call-tree instance) should be applied inside - /// by returning false to skip a sample. Call + /// by returning false to EXCLUDE a sample entirely (it counts + /// toward nothing, not even the total weight). Returning true with an empty frame list keeps + /// the sample's weight in the total (the percentage denominator) while attributing it to no + /// function or call-tree node — this is how a passed-filter sample whose stack fully failed to + /// resolve is handled, matching Core's FunctionProfileProcessor. Call /// once this returns. /// /// @@ -192,21 +196,36 @@ public void AddResolvedSamplesParallel(int startIndex, int endIndex, ResolvedSam tasks[worker] = Task.Run(() => { var frames = new List(); var chunkTree = buildCallTree ? callTreeBuilder_.CreateChunkTree(worker, workers) : null; + var workerTotal = TimeSpan.Zero; for (int i = chunkStart; i < chunkEnd; i++) { frames.Clear(); - if (!project(i, frames, out var weight, out int threadId) || frames.Count == 0) { + if (!project(i, frames, out var weight, out int threadId)) { + continue; // Filtered out (thread/instance/time) — excluded from the total, matching Core. + } + + // A sample that passed filtering counts toward the total weight (the percentage + // denominator) even when its stack fully failed to resolve — matching Core's + // FunctionProfileProcessor, which added every passed-filter sample's weight to the total + // before skipping unknown frames. Such samples contribute to no function/call-tree node. + workerTotal += weight; + + if (frames.Count == 0) { continue; } - sampleAggregator_.AddResolvedStack(weight, frames); + sampleAggregator_.AggregateResolvedStack(weight, frames); if (chunkTree != null) { callTreeBuilder_.AddResolvedStackToChunk(chunkTree, weight, threadId, frames); } } + // Fold this worker's total into the shared total with a single lock (instead of one lock per + // sample), reproducing the former per-chunk accumulate-then-merge without global contention. + sampleAggregator_.AddTotalWeight(workerTotal); + if (chunkTrees != null) { chunkTrees[worker] = chunkTree; } diff --git a/src/ProfileExplorer.Profiling/Profiling/SampleAggregator.cs b/src/ProfileExplorer.Profiling/Profiling/SampleAggregator.cs index 0aba74c7..62eba3ce 100644 --- a/src/ProfileExplorer.Profiling/Profiling/SampleAggregator.cs +++ b/src/ProfileExplorer.Profiling/Profiling/SampleAggregator.cs @@ -109,6 +109,43 @@ public void AddResolvedStack(TimeSpan weight, IReadOnlyList frame return; } + AggregateFrames(weight, framesLeafFirst); + + lock (totalWeightLock_) { + totalWeight_ += weight; + } + } + + /// + /// Aggregate one PRE-RESOLVED, PRE-FILTERED stack's frames into the shared per-function map + /// WITHOUT updating . Used by the parallel driver + /// (), which batches the total per worker + /// via so the global total lock is taken once per worker instead of + /// once per sample. Thread-safe (per-function locks). Instance filtering is the caller's job. + /// + public void AggregateResolvedStack(TimeSpan weight, IReadOnlyList framesLeafFirst) { + if (framesLeafFirst.Count == 0) return; + AggregateFrames(weight, framesLeafFirst); + } + + /// + /// Add a batch of weight to under a single lock. The parallel driver + /// accumulates a per-worker total (including passed-filter samples whose stack fully failed to + /// resolve — they contribute to the total denominator but to no function, matching Core's + /// FunctionProfileProcessor) and calls this once per worker. + /// + public void AddTotalWeight(TimeSpan weight) { + if (weight == TimeSpan.Zero) return; + + lock (totalWeightLock_) { + totalWeight_ += weight; + } + } + + // Aggregate a leaf-first resolved stack's frames into the shared per-function map: leaf frame gets + // exclusive weight; every unique function on the stack gets inclusive weight + a per-instruction + // sample (recursion credited once). Does not touch TotalWeight. Thread-safe (per-function locks). + private void AggregateFrames(TimeSpan weight, IReadOnlyList framesLeafFirst) { var credited = resolvedCredited_ ??= new HashSet(); credited.Clear(); bool isTop = true; @@ -131,10 +168,6 @@ public void AddResolvedStack(TimeSpan weight, IReadOnlyList frame isTop = false; } - - lock (totalWeightLock_) { - totalWeight_ += weight; - } } /// diff --git a/src/ProfileExplorerCore/Profile/Data/ProfileData.cs b/src/ProfileExplorerCore/Profile/Data/ProfileData.cs index 13a7825f..547b8b7d 100644 --- a/src/ProfileExplorerCore/Profile/Data/ProfileData.cs +++ b/src/ProfileExplorerCore/Profile/Data/ProfileData.cs @@ -325,6 +325,11 @@ public ProfileData ComputeProfile(ProfileData baseProfile, ProfileSampleFilter f // Thread-safe projection of one resolved stack into the library's neutral ResolvedFrame form, // applying the same thread/instance filtering the sequential path did. Called concurrently. + // Returns false only when the sample is filtered out (excluded from everything, including the + // total). A sample that passes filtering returns true even when every frame is unknown: its + // weight still counts toward the total (matching the former FunctionProfileProcessor, which added + // each passed-filter sample's weight to the total before skipping unknown frames), but it + // contributes no per-function or call-tree data. bool Project(int index, List frames, out TimeSpan weight, out int threadId) { var entry = samples[index]; var stack = entry.Stack; @@ -353,7 +358,7 @@ bool Project(int index, List frames, out TimeSpan weight, out int } } - return frames.Count > 0; + return true; } profiler.AddResolvedSamplesParallel(startIndex, endIndex, Project, computeCallTree, workerCount); From 06f72dac5693e6477c324866f107928549c09a2a Mon Sep 17 00:00:00 2001 From: Tristan Gibeau <41077386+trgibeau@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:57:04 -0700 Subject: [PATCH 3/3] Enhance PdbSymbolProvider and PDBDebugInfoProvider for improved DIA SDK handling and binary loading --- .../Symbols/PdbSymbolProvider.cs | 25 +++++++++++- .../Binary/PDBDebugInfoProvider.cs | 38 ++++++++++--------- .../Session/BaseSession.cs | 9 +++++ 3 files changed, 54 insertions(+), 18 deletions(-) diff --git a/src/ProfileExplorer.Profiling/Symbols/PdbSymbolProvider.cs b/src/ProfileExplorer.Profiling/Symbols/PdbSymbolProvider.cs index c36f5070..1649574d 100644 --- a/src/ProfileExplorer.Profiling/Symbols/PdbSymbolProvider.cs +++ b/src/ProfileExplorer.Profiling/Symbols/PdbSymbolProvider.cs @@ -269,7 +269,16 @@ private void TrySaveFunctionListToCache(SymbolFileDescriptor? cacheKey, string? } private void InitializeFunctionList(List symbolList) { - symbolList.Sort(); + // Sort by StartRVA (via CompareTo), breaking ties deterministically by name. ICF-folded functions + // share the same RVA (and often size), and DIA enumerates them in a non-deterministic order; an + // unstable RVA-only sort therefore lets that order leak into FindFunctionByRVA, making disassembly + // call-target names differ between runs. The ordinal name tie-break makes the list — and thus the + // resolved name for a folded address — stable and reproducible. + symbolList.Sort((a, b) => { + int rvaCompare = a.CompareTo(b); + return rvaCompare != 0 ? rvaCompare : string.CompareOrdinal(a.Name, b.Name); + }); + sortedFuncListOverlapping_ = false; for (int i = 0; i < symbolList.Count - 1; i++) { @@ -413,6 +422,20 @@ void Enumerate(SymTagEnum tag, Action handle) { catch { return null; } } + /// + /// Create a DIA data source (Dia2Lib.IDiaDataSource) using registration-free side-loading of + /// msdia140.dll, falling back to registered COM. Exposed so other DIA consumers (e.g. PE Core's + /// PDBDebugInfoProvider) share this activation path instead of depending on a + /// regsvr32-registered msdia140.dll — which may be missing or the wrong architecture (e.g. an x86 + /// build hijacking the CLSID machine-wide). Returned as so callers in other + /// assemblies don't need a direct Dia2Lib reference at the call site (they resolve the DIA type + /// transitively and cast). Returns null if both activation paths fail; see + /// . + /// + public static object? CreateDiaDataSource() { + return CreateDiaSource(); + } + private static IDiaDataSource? CreateDiaSource() { diaRegistrationError_ = null; var source = TryCreateViaSideLoad(); diff --git a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs index 03f3c374..07153b0a 100644 --- a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs +++ b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs @@ -629,31 +629,35 @@ private bool LoadDebugInfo(string debugFilePath, IDebugInfoProvider other = null try { debugFilePath_ = debugFilePath; - diaSource_ = new DiaSourceClass(); - diaSource_.loadDataFromPdb(debugFilePath); - diaSource_.openSession(out session_); - } - catch (Exception ex) { - // Check for DIA SDK COM registration issue - common in dev builds - bool isDiaRegistrationError = ex.Message.Contains("E6756135-1E65-4D17-8576-610761398C3C") || - ex.Message.Contains("8007007E") || - ex.Message.Contains("class factory"); - if (isDiaRegistrationError) { - string errorMsg = $"DIA SDK (msdia140.dll) is not registered! " + - $"Run as Admin: regsvr32 \"{AppContext.BaseDirectory}msdia140.dll\" " + - $"or use the installed version of Profile Explorer."; + + // Activate DIA (msdia140.dll) registration-free by side-loading the bundled x64 msdia140.dll, + // falling back to registered COM. Mirrors the library reader so PDB reading works even when + // msdia140.dll is unregistered or a wrong-architecture build is registered machine-wide (e.g. + // an x86 msdia140.dll planted by other software that hijacks the DIA CLSID). The factory + // returns object to avoid forcing a direct Dia2Lib reference here; cast to the DIA type. + diaSource_ = PdbSymbolProvider.CreateDiaDataSource() as IDiaDataSource; + + if (diaSource_ == null) { + string errorMsg = $"Failed to load DIA SDK (msdia140.dll). {PdbSymbolProvider.DiaRegistrationError} " + + $"Ensure msdia140.dll is present next to the application (\"{AppContext.BaseDirectory}\")."; DiagnosticLogger.LogError($"[PDBLoad] [CRITICAL] {errorMsg}"); Trace.TraceError(errorMsg); - // Set static flag so UI can detect and display error + // Set static flag so the UI can detect and display the error. if (!diaRegistrationFailed_) { diaRegistrationFailed_ = true; diaRegistrationError_ = errorMsg; } + + loadFailed_ = true; + return false; } - else { - DiagnosticLogger.LogError($"[PDBLoad] Failed to load {debugFilePath}: {ex.Message}"); - } + + diaSource_.loadDataFromPdb(debugFilePath); + diaSource_.openSession(out session_); + } + catch (Exception ex) { + DiagnosticLogger.LogError($"[PDBLoad] Failed to load {debugFilePath}: {ex.Message}"); Trace.TraceError($"Failed to load debug file {debugFilePath}: {ex.Message}"); loadFailed_ = true; return false; diff --git a/src/ProfileExplorerCore/Session/BaseSession.cs b/src/ProfileExplorerCore/Session/BaseSession.cs index bc60df62..2ba36476 100644 --- a/src/ProfileExplorerCore/Session/BaseSession.cs +++ b/src/ProfileExplorerCore/Session/BaseSession.cs @@ -79,6 +79,15 @@ public async Task LoadAndParseSection(IRTextSection section return null; } + // LAZY BINARY LOADING: the ETW load path skips binaries during trace load + // (skipBinaryDownload: true) and fetches them on-demand. Mirror the UI's MainWindowSession and the + // MCP executor so headless consumers (MCP server, tests) also load the binary here before + // disassembling. EnsureBinaryLoaded swaps the dummy loader for the real DisassemblerSectionLoader + // in place; it is idempotent (a no-op once the binary is already loaded). + if (!docInfo.BinaryFileExists && docInfo.EnsureBinaryLoaded != null) { + await docInfo.EnsureBinaryLoaded().ConfigureAwait(false); + } + var parsedSection = docInfo.Loader.LoadSection(section); if (parsedSection != null && parsedSection.Function != null) {