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) {