From 720d9733af27a58a63b3dbef7bc23e3b7dcc3869 Mon Sep 17 00:00:00 2001
From: Tristan Gibeau <41077386+trgibeau@users.noreply.github.com>
Date: Fri, 29 May 2026 13:19:39 -0700
Subject: [PATCH 01/12] Early Work
---
.vscode/settings.json | 3 +
global.json | 1 +
.../Helpers/SyntheticSampleBuilder.cs | 148 ++++++++
.../Helpers/TestDataHelper.cs | 68 ++++
.../Integration/DisassemblerTests.cs | 153 ++++++++
.../FunctionProfilerEndToEndTests.cs | 219 +++++++++++
.../Integration/PdbDiagnosticTests.cs | 51 +++
.../Integration/PdbSymbolProviderTests.cs | 199 ++++++++++
.../ProfileExplorer.Profiling.Tests.csproj | 22 ++
.../Unit/AssemblyAnnotatorTests.cs | 148 ++++++++
.../Unit/CallTreeBuilderTests.cs | 151 ++++++++
.../Unit/CounterAggregatorTests.cs | 91 +++++
.../Unit/FunctionProfileBuilderTests.cs | 89 +++++
.../Unit/IpSkidCorrectionTests.cs | 63 ++++
.../Unit/ManagedMethodResolverTests.cs | 88 +++++
.../Unit/ProfilerOptionsValidationTests.cs | 57 +++
.../Unit/SampleAggregatorTests.cs | 148 ++++++++
.../Unit/SymbolServerClientTests.cs | 62 ++++
.../Abstractions/IManagedMethodMapping.cs | 44 +++
.../Abstractions/IPerformanceCounterEvent.cs | 23 ++
.../Abstractions/IProfileImage.cs | 33 ++
.../Abstractions/IProfileSample.cs | 33 ++
.../Disassembly/AssemblyAnnotator.cs | 145 ++++++++
.../Disassembly/Disassembler.cs | 223 ++++++++++++
.../FunctionProfiler.cs | 339 ++++++++++++++++++
.../Models/AnnotatedAssembly.cs | 107 ++++++
.../Models/CallTree.cs | 130 +++++++
.../Models/FunctionProfile.cs | 88 +++++
.../Models/PerformanceCounters.cs | 105 ++++++
.../ProfileExplorer.Profiling.csproj | 56 +++
.../ProfilerOptions.cs | 81 +++++
.../Profiling/CallTreeBuilder.cs | 125 +++++++
.../Profiling/CounterAggregator.cs | 53 +++
.../Profiling/FunctionProfileBuilder.cs | 80 +++++
.../Profiling/InstructionOffsetConfig.cs | 61 ++++
.../Profiling/IpResolver.cs | 110 ++++++
.../Profiling/ManagedMethodResolver.cs | 83 +++++
.../Profiling/SampleAggregator.cs | 98 +++++
.../Symbols/FunctionDebugInfo.cs | 102 ++++++
.../Symbols/IDebugInfoProvider.cs | 76 ++++
.../Symbols/PdbSymbolProvider.cs | 329 +++++++++++++++++
.../Symbols/SourceDebugInfo.cs | 118 ++++++
.../Symbols/SymbolServerClient.cs | 314 ++++++++++++++++
.../ProfileExplorer.Profiling.targets | 16 +
src/ProfileExplorer.sln | 36 ++
45 files changed, 4769 insertions(+)
create mode 100644 .vscode/settings.json
create mode 100644 global.json
create mode 100644 src/ProfileExplorer.Profiling.Tests/Helpers/SyntheticSampleBuilder.cs
create mode 100644 src/ProfileExplorer.Profiling.Tests/Helpers/TestDataHelper.cs
create mode 100644 src/ProfileExplorer.Profiling.Tests/Integration/DisassemblerTests.cs
create mode 100644 src/ProfileExplorer.Profiling.Tests/Integration/FunctionProfilerEndToEndTests.cs
create mode 100644 src/ProfileExplorer.Profiling.Tests/Integration/PdbDiagnosticTests.cs
create mode 100644 src/ProfileExplorer.Profiling.Tests/Integration/PdbSymbolProviderTests.cs
create mode 100644 src/ProfileExplorer.Profiling.Tests/ProfileExplorer.Profiling.Tests.csproj
create mode 100644 src/ProfileExplorer.Profiling.Tests/Unit/AssemblyAnnotatorTests.cs
create mode 100644 src/ProfileExplorer.Profiling.Tests/Unit/CallTreeBuilderTests.cs
create mode 100644 src/ProfileExplorer.Profiling.Tests/Unit/CounterAggregatorTests.cs
create mode 100644 src/ProfileExplorer.Profiling.Tests/Unit/FunctionProfileBuilderTests.cs
create mode 100644 src/ProfileExplorer.Profiling.Tests/Unit/IpSkidCorrectionTests.cs
create mode 100644 src/ProfileExplorer.Profiling.Tests/Unit/ManagedMethodResolverTests.cs
create mode 100644 src/ProfileExplorer.Profiling.Tests/Unit/ProfilerOptionsValidationTests.cs
create mode 100644 src/ProfileExplorer.Profiling.Tests/Unit/SampleAggregatorTests.cs
create mode 100644 src/ProfileExplorer.Profiling.Tests/Unit/SymbolServerClientTests.cs
create mode 100644 src/ProfileExplorer.Profiling/Abstractions/IManagedMethodMapping.cs
create mode 100644 src/ProfileExplorer.Profiling/Abstractions/IPerformanceCounterEvent.cs
create mode 100644 src/ProfileExplorer.Profiling/Abstractions/IProfileImage.cs
create mode 100644 src/ProfileExplorer.Profiling/Abstractions/IProfileSample.cs
create mode 100644 src/ProfileExplorer.Profiling/Disassembly/AssemblyAnnotator.cs
create mode 100644 src/ProfileExplorer.Profiling/Disassembly/Disassembler.cs
create mode 100644 src/ProfileExplorer.Profiling/FunctionProfiler.cs
create mode 100644 src/ProfileExplorer.Profiling/Models/AnnotatedAssembly.cs
create mode 100644 src/ProfileExplorer.Profiling/Models/CallTree.cs
create mode 100644 src/ProfileExplorer.Profiling/Models/FunctionProfile.cs
create mode 100644 src/ProfileExplorer.Profiling/Models/PerformanceCounters.cs
create mode 100644 src/ProfileExplorer.Profiling/ProfileExplorer.Profiling.csproj
create mode 100644 src/ProfileExplorer.Profiling/ProfilerOptions.cs
create mode 100644 src/ProfileExplorer.Profiling/Profiling/CallTreeBuilder.cs
create mode 100644 src/ProfileExplorer.Profiling/Profiling/CounterAggregator.cs
create mode 100644 src/ProfileExplorer.Profiling/Profiling/FunctionProfileBuilder.cs
create mode 100644 src/ProfileExplorer.Profiling/Profiling/InstructionOffsetConfig.cs
create mode 100644 src/ProfileExplorer.Profiling/Profiling/IpResolver.cs
create mode 100644 src/ProfileExplorer.Profiling/Profiling/ManagedMethodResolver.cs
create mode 100644 src/ProfileExplorer.Profiling/Profiling/SampleAggregator.cs
create mode 100644 src/ProfileExplorer.Profiling/Symbols/FunctionDebugInfo.cs
create mode 100644 src/ProfileExplorer.Profiling/Symbols/IDebugInfoProvider.cs
create mode 100644 src/ProfileExplorer.Profiling/Symbols/PdbSymbolProvider.cs
create mode 100644 src/ProfileExplorer.Profiling/Symbols/SourceDebugInfo.cs
create mode 100644 src/ProfileExplorer.Profiling/Symbols/SymbolServerClient.cs
create mode 100644 src/ProfileExplorer.Profiling/build/net8.0-windows/ProfileExplorer.Profiling.targets
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 00000000..979c93de
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "vscode-nmake-tools.installOsRepoRustHelperExtension": false
+}
\ No newline at end of file
diff --git a/global.json b/global.json
new file mode 100644
index 00000000..4fbd42d5
--- /dev/null
+++ b/global.json
@@ -0,0 +1 @@
+{"sdk":{"version":"8.0.421","rollForward":"latestMinor"}}
diff --git a/src/ProfileExplorer.Profiling.Tests/Helpers/SyntheticSampleBuilder.cs b/src/ProfileExplorer.Profiling.Tests/Helpers/SyntheticSampleBuilder.cs
new file mode 100644
index 00000000..5eeb7b54
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/Helpers/SyntheticSampleBuilder.cs
@@ -0,0 +1,148 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+namespace ProfileExplorer.Profiling.Tests.Helpers;
+
+///
+/// Builds synthetic IProfileSample and IProfileImage instances for unit tests.
+///
+public static class SyntheticSampleBuilder {
+ ///
+ /// Create uniform samples — all at the same IP with the same weight.
+ ///
+ public static IReadOnlyList CreateUniform(
+ int count, string module, long baseIp, TimeSpan weight, int processId = 1, int threadId = 1) {
+ var samples = new List(count);
+ for (int i = 0; i < count; i++) {
+ samples.Add(new SyntheticSample(baseIp, weight, processId, threadId, module, baseIp - 0x1000));
+ }
+
+ return samples;
+ }
+
+ ///
+ /// Create a hotspot — most samples at one IP, the rest distributed.
+ ///
+ public static IReadOnlyList CreateHotspot(
+ int total, string module, long hotIp, double hotPercent,
+ long baseIp = 0x1000, int spread = 10,
+ int processId = 1, int threadId = 1) {
+ int hotCount = (int)(total * hotPercent / 100.0);
+ int coldCount = total - hotCount;
+ var weight = TimeSpan.FromMilliseconds(1);
+ long moduleBase = baseIp - 0x1000;
+
+ var samples = new List(total);
+
+ for (int i = 0; i < hotCount; i++) {
+ samples.Add(new SyntheticSample(hotIp, weight, processId, threadId, module, moduleBase));
+ }
+
+ for (int i = 0; i < coldCount; i++) {
+ long ip = baseIp + (i % spread) * 4; // Distribute across instruction offsets
+ samples.Add(new SyntheticSample(ip, weight, processId, threadId, module, moduleBase));
+ }
+
+ return samples;
+ }
+
+ ///
+ /// Create samples across multiple functions.
+ ///
+ public static IReadOnlyList CreateMultiFunction(
+ params (string module, long baseIp, int sampleCount)[] functions) {
+ var samples = new List();
+ var weight = TimeSpan.FromMilliseconds(1);
+
+ foreach (var (module, baseIp, count) in functions) {
+ long moduleBase = baseIp - 0x1000;
+ for (int i = 0; i < count; i++) {
+ samples.Add(new SyntheticSample(baseIp + (i % 5) * 4, weight, 1, 1, module, moduleBase));
+ }
+ }
+
+ return samples;
+ }
+
+ ///
+ /// Create samples with stack frames for call tree testing.
+ ///
+ public static IReadOnlyList CreateWithStacks(
+ params (string module, long leafIp, long[] stackIps, int count)[] entries) {
+ var samples = new List();
+ var weight = TimeSpan.FromMilliseconds(1);
+
+ foreach (var (module, leafIp, stackIps, count) in entries) {
+ long moduleBase = leafIp - 0x1000;
+ // Stack is leaf-first.
+ var fullStack = new long[] { leafIp }.Concat(stackIps).ToList();
+
+ for (int i = 0; i < count; i++) {
+ samples.Add(new SyntheticSample(leafIp, weight, 1, 1, module, moduleBase, fullStack));
+ }
+ }
+
+ return samples;
+ }
+
+ ///
+ /// Create fake image definitions.
+ ///
+ public static IReadOnlyList CreateImages(
+ params (string name, long baseAddr, int size)[] images) {
+ return images.Select(img => new SyntheticImage(
+ img.name, img.baseAddr, img.size, 0, Guid.Empty, 0, img.name + ".pdb", 1)).ToList();
+ }
+
+ ///
+ /// Create fake image definitions with PDB identity.
+ ///
+ public static IReadOnlyList CreateImagesWithPdb(
+ params (string name, long baseAddr, int size, Guid pdbGuid, int pdbAge)[] images) {
+ return images.Select(img => new SyntheticImage(
+ img.name, img.baseAddr, img.size, 0, img.pdbGuid, img.pdbAge, img.name + ".pdb", 1)).ToList();
+ }
+}
+
+internal class SyntheticSample : IProfileSample {
+ public SyntheticSample(long ip, TimeSpan weight, int pid, int tid, string? imageName,
+ long imageBase, IReadOnlyList? stackFrames = null) {
+ InstructionPointer = ip;
+ Weight = weight;
+ ProcessId = pid;
+ ThreadId = tid;
+ ImageName = imageName;
+ ImageBaseAddress = imageBase;
+ 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 { get; }
+}
+
+internal class SyntheticImage : IProfileImage {
+ public SyntheticImage(string name, long baseAddr, int size, int timeStamp,
+ Guid pdbGuid, int pdbAge, string pdbName, int processId) {
+ ImageName = name;
+ BaseAddress = baseAddr;
+ Size = size;
+ TimeDateStamp = timeStamp;
+ PdbGuid = pdbGuid;
+ PdbAge = pdbAge;
+ PdbName = pdbName;
+ 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; }
+}
diff --git a/src/ProfileExplorer.Profiling.Tests/Helpers/TestDataHelper.cs b/src/ProfileExplorer.Profiling.Tests/Helpers/TestDataHelper.cs
new file mode 100644
index 00000000..c5682cfc
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/Helpers/TestDataHelper.cs
@@ -0,0 +1,68 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+namespace ProfileExplorer.Profiling.Tests.Helpers;
+
+///
+/// Locates test data (ETL, PDB, DLL) relative to the test assembly output directory.
+/// Shares test data with ProfileExplorerCoreTests/TestData/.
+///
+public static class TestDataHelper {
+ private static string? testDataRoot_;
+
+ ///
+ /// Get the root TestData directory by walking up from the assembly output.
+ /// Looks for ProfileExplorerCoreTests/TestData/ relative to the src/ directory.
+ ///
+ public static string GetTestDataRoot() {
+ if (testDataRoot_ != null) return testDataRoot_;
+
+ // Walk up from bin/Release/net8.0-windows/ to find the src directory.
+ string assemblyDir = Path.GetDirectoryName(typeof(TestDataHelper).Assembly.Location)!;
+ var dir = new DirectoryInfo(assemblyDir);
+
+ while (dir != null) {
+ // Check if this is the src/ directory containing ProfileExplorerCoreTests.
+ string candidatePath = Path.Combine(dir.FullName, "ProfileExplorerCoreTests", "TestData");
+ if (Directory.Exists(candidatePath)) {
+ testDataRoot_ = candidatePath;
+ return testDataRoot_;
+ }
+
+ dir = dir.Parent;
+ }
+
+ throw new DirectoryNotFoundException(
+ $"Could not find ProfileExplorerCoreTests/TestData/ directory from {assemblyDir}");
+ }
+
+ public static string GetTracePath(string testCaseName, string fileName = "trace.etl") =>
+ Path.Combine(GetTestDataRoot(), "Traces", testCaseName, fileName);
+
+ public static string GetSymbolsPath(string testCaseName) =>
+ Path.Combine(GetTestDataRoot(), "Symbols", testCaseName);
+
+ public static string GetSymbolFilePath(string testCaseName, string fileName) =>
+ Path.Combine(GetTestDataRoot(), "Symbols", testCaseName, fileName);
+
+ public static string GetBinariesPath(string testCaseName) =>
+ Path.Combine(GetTestDataRoot(), "Binaries", testCaseName);
+
+ public static string GetBinaryFilePath(string testCaseName, string fileName) =>
+ Path.Combine(GetTestDataRoot(), "Binaries", testCaseName, fileName);
+
+ public static bool HasTestData(string testCaseName) {
+ try {
+ return File.Exists(GetTracePath(testCaseName));
+ }
+ catch {
+ return false;
+ }
+ }
+
+ // MsoTrace constants.
+ public const string MsoTrace = "MsoTrace";
+ public const string MsoPdbFile = "Mso20Win32Client.pdb";
+ public const string MsoDllFile = "Mso20win32client.dll";
+ public const string MsoModuleName = "Mso20win32client.dll";
+ public const string MsoTopFunction = "Mso::Experiment::EcsNS::Private::SortByParameterGroups";
+}
diff --git a/src/ProfileExplorer.Profiling.Tests/Integration/DisassemblerTests.cs b/src/ProfileExplorer.Profiling.Tests/Integration/DisassemblerTests.cs
new file mode 100644
index 00000000..8d0370a0
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/Integration/DisassemblerTests.cs
@@ -0,0 +1,153 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ProfileExplorer.Profiling.Disassembly;
+using ProfileExplorer.Profiling.Symbols;
+using ProfileExplorer.Profiling.Tests.Helpers;
+
+namespace ProfileExplorer.Profiling.Tests.Integration;
+
+[TestClass]
+[TestCategory("Integration")]
+public class DisassemblerTests {
+ private static string DllPath => TestDataHelper.GetBinaryFilePath(TestDataHelper.MsoTrace, TestDataHelper.MsoDllFile);
+ private static string PdbPath => TestDataHelper.GetSymbolFilePath(TestDataHelper.MsoTrace, TestDataHelper.MsoPdbFile);
+
+ private static bool CanRun() {
+ return TestDataHelper.HasTestData(TestDataHelper.MsoTrace) &&
+ File.Exists(DllPath) &&
+ Disassembler.CapstoneAvailable;
+ }
+
+ private static (FunctionDebugInfo? func, PdbSymbolProvider? provider) FindTestFunction() {
+ if (!File.Exists(PdbPath)) return (null, null);
+
+ var provider = new PdbSymbolProvider();
+ if (!provider.LoadDebugInfo(PdbPath)) {
+ provider.Dispose();
+ return (null, null);
+ }
+
+ var functions = provider.GetSortedFunctions();
+ // Find SortByParameterGroups or any function with reasonable size.
+ var func = functions.FirstOrDefault(f => f.Name.Contains("SortByParameterGroups"))
+ ?? functions.FirstOrDefault(f => f.Size > 20 && f.Size < 10000);
+
+ return (func, provider);
+ }
+
+ [TestMethod]
+ public void Disassemble_x64Function_ReturnsInstructions() {
+ if (!CanRun()) { Assert.Inconclusive("Test data not available."); return; }
+
+ var (func, provider) = FindTestFunction();
+ if (func == null) { Assert.Inconclusive("No suitable function found."); provider?.Dispose(); return; }
+
+ using (provider) {
+ using var disassembler = new Disassembler();
+ var instructions = disassembler.DisassembleFunction(
+ DllPath, func.RVA, (int)func.Size, 0x180000000, // Typical ASLR base
+ ProcessorArchitecture.Amd64, provider);
+
+ Assert.IsTrue(instructions.Count > 0,
+ $"Should produce instructions for {func.Name} (RVA={func.RVA:X}, Size={func.Size})");
+
+ // First instruction should be a valid x64 instruction.
+ Assert.IsFalse(string.IsNullOrEmpty(instructions[0].Text));
+ Assert.IsTrue(instructions[0].Size > 0);
+ }
+ }
+
+ [TestMethod]
+ public void Disassemble_ResolvesCallTargets() {
+ if (!CanRun()) { Assert.Inconclusive("Test data not available."); return; }
+
+ var (func, provider) = FindTestFunction();
+ if (func == null) { Assert.Inconclusive("No suitable function found."); provider?.Dispose(); return; }
+
+ using (provider) {
+ using var disassembler = new Disassembler();
+
+ // Use a larger function to increase chance of call instructions.
+ var functions = provider!.GetSortedFunctions();
+ var bigFunc = functions.Where(f => f.Size > 200).OrderByDescending(f => f.Size).FirstOrDefault();
+ if (bigFunc == null) { Assert.Inconclusive("No large function found."); return; }
+
+ var instructions = disassembler.DisassembleFunction(
+ DllPath, bigFunc.RVA, (int)bigFunc.Size, 0x180000000,
+ ProcessorArchitecture.Amd64, provider);
+
+ // Check that at least some call instructions have resolved names.
+ var callInstructions = instructions.Where(i => i.Text.StartsWith("call")).ToList();
+
+ // It's valid if there are calls; they may or may not resolve depending on targets.
+ if (callInstructions.Count > 0) {
+ // At least verify they have text content.
+ Assert.IsTrue(callInstructions.All(c => !string.IsNullOrEmpty(c.Text)));
+ }
+ }
+ }
+
+ [TestMethod]
+ public void Disassemble_FunctionBoundaries() {
+ if (!CanRun()) { Assert.Inconclusive("Test data not available."); return; }
+
+ var (func, provider) = FindTestFunction();
+ if (func == null) { Assert.Inconclusive("No suitable function found."); provider?.Dispose(); return; }
+
+ using (provider) {
+ long imageBase = 0x180000000;
+ using var disassembler = new Disassembler();
+ var instructions = disassembler.DisassembleFunction(
+ DllPath, func.RVA, (int)func.Size, imageBase,
+ ProcessorArchitecture.Amd64, provider);
+
+ if (instructions.Count == 0) { Assert.Inconclusive("No instructions produced."); return; }
+
+ // All instruction RVAs should be within [funcRVA, funcRVA + funcSize).
+ long funcStart = func.RVA;
+ long funcEnd = func.RVA + func.Size;
+
+ foreach (var instr in instructions) {
+ Assert.IsTrue(instr.Rva >= funcStart && instr.Rva < funcEnd,
+ $"Instruction at RVA {instr.Rva:X} outside function bounds [{funcStart:X}, {funcEnd:X})");
+ }
+ }
+ }
+
+ [TestMethod]
+ public void Disassemble_HandlesShortFunctions() {
+ if (!CanRun()) { Assert.Inconclusive("Test data not available."); return; }
+
+ var (_, provider) = FindTestFunction();
+ if (provider == null) { Assert.Inconclusive("PDB load failed."); return; }
+
+ using (provider) {
+ // Find a function small enough to be short but big enough to contain code.
+ var functions = provider.GetSortedFunctions();
+ var shortFunc = functions.FirstOrDefault(f => f.Size is > 4 and <= 32);
+
+ if (shortFunc == null) { Assert.Inconclusive("No short function found."); return; }
+
+ using var disassembler = new Disassembler();
+ var instructions = disassembler.DisassembleFunction(
+ DllPath, shortFunc.RVA, (int)shortFunc.Size, 0x180000000,
+ ProcessorArchitecture.Amd64, provider);
+
+ // Short functions should produce at least 1 instruction.
+ Assert.IsTrue(instructions.Count > 0,
+ $"Short function {shortFunc.Name} (Size={shortFunc.Size}) should produce instructions.");
+ }
+ }
+
+ [TestMethod]
+ public void Disassemble_InvalidBinary_ReturnsEmpty() {
+ using var disassembler = new Disassembler();
+ var instructions = disassembler.DisassembleFunction(
+ @"C:\nonexistent\fake.dll", 0x1000, 100, 0x180000000,
+ ProcessorArchitecture.Amd64);
+
+ Assert.AreEqual(0, instructions.Count);
+ }
+}
diff --git a/src/ProfileExplorer.Profiling.Tests/Integration/FunctionProfilerEndToEndTests.cs b/src/ProfileExplorer.Profiling.Tests/Integration/FunctionProfilerEndToEndTests.cs
new file mode 100644
index 00000000..7574b683
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/Integration/FunctionProfilerEndToEndTests.cs
@@ -0,0 +1,219 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ProfileExplorer.Profiling.Disassembly;
+using ProfileExplorer.Profiling.Symbols;
+using ProfileExplorer.Profiling.Tests.Helpers;
+
+namespace ProfileExplorer.Profiling.Tests.Integration;
+
+///
+/// End-to-end integration tests using the MsoTrace PDB + DLL test data.
+/// These tests validate the full pipeline: PDB loading → function enumeration →
+/// synthetic sample aggregation → disassembly → annotation.
+///
+/// Note: These do NOT use the ETL trace (that would require TraceEvent or DataLayer).
+/// Instead, they create synthetic samples at known function RVAs from the PDB
+/// and verify the full profiling + disassembly pipeline works correctly.
+///
+[TestClass]
+[TestCategory("Integration")]
+public class FunctionProfilerEndToEndTests {
+ private static string PdbPath => TestDataHelper.GetSymbolFilePath(TestDataHelper.MsoTrace, TestDataHelper.MsoPdbFile);
+ private static string DllPath => TestDataHelper.GetBinaryFilePath(TestDataHelper.MsoTrace, TestDataHelper.MsoDllFile);
+
+ private static bool CanRun() {
+ return TestDataHelper.HasTestData(TestDataHelper.MsoTrace) &&
+ File.Exists(PdbPath) && File.Exists(DllPath);
+ }
+
+ [TestMethod]
+ public void FullPipeline_PdbAndDll_ProducesAnnotatedAssembly() {
+ if (!CanRun()) { Assert.Inconclusive("Test data not available."); return; }
+
+ // Step 1: Load PDB to discover functions and their RVAs.
+ using var pdbProvider = new PdbSymbolProvider();
+ if (!pdbProvider.LoadDebugInfo(PdbPath)) {
+ Assert.Inconclusive("PDB load failed (DIA SDK not registered?).");
+ return;
+ }
+
+ var allFunctions = pdbProvider.GetSortedFunctions();
+ Assert.IsTrue(allFunctions.Count > 0, "PDB should contain functions.");
+
+ // Find SortByParameterGroups.
+ var targetFunc = allFunctions.FirstOrDefault(f => f.Name.Contains("SortByParameterGroups"));
+ if (targetFunc == null) {
+ Assert.Inconclusive("Could not find SortByParameterGroups in PDB.");
+ return;
+ }
+
+ // Step 2: Create synthetic samples at known instruction offsets in the function.
+ long moduleBase = 0x180000000; // Typical ASLR base for 64-bit DLLs.
+ long funcAbsAddr = moduleBase + targetFunc.RVA;
+
+ var images = new IProfileImage[] {
+ new TestProfileImage(TestDataHelper.MsoModuleName, moduleBase, 0x1000000, // 16MB
+ 0, Guid.Empty, 0, TestDataHelper.MsoPdbFile, 1)
+ };
+
+ // Distribute 100 samples across the function body.
+ var samples = new List();
+ int sampleCount = 100;
+ for (int i = 0; i < sampleCount; i++) {
+ long offset = (i * 4) % (int)targetFunc.Size;
+ long ip = funcAbsAddr + offset;
+ samples.Add(new SyntheticSample(ip, TimeSpan.FromMilliseconds(1), 1, 1,
+ TestDataHelper.MsoModuleName, moduleBase));
+ }
+
+ // Step 3: Use FunctionProfiler with a pre-loaded PDB (bypass symbol server).
+ // We'll test the lower-level components directly since we have local files.
+ var ipResolver = new Profiling.IpResolver();
+ ipResolver.AddImage(TestDataHelper.MsoModuleName, moduleBase, 0x1000000);
+ ipResolver.SetFunctions(TestDataHelper.MsoModuleName, allFunctions);
+
+ var aggregator = new Profiling.SampleAggregator(ipResolver);
+ aggregator.AddSamples(samples);
+
+ var profiles = aggregator.Build();
+ Assert.IsTrue(profiles.Count > 0, "Should have at least one function profile.");
+
+ // Find the target function's profile.
+ var profile = profiles.FirstOrDefault(p => p.FunctionName.Contains("SortByParameterGroups"));
+ Assert.IsNotNull(profile, "Should have a profile for SortByParameterGroups.");
+ Assert.IsTrue(profile.ExclusiveWeight.TotalMilliseconds >= sampleCount * 0.9,
+ $"Most samples should be attributed to this function. Got {profile.ExclusiveWeight.TotalMilliseconds}ms, expected ~{sampleCount}ms.");
+
+ // Step 4: Disassemble the function (requires Capstone).
+ if (!Disassembler.CapstoneAvailable) {
+ // Capstone not installed — verify profiling worked and skip assembly steps.
+ Assert.IsTrue(profile.InstructionWeights.Count > 0, "Should have instruction weights.");
+ return;
+ }
+
+ using var disassembler = new Disassembler();
+ var instructions = disassembler.DisassembleFunction(
+ DllPath, profile.FunctionRva, profile.FunctionSize,
+ moduleBase, ProcessorArchitecture.Amd64, pdbProvider);
+
+ Assert.IsTrue(instructions.Count > 0,
+ $"Should disassemble {profile.FunctionName} (Size={profile.FunctionSize}).");
+
+ // Step 5: Annotate with timing data.
+ var funcDebugInfo = pdbProvider.FindFunctionByRVA(profile.FunctionRva);
+ var annotated = AssemblyAnnotator.Annotate(
+ instructions, profile.InstructionWeights, profile.FunctionRva,
+ pdbProvider, funcDebugInfo, ProcessorArchitecture.Amd64,
+ minHotLinePercent: 1.0, maxHotLines: 10);
+
+ Assert.IsNotNull(annotated);
+ Assert.IsTrue(annotated.Lines.Count > 0, "Annotated assembly should have lines.");
+ Assert.IsFalse(string.IsNullOrEmpty(annotated.FullText), "Full text should not be empty.");
+
+ // Verify hot lines are present (we distributed samples across the function).
+ Assert.IsTrue(annotated.HotLines.Count > 0, "Should have at least one hot line.");
+ Assert.IsTrue(annotated.HotLines[0].Percent > 0, "Top hot line should have positive percent.");
+
+ // Verify hot lines are sorted descending.
+ for (int i = 1; i < annotated.HotLines.Count; i++) {
+ Assert.IsTrue(annotated.HotLines[i].Percent <= annotated.HotLines[i - 1].Percent,
+ "Hot lines should be sorted descending by percent.");
+ }
+
+ // Verify the full text contains timing annotations.
+ Assert.IsTrue(annotated.FullText.Contains("[Time(%):"),
+ "Full text should contain timing annotations.");
+ }
+
+ [TestMethod]
+ public void PdbFunctions_ConsistentWithBinarySearch() {
+ if (!CanRun()) { Assert.Inconclusive("Test data not available."); return; }
+
+ using var provider = new PdbSymbolProvider();
+ if (!provider.LoadDebugInfo(PdbPath)) {
+ Assert.Inconclusive("PDB load failed.");
+ return;
+ }
+
+ var functions = provider.GetSortedFunctions();
+ Assert.IsTrue(functions.Count > 10);
+
+ // Verify binary search finds most functions by their own RVA.
+ // Some may not round-trip exactly due to overlapping/PGO-split functions.
+ int checked_ = 0;
+ int found = 0;
+ foreach (var func in functions.Take(50)) {
+ var result = FunctionDebugInfo.BinarySearch(functions, func.RVA);
+ if (result != null) found++;
+ checked_++;
+ }
+
+ Assert.IsTrue(found > checked_ * 0.9, $"Binary search should find most functions ({found}/{checked_}).");
+ }
+
+ [TestMethod]
+ public void MultipleModules_IndependentProfiles() {
+ if (!CanRun()) { Assert.Inconclusive("Test data not available."); return; }
+
+ using var provider = new PdbSymbolProvider();
+ if (!provider.LoadDebugInfo(PdbPath)) {
+ Assert.Inconclusive("PDB load failed.");
+ return;
+ }
+
+ var functions = provider.GetSortedFunctions();
+ if (functions.Count < 2) { Assert.Inconclusive("Need at least 2 functions."); return; }
+
+ long moduleBase = 0x180000000;
+
+ // Create samples for two different functions.
+ var func1 = functions[0];
+ var func2 = functions.First(f => f.RVA != func1.RVA);
+
+ var ipResolver = new Profiling.IpResolver();
+ ipResolver.AddImage(TestDataHelper.MsoModuleName, moduleBase, 0x1000000);
+ ipResolver.SetFunctions(TestDataHelper.MsoModuleName, functions);
+
+ var aggregator = new Profiling.SampleAggregator(ipResolver);
+ aggregator.AddSamples([
+ new SyntheticSample(moduleBase + func1.RVA, TimeSpan.FromMilliseconds(30), 1, 1,
+ TestDataHelper.MsoModuleName, moduleBase),
+ new SyntheticSample(moduleBase + func2.RVA, TimeSpan.FromMilliseconds(70), 1, 1,
+ TestDataHelper.MsoModuleName, moduleBase)
+ ]);
+
+ var profiles = aggregator.Build();
+ Assert.AreEqual(2, profiles.Count, "Should have exactly 2 function profiles.");
+
+ var p1 = profiles.First(p => p.FunctionName == func1.Name);
+ var p2 = profiles.First(p => p.FunctionName == func2.Name);
+
+ Assert.AreEqual(30.0, p1.ExclusivePercent, 0.1);
+ Assert.AreEqual(70.0, p2.ExclusivePercent, 0.1);
+ }
+}
+
+internal class TestProfileImage : IProfileImage {
+ public TestProfileImage(string name, long baseAddr, int size, int timeStamp,
+ Guid pdbGuid, int pdbAge, string pdbName, int processId) {
+ ImageName = name;
+ BaseAddress = baseAddr;
+ Size = size;
+ TimeDateStamp = timeStamp;
+ PdbGuid = pdbGuid;
+ PdbAge = pdbAge;
+ PdbName = pdbName;
+ 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; }
+}
diff --git a/src/ProfileExplorer.Profiling.Tests/Integration/PdbDiagnosticTests.cs b/src/ProfileExplorer.Profiling.Tests/Integration/PdbDiagnosticTests.cs
new file mode 100644
index 00000000..ab12afd1
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/Integration/PdbDiagnosticTests.cs
@@ -0,0 +1,51 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ProfileExplorer.Profiling.Symbols;
+
+namespace ProfileExplorer.Profiling.Tests.Integration;
+
+[TestClass]
+[TestCategory("Integration")]
+public class PdbDiagnosticTests
+{
+ [TestMethod]
+ public void DiagnoseRealWindowsPdb()
+ {
+ string? pdbPath = Environment.GetEnvironmentVariable("PE_TEST_PDB_PATH");
+ if (string.IsNullOrEmpty(pdbPath) || !File.Exists(pdbPath))
+ {
+ Assert.Inconclusive("Set PE_TEST_PDB_PATH to a valid PDB path to run this test.");
+ return;
+ }
+
+ // Set msdia path relative to repo layout: src/external/msdia140.dll
+ string repoRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", ".."));
+ string msdiaPath = Path.Combine(repoRoot, "src", "external", "msdia140.dll");
+ if (File.Exists(msdiaPath))
+ PdbSymbolProvider.MsDiaPath = msdiaPath;
+
+ using var provider = new PdbSymbolProvider();
+ Console.WriteLine($"DIA error before: {PdbSymbolProvider.DiaRegistrationError}");
+
+ bool loaded = provider.LoadDebugInfo(pdbPath);
+ Console.WriteLine($"Loaded: {loaded}");
+ Console.WriteLine($"DIA error after: {PdbSymbolProvider.DiaRegistrationError}");
+
+ Assert.IsTrue(loaded, $"PDB load failed: {PdbSymbolProvider.DiaRegistrationError}");
+
+ var funcs = provider.GetSortedFunctions();
+ Console.WriteLine($"Total functions: {funcs.Count}");
+ Assert.IsTrue(funcs.Count > 0, "No functions enumerated");
+
+ // Show first 5
+ foreach (var f in funcs.Take(5))
+ Console.WriteLine($" [{f.RVA:X8}] {f.Name} (size={f.Size})");
+
+ // Search for MeasureCore
+ var matches = funcs.Where(f => f.Name.Contains("MeasureCore", StringComparison.OrdinalIgnoreCase)).ToList();
+ Console.WriteLine($"MeasureCore matches: {matches.Count}");
+ foreach (var m in matches.Take(5))
+ Console.WriteLine($" [{m.RVA:X8}] {m.Name} (size={m.Size})");
+
+ Assert.IsTrue(matches.Count > 0, "MeasureCore not found in PDB functions");
+ }
+}
diff --git a/src/ProfileExplorer.Profiling.Tests/Integration/PdbSymbolProviderTests.cs b/src/ProfileExplorer.Profiling.Tests/Integration/PdbSymbolProviderTests.cs
new file mode 100644
index 00000000..2219a407
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/Integration/PdbSymbolProviderTests.cs
@@ -0,0 +1,199 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ProfileExplorer.Profiling.Symbols;
+using ProfileExplorer.Profiling.Tests.Helpers;
+
+namespace ProfileExplorer.Profiling.Tests.Integration;
+
+[TestClass]
+[TestCategory("Integration")]
+public class PdbSymbolProviderTests {
+ private static string PdbPath => TestDataHelper.GetSymbolFilePath(TestDataHelper.MsoTrace, TestDataHelper.MsoPdbFile);
+
+ [ClassInitialize]
+ public static void ClassInit(TestContext _) {
+ // Set the path to msdia140.dll for side-loading (located in src/external/).
+ string assemblyDir = Path.GetDirectoryName(typeof(PdbSymbolProviderTests).Assembly.Location)!;
+ // Walk up from bin/Release/net8.0-windows to src/external.
+ var dir = new DirectoryInfo(assemblyDir);
+ while (dir != null) {
+ string candidate = Path.Combine(dir.FullName, "external", "msdia140.dll");
+ if (File.Exists(candidate)) {
+ PdbSymbolProvider.MsDiaPath = candidate;
+ break;
+ }
+
+ candidate = Path.Combine(dir.FullName, "src", "external", "msdia140.dll");
+ if (File.Exists(candidate)) {
+ PdbSymbolProvider.MsDiaPath = candidate;
+ break;
+ }
+
+ dir = dir.Parent;
+ }
+ }
+
+ private static bool CanRun() {
+ // DIA SDK (msdia140.dll) must be registered.
+ // If not available, skip these tests gracefully.
+ if (!TestDataHelper.HasTestData(TestDataHelper.MsoTrace)) return false;
+ if (!File.Exists(PdbPath)) return false;
+ return true;
+ }
+
+ [TestMethod]
+ public void LoadPdb_EnumeratesFunctions() {
+ if (!CanRun()) { Assert.Inconclusive("Test data or DIA SDK not available."); return; }
+
+ using var provider = new PdbSymbolProvider();
+ bool loaded = provider.LoadDebugInfo(PdbPath);
+
+ if (!loaded && PdbSymbolProvider.DiaRegistrationFailed) {
+ Assert.Inconclusive($"DIA SDK not registered: {PdbSymbolProvider.DiaRegistrationError}");
+ return;
+ }
+
+ Assert.IsTrue(loaded, $"PDB should load successfully. Error: {PdbSymbolProvider.DiaRegistrationError}");
+ var functions = provider.GetSortedFunctions();
+ Assert.IsTrue(functions.Count > 0, "Should enumerate at least one function.");
+ Assert.IsTrue(functions.Count > 100, $"Expected many functions, got {functions.Count}.");
+ }
+
+ [TestMethod]
+ public void FindFunctionByName_ReturnsKnownFunction() {
+ if (!CanRun()) { Assert.Inconclusive("Test data not available."); return; }
+
+ using var provider = new PdbSymbolProvider();
+ if (!provider.LoadDebugInfo(PdbPath)) {
+ Assert.Inconclusive("PDB load failed (DIA SDK?).");
+ return;
+ }
+
+ // This function is the top self-time function in the MsoTrace baseline.
+ var func = provider.FindFunction(TestDataHelper.MsoTopFunction);
+
+ // May not find by exact name if mangled differently; try searching sorted list.
+ if (func == null) {
+ var allFuncs = provider.GetSortedFunctions();
+ func = allFuncs.FirstOrDefault(f => f.Name.Contains("SortByParameterGroups"));
+ }
+
+ Assert.IsNotNull(func, "Should find SortByParameterGroups function.");
+ Assert.IsTrue(func.Size > 0, "Function should have non-zero size.");
+ Assert.IsTrue(func.RVA > 0, "Function should have non-zero RVA.");
+ }
+
+ [TestMethod]
+ public void FindFunctionByRVA_ReturnsCorrectName() {
+ if (!CanRun()) { Assert.Inconclusive("Test data not available."); return; }
+
+ using var provider = new PdbSymbolProvider();
+ if (!provider.LoadDebugInfo(PdbPath)) {
+ Assert.Inconclusive("PDB load failed.");
+ return;
+ }
+
+ // Find a known function first, then look it up by RVA.
+ var functions = provider.GetSortedFunctions();
+ Assert.IsTrue(functions.Count > 0);
+
+ var firstFunc = functions[0];
+ var found = provider.FindFunctionByRVA(firstFunc.RVA);
+
+ Assert.IsNotNull(found);
+ Assert.AreEqual(firstFunc.RVA, found.RVA);
+ Assert.AreEqual(firstFunc.Name, found.Name);
+ }
+
+ [TestMethod]
+ public void FindFunctionByRVA_UnknownRVA_ReturnsNullOrDifferentRVA() {
+ if (!CanRun()) { Assert.Inconclusive("Test data not available."); return; }
+
+ using var provider = new PdbSymbolProvider();
+ if (!provider.LoadDebugInfo(PdbPath)) {
+ Assert.Inconclusive("PDB load failed.");
+ return;
+ }
+
+ // DIA may return the nearest function for any RVA. Verify that at minimum
+ // a lookup at RVA=0 returns something different from the last function.
+ var result = provider.FindFunctionByRVA(0x7FFFFFFF);
+ // It's OK if DIA returns a result — just verify it doesn't crash.
+ // The important thing is that valid RVAs return the correct function.
+ }
+
+ [TestMethod]
+ public void PopulateSourceLines_ReturnsMappings() {
+ if (!CanRun()) { Assert.Inconclusive("Test data not available."); return; }
+
+ using var provider = new PdbSymbolProvider();
+ if (!provider.LoadDebugInfo(PdbPath)) {
+ Assert.Inconclusive("PDB load failed.");
+ return;
+ }
+
+ // Find a function and populate its source lines.
+ var functions = provider.GetSortedFunctions();
+ var funcWithSize = functions.FirstOrDefault(f => f.Size > 20);
+ Assert.IsNotNull(funcWithSize, "Should have a function with size > 20.");
+
+ bool populated = provider.PopulateSourceLines(funcWithSize);
+
+ // Source lines may or may not be available depending on PDB type.
+ if (populated) {
+ Assert.IsTrue(funcWithSize.HasSourceLines);
+ Assert.IsTrue(funcWithSize.SourceLines!.Count > 0);
+ Assert.IsTrue(funcWithSize.SourceLines[0].Line > 0, "Line number should be positive.");
+ }
+ // If not populated, that's OK — PDB may be stripped. Don't fail.
+ }
+
+ [TestMethod]
+ public void FindSourceLineByRVA_ReturnsLineInfo() {
+ if (!CanRun()) { Assert.Inconclusive("Test data not available."); return; }
+
+ using var provider = new PdbSymbolProvider();
+ if (!provider.LoadDebugInfo(PdbPath)) {
+ Assert.Inconclusive("PDB load failed.");
+ return;
+ }
+
+ var functions = provider.GetSortedFunctions();
+ var func = functions.FirstOrDefault(f => f.Size > 20);
+ Assert.IsNotNull(func);
+
+ var lineInfo = provider.FindSourceLineByRVA(func.RVA);
+
+ // May or may not have source info depending on PDB.
+ if (!lineInfo.IsUnknown) {
+ Assert.IsTrue(lineInfo.Line > 0);
+ }
+ }
+
+ [TestMethod]
+ public void GetSortedFunctions_IsSortedByRVA() {
+ if (!CanRun()) { Assert.Inconclusive("Test data not available."); return; }
+
+ using var provider = new PdbSymbolProvider();
+ if (!provider.LoadDebugInfo(PdbPath)) {
+ Assert.Inconclusive("PDB load failed.");
+ return;
+ }
+
+ var functions = provider.GetSortedFunctions();
+ Assert.IsTrue(functions.Count > 1);
+
+ for (int i = 1; i < functions.Count; i++) {
+ Assert.IsTrue(functions[i].RVA >= functions[i - 1].RVA,
+ $"Functions not sorted: [{i - 1}] RVA={functions[i - 1].RVA} > [{i}] RVA={functions[i].RVA}");
+ }
+ }
+
+ [TestMethod]
+ public void LoadPdb_InvalidPath_ReturnsFalse() {
+ using var provider = new PdbSymbolProvider();
+ bool result = provider.LoadDebugInfo(@"C:\nonexistent\fake.pdb");
+ Assert.IsFalse(result);
+ }
+}
diff --git a/src/ProfileExplorer.Profiling.Tests/ProfileExplorer.Profiling.Tests.csproj b/src/ProfileExplorer.Profiling.Tests/ProfileExplorer.Profiling.Tests.csproj
new file mode 100644
index 00000000..cae91c79
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/ProfileExplorer.Profiling.Tests.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net8.0-windows
+ enable
+ enable
+ false
+ true
+ ProfileExplorer.Profiling.Tests
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ProfileExplorer.Profiling.Tests/Unit/AssemblyAnnotatorTests.cs b/src/ProfileExplorer.Profiling.Tests/Unit/AssemblyAnnotatorTests.cs
new file mode 100644
index 00000000..7e7a7e97
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/Unit/AssemblyAnnotatorTests.cs
@@ -0,0 +1,148 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ProfileExplorer.Profiling.Disassembly;
+
+namespace ProfileExplorer.Profiling.Tests.Unit;
+
+[TestClass]
+[TestCategory("Unit")]
+public class AssemblyAnnotatorTests {
+ private static IReadOnlyList CreateInstructions(long baseAddress, long baseRva, int count) {
+ var instructions = new List();
+ for (int i = 0; i < count; i++) {
+ instructions.Add(new DisassembledInstruction(
+ baseAddress + i * 4,
+ baseRva + i * 4,
+ i == 0 ? "push rbp" : i == count - 1 ? "ret" : $"mov eax, [{i * 4}]",
+ 4));
+ }
+
+ return instructions;
+ }
+
+ [TestMethod]
+ public void AnnotatesHotInstructions_WithTimePercent() {
+ long funcRva = 0x100;
+ var instructions = CreateInstructions(0x7FF00100, funcRva, 5);
+
+ // 50ms on first instruction, 50ms on third.
+ var weights = new Dictionary {
+ [0] = TimeSpan.FromMilliseconds(50),
+ [8] = TimeSpan.FromMilliseconds(50)
+ };
+
+ var result = AssemblyAnnotator.Annotate(instructions, weights, funcRva, null, null,
+ ProcessorArchitecture.Amd64, 1.0, 10);
+
+ Assert.AreEqual(5, result.Lines.Count);
+ Assert.AreEqual(50.0, result.Lines[0].Percent, 0.1);
+ Assert.AreEqual(50.0, result.Lines[2].Percent, 0.1);
+ Assert.AreEqual(0.0, result.Lines[1].Percent, 0.1);
+ }
+
+ [TestMethod]
+ public void ColdInstructions_NoAnnotation() {
+ long funcRva = 0x100;
+ var instructions = CreateInstructions(0x7FF00100, funcRva, 3);
+
+ // No weight on second instruction.
+ var weights = new Dictionary {
+ [0] = TimeSpan.FromMilliseconds(10)
+ };
+
+ var result = AssemblyAnnotator.Annotate(instructions, weights, funcRva, null, null,
+ ProcessorArchitecture.Amd64, 1.0, 10);
+
+ // Full text should NOT have [Time(%)] for the cold instruction.
+ var coldLine = result.Lines[1];
+ Assert.AreEqual(0.0, coldLine.Percent, 0.01);
+ Assert.IsFalse(result.FullText.Contains("mov eax, [4]") &&
+ result.FullText.Contains("[Time(%):") &&
+ result.FullText.Split("Time(%)").Length > 2);
+ }
+
+ [TestMethod]
+ public void HotLineExtraction_AboveThreshold() {
+ long funcRva = 0x100;
+ var instructions = CreateInstructions(0x7FF00100, funcRva, 10);
+
+ var weights = new Dictionary {
+ [0] = TimeSpan.FromMilliseconds(50), // 50%
+ [4] = TimeSpan.FromMilliseconds(30), // 30%
+ [8] = TimeSpan.FromMilliseconds(15), // 15%
+ [12] = TimeSpan.FromMilliseconds(5), // 5%
+ };
+
+ var result = AssemblyAnnotator.Annotate(instructions, weights, funcRva, null, null,
+ ProcessorArchitecture.Amd64, 10.0, 10);
+
+ // MinPercent=10 → only 50%, 30%, 15% qualify.
+ Assert.AreEqual(3, result.HotLines.Count);
+ }
+
+ [TestMethod]
+ public void HotLineExtraction_OrderedByPercent() {
+ long funcRva = 0x100;
+ var instructions = CreateInstructions(0x7FF00100, funcRva, 5);
+
+ var weights = new Dictionary {
+ [0] = TimeSpan.FromMilliseconds(10),
+ [4] = TimeSpan.FromMilliseconds(50),
+ [8] = TimeSpan.FromMilliseconds(30),
+ };
+
+ var result = AssemblyAnnotator.Annotate(instructions, weights, funcRva, null, null,
+ ProcessorArchitecture.Amd64, 1.0, 10);
+
+ Assert.IsTrue(result.HotLines[0].Percent >= result.HotLines[1].Percent);
+ Assert.IsTrue(result.HotLines[1].Percent >= result.HotLines[2].Percent);
+ }
+
+ [TestMethod]
+ public void HotLineExtraction_CappedByMaxHotLines() {
+ long funcRva = 0x100;
+ var instructions = CreateInstructions(0x7FF00100, funcRva, 20);
+
+ var weights = new Dictionary();
+ for (int i = 0; i < 20; i++) {
+ weights[i * 4] = TimeSpan.FromMilliseconds(5);
+ }
+
+ var result = AssemblyAnnotator.Annotate(instructions, weights, funcRva, null, null,
+ ProcessorArchitecture.Amd64, 1.0, 3); // Max 3 hot lines.
+
+ Assert.AreEqual(3, result.HotLines.Count);
+ }
+
+ [TestMethod]
+ public void FullText_ContainsTimingAnnotation() {
+ long funcRva = 0x100;
+ var instructions = new List {
+ new(0x7FF00100, 0x100, "call CIconCache::GetIcon", 5)
+ };
+
+ var weights = new Dictionary {
+ [0] = TimeSpan.FromMilliseconds(58.18)
+ };
+
+ var result = AssemblyAnnotator.Annotate(instructions, weights, funcRva, null, null,
+ ProcessorArchitecture.Amd64, 1.0, 10);
+
+ Assert.IsTrue(result.FullText.Contains("[Time(%): 100.00%"));
+ Assert.IsTrue(result.FullText.Contains("call CIconCache::GetIcon"));
+ }
+
+ [TestMethod]
+ public void EmptyWeights_NoHotLines() {
+ long funcRva = 0x100;
+ var instructions = CreateInstructions(0x7FF00100, funcRva, 5);
+
+ var result = AssemblyAnnotator.Annotate(instructions, new Dictionary(),
+ funcRva, null, null, ProcessorArchitecture.Amd64, 1.0, 10);
+
+ Assert.AreEqual(0, result.HotLines.Count);
+ Assert.AreEqual(5, result.Lines.Count);
+ }
+}
diff --git a/src/ProfileExplorer.Profiling.Tests/Unit/CallTreeBuilderTests.cs b/src/ProfileExplorer.Profiling.Tests/Unit/CallTreeBuilderTests.cs
new file mode 100644
index 00000000..ae9c61fb
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/Unit/CallTreeBuilderTests.cs
@@ -0,0 +1,151 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ProfileExplorer.Profiling.Profiling;
+using ProfileExplorer.Profiling.Symbols;
+using ProfileExplorer.Profiling.Tests.Helpers;
+
+namespace ProfileExplorer.Profiling.Tests.Unit;
+
+[TestClass]
+[TestCategory("Unit")]
+public class CallTreeBuilderTests {
+ private (IpResolver resolver, CallTreeBuilder builder) CreateBuilderWithFunctions(
+ params (string module, long moduleBase, int moduleSize, string funcName, long funcRva, uint funcSize)[] functions) {
+ var resolver = new IpResolver();
+ foreach (var (module, moduleBase, moduleSize, _, _, _) in functions) {
+ resolver.AddImage(module, moduleBase, moduleSize);
+ }
+
+ var grouped = functions.GroupBy(f => f.module);
+ foreach (var group in grouped) {
+ var funcList = group.Select(f => new FunctionDebugInfo(f.funcName, f.funcRva, f.funcSize)).ToList();
+ resolver.SetFunctions(group.Key, funcList);
+ }
+
+ return (resolver, new CallTreeBuilder(resolver));
+ }
+
+ [TestMethod]
+ public void SingleStack_CreatesLinearTree() {
+ var (resolver, builder) = CreateBuilderWithFunctions(
+ ("test.dll", 0x1000, 0x10000, "Root", 0x100, 0x50),
+ ("test.dll", 0x1000, 0x10000, "Mid", 0x200, 0x50),
+ ("test.dll", 0x1000, 0x10000, "Leaf", 0x300, 0x50));
+
+ // Stack: leaf=0x1300, frames=[0x1300, 0x1200, 0x1100] (leaf-first).
+ var sample = new SyntheticSample(0x1300, TimeSpan.FromMilliseconds(1), 1, 1, "test.dll", 0x1000,
+ [0x1300, 0x1200, 0x1100]);
+ builder.AddSamples([sample]);
+
+ var root = builder.Build();
+
+ Assert.AreEqual(1, root.Children.Count); // One root function.
+ Assert.AreEqual("Root", root.Children[0].FunctionName);
+ Assert.AreEqual(1, root.Children[0].Children.Count);
+ Assert.AreEqual("Mid", root.Children[0].Children[0].FunctionName);
+ Assert.AreEqual(1, root.Children[0].Children[0].Children.Count);
+ Assert.AreEqual("Leaf", root.Children[0].Children[0].Children[0].FunctionName);
+ }
+
+ [TestMethod]
+ public void SharedPrefix_MergesNodes() {
+ var (resolver, builder) = CreateBuilderWithFunctions(
+ ("test.dll", 0x1000, 0x10000, "Root", 0x100, 0x50),
+ ("test.dll", 0x1000, 0x10000, "LeafA", 0x200, 0x50),
+ ("test.dll", 0x1000, 0x10000, "LeafB", 0x300, 0x50));
+
+ builder.AddSamples([
+ new SyntheticSample(0x1200, TimeSpan.FromMilliseconds(1), 1, 1, "test.dll", 0x1000, [0x1200, 0x1100]),
+ new SyntheticSample(0x1300, TimeSpan.FromMilliseconds(1), 1, 1, "test.dll", 0x1000, [0x1300, 0x1100])
+ ]);
+
+ var root = builder.Build();
+
+ Assert.AreEqual(1, root.Children.Count); // Single root.
+ Assert.AreEqual("Root", root.Children[0].FunctionName);
+ Assert.AreEqual(2, root.Children[0].Children.Count); // Two children.
+ }
+
+ [TestMethod]
+ public void InclusiveWeight_PropagatesToRoot() {
+ var (resolver, builder) = CreateBuilderWithFunctions(
+ ("test.dll", 0x1000, 0x10000, "Root", 0x100, 0x50),
+ ("test.dll", 0x1000, 0x10000, "Leaf", 0x200, 0x50));
+
+ builder.AddSamples([
+ new SyntheticSample(0x1200, TimeSpan.FromMilliseconds(3), 1, 1, "test.dll", 0x1000, [0x1200, 0x1100])
+ ]);
+
+ var root = builder.Build();
+ var rootFunc = root.Children[0];
+
+ Assert.AreEqual(3.0, rootFunc.InclusiveWeight.TotalMilliseconds, 0.01);
+ }
+
+ [TestMethod]
+ public void ExclusiveWeight_OnlyOnLeaf() {
+ var (resolver, builder) = CreateBuilderWithFunctions(
+ ("test.dll", 0x1000, 0x10000, "Root", 0x100, 0x50),
+ ("test.dll", 0x1000, 0x10000, "Leaf", 0x200, 0x50));
+
+ builder.AddSamples([
+ new SyntheticSample(0x1200, TimeSpan.FromMilliseconds(5), 1, 1, "test.dll", 0x1000, [0x1200, 0x1100])
+ ]);
+
+ var root = builder.Build();
+ var rootFunc = root.Children[0];
+ var leafFunc = rootFunc.Children[0];
+
+ Assert.AreEqual(0.0, rootFunc.ExclusiveWeight.TotalMilliseconds, 0.01);
+ Assert.AreEqual(5.0, leafFunc.ExclusiveWeight.TotalMilliseconds, 0.01);
+ }
+
+ [TestMethod]
+ public void PerThreadWeights_Tracked() {
+ var (resolver, builder) = CreateBuilderWithFunctions(
+ ("test.dll", 0x1000, 0x10000, "Func", 0x100, 0x50));
+
+ builder.AddSamples([
+ new SyntheticSample(0x1100, TimeSpan.FromMilliseconds(3), 1, 10, "test.dll", 0x1000, [0x1100]),
+ new SyntheticSample(0x1100, TimeSpan.FromMilliseconds(7), 1, 20, "test.dll", 0x1000, [0x1100])
+ ]);
+
+ var root = builder.Build();
+ var func = root.Children[0];
+
+ Assert.AreEqual(2, func.ThreadWeights.Count);
+ Assert.AreEqual(3.0, func.ThreadWeights[10].Inclusive.TotalMilliseconds, 0.01);
+ Assert.AreEqual(7.0, func.ThreadWeights[20].Inclusive.TotalMilliseconds, 0.01);
+ }
+
+ [TestMethod]
+ public void EmptyStacks_Skipped() {
+ var (resolver, builder) = CreateBuilderWithFunctions(
+ ("test.dll", 0x1000, 0x10000, "Func", 0x100, 0x50));
+
+ // Sample with no stack frames.
+ builder.AddSamples([
+ new SyntheticSample(0x1100, TimeSpan.FromMilliseconds(1), 1, 1, "test.dll", 0x1000)
+ ]);
+
+ var root = builder.Build();
+ Assert.AreEqual(0, root.Children.Count);
+ }
+
+ [TestMethod]
+ public void MultipleSamples_SameStack_AccumulatesWeight() {
+ var (resolver, builder) = CreateBuilderWithFunctions(
+ ("test.dll", 0x1000, 0x10000, "Func", 0x100, 0x50));
+
+ var samples = Enumerable.Range(0, 10).Select(_ =>
+ new SyntheticSample(0x1100, TimeSpan.FromMilliseconds(1), 1, 1, "test.dll", 0x1000, [0x1100])
+ ).ToList();
+
+ builder.AddSamples(samples);
+ var root = builder.Build();
+
+ Assert.AreEqual(10.0, root.Children[0].InclusiveWeight.TotalMilliseconds, 0.01);
+ Assert.AreEqual(10.0, root.Children[0].ExclusiveWeight.TotalMilliseconds, 0.01);
+ }
+}
diff --git a/src/ProfileExplorer.Profiling.Tests/Unit/CounterAggregatorTests.cs b/src/ProfileExplorer.Profiling.Tests/Unit/CounterAggregatorTests.cs
new file mode 100644
index 00000000..d9dbdc5c
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/Unit/CounterAggregatorTests.cs
@@ -0,0 +1,91 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ProfileExplorer.Profiling.Profiling;
+using ProfileExplorer.Profiling.Symbols;
+
+namespace ProfileExplorer.Profiling.Tests.Unit;
+
+[TestClass]
+[TestCategory("Unit")]
+public class CounterAggregatorTests {
+ private class TestCounterEvent : IPerformanceCounterEvent {
+ public long InstructionPointer { get; init; }
+ public TimeSpan Timestamp { get; init; }
+ public int ProcessId { get; init; }
+ public int ThreadId { get; init; }
+ public short CounterId { get; init; }
+ }
+
+ private (IpResolver resolver, CounterAggregator aggregator) Setup() {
+ var resolver = new IpResolver();
+ resolver.AddImage("test.dll", 0x1000, 0x10000);
+ resolver.SetFunctions("test.dll", [
+ new FunctionDebugInfo("HotFunc", 0x100, 0x50)
+ ]);
+ return (resolver, new CounterAggregator(resolver));
+ }
+
+ [TestMethod]
+ public void SingleCounter_AttributedToFunction() {
+ var (_, aggregator) = Setup();
+
+ aggregator.AddEvents([
+ new TestCounterEvent { InstructionPointer = 0x1100, CounterId = 1, ProcessId = 1 }
+ ]);
+
+ var counters = aggregator.GetCounters("test.dll!HotFunc");
+ Assert.IsNotNull(counters);
+ Assert.AreEqual(1, counters.Count);
+ }
+
+ [TestMethod]
+ public void MultipleCounters_SameInstruction() {
+ var (_, aggregator) = Setup();
+
+ aggregator.AddEvents([
+ new TestCounterEvent { InstructionPointer = 0x1110, CounterId = 1 },
+ new TestCounterEvent { InstructionPointer = 0x1110, CounterId = 2 },
+ new TestCounterEvent { InstructionPointer = 0x1110, CounterId = 3 }
+ ]);
+
+ var counters = aggregator.GetCounters("test.dll!HotFunc");
+ Assert.IsNotNull(counters);
+
+ // All three counter types at same instruction offset.
+ long offset = 0x110 - 0x100; // 0x10
+ Assert.IsTrue(counters.ContainsKey(offset));
+ Assert.AreEqual(1, counters[offset].GetCounterValue(1));
+ Assert.AreEqual(1, counters[offset].GetCounterValue(2));
+ Assert.AreEqual(1, counters[offset].GetCounterValue(3));
+ }
+
+ [TestMethod]
+ public void DerivedMetric_ComputesCorrectly() {
+ var metric = new PerformanceMetricInfo("CacheMissRate", "CacheReferences", "CacheMisses", true);
+
+ Assert.AreEqual(0.25, metric.ComputeMetric(100, 25), 0.001);
+ Assert.AreEqual(0.0, metric.ComputeMetric(0, 25), 0.001); // Division by zero → 0.
+ Assert.AreEqual(1.0, metric.ComputeMetric(50, 100), 0.001); // Capped at 1.0 for percentage.
+ }
+
+ [TestMethod]
+ public void UnresolvedIP_Skipped() {
+ var resolver = new IpResolver(); // No images registered.
+ var aggregator = new CounterAggregator(resolver);
+
+ aggregator.AddEvents([
+ new TestCounterEvent { InstructionPointer = 0xDEADBEEF, CounterId = 1 }
+ ]);
+
+ var counters = aggregator.GetCounters("anything");
+ Assert.IsNull(counters);
+ }
+
+ [TestMethod]
+ public void NoCountersRegistered_ReturnsNull() {
+ var (_, aggregator) = Setup();
+ var counters = aggregator.GetCounters("test.dll!HotFunc");
+ Assert.IsNull(counters);
+ }
+}
diff --git a/src/ProfileExplorer.Profiling.Tests/Unit/FunctionProfileBuilderTests.cs b/src/ProfileExplorer.Profiling.Tests/Unit/FunctionProfileBuilderTests.cs
new file mode 100644
index 00000000..acb942d2
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/Unit/FunctionProfileBuilderTests.cs
@@ -0,0 +1,89 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ProfileExplorer.Profiling.Profiling;
+
+namespace ProfileExplorer.Profiling.Tests.Unit;
+
+[TestClass]
+[TestCategory("Unit")]
+public class FunctionProfileBuilderTests {
+ [TestMethod]
+ public void AddWeight_AccumulatesCorrectly() {
+ var builder = new FunctionProfileBuilder("test.dll", "Foo", 0x100, 0x50, false);
+ var weight = TimeSpan.FromMilliseconds(1);
+
+ builder.AddSample(0, weight);
+ builder.AddSample(0, weight);
+ builder.AddSample(0, weight);
+
+ Assert.AreEqual(3.0, builder.GetExclusiveWeight().TotalMilliseconds, 0.01);
+ }
+
+ [TestMethod]
+ public void InstructionWeightMap_DistinctOffsets() {
+ var builder = new FunctionProfileBuilder("test.dll", "Foo", 0x100, 0x50, false);
+ var weight = TimeSpan.FromMilliseconds(1);
+
+ for (int i = 0; i < 10; i++) {
+ builder.AddSample(i * 4, weight);
+ }
+
+ var weights = builder.GetInstructionWeights();
+ Assert.AreEqual(10, weights.Count);
+ }
+
+ [TestMethod]
+ public void InstructionWeightMap_SameOffset_Merges() {
+ var builder = new FunctionProfileBuilder("test.dll", "Foo", 0x100, 0x50, false);
+ var weight = TimeSpan.FromMilliseconds(2);
+
+ for (int i = 0; i < 5; i++) {
+ builder.AddSample(0x10, weight);
+ }
+
+ var weights = builder.GetInstructionWeights();
+ Assert.AreEqual(1, weights.Count);
+ Assert.AreEqual(10.0, weights[0x10].TotalMilliseconds, 0.01);
+ }
+
+ [TestMethod]
+ public void ExclusiveWeight_EqualsInstructionWeightSum() {
+ var builder = new FunctionProfileBuilder("test.dll", "Foo", 0x100, 0x50, false);
+
+ builder.AddSample(0, TimeSpan.FromMilliseconds(5));
+ builder.AddSample(4, TimeSpan.FromMilliseconds(3));
+ builder.AddSample(8, TimeSpan.FromMilliseconds(7));
+
+ var weights = builder.GetInstructionWeights();
+ double sumMs = weights.Values.Sum(w => w.TotalMilliseconds);
+
+ Assert.AreEqual(15.0, builder.GetExclusiveWeight().TotalMilliseconds, 0.01);
+ Assert.AreEqual(builder.GetExclusiveWeight().TotalMilliseconds, sumMs, 0.01);
+ }
+
+ [TestMethod]
+ public void InclusiveWeight_IncludesCallerWeight() {
+ var builder = new FunctionProfileBuilder("test.dll", "Foo", 0x100, 0x50, false);
+
+ builder.AddSample(0, TimeSpan.FromMilliseconds(10)); // Exclusive (leaf)
+ builder.AddInclusiveWeight(TimeSpan.FromMilliseconds(25)); // From callee stacks
+
+ Assert.AreEqual(10.0, builder.GetExclusiveWeight().TotalMilliseconds, 0.01);
+ Assert.AreEqual(35.0, builder.GetInclusiveWeight().TotalMilliseconds, 0.01);
+ }
+
+ [TestMethod]
+ public void GetInstructionWeights_ReturnsSnapshot() {
+ var builder = new FunctionProfileBuilder("test.dll", "Foo", 0x100, 0x50, false);
+ builder.AddSample(0, TimeSpan.FromMilliseconds(1));
+
+ var snapshot1 = builder.GetInstructionWeights();
+ builder.AddSample(4, TimeSpan.FromMilliseconds(1));
+ var snapshot2 = builder.GetInstructionWeights();
+
+ // Snapshot1 should not be modified by later adds.
+ Assert.AreEqual(1, snapshot1.Count);
+ Assert.AreEqual(2, snapshot2.Count);
+ }
+}
diff --git a/src/ProfileExplorer.Profiling.Tests/Unit/IpSkidCorrectionTests.cs b/src/ProfileExplorer.Profiling.Tests/Unit/IpSkidCorrectionTests.cs
new file mode 100644
index 00000000..01a85e7f
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/Unit/IpSkidCorrectionTests.cs
@@ -0,0 +1,63 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using System.Reflection;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ProfileExplorer.Profiling.Profiling;
+
+namespace ProfileExplorer.Profiling.Tests.Unit;
+
+[TestClass]
+[TestCategory("Unit")]
+public class IpSkidCorrectionTests {
+ [TestMethod]
+ public void ExactMatch_NoCorrection() {
+ var known = new HashSet { 0, 4, 8, 12 };
+ long result = InstructionOffsetConfig.CorrectSkid(4, known, ProcessorArchitecture.Amd64);
+ Assert.AreEqual(4, result);
+ }
+
+ [TestMethod]
+ public void x64_SkidsBackByOne_FindsInstruction() {
+ var known = new HashSet { 0, 5, 10 };
+ // IP = 6, one byte past instruction at 5.
+ long result = InstructionOffsetConfig.CorrectSkid(6, known, ProcessorArchitecture.Amd64);
+ Assert.AreEqual(5, result);
+ }
+
+ [TestMethod]
+ public void x64_SkidsBackVariable_FindsNearestInstruction() {
+ var known = new HashSet { 0, 10, 20 };
+ // IP = 15, 5 bytes past instruction at 10.
+ long result = InstructionOffsetConfig.CorrectSkid(15, known, ProcessorArchitecture.Amd64);
+ Assert.AreEqual(10, result);
+ }
+
+ [TestMethod]
+ public void x64_BeyondMaxAdjust_ReturnsOriginal() {
+ var known = new HashSet { 0 };
+ // IP = 100, far beyond max adjustment (16 bytes).
+ long result = InstructionOffsetConfig.CorrectSkid(100, known, ProcessorArchitecture.Amd64);
+ Assert.AreEqual(100, result);
+ }
+
+ [TestMethod]
+ public void ARM64_FixedFourByteCorrection() {
+ var known = new HashSet { 0, 4, 8, 12 };
+ // IP = 5, should walk back by 4 to find instruction at 4... but 5-4=1 which is not in the set.
+ // Actually ARM64 adjusts by 4 each time: 5-4=1 (no), done since VariableSize(4,4).
+ // Let's test with IP = 8 (exact match).
+ long result = InstructionOffsetConfig.CorrectSkid(8, known, ProcessorArchitecture.Arm);
+ Assert.AreEqual(8, result);
+
+ // IP = 6: ARM adjusts by 4 → 6-4=2, not found. Max adjust = 4, so 1*4=4 <= 4, done.
+ result = InstructionOffsetConfig.CorrectSkid(6, known, ProcessorArchitecture.Arm);
+ Assert.AreEqual(6, result); // No correction possible.
+ }
+
+ [TestMethod]
+ public void EmptyInstructionMap_ReturnsOriginal() {
+ var known = new HashSet();
+ long result = InstructionOffsetConfig.CorrectSkid(10, known, ProcessorArchitecture.Amd64);
+ Assert.AreEqual(10, result);
+ }
+}
diff --git a/src/ProfileExplorer.Profiling.Tests/Unit/ManagedMethodResolverTests.cs b/src/ProfileExplorer.Profiling.Tests/Unit/ManagedMethodResolverTests.cs
new file mode 100644
index 00000000..7eac6ffb
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/Unit/ManagedMethodResolverTests.cs
@@ -0,0 +1,88 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ProfileExplorer.Profiling.Profiling;
+
+namespace ProfileExplorer.Profiling.Tests.Unit;
+
+[TestClass]
+[TestCategory("Unit")]
+public class ManagedMethodResolverTests {
+ private class TestManagedMethod : IManagedMethodMapping {
+ public int ProcessId { get; init; }
+ public string MethodName { get; init; } = "";
+ public long NativeStartAddress { get; init; }
+ public int NativeSize { get; init; }
+ public int MethodToken { get; init; }
+ public string? ModuleName { get; init; }
+ public Guid ManagedPdbGuid { get; init; }
+ public int ManagedPdbAge { get; init; }
+ public string? ManagedPdbName { get; init; }
+ public IReadOnlyList? ILMappings { get; init; }
+ }
+
+ [TestMethod]
+ public void RegisterMethod_FindByIP() {
+ var resolver = new ManagedMethodResolver();
+ resolver.AddMethod(new TestManagedMethod {
+ ProcessId = 1,
+ MethodName = "System.String.Concat",
+ NativeStartAddress = 0x7FF00000,
+ NativeSize = 0x100,
+ ModuleName = "System.Private.CoreLib"
+ });
+
+ var result = resolver.FindMethod(0x7FF00050);
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual("System.String.Concat", result.MethodName);
+ }
+
+ [TestMethod]
+ public void IPOutsideRange_ReturnsNull() {
+ var resolver = new ManagedMethodResolver();
+ resolver.AddMethod(new TestManagedMethod {
+ ProcessId = 1,
+ MethodName = "Foo",
+ NativeStartAddress = 0x1000,
+ NativeSize = 0x100
+ });
+
+ Assert.IsNull(resolver.FindMethod(0x2000));
+ Assert.IsNull(resolver.FindMethod(0x0500));
+ }
+
+ [TestMethod]
+ public void MultipleMethodsRegistered_FindCorrectOne() {
+ var resolver = new ManagedMethodResolver();
+ resolver.AddMethod(new TestManagedMethod {
+ MethodName = "MethodA", NativeStartAddress = 0x1000, NativeSize = 0x100
+ });
+ resolver.AddMethod(new TestManagedMethod {
+ MethodName = "MethodB", NativeStartAddress = 0x2000, NativeSize = 0x200
+ });
+ resolver.AddMethod(new TestManagedMethod {
+ MethodName = "MethodC", NativeStartAddress = 0x3000, NativeSize = 0x50
+ });
+
+ Assert.AreEqual("MethodA", resolver.FindMethod(0x1050)?.MethodName);
+ Assert.AreEqual("MethodB", resolver.FindMethod(0x2100)?.MethodName);
+ Assert.AreEqual("MethodC", resolver.FindMethod(0x3020)?.MethodName);
+ }
+
+ [TestMethod]
+ public void EmptyResolver_ReturnsNull() {
+ var resolver = new ManagedMethodResolver();
+ Assert.IsNull(resolver.FindMethod(0x1000));
+ }
+
+ [TestMethod]
+ public void ExactStartAddress_FindsMethod() {
+ var resolver = new ManagedMethodResolver();
+ resolver.AddMethod(new TestManagedMethod {
+ MethodName = "Foo", NativeStartAddress = 0x5000, NativeSize = 0x80
+ });
+
+ Assert.AreEqual("Foo", resolver.FindMethod(0x5000)?.MethodName);
+ }
+}
diff --git a/src/ProfileExplorer.Profiling.Tests/Unit/ProfilerOptionsValidationTests.cs b/src/ProfileExplorer.Profiling.Tests/Unit/ProfilerOptionsValidationTests.cs
new file mode 100644
index 00000000..3eca98ef
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/Unit/ProfilerOptionsValidationTests.cs
@@ -0,0 +1,57 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace ProfileExplorer.Profiling.Tests.Unit;
+
+[TestClass]
+[TestCategory("Unit")]
+public class ProfilerOptionsValidationTests {
+ [TestMethod]
+ public void DefaultOptions_WithSymbolPaths_AreValid() {
+ var options = new ProfilerOptions {
+ SymbolPaths = ["srv*C:\\Symbols*https://symbolserver.example.com"]
+ };
+
+ options.Validate(); // Should not throw.
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(ArgumentException))]
+ public void EmptySymbolPaths_Throws() {
+ var options = new ProfilerOptions { SymbolPaths = [] };
+ options.Validate();
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(ArgumentOutOfRangeException))]
+ public void NegativeTimeout_Throws() {
+ var options = new ProfilerOptions {
+ SymbolPaths = ["srv*https://example.com"],
+ SymbolTimeoutSeconds = -1
+ };
+ options.Validate();
+ }
+
+ [TestMethod]
+ public void MinSelfPercent_ClampedToValidRange() {
+ var options = new ProfilerOptions {
+ SymbolPaths = ["srv*https://example.com"],
+ MinSelfPercent = 150 // Over 100
+ };
+
+ options.Validate();
+ Assert.AreEqual(100, options.MinSelfPercent);
+ }
+
+ [TestMethod]
+ public void MinSelfPercent_NegativeClamped() {
+ var options = new ProfilerOptions {
+ SymbolPaths = ["srv*https://example.com"],
+ MinSelfPercent = -5
+ };
+
+ options.Validate();
+ Assert.AreEqual(0, options.MinSelfPercent);
+ }
+}
diff --git a/src/ProfileExplorer.Profiling.Tests/Unit/SampleAggregatorTests.cs b/src/ProfileExplorer.Profiling.Tests/Unit/SampleAggregatorTests.cs
new file mode 100644
index 00000000..02d562c4
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/Unit/SampleAggregatorTests.cs
@@ -0,0 +1,148 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ProfileExplorer.Profiling.Profiling;
+using ProfileExplorer.Profiling.Symbols;
+using ProfileExplorer.Profiling.Tests.Helpers;
+
+namespace ProfileExplorer.Profiling.Tests.Unit;
+
+[TestClass]
+[TestCategory("Unit")]
+public class SampleAggregatorTests {
+ private IpResolver CreateResolverWithFunction(string module, long moduleBase, int moduleSize,
+ string funcName, long funcRva, uint funcSize) {
+ var resolver = new IpResolver();
+ resolver.AddImage(module, moduleBase, moduleSize);
+ var functions = new List {
+ new(funcName, funcRva, funcSize)
+ };
+ resolver.SetFunctions(module, functions);
+ return resolver;
+ }
+
+ [TestMethod]
+ public void SingleSample_CreatesOneFunctionProfile() {
+ var resolver = CreateResolverWithFunction("test.dll", 0x1000, 0x10000, "Foo", 0x100, 0x50);
+ var aggregator = new SampleAggregator(resolver);
+ var samples = SyntheticSampleBuilder.CreateUniform(1, "test.dll", 0x1100, TimeSpan.FromMilliseconds(1));
+
+ aggregator.AddSamples(samples);
+ var profiles = aggregator.Build();
+
+ Assert.AreEqual(1, profiles.Count);
+ Assert.AreEqual("Foo", profiles[0].FunctionName);
+ Assert.AreEqual(1.0, profiles[0].ExclusiveWeight.TotalMilliseconds, 0.01);
+ }
+
+ [TestMethod]
+ public void MultipleSamples_SameFunction_AggregatesWeight() {
+ var resolver = CreateResolverWithFunction("test.dll", 0x1000, 0x10000, "Foo", 0x100, 0x50);
+ var aggregator = new SampleAggregator(resolver);
+ var samples = SyntheticSampleBuilder.CreateUniform(100, "test.dll", 0x1100, TimeSpan.FromMilliseconds(1));
+
+ aggregator.AddSamples(samples);
+ var profiles = aggregator.Build();
+
+ Assert.AreEqual(1, profiles.Count);
+ Assert.AreEqual(100.0, profiles[0].ExclusiveWeight.TotalMilliseconds, 0.01);
+ }
+
+ [TestMethod]
+ public void MultipleSamples_DifferentFunctions_SeparateProfiles() {
+ var resolver = new IpResolver();
+ resolver.AddImage("test.dll", 0x1000, 0x10000);
+ resolver.SetFunctions("test.dll", [
+ new FunctionDebugInfo("Foo", 0x100, 0x50),
+ new FunctionDebugInfo("Bar", 0x200, 0x50),
+ new FunctionDebugInfo("Baz", 0x300, 0x50)
+ ]);
+
+ var aggregator = new SampleAggregator(resolver);
+ var weight = TimeSpan.FromMilliseconds(1);
+ aggregator.AddSamples([
+ new SyntheticSample(0x1100, weight, 1, 1, "test.dll", 0x1000),
+ new SyntheticSample(0x1200, weight, 1, 1, "test.dll", 0x1000),
+ new SyntheticSample(0x1300, weight, 1, 1, "test.dll", 0x1000)
+ ]);
+
+ var profiles = aggregator.Build();
+ Assert.AreEqual(3, profiles.Count);
+ }
+
+ [TestMethod]
+ public void InstructionWeights_AggregatesPerOffset() {
+ var resolver = CreateResolverWithFunction("test.dll", 0x1000, 0x10000, "Foo", 0x100, 0x50);
+ var aggregator = new SampleAggregator(resolver);
+ var weight = TimeSpan.FromMilliseconds(1);
+
+ // 10 samples each at 5 different offsets within the function.
+ var samples = new List();
+ for (int offset = 0; offset < 5; offset++) {
+ for (int i = 0; i < 10; i++) {
+ samples.Add(new SyntheticSample(0x1100 + offset * 4, weight, 1, 1, "test.dll", 0x1000));
+ }
+ }
+
+ aggregator.AddSamples(samples);
+ var profiles = aggregator.Build();
+
+ Assert.AreEqual(1, profiles.Count);
+ Assert.AreEqual(5, profiles[0].InstructionWeights.Count);
+ Assert.AreEqual(50.0, profiles[0].ExclusiveWeight.TotalMilliseconds, 0.01);
+ }
+
+ [TestMethod]
+ public void EmptySamples_ReturnsEmptyProfiles() {
+ var resolver = new IpResolver();
+ var aggregator = new SampleAggregator(resolver);
+
+ aggregator.AddSamples([]);
+ var profiles = aggregator.Build();
+
+ Assert.AreEqual(0, profiles.Count);
+ }
+
+ [TestMethod]
+ public void SamplesWithNoImage_SkippedGracefully() {
+ var resolver = new IpResolver();
+ var aggregator = new SampleAggregator(resolver);
+ var samples = new List {
+ new SyntheticSample(0x1000, TimeSpan.FromMilliseconds(1), 1, 1, null, 0)
+ };
+
+ aggregator.AddSamples(samples);
+ var profiles = aggregator.Build();
+
+ Assert.AreEqual(0, profiles.Count);
+ }
+
+ [TestMethod]
+ public void PercentCalculation_RelativeToTotalWeight() {
+ var resolver = new IpResolver();
+ resolver.AddImage("test.dll", 0x1000, 0x10000);
+ resolver.SetFunctions("test.dll", [
+ new FunctionDebugInfo("Foo", 0x100, 0x50),
+ new FunctionDebugInfo("Bar", 0x200, 0x50)
+ ]);
+
+ var aggregator = new SampleAggregator(resolver);
+ var weight = TimeSpan.FromMilliseconds(1);
+
+ // 75 samples to Foo, 25 to Bar.
+ var samples = new List();
+ for (int i = 0; i < 75; i++)
+ samples.Add(new SyntheticSample(0x1100, weight, 1, 1, "test.dll", 0x1000));
+ for (int i = 0; i < 25; i++)
+ samples.Add(new SyntheticSample(0x1200, weight, 1, 1, "test.dll", 0x1000));
+
+ aggregator.AddSamples(samples);
+ var profiles = aggregator.Build();
+
+ var foo = profiles.First(p => p.FunctionName == "Foo");
+ var bar = profiles.First(p => p.FunctionName == "Bar");
+
+ Assert.AreEqual(75.0, foo.ExclusivePercent, 0.1);
+ Assert.AreEqual(25.0, bar.ExclusivePercent, 0.1);
+ }
+}
diff --git a/src/ProfileExplorer.Profiling.Tests/Unit/SymbolServerClientTests.cs b/src/ProfileExplorer.Profiling.Tests/Unit/SymbolServerClientTests.cs
new file mode 100644
index 00000000..47190722
--- /dev/null
+++ b/src/ProfileExplorer.Profiling.Tests/Unit/SymbolServerClientTests.cs
@@ -0,0 +1,62 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using ProfileExplorer.Profiling.Symbols;
+
+namespace ProfileExplorer.Profiling.Tests.Unit;
+
+[TestClass]
+[TestCategory("Unit")]
+public class SymbolServerClientTests {
+ [TestMethod]
+ public void ParseSymbolPath_SrvCacheServer() {
+ var options = new ProfilerOptions {
+ SymbolPaths = ["srv*C:\\Symbols*https://symbolserver.example.com"]
+ };
+
+ using var client = new SymbolServerClient(options);
+
+ // Should have at least a local + remote server.
+ Assert.IsTrue(client.Servers.Count >= 2);
+ Assert.IsTrue(client.Servers.Any(s => !s.IsRemote)); // Local cache
+ Assert.IsTrue(client.Servers.Any(s => s.IsRemote && s.Url.Contains("symbolserver"))); // Remote
+ }
+
+ [TestMethod]
+ public void ParseSymbolPath_MultipleServers() {
+ var options = new ProfilerOptions {
+ SymbolPaths = [
+ "srv*C:\\cache1*https://server1.com",
+ "srv*C:\\cache2*https://server2.com"
+ ]
+ };
+
+ using var client = new SymbolServerClient(options);
+
+ Assert.IsTrue(client.Servers.Any(s => s.IsRemote && s.Url.Contains("server1")));
+ Assert.IsTrue(client.Servers.Any(s => s.IsRemote && s.Url.Contains("server2")));
+ }
+
+ [TestMethod]
+ public void ParseSymbolPath_LocalPath() {
+ var options = new ProfilerOptions {
+ SymbolPaths = ["C:\\LocalSymbols"]
+ };
+
+ using var client = new SymbolServerClient(options);
+
+ Assert.IsTrue(client.Servers.Any(s => !s.IsRemote && s.Url.Contains("LocalSymbols")));
+ }
+
+ [TestMethod]
+ public void ParseSymbolPath_SymwebRequiresAuth() {
+ var options = new ProfilerOptions {
+ SymbolPaths = ["srv*https://symbolserver.example.com"]
+ };
+
+ using var client = new SymbolServerClient(options);
+
+ var remoteServer = client.Servers.First(s => s.IsRemote);
+ Assert.IsTrue(remoteServer.RequiresAuth);
+ }
+}
diff --git a/src/ProfileExplorer.Profiling/Abstractions/IManagedMethodMapping.cs b/src/ProfileExplorer.Profiling/Abstractions/IManagedMethodMapping.cs
new file mode 100644
index 00000000..a372faa8
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Abstractions/IManagedMethodMapping.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+namespace ProfileExplorer.Profiling;
+
+///
+/// A JIT-compiled managed method mapping from CLR events.
+/// Consumers implement this to provide .NET method resolution data.
+///
+public interface IManagedMethodMapping {
+ /// Process ID this method belongs to.
+ int ProcessId { get; }
+
+ /// Fully qualified method name (e.g., "System.String.Concat").
+ string MethodName { get; }
+
+ /// Native (JIT-compiled) code start address.
+ long NativeStartAddress { get; }
+
+ /// Native code size in bytes.
+ int NativeSize { get; }
+
+ /// Metadata token for the method.
+ int MethodToken { get; }
+
+ /// Managed assembly name (e.g., "System.Private.CoreLib").
+ string? ModuleName { get; }
+
+ /// Portable PDB GUID for managed symbol resolution.
+ Guid ManagedPdbGuid { get; }
+
+ /// Portable PDB Age.
+ int ManagedPdbAge { get; }
+
+ /// Portable PDB file name.
+ string? ManagedPdbName { get; }
+
+ /// IL offset to native offset mappings. Optional.
+ IReadOnlyList? ILMappings { get; }
+}
+
+///
+/// Maps an IL offset range to a native code offset range within a JIT-compiled method.
+///
+public readonly record struct ILToNativeMapping(int ILOffset, int NativeStartOffset, int NativeEndOffset);
diff --git a/src/ProfileExplorer.Profiling/Abstractions/IPerformanceCounterEvent.cs b/src/ProfileExplorer.Profiling/Abstractions/IPerformanceCounterEvent.cs
new file mode 100644
index 00000000..a0765295
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Abstractions/IPerformanceCounterEvent.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+namespace ProfileExplorer.Profiling;
+
+///
+/// A hardware performance counter sample event (PMU/PMC).
+///
+public interface IPerformanceCounterEvent {
+ /// Instruction pointer where the counter event was captured.
+ long InstructionPointer { get; }
+
+ /// Timestamp of the counter event.
+ TimeSpan Timestamp { get; }
+
+ /// Process ID.
+ int ProcessId { get; }
+
+ /// Thread ID.
+ int ThreadId { get; }
+
+ /// Counter source identifier (e.g., InstructionsRetired, CacheMisses).
+ short CounterId { get; }
+}
diff --git a/src/ProfileExplorer.Profiling/Abstractions/IProfileImage.cs b/src/ProfileExplorer.Profiling/Abstractions/IProfileImage.cs
new file mode 100644
index 00000000..4b6148a9
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Abstractions/IProfileImage.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+namespace ProfileExplorer.Profiling;
+
+///
+/// Describes a loaded module/image with its PDB identity for symbol resolution.
+/// Consumers implement this to bridge their image/module source.
+///
+public interface IProfileImage {
+ /// Image file name (e.g., "ntdll.dll").
+ string ImageName { get; }
+
+ /// Base address where the image is loaded in the process.
+ long BaseAddress { get; }
+
+ /// Size of the image in bytes.
+ int Size { get; }
+
+ /// PE TimeDateStamp from the file header (used for binary download).
+ int TimeDateStamp { get; }
+
+ /// PDB GUID from the CodeView debug directory.
+ Guid PdbGuid { get; }
+
+ /// PDB Age from the CodeView debug directory.
+ int PdbAge { get; }
+
+ /// PDB file name (e.g., "ntdll.pdb").
+ string PdbName { get; }
+
+ /// Process ID this image belongs to.
+ int ProcessId { get; }
+}
diff --git a/src/ProfileExplorer.Profiling/Abstractions/IProfileSample.cs b/src/ProfileExplorer.Profiling/Abstractions/IProfileSample.cs
new file mode 100644
index 00000000..a80c091c
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Abstractions/IProfileSample.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+namespace ProfileExplorer.Profiling;
+
+///
+/// A single CPU sample with an instruction pointer and weight.
+/// Consumers implement this to bridge their sample source (DataLayer, TraceEvent, etc.).
+///
+public interface IProfileSample {
+ /// Absolute instruction pointer (virtual address) where the sample was taken.
+ long InstructionPointer { get; }
+
+ /// Estimated duration this sample represents (typically ~1ms for 1kHz sampling).
+ TimeSpan Weight { get; }
+
+ /// Process ID that was running when this sample was taken.
+ int ProcessId { get; }
+
+ /// Thread ID that was running when this sample was taken.
+ int ThreadId { get; }
+
+ /// Name of the module/image that owns the instruction pointer (e.g., "ntdll.dll").
+ string? ImageName { get; }
+
+ /// Base address of the module in the process address space.
+ long ImageBaseAddress { get; }
+
+ ///
+ /// Full stack frame instruction pointers, leaf-first.
+ /// Optional — only needed for call tree construction and inclusive weight calculation.
+ ///
+ IReadOnlyList? StackFrames { get; }
+}
diff --git a/src/ProfileExplorer.Profiling/Disassembly/AssemblyAnnotator.cs b/src/ProfileExplorer.Profiling/Disassembly/AssemblyAnnotator.cs
new file mode 100644
index 00000000..58143188
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Disassembly/AssemblyAnnotator.cs
@@ -0,0 +1,145 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using System.Reflection;
+using System.Text;
+using ProfileExplorer.Profiling.Profiling;
+using ProfileExplorer.Profiling.Symbols;
+
+namespace ProfileExplorer.Profiling.Disassembly;
+
+///
+/// Combines disassembled instructions with profiling weights and source line information
+/// to produce annotated assembly output.
+///
+internal class AssemblyAnnotator {
+ ///
+ /// Annotate a list of disassembled instructions with per-instruction timing data.
+ ///
+ public static AnnotatedAssembly Annotate(
+ IReadOnlyList instructions,
+ IReadOnlyDictionary instructionWeights,
+ long functionRva,
+ IDebugInfoProvider? debugInfo,
+ FunctionDebugInfo? funcDebugInfo,
+ ProcessorArchitecture architecture,
+ double minHotLinePercent,
+ int maxHotLines) {
+ // Build a set of known instruction offsets for skid correction.
+ var knownOffsets = new HashSet();
+ foreach (var instr in instructions) {
+ knownOffsets.Add(instr.Rva - functionRva);
+ }
+
+ // Map instruction weights with skid correction.
+ var correctedWeights = new Dictionary();
+ TimeSpan totalFunctionWeight = TimeSpan.Zero;
+
+ foreach (var (offset, weight) in instructionWeights) {
+ long corrected = InstructionOffsetConfig.CorrectSkid(offset, knownOffsets, architecture);
+ if (correctedWeights.TryGetValue(corrected, out var existing)) {
+ correctedWeights[corrected] = existing + weight;
+ }
+ else {
+ correctedWeights[corrected] = weight;
+ }
+
+ totalFunctionWeight += weight;
+ }
+
+ // Populate source lines if available.
+ if (debugInfo != null && funcDebugInfo != null && !funcDebugInfo.HasSourceLines) {
+ debugInfo.PopulateSourceLines(funcDebugInfo);
+ }
+
+ // Build annotated lines.
+ var lines = new List(instructions.Count);
+ var hotLines = new List();
+ var sb = new StringBuilder();
+
+ foreach (var instr in instructions) {
+ long offset = instr.Rva - functionRva;
+ correctedWeights.TryGetValue(offset, out var weight);
+
+ double percent = totalFunctionWeight > TimeSpan.Zero
+ ? weight.TotalMilliseconds / totalFunctionWeight.TotalMilliseconds * 100
+ : 0;
+
+ // Find source line info.
+ string? sourceFile = null;
+ int? sourceLine = null;
+
+ if (funcDebugInfo?.HasSourceLines == true) {
+ var srcLine = FindSourceLineForOffset(funcDebugInfo.SourceLines!, (int)offset);
+ if (!srcLine.IsUnknown) {
+ sourceFile = srcLine.FilePath ?? funcDebugInfo.SourceFileName;
+ sourceLine = srcLine.Line;
+ }
+ }
+
+ var assemblyLine = new AssemblyLine(
+ address: instr.Address,
+ rva: instr.Rva,
+ instructionText: instr.Text,
+ weight: weight,
+ percent: percent,
+ sourceFile: sourceFile,
+ sourceLine: sourceLine);
+
+ lines.Add(assemblyLine);
+
+ // Build text line.
+ sb.Append($"{instr.Address:X}: {instr.Text}");
+ if (percent >= minHotLinePercent) {
+ sb.Append($" [Time(%): {percent:F2}%, Time: {weight.TotalMilliseconds:F2} ms]");
+ }
+
+ sb.AppendLine();
+
+ // Track hot lines.
+ if (percent >= minHotLinePercent) {
+ hotLines.Add(new HotLine(
+ instructionOffset: offset,
+ percent: percent,
+ time: weight,
+ instructionText: instr.Text,
+ sourceFile: sourceFile,
+ sourceLine: sourceLine));
+ }
+ }
+
+ // Sort hot lines descending by percent and limit.
+ hotLines.Sort((a, b) => b.Percent.CompareTo(a.Percent));
+ if (hotLines.Count > maxHotLines) {
+ hotLines.RemoveRange(maxHotLines, hotLines.Count - maxHotLines);
+ }
+
+ return new AnnotatedAssembly(sb.ToString(), lines, hotLines);
+ }
+
+ private static SourceLineDebugInfo FindSourceLineForOffset(List sourceLines, int offset) {
+ // Source lines are sorted by OffsetStart. Find the line that contains this offset.
+ int low = 0;
+ int high = sourceLines.Count - 1;
+ SourceLineDebugInfo best = SourceLineDebugInfo.Unknown;
+
+ while (low <= high) {
+ int mid = low + (high - low) / 2;
+ var line = sourceLines[mid];
+
+ if (line.OffsetStart <= offset) {
+ best = line;
+ low = mid + 1;
+ }
+ else {
+ high = mid - 1;
+ }
+ }
+
+ return best;
+ }
+}
+
+///
+/// A raw disassembled instruction (before annotation).
+///
+internal record DisassembledInstruction(long Address, long Rva, string Text, int Size);
diff --git a/src/ProfileExplorer.Profiling/Disassembly/Disassembler.cs b/src/ProfileExplorer.Profiling/Disassembly/Disassembler.cs
new file mode 100644
index 00000000..650b1472
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Disassembly/Disassembler.cs
@@ -0,0 +1,223 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using System.Runtime.InteropServices;
+using ProfileExplorer.Profiling.Symbols;
+
+namespace ProfileExplorer.Profiling.Disassembly;
+
+///
+/// x86/x64/ARM64 disassembler using the Capstone engine via P/Invoke.
+///
+internal class Disassembler : IDisposable {
+ private nint handle_;
+ private bool isOpen_;
+ private static bool? capstoneAvailable_;
+
+ /// Whether capstone.dll is loadable on this system.
+ public static bool CapstoneAvailable {
+ get {
+ capstoneAvailable_ ??= CheckCapstoneAvailable();
+ return capstoneAvailable_.Value;
+ }
+ }
+
+ private static bool CheckCapstoneAvailable() {
+ try {
+ // Try to call cs_open — if DLL is missing, this throws DllNotFoundException.
+ int err = Interop.cs_open(Interop.CS_ARCH_X86, Interop.CS_MODE_64, out var testHandle);
+ if (err == 0) Interop.cs_close(ref testHandle);
+ return true;
+ }
+ catch (DllNotFoundException) { return false; }
+ catch { return false; }
+ }
+
+ ///
+ /// Disassemble a function from a binary file.
+ ///
+ /// Path to the PE binary file.
+ /// RVA of the function start.
+ /// Size of the function in bytes.
+ /// Image base address from the PE header.
+ /// Target architecture (x86, x64, ARM64).
+ /// Optional debug info for resolving call targets.
+ /// List of disassembled instructions.
+ public List DisassembleFunction(
+ string binaryPath,
+ long functionRva,
+ int functionSize,
+ long imageBase,
+ System.Reflection.ProcessorArchitecture architecture,
+ IDebugInfoProvider? debugInfo = null) {
+ var instructions = new List();
+
+ if (functionSize <= 0) return instructions;
+
+ // Check if Capstone DLL is available.
+ if (!CapstoneAvailable) return instructions;
+
+ // Read the function bytes from the PE binary.
+ byte[]? code = ReadFunctionBytes(binaryPath, functionRva, functionSize);
+ if (code == null) return instructions;
+
+ // Initialize Capstone.
+ var (csArch, csMode) = GetCapstoneParams(architecture);
+ if (!Open(csArch, csMode)) return instructions;
+
+ try {
+ long baseAddress = imageBase + functionRva;
+ var codeHandle = GCHandle.Alloc(code, GCHandleType.Pinned);
+ try {
+ nint codePtr = codeHandle.AddrOfPinnedObject();
+ nint codeSize = (nint)code.Length;
+ long address = baseAddress;
+
+ // Allocate instruction struct.
+ nint insn = Interop.cs_malloc(handle_);
+ if (insn == 0) return instructions;
+
+ try {
+ while (Interop.cs_disasm_iter(handle_, ref codePtr, ref codeSize, ref address, insn)) {
+ string mnemonic = Marshal.PtrToStringAnsi(insn + Interop.InsnMnemonicOffset) ?? "";
+ string opStr = Marshal.PtrToStringAnsi(insn + Interop.InsnOpStrOffset) ?? "";
+ int size = Marshal.ReadInt16(insn + Interop.InsnSizeOffset);
+ long instrAddress = Marshal.ReadInt64(insn + Interop.InsnAddressOffset);
+ long instrRva = instrAddress - imageBase;
+
+ string text = string.IsNullOrEmpty(opStr) ? mnemonic : $"{mnemonic} {opStr}";
+
+ // Resolve call/jump targets to function names.
+ if (debugInfo != null && (mnemonic == "call" || mnemonic == "jmp") &&
+ TryParseAddress(opStr, out long targetAddr)) {
+ long targetRva = targetAddr - imageBase;
+ var targetFunc = debugInfo.FindFunctionByRVA(targetRva);
+ if (targetFunc != null) {
+ text = $"{mnemonic} {targetFunc.Name}";
+ }
+ }
+
+ instructions.Add(new DisassembledInstruction(instrAddress, instrRva, text, size));
+ }
+ }
+ finally {
+ Interop.cs_free(insn, 1);
+ }
+ }
+ finally {
+ codeHandle.Free();
+ }
+ }
+ finally {
+ Close();
+ }
+
+ return instructions;
+ }
+
+ private static byte[]? ReadFunctionBytes(string binaryPath, long functionRva, int functionSize) {
+ try {
+ using var stream = File.OpenRead(binaryPath);
+ using var peReader = new System.Reflection.PortableExecutable.PEReader(stream);
+
+ // Find the section containing the function RVA.
+ foreach (var section in peReader.PEHeaders.SectionHeaders) {
+ if (functionRva >= section.VirtualAddress &&
+ functionRva < section.VirtualAddress + section.VirtualSize) {
+ int offsetInSection = (int)(functionRva - section.VirtualAddress);
+ int fileOffset = section.PointerToRawData + offsetInSection;
+
+ int bytesToRead = Math.Min(functionSize, section.SizeOfRawData - offsetInSection);
+ if (bytesToRead <= 0) return null;
+
+ var data = new byte[bytesToRead];
+ stream.Position = fileOffset;
+ int read = stream.Read(data, 0, bytesToRead);
+ return read == bytesToRead ? data : null;
+ }
+ }
+ }
+ catch {
+ // Binary read failure.
+ }
+
+ return null;
+ }
+
+ private static (int arch, int mode) GetCapstoneParams(System.Reflection.ProcessorArchitecture architecture) {
+ return architecture switch {
+ System.Reflection.ProcessorArchitecture.X86 => (Interop.CS_ARCH_X86, Interop.CS_MODE_32),
+ System.Reflection.ProcessorArchitecture.Arm => (Interop.CS_ARCH_ARM64, Interop.CS_MODE_ARM),
+ _ => (Interop.CS_ARCH_X86, Interop.CS_MODE_64) // Default to x64
+ };
+ }
+
+ private bool Open(int arch, int mode) {
+ if (isOpen_) Close();
+ int err = Interop.cs_open(arch, mode, out handle_);
+ isOpen_ = err == 0;
+ return isOpen_;
+ }
+
+ private void Close() {
+ if (!isOpen_) return;
+ Interop.cs_close(ref handle_);
+ isOpen_ = false;
+ }
+
+ private static bool TryParseAddress(string operand, out long address) {
+ operand = operand.Trim();
+ if (operand.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) {
+ return long.TryParse(operand[2..], System.Globalization.NumberStyles.HexNumber, null, out address);
+ }
+
+ return long.TryParse(operand, System.Globalization.NumberStyles.HexNumber, null, out address);
+ }
+
+ public void Dispose() {
+ Close();
+ }
+
+ ///
+ /// Capstone P/Invoke interop.
+ ///
+ private static class Interop {
+ public const int CS_ARCH_X86 = 3;
+ public const int CS_ARCH_ARM64 = 1;
+ public const int CS_MODE_32 = 1 << 2;
+ public const int CS_MODE_64 = 1 << 3;
+ public const int CS_MODE_ARM = 0;
+
+ // cs_insn struct layout for Capstone 5.x (must match native struct exactly).
+ // struct cs_insn {
+ // uint32_t id; // offset 0, size 4
+ // // 4 bytes padding
+ // uint64_t alias_id; // offset 8, size 8 (NEW in v5)
+ // uint64_t address; // offset 16, size 8
+ // uint16_t size; // offset 24, size 2
+ // uint8_t bytes[24]; // offset 26, size 24
+ // char mnemonic[32]; // offset 50, size 32
+ // char op_str[160]; // offset 82, size 160
+ // ...
+ // }
+ public const int InsnAddressOffset = 16;
+ public const int InsnSizeOffset = 24;
+ public const int InsnMnemonicOffset = 50;
+ public const int InsnOpStrOffset = 82;
+
+ [DllImport("capstone.dll", CallingConvention = CallingConvention.Cdecl)]
+ public static extern int cs_open(int arch, int mode, out nint handle);
+
+ [DllImport("capstone.dll", CallingConvention = CallingConvention.Cdecl)]
+ public static extern int cs_close(ref nint handle);
+
+ [DllImport("capstone.dll", CallingConvention = CallingConvention.Cdecl)]
+ public static extern nint cs_malloc(nint handle);
+
+ [DllImport("capstone.dll", CallingConvention = CallingConvention.Cdecl)]
+ public static extern void cs_free(nint insn, nint count);
+
+ [DllImport("capstone.dll", CallingConvention = CallingConvention.Cdecl)]
+ [return: MarshalAs(UnmanagedType.I1)]
+ public static extern bool cs_disasm_iter(nint handle, ref nint code, ref nint size, ref long address, nint insn);
+ }
+}
diff --git a/src/ProfileExplorer.Profiling/FunctionProfiler.cs b/src/ProfileExplorer.Profiling/FunctionProfiler.cs
new file mode 100644
index 00000000..8ef05839
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/FunctionProfiler.cs
@@ -0,0 +1,339 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using ProfileExplorer.Profiling.Disassembly;
+using ProfileExplorer.Profiling.Profiling;
+using ProfileExplorer.Profiling.Symbols;
+
+namespace ProfileExplorer.Profiling;
+
+///
+/// Main entry point for function-level CPU profiling with disassembly annotation.
+/// Consumes CPU samples from any source (DataLayer, TraceEvent, etc.) via ,
+/// resolves symbols via its own PDB reader, and produces per-function/per-instruction profiles
+/// with optional annotated disassembly.
+///
+public class FunctionProfiler : IDisposable {
+ private readonly ProfilerOptions options_;
+ private readonly SymbolServerClient symbolServer_;
+ private readonly IpResolver ipResolver_;
+ private readonly SampleAggregator sampleAggregator_;
+ private readonly CallTreeBuilder callTreeBuilder_;
+ private readonly CounterAggregator? counterAggregator_;
+ private readonly ManagedMethodResolver? managedResolver_;
+
+ private readonly Dictionary imagesByModule_ = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary debugInfoByModule_ = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary pdbPathByModule_ = new(StringComparer.OrdinalIgnoreCase);
+ private readonly Dictionary binaryPathByModule_ = new(StringComparer.OrdinalIgnoreCase);
+
+ private IReadOnlyList? cachedProfiles_;
+ private bool symbolsLoaded_;
+
+ public FunctionProfiler(ProfilerOptions options) {
+ options.Validate();
+ options_ = options;
+ symbolServer_ = new SymbolServerClient(options);
+
+ managedResolver_ = options.IncludeManagedCode ? new ManagedMethodResolver() : null;
+ ipResolver_ = new IpResolver(managedResolver_);
+ sampleAggregator_ = new SampleAggregator(ipResolver_);
+ callTreeBuilder_ = new CallTreeBuilder(ipResolver_);
+ counterAggregator_ = options.IncludePerformanceCounters ? new CounterAggregator(ipResolver_) : null;
+ }
+
+ ///
+ /// Register loaded images (modules) with their PDB identity for symbol resolution.
+ ///
+ public void AddImages(IEnumerable images) {
+ foreach (var image in images) {
+ string key = image.ImageName;
+ imagesByModule_[key] = image;
+ ipResolver_.AddImage(key, image.BaseAddress, image.Size);
+ }
+
+ cachedProfiles_ = null;
+ }
+
+ ///
+ /// Add CPU samples. Can be called multiple times (e.g., per-processor batches).
+ ///
+ public void AddSamples(IEnumerable samples) {
+ var sampleList = samples as IReadOnlyList ?? samples.ToList();
+ sampleAggregator_.AddSamples(sampleList);
+ callTreeBuilder_.AddSamples(sampleList);
+ cachedProfiles_ = null;
+ }
+
+ ///
+ /// Add hardware performance counter events (PMU/PMC).
+ /// Only processed if is true.
+ ///
+ public void AddPerformanceCounterEvents(IEnumerable events) {
+ counterAggregator_?.AddEvents(events);
+ }
+
+ ///
+ /// Register managed/.NET method mappings (from CLR JIT events).
+ /// Only processed if is true.
+ ///
+ public void AddManagedMethods(IEnumerable methods) {
+ if (managedResolver_ == null) return;
+
+ foreach (var method in methods) {
+ managedResolver_.AddMethod(method);
+ }
+ }
+
+ ///
+ /// Load symbols for all registered images. Downloads PDBs from the symbol server.
+ ///
+ public async Task LoadSymbolsAsync(CancellationToken ct = default) {
+ if (symbolsLoaded_) return;
+
+ foreach (var (moduleName, image) in imagesByModule_) {
+ if (image.PdbGuid == Guid.Empty) continue;
+
+ try {
+ string pdbName = !string.IsNullOrEmpty(image.PdbName)
+ ? Path.GetFileName(image.PdbName)
+ : Path.ChangeExtension(image.ImageName, ".pdb");
+
+ string? pdbPath = await symbolServer_.FindSymbolFileAsync(pdbName, image.PdbGuid, image.PdbAge, ct);
+ if (pdbPath == null) continue;
+
+ pdbPathByModule_[moduleName] = pdbPath;
+
+ // Load debug info and register function list with the IP resolver.
+ var provider = new PdbSymbolProvider();
+ if (provider.LoadDebugInfo(pdbPath)) {
+ debugInfoByModule_[moduleName] = provider;
+ var sortedFunctions = provider.GetSortedFunctions();
+ if (sortedFunctions.Count > 0) {
+ ipResolver_.SetFunctions(moduleName, sortedFunctions);
+ }
+ else {
+ Console.Error.WriteLine($" PDB loaded but 0 functions: {moduleName} ({pdbPath})");
+ }
+ }
+ else {
+ Console.Error.WriteLine($" PDB load FAILED: {moduleName} - {PdbSymbolProvider.DiaRegistrationError}");
+ provider.Dispose();
+ }
+ }
+ catch (Exception) {
+ // Symbol loading failure for this module — continue with others.
+ }
+ }
+
+ symbolsLoaded_ = true;
+ cachedProfiles_ = null;
+ }
+
+ ///
+ /// Build aggregated per-function profiles from added samples.
+ ///
+ public IReadOnlyList GetFunctionProfiles(
+ string? processName = null,
+ int? processId = null) {
+ if (cachedProfiles_ != null) return cachedProfiles_;
+
+ var profiles = sampleAggregator_.Build(processName, processId);
+
+ // Enrich with source info and counter data.
+ var enrichedProfiles = new List(profiles.Count);
+
+ foreach (var profile in profiles) {
+ string? sourceFile = profile.SourceFile;
+ int? sourceLine = profile.SourceLine;
+ bool hasAssembly = profile.HasAssembly;
+
+ // Source file info from debug info.
+ if (debugInfoByModule_.TryGetValue(profile.ModuleName, out var debugInfo)) {
+ var funcInfo = debugInfo.FindFunctionByRVA(profile.FunctionRva);
+ if (funcInfo != null) {
+ var sourceFileInfo = debugInfo.FindSourceFilePathByRVA(profile.FunctionRva);
+ if (sourceFileInfo.HasFilePath) {
+ sourceFile = sourceFileInfo.FilePath;
+ sourceLine = sourceFileInfo.StartLine;
+ }
+ }
+
+ hasAssembly = true;
+ }
+
+ var enriched = new FunctionProfile(
+ moduleName: profile.ModuleName,
+ functionName: profile.FunctionName,
+ functionRva: profile.FunctionRva,
+ functionSize: profile.FunctionSize,
+ inclusiveWeight: profile.InclusiveWeight,
+ exclusiveWeight: profile.ExclusiveWeight,
+ inclusivePercent: profile.InclusivePercent,
+ exclusivePercent: profile.ExclusivePercent,
+ sourceFile: sourceFile,
+ sourceLine: sourceLine,
+ isManaged: profile.IsManaged,
+ instructionWeights: (Dictionary)profile.InstructionWeights);
+
+ enriched.HasAssembly = hasAssembly;
+
+ // Counter data.
+ if (counterAggregator_ != null) {
+ var counters = counterAggregator_.GetCounters(enriched.QualifiedName);
+ if (counters != null) {
+ enriched.InstructionCounters = counters;
+ }
+ }
+
+ enrichedProfiles.Add(enriched);
+ }
+
+ // Filter by minimum self percent.
+ if (options_.MinSelfPercent > 0) {
+ enrichedProfiles = enrichedProfiles.Where(p => p.ExclusivePercent >= options_.MinSelfPercent).ToList();
+ }
+
+ cachedProfiles_ = enrichedProfiles;
+ return enrichedProfiles;
+ }
+
+ ///
+ /// Build a call tree from added samples. Requires StackFrames on IProfileSample.
+ ///
+ public CallTreeNode GetCallTree(
+ string? processName = null,
+ int? processId = null) {
+ return callTreeBuilder_.Build();
+ }
+
+ ///
+ /// Get annotated disassembly for a specific function.
+ /// Downloads the binary on-demand, disassembles via Capstone, and annotates with timing data.
+ ///
+ public async Task GetAnnotatedAssemblyAsync(
+ FunctionProfile function,
+ CancellationToken ct = default) {
+ // Download binary if not already cached.
+ if (!binaryPathByModule_.TryGetValue(function.ModuleName, out var binaryPath)) {
+ if (imagesByModule_.TryGetValue(function.ModuleName, out var image)) {
+ binaryPath = await symbolServer_.FindBinaryFileAsync(
+ image.ImageName, image.TimeDateStamp, image.Size, ct);
+
+ if (binaryPath != null) {
+ binaryPathByModule_[function.ModuleName] = binaryPath;
+ }
+ }
+ }
+
+ if (binaryPath == null) {
+ // Binary not available — try to return hot lines from instruction weights
+ // without disassembly. Avoids DIA COM calls (AccessViolationException risk).
+ Console.Error.WriteLine($" Binary not found for {function.ModuleName}, falling back to instruction weights ({function.InstructionWeights.Count} offsets, {function.ExclusiveWeight.TotalMilliseconds:F1}ms)");
+ try {
+ var result = GetHotLinesWithoutBinary(function);
+ Console.Error.WriteLine($" GetHotLinesWithoutBinary: {(result != null ? $"{result.HotLines.Count} hot lines" : "null")}");
+ return result;
+ }
+ catch (Exception ex) {
+ Console.Error.WriteLine($" GetHotLinesWithoutBinary failed for {function.QualifiedName}: {ex.GetType().Name}: {ex.Message}");
+ return null;
+ }
+ }
+
+ // Get image base for address calculation.
+ long imageBase = 0;
+ if (imagesByModule_.TryGetValue(function.ModuleName, out var img)) {
+ imageBase = img.BaseAddress;
+ }
+
+ // Disassemble.
+ using var disassembler = new Disassembler();
+ var instructions = disassembler.DisassembleFunction(
+ binaryPath,
+ function.FunctionRva,
+ function.FunctionSize,
+ imageBase,
+ options_.Architecture,
+ debugInfoByModule_.GetValueOrDefault(function.ModuleName));
+
+ if (instructions.Count == 0) return null;
+
+ // Get debug info for source line annotation.
+ debugInfoByModule_.TryGetValue(function.ModuleName, out var debugInfoProvider);
+ FunctionDebugInfo? funcDebugInfo = debugInfoProvider?.FindFunctionByRVA(function.FunctionRva);
+
+ // Annotate.
+ return AssemblyAnnotator.Annotate(
+ instructions,
+ function.InstructionWeights,
+ function.FunctionRva,
+ debugInfoProvider,
+ funcDebugInfo,
+ options_.Architecture,
+ options_.MinHotLinePercent,
+ options_.MaxHotLines);
+ }
+
+ public void Dispose() {
+ symbolServer_.Dispose();
+
+ foreach (var (_, provider) in debugInfoByModule_) {
+ provider.Dispose();
+ }
+
+ debugInfoByModule_.Clear();
+ }
+
+ ///
+ /// Generate hot lines from instruction weights only, without requiring
+ /// the binary for Capstone disassembly. Avoids DIA COM calls to prevent
+ /// AccessViolationException from cross-thread COM access.
+ /// Uses pre-loaded source file/line from the FunctionProfile if available.
+ ///
+ private AnnotatedAssembly? GetHotLinesWithoutBinary(FunctionProfile function) {
+ if (function.InstructionWeights.Count == 0) return null;
+
+ var totalWeight = function.InstructionWeights.Values.Aggregate(TimeSpan.Zero, (sum, w) => sum + w);
+ var hotLines = new List();
+ var lines = new List();
+ var sb = new System.Text.StringBuilder();
+
+ // Use source file from FunctionProfile (populated during GetFunctionProfiles enrichment).
+ string? sourceFile = function.SourceFile;
+
+ foreach (var (offset, weight) in function.InstructionWeights.OrderByDescending(kv => kv.Value)) {
+ double percent = totalWeight > TimeSpan.Zero
+ ? weight.TotalMilliseconds / totalWeight.TotalMilliseconds * 100 : 0;
+
+ if (percent < options_.MinHotLinePercent) continue;
+
+ string text = $"[offset +0x{offset:X}]";
+
+ var line = new AssemblyLine(
+ address: function.FunctionRva + offset,
+ rva: function.FunctionRva + offset,
+ instructionText: text,
+ weight: weight,
+ percent: percent,
+ sourceFile: sourceFile,
+ sourceLine: null);
+ lines.Add(line);
+
+ sb.AppendLine($"{function.FunctionRva + offset:X}: {text} [Time(%): {percent:F2}%, Time: {weight.TotalMilliseconds:F2} ms]");
+
+ hotLines.Add(new HotLine(
+ instructionOffset: offset,
+ percent: percent,
+ time: weight,
+ instructionText: text,
+ sourceFile: sourceFile,
+ sourceLine: null));
+
+ if (hotLines.Count >= options_.MaxHotLines) break;
+ }
+
+ if (hotLines.Count == 0) return null;
+
+ return new AnnotatedAssembly(sb.ToString(), lines, hotLines);
+ }
+}
diff --git a/src/ProfileExplorer.Profiling/Models/AnnotatedAssembly.cs b/src/ProfileExplorer.Profiling/Models/AnnotatedAssembly.cs
new file mode 100644
index 00000000..01b6c477
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Models/AnnotatedAssembly.cs
@@ -0,0 +1,107 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+namespace ProfileExplorer.Profiling;
+
+///
+/// Annotated disassembly output for a function, including per-instruction timing.
+///
+public class AnnotatedAssembly {
+ public AnnotatedAssembly(
+ string fullText,
+ IReadOnlyList lines,
+ IReadOnlyList hotLines) {
+ FullText = fullText;
+ Lines = lines;
+ HotLines = hotLines;
+ }
+
+ /// Complete annotated disassembly text.
+ public string FullText { get; }
+
+ /// All disassembled instructions with their profiling data.
+ public IReadOnlyList Lines { get; }
+
+ /// Only instructions above the minimum percent threshold, sorted descending.
+ public IReadOnlyList HotLines { get; }
+}
+
+///
+/// A single disassembled instruction with profiling attribution.
+///
+public class AssemblyLine {
+ public AssemblyLine(
+ long address,
+ long rva,
+ string instructionText,
+ TimeSpan weight,
+ double percent,
+ string? sourceFile,
+ int? sourceLine) {
+ Address = address;
+ Rva = rva;
+ InstructionText = instructionText;
+ Weight = weight;
+ Percent = percent;
+ SourceFile = sourceFile;
+ SourceLine = sourceLine;
+ }
+
+ /// Absolute virtual address of the instruction.
+ public long Address { get; }
+
+ /// Relative Virtual Address within the module.
+ public long Rva { get; }
+
+ /// Disassembled instruction text (e.g., "call CIconCache::GetIcon").
+ public string InstructionText { get; }
+
+ /// Accumulated CPU time on this instruction.
+ public TimeSpan Weight { get; }
+
+ /// Percentage of function's total CPU time.
+ public double Percent { get; }
+
+ /// Source file path (if available from PDB).
+ public string? SourceFile { get; }
+
+ /// Source line number (if available from PDB).
+ public int? SourceLine { get; }
+}
+
+///
+/// A hot instruction — an instruction that consumed a significant portion of CPU time.
+///
+public class HotLine {
+ public HotLine(
+ long instructionOffset,
+ double percent,
+ TimeSpan time,
+ string instructionText,
+ string? sourceFile,
+ int? sourceLine) {
+ InstructionOffset = instructionOffset;
+ Percent = percent;
+ Time = time;
+ InstructionText = instructionText;
+ SourceFile = sourceFile;
+ SourceLine = sourceLine;
+ }
+
+ /// RVA offset from function start.
+ public long InstructionOffset { get; }
+
+ /// Percentage of function's total CPU time.
+ public double Percent { get; }
+
+ /// Absolute CPU time on this instruction.
+ public TimeSpan Time { get; }
+
+ /// Disassembled instruction text.
+ public string InstructionText { get; }
+
+ /// Source file path (if available).
+ public string? SourceFile { get; }
+
+ /// Source line number (if available).
+ public int? SourceLine { get; }
+}
diff --git a/src/ProfileExplorer.Profiling/Models/CallTree.cs b/src/ProfileExplorer.Profiling/Models/CallTree.cs
new file mode 100644
index 00000000..514bc107
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Models/CallTree.cs
@@ -0,0 +1,130 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+namespace ProfileExplorer.Profiling;
+
+///
+/// A node in a profiling call tree representing one function in a specific call path.
+///
+public class CallTreeNode {
+ private readonly List children_ = [];
+ private readonly List callers_ = [];
+ private readonly List callSites_ = [];
+ private readonly Dictionary threadWeights_ = [];
+
+ public CallTreeNode(string moduleName, string functionName, long functionRva, CallTreeNodeKind kind) {
+ ModuleName = moduleName;
+ FunctionName = functionName;
+ FunctionRva = functionRva;
+ Kind = kind;
+ }
+
+ /// Module/image name.
+ public string ModuleName { get; }
+
+ /// Function name.
+ public string FunctionName { get; }
+
+ /// Function RVA.
+ public long FunctionRva { get; }
+
+ /// Total time including all descendants (inclusive weight).
+ public TimeSpan InclusiveWeight { get; internal set; }
+
+ /// Self time only — leaf samples (exclusive weight).
+ public TimeSpan ExclusiveWeight { get; internal set; }
+
+ /// Inclusive weight as percentage of total trace time.
+ public double InclusivePercent { get; internal set; }
+
+ /// Exclusive weight as percentage of total trace time.
+ public double ExclusivePercent { get; internal set; }
+
+ /// Child nodes (callees from this function).
+ public IReadOnlyList Children => children_;
+
+ /// Parent nodes (callers of this function).
+ public IReadOnlyList Callers => callers_;
+
+ /// Per-thread weight breakdown.
+ public IReadOnlyDictionary ThreadWeights => threadWeights_;
+
+ /// Whether this is native user, native kernel, or managed code.
+ public CallTreeNodeKind Kind { get; }
+
+ /// Call sites within this function that call into children.
+ public IReadOnlyList CallSites => callSites_;
+
+ /// Qualified name in "module!function" format.
+ public string QualifiedName => $"{ModuleName}!{FunctionName}";
+
+ internal void AddChild(CallTreeNode child) {
+ children_.Add(child);
+ }
+
+ internal void AddCaller(CallTreeNode caller) {
+ if (!callers_.Contains(caller)) {
+ callers_.Add(caller);
+ }
+ }
+
+ internal void AddCallSite(CallSite site) {
+ callSites_.Add(site);
+ }
+
+ internal void AccumulateWeight(TimeSpan weight) {
+ InclusiveWeight += weight;
+ }
+
+ internal void AccumulateExclusiveWeight(TimeSpan weight) {
+ ExclusiveWeight += weight;
+ }
+
+ internal void AccumulateThreadWeight(int threadId, TimeSpan inclusive, TimeSpan exclusive) {
+ if (threadWeights_.TryGetValue(threadId, out var existing)) {
+ threadWeights_[threadId] = new ThreadWeight(
+ existing.Inclusive + inclusive,
+ existing.Exclusive + exclusive);
+ }
+ else {
+ threadWeights_[threadId] = new ThreadWeight(inclusive, exclusive);
+ }
+ }
+
+ public override string ToString() =>
+ $"{QualifiedName} (Incl: {InclusiveWeight.TotalMilliseconds:F1}ms, Excl: {ExclusiveWeight.TotalMilliseconds:F1}ms)";
+}
+
+///
+/// A call site within a function — a specific instruction that calls other functions.
+///
+public class CallSite {
+ private readonly List<(CallTreeNode Target, TimeSpan Weight)> targets_ = [];
+
+ public CallSite(long rva) {
+ Rva = rva;
+ }
+
+ /// RVA of the call instruction.
+ public long Rva { get; }
+
+ /// Total weight through this call site.
+ public TimeSpan Weight { get; internal set; }
+
+ /// Target functions called from this site (supports polymorphic/indirect calls).
+ public IReadOnlyList<(CallTreeNode Target, TimeSpan Weight)> Targets => targets_;
+
+ internal void AddTarget(CallTreeNode target, TimeSpan weight) {
+ targets_.Add((target, weight));
+ Weight += weight;
+ }
+}
+
+/// Per-thread inclusive and exclusive weight.
+public readonly record struct ThreadWeight(TimeSpan Inclusive, TimeSpan Exclusive);
+
+/// Kind of code a call tree node represents.
+public enum CallTreeNodeKind {
+ NativeUser,
+ NativeKernel,
+ Managed
+}
diff --git a/src/ProfileExplorer.Profiling/Models/FunctionProfile.cs b/src/ProfileExplorer.Profiling/Models/FunctionProfile.cs
new file mode 100644
index 00000000..b8c4630f
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Models/FunctionProfile.cs
@@ -0,0 +1,88 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+namespace ProfileExplorer.Profiling;
+
+///
+/// Aggregated profiling data for a single function.
+///
+public class FunctionProfile {
+ public FunctionProfile(
+ string moduleName,
+ string functionName,
+ long functionRva,
+ int functionSize,
+ TimeSpan inclusiveWeight,
+ TimeSpan exclusiveWeight,
+ double inclusivePercent,
+ double exclusivePercent,
+ string? sourceFile,
+ int? sourceLine,
+ bool isManaged,
+ Dictionary instructionWeights) {
+ ModuleName = moduleName;
+ FunctionName = functionName;
+ FunctionRva = functionRva;
+ FunctionSize = functionSize;
+ InclusiveWeight = inclusiveWeight;
+ ExclusiveWeight = exclusiveWeight;
+ InclusivePercent = inclusivePercent;
+ ExclusivePercent = exclusivePercent;
+ SourceFile = sourceFile;
+ SourceLine = sourceLine;
+ IsManaged = isManaged;
+ InstructionWeights = instructionWeights;
+ }
+
+ /// Module/image name (e.g., "ntdll.dll").
+ public string ModuleName { get; }
+
+ /// Function name (e.g., "RtlAllocateHeap").
+ public string FunctionName { get; }
+
+ /// Relative Virtual Address of the function start.
+ public long FunctionRva { get; }
+
+ /// Function size in bytes.
+ public int FunctionSize { get; }
+
+ /// Total time including callees (inclusive/total weight).
+ public TimeSpan InclusiveWeight { get; internal set; }
+
+ /// Self time only — samples where IP was inside this function (exclusive weight).
+ public TimeSpan ExclusiveWeight { get; }
+
+ /// Inclusive weight as percentage of total trace time.
+ public double InclusivePercent { get; internal set; }
+
+ /// Exclusive weight as percentage of total trace time.
+ public double ExclusivePercent { get; }
+
+ /// Source file path for the function entry point (if available from PDB).
+ public string? SourceFile { get; }
+
+ /// Source line number for the function entry point.
+ public int? SourceLine { get; }
+
+ /// Whether the binary is available for disassembly.
+ public bool HasAssembly { get; internal set; }
+
+ ///
+ /// Per-instruction-offset weights. Key = RVA offset from function start, Value = accumulated CPU time.
+ ///
+ public IReadOnlyDictionary InstructionWeights { get; }
+
+ ///
+ /// Per-instruction PMU counter values. Only populated when IncludePerformanceCounters is enabled.
+ /// Key = RVA offset from function start.
+ ///
+ public IReadOnlyDictionary? InstructionCounters { get; internal set; }
+
+ /// Whether this is a managed (.NET) function.
+ public bool IsManaged { get; }
+
+ /// Qualified name in "module!function" format.
+ public string QualifiedName => $"{ModuleName}!{FunctionName}";
+
+ public override string ToString() =>
+ $"{QualifiedName} (Self: {ExclusiveWeight.TotalMilliseconds:F1}ms / {ExclusivePercent:F2}%)";
+}
diff --git a/src/ProfileExplorer.Profiling/Models/PerformanceCounters.cs b/src/ProfileExplorer.Profiling/Models/PerformanceCounters.cs
new file mode 100644
index 00000000..f0140ad8
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Models/PerformanceCounters.cs
@@ -0,0 +1,105 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using System.Runtime.InteropServices;
+using ProtoBuf;
+
+namespace ProfileExplorer.Profiling;
+
+///
+/// Per-instruction counter values for a single instruction offset.
+/// Groups all PMU counter types for one instruction.
+///
+[ProtoContract(SkipConstructor = true)]
+public class InstructionCounterValues {
+ [ProtoMember(1)]
+ private List counters_;
+
+ public InstructionCounterValues() {
+ counters_ = [];
+ }
+
+ /// Counter values keyed by counter ID.
+ public IReadOnlyList Counters => counters_;
+
+ /// Get the accumulated value for a specific counter.
+ public long GetCounterValue(int counterId) {
+ int index = counters_.FindIndex(c => c.CounterId == counterId);
+ return index != -1 ? counters_[index].Value : 0;
+ }
+
+ /// Add a sample to a specific counter.
+ public void AddCounterSample(int counterId, long value) {
+ int index = counters_.FindIndex(c => c.CounterId == counterId);
+ var span = CollectionsMarshal.AsSpan(counters_);
+
+ if (index != -1) {
+ ref var counterRef = ref span[index];
+ counterRef = new CounterValue(counterRef.CounterId, counterRef.Value + value);
+ }
+ else {
+ // Keep sorted by counter ID.
+ int insertAt = 0;
+ for (int i = 0; i < counters_.Count; i++, insertAt++) {
+ if (counters_[i].CounterId >= counterId)
+ break;
+ }
+
+ counters_.Insert(insertAt, new CounterValue(counterId, value));
+ }
+ }
+
+ /// Merge another set of counter values into this one.
+ public void Add(InstructionCounterValues other) {
+ foreach (var counter in other.counters_) {
+ AddCounterSample(counter.CounterId, counter.Value);
+ }
+ }
+}
+
+///
+/// A single counter type's accumulated value.
+///
+[ProtoContract(SkipConstructor = true)]
+[StructLayout(LayoutKind.Sequential, Pack = 1)]
+public readonly record struct CounterValue(
+ [property: ProtoMember(1)] int CounterId,
+ [property: ProtoMember(2)] long Value);
+
+///
+/// Describes a registered hardware performance counter source.
+///
+public class PerformanceCounterInfo {
+ public PerformanceCounterInfo(int id, string name, long frequency) {
+ Id = id;
+ Name = name;
+ Frequency = frequency;
+ }
+
+ public int Id { get; }
+ public string Name { get; }
+ public long Frequency { get; }
+ public int Index { get; set; }
+}
+
+///
+/// A derived metric computed from two base counters (e.g., cache miss rate = misses / references).
+///
+public class PerformanceMetricInfo {
+ public PerformanceMetricInfo(string name, string baseCounterName, string relativeCounterName, bool isPercentage) {
+ Name = name;
+ BaseCounterName = baseCounterName;
+ RelativeCounterName = relativeCounterName;
+ IsPercentage = isPercentage;
+ }
+
+ public string Name { get; }
+ public string BaseCounterName { get; }
+ public string RelativeCounterName { get; }
+ public bool IsPercentage { get; }
+
+ public double ComputeMetric(long baseValue, long relativeValue) {
+ if (baseValue == 0) return 0;
+ double result = relativeValue / (double)baseValue;
+ return IsPercentage ? Math.Min(result, 1.0) : result;
+ }
+}
diff --git a/src/ProfileExplorer.Profiling/ProfileExplorer.Profiling.csproj b/src/ProfileExplorer.Profiling/ProfileExplorer.Profiling.csproj
new file mode 100644
index 00000000..6a09caaf
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/ProfileExplorer.Profiling.csproj
@@ -0,0 +1,56 @@
+
+
+
+ net8.0-windows
+ enable
+ enable
+ true
+ ProfileExplorer.Profiling
+ ProfileExplorer.Profiling
+ Function-level CPU profiling, disassembly, and performance counter attribution from ETW traces.
+ ProfileExplorer.Profiling
+ Microsoft
+ Microsoft
+ MIT
+
+
+
+
+
+
+
+
+
+
+
+
+
+ external\Dia2Lib.dll
+
+
+
+
+
+
+
+ PreserveNewest
+ capstone.dll
+
+
+ PreserveNewest
+ msdia140.dll
+
+
+
+
+
+
+
+
+
+
+
+ lib
+
+
+
diff --git a/src/ProfileExplorer.Profiling/ProfilerOptions.cs b/src/ProfileExplorer.Profiling/ProfilerOptions.cs
new file mode 100644
index 00000000..df935e1e
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/ProfilerOptions.cs
@@ -0,0 +1,81 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using System.Reflection;
+
+namespace ProfileExplorer.Profiling;
+
+///
+/// Configuration options for the function profiler.
+///
+public class ProfilerOptions {
+ ///
+ /// Symbol server paths in standard format (e.g., "srv*C:\Symbols*https://symbolserver.example.com").
+ ///
+ public IReadOnlyList SymbolPaths { get; set; } = [];
+
+ /// Normal symbol download timeout in seconds.
+ public int SymbolTimeoutSeconds { get; set; } = 30;
+
+ /// Bellwether test timeout in seconds (quick health check before bulk downloads).
+ public int BellwetherTimeoutSeconds { get; set; } = 5;
+
+ /// Reduced timeout when the server has been detected as slow.
+ public int DegradedTimeoutSeconds { get; set; } = 3;
+
+ /// Only load symbols for modules matching this company name.
+ public bool EnableCompanyFilter { get; set; } = true;
+
+ /// Company name for the module filter.
+ public string? CompanyName { get; set; } = "Microsoft";
+
+ /// Skip download attempts for previously-failed files within the negative cache TTL.
+ public bool EnableNegativeCache { get; set; } = true;
+
+ /// Minimum self-time percent threshold for GetFunctionProfiles results.
+ public double MinSelfPercent { get; set; } = 0.0;
+
+ /// Minimum hot-line percent threshold for annotated assembly output.
+ public double MinHotLinePercent { get; set; } = 1.0;
+
+ /// Maximum number of hot lines per function in annotated assembly.
+ public int MaxHotLines { get; set; } = 10;
+
+ /// Target processor architecture.
+ public ProcessorArchitecture Architecture { get; set; } = ProcessorArchitecture.Amd64;
+
+ /// Whether to process PMU/PMC hardware counter data.
+ public bool IncludePerformanceCounters { get; set; } = false;
+
+ /// Whether to resolve managed (.NET) JIT methods.
+ public bool IncludeManagedCode { get; set; } = true;
+
+ /// Local cache directory for downloaded PDBs and binaries.
+ public string? SymbolCacheDirectory { get; set; }
+
+ ///
+ /// Optional pre-authenticated bearer token for the symbol server.
+ /// When set, the SymbolServerClient uses this token instead of trying Azure Identity.
+ /// The consumer is responsible for obtaining the token (e.g., via SymwebHandler from TraceEvent).
+ ///
+ public string? SymwebBearerToken { get; set; }
+
+ ///
+ /// Validate options and throw if invalid.
+ ///
+ public void Validate() {
+ if (SymbolPaths is not { Count: > 0 }) {
+ throw new ArgumentException("At least one symbol path must be specified.", nameof(SymbolPaths));
+ }
+
+ if (SymbolTimeoutSeconds < 0) {
+ throw new ArgumentOutOfRangeException(nameof(SymbolTimeoutSeconds), "Timeout must be non-negative.");
+ }
+
+ if (BellwetherTimeoutSeconds < 0) {
+ throw new ArgumentOutOfRangeException(nameof(BellwetherTimeoutSeconds), "Timeout must be non-negative.");
+ }
+
+ MinSelfPercent = Math.Clamp(MinSelfPercent, 0, 100);
+ MinHotLinePercent = Math.Clamp(MinHotLinePercent, 0, 100);
+ }
+}
diff --git a/src/ProfileExplorer.Profiling/Profiling/CallTreeBuilder.cs b/src/ProfileExplorer.Profiling/Profiling/CallTreeBuilder.cs
new file mode 100644
index 00000000..0ea2fba6
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Profiling/CallTreeBuilder.cs
@@ -0,0 +1,125 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using System.Collections.Concurrent;
+
+namespace ProfileExplorer.Profiling.Profiling;
+
+///
+/// Builds a profiling call tree from resolved stack samples.
+/// Thread-safe — supports parallel chunk-based construction with merge.
+///
+internal class CallTreeBuilder {
+ private readonly ConcurrentDictionary rootNodes_ = new(StringComparer.OrdinalIgnoreCase);
+ private readonly IpResolver ipResolver_;
+ private TimeSpan totalWeight_;
+ private readonly object weightLock_ = new();
+
+ public CallTreeBuilder(IpResolver ipResolver) {
+ 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) {
+ TimeSpan batchWeight = TimeSpan.Zero;
+
+ foreach (var sample in samples) {
+ if (sample.StackFrames is not { Count: > 0 }) continue;
+
+ batchWeight += sample.Weight;
+
+ // Resolve all frames.
+ var resolvedFrames = new List(sample.StackFrames.Count);
+ for (int i = sample.StackFrames.Count - 1; i >= 0; i--) { // Walk root-to-leaf
+ var resolved = ipResolver_.Resolve(sample.StackFrames[i]);
+ if (resolved != null) {
+ resolvedFrames.Add(resolved);
+ }
+ }
+
+ if (resolvedFrames.Count == 0) continue;
+
+ // Build tree path root → leaf.
+ CallTreeNode? parent = null;
+
+ for (int i = 0; i < resolvedFrames.Count; i++) {
+ var frame = resolvedFrames[i];
+ bool isLeaf = i == resolvedFrames.Count - 1;
+ string funcName = frame.FunctionName ?? $"";
+ var kind = frame.IsManaged ? CallTreeNodeKind.Managed : CallTreeNodeKind.NativeUser;
+
+ CallTreeNode node;
+
+ if (parent == null) {
+ // Root node.
+ string rootKey = $"{frame.ModuleName}!{funcName}";
+ node = rootNodes_.GetOrAdd(rootKey, _ => new CallTreeNode(frame.ModuleName, funcName, frame.Rva, kind));
+ }
+ else {
+ // Child node — find or create.
+ node = FindOrAddChild(parent, frame.ModuleName, funcName, frame.Rva, kind);
+ }
+
+ node.AccumulateWeight(sample.Weight);
+ node.AccumulateThreadWeight(sample.ThreadId, sample.Weight, isLeaf ? sample.Weight : TimeSpan.Zero);
+
+ if (isLeaf) {
+ node.AccumulateExclusiveWeight(sample.Weight);
+ }
+
+ parent = node;
+ }
+ }
+
+ lock (weightLock_) {
+ totalWeight_ += batchWeight;
+ }
+ }
+
+ ///
+ /// Build the final call tree. Returns a synthetic root node containing all actual roots as children.
+ ///
+ public CallTreeNode Build() {
+ var root = new CallTreeNode("[Root]", "[Root]", 0, CallTreeNodeKind.NativeUser);
+ double totalMs = totalWeight_.TotalMilliseconds;
+
+ foreach (var (_, node) in rootNodes_) {
+ root.AddChild(node);
+ root.AccumulateWeight(node.InclusiveWeight);
+ ComputePercents(node, totalMs);
+ }
+
+ root.InclusivePercent = 100.0;
+ return root;
+ }
+
+ private static CallTreeNode FindOrAddChild(CallTreeNode parent, string moduleName, string funcName,
+ long rva, CallTreeNodeKind kind) {
+ // Check existing children.
+ foreach (var child in parent.Children) {
+ if (string.Equals(child.ModuleName, moduleName, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(child.FunctionName, funcName, StringComparison.Ordinal)) {
+ return child;
+ }
+ }
+
+ // Create new child.
+ var newChild = new CallTreeNode(moduleName, funcName, rva, kind);
+ parent.AddChild(newChild);
+ newChild.AddCaller(parent);
+ return newChild;
+ }
+
+ private static void ComputePercents(CallTreeNode node, double totalMs) {
+ if (totalMs > 0) {
+ node.InclusivePercent = node.InclusiveWeight.TotalMilliseconds / totalMs * 100;
+ node.ExclusivePercent = node.ExclusiveWeight.TotalMilliseconds / totalMs * 100;
+ }
+
+ foreach (var child in node.Children) {
+ ComputePercents(child, totalMs);
+ }
+ }
+}
diff --git a/src/ProfileExplorer.Profiling/Profiling/CounterAggregator.cs b/src/ProfileExplorer.Profiling/Profiling/CounterAggregator.cs
new file mode 100644
index 00000000..a1598f44
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Profiling/CounterAggregator.cs
@@ -0,0 +1,53 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+namespace ProfileExplorer.Profiling.Profiling;
+
+///
+/// Aggregates hardware performance counter events into per-function/instruction counter values.
+///
+internal class CounterAggregator {
+ private readonly IpResolver ipResolver_;
+ private readonly Dictionary> countersByFunction_ = new(StringComparer.OrdinalIgnoreCase);
+ private readonly object lock_ = new();
+
+ public CounterAggregator(IpResolver ipResolver) {
+ ipResolver_ = ipResolver;
+ }
+
+ ///
+ /// Add a batch of performance counter events.
+ ///
+ public void AddEvents(IEnumerable events) {
+ foreach (var evt in events) {
+ var resolved = ipResolver_.Resolve(evt.InstructionPointer);
+ if (resolved?.FunctionName == null) continue;
+
+ string key = $"{resolved.ModuleName}!{resolved.FunctionName}";
+
+ lock (lock_) {
+ if (!countersByFunction_.TryGetValue(key, out var instrCounters)) {
+ instrCounters = [];
+ countersByFunction_[key] = instrCounters;
+ }
+
+ if (!instrCounters.TryGetValue(resolved.InstructionOffset, out var counterSet)) {
+ counterSet = new InstructionCounterValues();
+ instrCounters[resolved.InstructionOffset] = counterSet;
+ }
+
+ counterSet.AddCounterSample(evt.CounterId, 1);
+ }
+ }
+ }
+
+ ///
+ /// Get the per-instruction counter values for a specific function.
+ ///
+ public IReadOnlyDictionary? GetCounters(string qualifiedFunctionName) {
+ lock (lock_) {
+ return countersByFunction_.TryGetValue(qualifiedFunctionName, out var counters)
+ ? new Dictionary(counters)
+ : null;
+ }
+ }
+}
diff --git a/src/ProfileExplorer.Profiling/Profiling/FunctionProfileBuilder.cs b/src/ProfileExplorer.Profiling/Profiling/FunctionProfileBuilder.cs
new file mode 100644
index 00000000..1c29390d
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Profiling/FunctionProfileBuilder.cs
@@ -0,0 +1,80 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+namespace ProfileExplorer.Profiling.Profiling;
+
+///
+/// Accumulates per-instruction weights for a single function.
+/// Thread-safe — uses locking for concurrent sample addition.
+///
+internal class FunctionProfileBuilder {
+ private readonly Dictionary instructionWeights_ = [];
+ private TimeSpan exclusiveWeight_;
+ private TimeSpan additionalInclusiveWeight_;
+ private readonly object lock_ = new();
+
+ public FunctionProfileBuilder(string moduleName, string functionName, long functionRva, int functionSize, bool isManaged) {
+ ModuleName = moduleName;
+ FunctionName = functionName;
+ FunctionRva = functionRva;
+ FunctionSize = functionSize;
+ IsManaged = isManaged;
+ }
+
+ public string ModuleName { get; }
+ public string FunctionName { get; }
+ public long FunctionRva { get; }
+ public int FunctionSize { get; }
+ public bool IsManaged { get; }
+
+ ///
+ /// Add a sample at a specific instruction offset (exclusive/self weight).
+ ///
+ public void AddSample(long instructionOffset, TimeSpan weight) {
+ lock (lock_) {
+ exclusiveWeight_ += weight;
+
+ if (instructionWeights_.TryGetValue(instructionOffset, out var existing)) {
+ instructionWeights_[instructionOffset] = existing + weight;
+ }
+ else {
+ instructionWeights_[instructionOffset] = weight;
+ }
+ }
+ }
+
+ ///
+ /// Add inclusive weight from a stack frame where this function is a caller (not the leaf).
+ ///
+ public void AddInclusiveWeight(TimeSpan weight) {
+ lock (lock_) {
+ additionalInclusiveWeight_ += weight;
+ }
+ }
+
+ ///
+ /// Get the exclusive (self) weight — sum of all instruction weights.
+ ///
+ public TimeSpan GetExclusiveWeight() {
+ lock (lock_) {
+ return exclusiveWeight_;
+ }
+ }
+
+ ///
+ /// Get the inclusive (total) weight — exclusive + caller-attributed inclusive.
+ ///
+ public TimeSpan GetInclusiveWeight() {
+ lock (lock_) {
+ return exclusiveWeight_ + additionalInclusiveWeight_;
+ }
+ }
+
+ ///
+ /// Get a snapshot of per-instruction-offset weights.
+ ///
+ public Dictionary GetInstructionWeights() {
+ lock (lock_) {
+ return new Dictionary(instructionWeights_);
+ }
+ }
+}
diff --git a/src/ProfileExplorer.Profiling/Profiling/InstructionOffsetConfig.cs b/src/ProfileExplorer.Profiling/Profiling/InstructionOffsetConfig.cs
new file mode 100644
index 00000000..161e9f4b
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Profiling/InstructionOffsetConfig.cs
@@ -0,0 +1,61 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using System.Reflection;
+using ProfileExplorer.Profiling.Symbols;
+
+namespace ProfileExplorer.Profiling.Profiling;
+
+///
+/// IP skid correction constants per processor architecture.
+/// CPU sampling IPs may point past the instruction that was actually executing
+/// due to instruction pipeline effects. This corrects by walking backward.
+///
+public static class InstructionOffsetConfig {
+ ///
+ /// Get the IP skid correction parameters for the given architecture.
+ ///
+ public static SkidCorrectionParams GetSkidCorrection(ProcessorArchitecture architecture) {
+ return architecture switch {
+ ProcessorArchitecture.Arm => new SkidCorrectionParams(4, 4, 1), // ARM: fixed 4-byte instructions
+ _ => new SkidCorrectionParams(1, 16, 1) // x86/x64: variable-length 1-16 bytes
+ };
+ }
+
+ ///
+ /// Try to find the actual instruction at or before the given offset,
+ /// compensating for IP skid on variable-length instruction architectures.
+ ///
+ /// The sampled instruction offset (relative to function start).
+ /// Set of known instruction offsets from disassembly.
+ /// Target processor architecture.
+ /// The corrected offset, or the original if no correction is possible.
+ public static long CorrectSkid(long offset, IReadOnlySet knownOffsets, ProcessorArchitecture architecture) {
+ if (knownOffsets.Contains(offset)) {
+ return offset; // Exact match, no correction needed.
+ }
+
+ var skid = GetSkidCorrection(architecture);
+
+ // Walk backward to find the nearest known instruction.
+ for (int multiplier = skid.InitialMultiplier;
+ multiplier * skid.AdjustIncrement <= skid.MaxAdjust;
+ multiplier++) {
+ long adjusted = offset - multiplier * skid.AdjustIncrement;
+ if (adjusted < 0) break;
+
+ if (knownOffsets.Contains(adjusted)) {
+ return adjusted;
+ }
+ }
+
+ return offset; // No known instruction found — return original.
+ }
+}
+
+///
+/// Parameters for IP skid correction.
+///
+/// Bytes to step backward per attempt (1 for x86, 4 for ARM).
+/// Maximum total bytes to walk backward.
+/// Starting multiplier (typically 1).
+public readonly record struct SkidCorrectionParams(int AdjustIncrement, int MaxAdjust, int InitialMultiplier);
diff --git a/src/ProfileExplorer.Profiling/Profiling/IpResolver.cs b/src/ProfileExplorer.Profiling/Profiling/IpResolver.cs
new file mode 100644
index 00000000..01a2cdb1
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Profiling/IpResolver.cs
@@ -0,0 +1,110 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using ProfileExplorer.Profiling.Symbols;
+
+namespace ProfileExplorer.Profiling.Profiling;
+
+///
+/// Resolves instruction pointers to module/function pairs using registered images and debug info.
+/// Shared infrastructure used by SampleAggregator and CounterAggregator.
+///
+internal class IpResolver {
+ private readonly SortedList imagesByBaseAddress_ = [];
+ private readonly Dictionary> sortedFunctionsByModule_ = new(StringComparer.OrdinalIgnoreCase);
+ private readonly ManagedMethodResolver? managedResolver_;
+
+ public IpResolver(ManagedMethodResolver? managedResolver = null) {
+ managedResolver_ = managedResolver;
+ }
+
+ ///
+ /// Register a loaded image with its base address.
+ ///
+ public void AddImage(string imageName, long baseAddress, int size) {
+ imagesByBaseAddress_[baseAddress] = new ImageInfo(imageName, baseAddress, size);
+ }
+
+ ///
+ /// Register sorted function debug info for a module.
+ ///
+ public void SetFunctions(string moduleName, List sortedFunctions) {
+ sortedFunctionsByModule_[moduleName] = sortedFunctions;
+ }
+
+ ///
+ /// Resolve an instruction pointer to a module name and RVA within that module.
+ ///
+ public ResolvedIp? Resolve(long ip) {
+ // Try managed method resolution first (if enabled).
+ if (managedResolver_ != null) {
+ var managed = managedResolver_.FindMethod(ip);
+ if (managed != null) {
+ long rva = ip - managed.NativeStartAddress;
+ return new ResolvedIp(managed.ModuleName ?? "[managed]", rva, managed.MethodName, ip, rva, managed.NativeSize, true);
+ }
+ }
+
+ // Find the module that contains this IP.
+ var image = FindImage(ip);
+ if (image == null) return null;
+
+ long moduleRva = ip - image.BaseAddress;
+
+ // Find the function within the module.
+ if (sortedFunctionsByModule_.TryGetValue(image.Name, out var functions)) {
+ var func = FunctionDebugInfo.BinarySearch(functions, moduleRva);
+ if (func != null) {
+ return new ResolvedIp(image.Name, func.RVA, func.Name, ip,
+ moduleRva - func.RVA,
+ (int)func.Size);
+ }
+ }
+
+ // Module found but function not resolved.
+ return new ResolvedIp(image.Name, moduleRva, null, ip);
+ }
+
+ private ImageInfo? FindImage(long ip) {
+ // Binary search for the image with the largest base address <= ip.
+ var keys = imagesByBaseAddress_.Keys;
+ int low = 0;
+ int high = keys.Count - 1;
+ ImageInfo? best = null;
+
+ while (low <= high) {
+ int mid = low + (high - low) / 2;
+ long baseAddr = keys[mid];
+
+ if (baseAddr <= ip) {
+ var candidate = imagesByBaseAddress_[baseAddr];
+ if (ip < baseAddr + candidate.Size) {
+ best = candidate;
+ }
+
+ low = mid + 1;
+ }
+ else {
+ high = mid - 1;
+ }
+ }
+
+ return best;
+ }
+}
+
+///
+/// Result of resolving an instruction pointer.
+///
+internal record ResolvedIp(
+ string ModuleName,
+ long Rva,
+ string? FunctionName,
+ long OriginalIp,
+ long InstructionOffset = 0,
+ int FunctionSize = 0,
+ bool IsManaged = false);
+
+///
+/// Information about a loaded image/module.
+///
+internal record ImageInfo(string Name, long BaseAddress, int Size);
diff --git a/src/ProfileExplorer.Profiling/Profiling/ManagedMethodResolver.cs b/src/ProfileExplorer.Profiling/Profiling/ManagedMethodResolver.cs
new file mode 100644
index 00000000..5a0eb759
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Profiling/ManagedMethodResolver.cs
@@ -0,0 +1,83 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+namespace ProfileExplorer.Profiling.Profiling;
+
+///
+/// Resolves instruction pointers to managed (.NET) JIT-compiled methods via binary search.
+///
+internal class ManagedMethodResolver {
+ private readonly List methods_ = [];
+ private bool sorted_;
+ private readonly object lock_ = new();
+
+ ///
+ /// Register a managed method mapping.
+ ///
+ public void AddMethod(IManagedMethodMapping mapping) {
+ lock (lock_) {
+ methods_.Add(new ManagedMethodEntry(
+ mapping.ProcessId,
+ mapping.MethodName,
+ mapping.NativeStartAddress,
+ mapping.NativeSize,
+ mapping.MethodToken,
+ mapping.ModuleName,
+ mapping.ManagedPdbGuid,
+ mapping.ManagedPdbAge,
+ mapping.ManagedPdbName,
+ mapping.ILMappings));
+ sorted_ = false;
+ }
+ }
+
+ ///
+ /// Find the managed method containing the given instruction pointer.
+ ///
+ public ManagedMethodEntry? FindMethod(long ip) {
+ lock (lock_) {
+ EnsureSorted();
+
+ // Binary search by native start address.
+ int low = 0;
+ int high = methods_.Count - 1;
+
+ while (low <= high) {
+ int mid = low + (high - low) / 2;
+ var method = methods_[mid];
+
+ if (ip < method.NativeStartAddress) {
+ high = mid - 1;
+ }
+ else if (ip >= method.NativeStartAddress + method.NativeSize) {
+ low = mid + 1;
+ }
+ else {
+ return method; // IP is within this method's code range.
+ }
+ }
+
+ return null;
+ }
+ }
+
+ private void EnsureSorted() {
+ if (sorted_) return;
+ methods_.Sort((a, b) => a.NativeStartAddress.CompareTo(b.NativeStartAddress));
+ sorted_ = true;
+ }
+}
+
+///
+/// Internal representation of a managed method mapping.
+///
+internal record ManagedMethodEntry(
+ int ProcessId,
+ string MethodName,
+ long NativeStartAddress,
+ int NativeSize,
+ int MethodToken,
+ string? ModuleName,
+ Guid ManagedPdbGuid,
+ int ManagedPdbAge,
+ string? ManagedPdbName,
+ IReadOnlyList? ILMappings);
diff --git a/src/ProfileExplorer.Profiling/Profiling/SampleAggregator.cs b/src/ProfileExplorer.Profiling/Profiling/SampleAggregator.cs
new file mode 100644
index 00000000..d876c5a7
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Profiling/SampleAggregator.cs
@@ -0,0 +1,98 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using System.Collections.Concurrent;
+using ProfileExplorer.Profiling.Symbols;
+
+namespace ProfileExplorer.Profiling.Profiling;
+
+///
+/// Aggregates CPU samples into per-function and per-instruction profiles.
+/// Thread-safe — processes samples in parallel chunks.
+///
+internal class SampleAggregator {
+ private readonly IpResolver ipResolver_;
+ private readonly ConcurrentDictionary builders_ = new(StringComparer.OrdinalIgnoreCase);
+ private TimeSpan totalWeight_;
+ private readonly object totalWeightLock_ = new();
+
+ public SampleAggregator(IpResolver ipResolver) {
+ ipResolver_ = ipResolver;
+ }
+
+ ///
+ /// Add a batch of samples. Thread-safe.
+ ///
+ public void AddSamples(IEnumerable samples) {
+ TimeSpan batchWeight = TimeSpan.Zero;
+
+ foreach (var sample in samples) {
+ if (string.IsNullOrEmpty(sample.ImageName)) continue;
+
+ var resolved = ipResolver_.Resolve(sample.InstructionPointer);
+ if (resolved == null) continue;
+
+ string key = $"{resolved.ModuleName}!{resolved.FunctionName ?? $""}";
+
+ var builder = builders_.GetOrAdd(key, _ => new FunctionProfileBuilder(
+ resolved.ModuleName, resolved.FunctionName ?? $"",
+ resolved.Rva, resolved.FunctionSize, resolved.IsManaged));
+
+ builder.AddSample(resolved.InstructionOffset, sample.Weight);
+ batchWeight += sample.Weight;
+
+ // Handle inclusive weight via stack frames.
+ if (sample.StackFrames is { Count: > 1 }) {
+ // Stack is leaf-first. Skip index 0 (leaf — already counted as exclusive).
+ for (int i = 1; i < sample.StackFrames.Count; i++) {
+ var callerResolved = ipResolver_.Resolve(sample.StackFrames[i]);
+ if (callerResolved == null) continue;
+
+ string callerKey = $"{callerResolved.ModuleName}!{callerResolved.FunctionName ?? $""}";
+
+ var callerBuilder = builders_.GetOrAdd(callerKey, _ => new FunctionProfileBuilder(
+ callerResolved.ModuleName, callerResolved.FunctionName ?? $"",
+ callerResolved.Rva, callerResolved.FunctionSize, callerResolved.IsManaged));
+
+ callerBuilder.AddInclusiveWeight(sample.Weight);
+ }
+ }
+ }
+
+ lock (totalWeightLock_) {
+ totalWeight_ += batchWeight;
+ }
+ }
+
+ ///
+ /// Build the final function profiles.
+ ///
+ public IReadOnlyList Build(string? processName = null, int? processId = null) {
+ var profiles = new List(builders_.Count);
+ double totalMs = totalWeight_.TotalMilliseconds;
+
+ foreach (var (_, builder) in builders_) {
+ var exclusiveWeight = builder.GetExclusiveWeight();
+ var inclusiveWeight = builder.GetInclusiveWeight();
+ double exclusivePercent = totalMs > 0 ? exclusiveWeight.TotalMilliseconds / totalMs * 100 : 0;
+ double inclusivePercent = totalMs > 0 ? inclusiveWeight.TotalMilliseconds / totalMs * 100 : 0;
+
+ profiles.Add(new FunctionProfile(
+ moduleName: builder.ModuleName,
+ functionName: builder.FunctionName,
+ functionRva: builder.FunctionRva,
+ functionSize: builder.FunctionSize,
+ inclusiveWeight: inclusiveWeight,
+ exclusiveWeight: exclusiveWeight,
+ inclusivePercent: inclusivePercent,
+ exclusivePercent: exclusivePercent,
+ sourceFile: null, // Populated later by symbol resolution.
+ sourceLine: null,
+ isManaged: builder.IsManaged,
+ instructionWeights: builder.GetInstructionWeights()));
+ }
+
+ return profiles;
+ }
+
+ public TimeSpan TotalWeight => totalWeight_;
+}
diff --git a/src/ProfileExplorer.Profiling/Symbols/FunctionDebugInfo.cs b/src/ProfileExplorer.Profiling/Symbols/FunctionDebugInfo.cs
new file mode 100644
index 00000000..013956a3
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Symbols/FunctionDebugInfo.cs
@@ -0,0 +1,102 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using ProtoBuf;
+
+namespace ProfileExplorer.Profiling.Symbols;
+
+///
+/// Debug information for a single function: name, RVA, size, and source line mappings.
+/// Supports binary search by RVA for efficient lookup.
+///
+[ProtoContract(SkipConstructor = true)]
+public class FunctionDebugInfo : IEquatable, IComparable, IComparable {
+ public static readonly FunctionDebugInfo Unknown = new(null!, 0, 0);
+
+ public FunctionDebugInfo(string name, long rva, uint size, short optLevel = 0, int id = -1, short auxId = -1) {
+ Name = name;
+ RVA = rva;
+ Size = size;
+ OptimizationLevel = optLevel;
+ Id = id;
+ AuxiliaryId = auxId;
+ }
+
+ [ProtoMember(1)] public long Id { get; set; }
+ [ProtoMember(2)] public string Name { get; set; }
+ [ProtoMember(3)] public List? SourceLines { get; set; }
+ [ProtoMember(4)] public long AuxiliaryId { get; set; }
+ [ProtoMember(5)] public long RVA { get; set; }
+ [ProtoMember(6)] public uint Size { get; set; }
+ [ProtoMember(7)] public short OptimizationLevel { get; set; }
+
+ public bool HasSourceLines => SourceLines is { Count: > 0 };
+ public SourceLineDebugInfo FirstSourceLine => HasSourceLines ? SourceLines![0] : SourceLineDebugInfo.Unknown;
+ public SourceLineDebugInfo LastSourceLine => HasSourceLines ? SourceLines![^1] : SourceLineDebugInfo.Unknown;
+ public string? SourceFileName { get; set; }
+ public string? OriginalSourceFileName { get; set; }
+ public long StartRVA => RVA;
+ public long EndRVA => RVA + Size - 1;
+ public bool IsUnknown => RVA == 0 && Size == 0;
+
+ public int CompareTo(FunctionDebugInfo? other) {
+ if (other == null) return 0;
+ return StartRVA.CompareTo(other.StartRVA);
+ }
+
+ public int CompareTo(long value) {
+ if (value < StartRVA) return 1;
+ if (value > EndRVA) return -1;
+ return 0;
+ }
+
+ public bool Equals(FunctionDebugInfo? other) {
+ if (other is null) return false;
+ if (ReferenceEquals(this, other)) return true;
+ return RVA == other.RVA && Size == other.Size && Id == other.Id && AuxiliaryId == other.AuxiliaryId;
+ }
+
+ public override bool Equals(object? obj) => obj is FunctionDebugInfo other && Equals(other);
+ public override int GetHashCode() => HashCode.Combine(RVA, Size, Id, AuxiliaryId);
+
+ ///
+ /// Binary search a sorted list of FunctionDebugInfo for the function containing the given RVA.
+ ///
+ public static FunctionDebugInfo? BinarySearch(List ranges, long value,
+ bool hasOverlappingFuncts = false) {
+ int low = 0;
+ int high = ranges.Count - 1;
+
+ while (low <= high) {
+ int mid = low + (high - low) / 2;
+ var range = ranges[mid];
+ int result = range.CompareTo(value);
+
+ if (result == 0) {
+ if (hasOverlappingFuncts) {
+ // With overlapping functions (assembly code with multiple entry points),
+ // pick the outermost function that contains the RVA.
+ int count = 0;
+ while (--mid >= 0 && count++ < 10) {
+ var otherRange = ranges[mid];
+ if (otherRange.CompareTo(value) == 0 && otherRange.Size >= range.Size) {
+ range = otherRange;
+ }
+ }
+ }
+
+ return range;
+ }
+
+ if (result > 0) {
+ high = mid - 1;
+ }
+ else {
+ low = mid + 1;
+ }
+ }
+
+ return null;
+ }
+
+ public override string ToString() => $"{Name} RVA={RVA:X} Size={Size}";
+}
diff --git a/src/ProfileExplorer.Profiling/Symbols/IDebugInfoProvider.cs b/src/ProfileExplorer.Profiling/Symbols/IDebugInfoProvider.cs
new file mode 100644
index 00000000..e46ef2e7
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Symbols/IDebugInfoProvider.cs
@@ -0,0 +1,76 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using ProtoBuf;
+
+namespace ProfileExplorer.Profiling.Symbols;
+
+///
+/// Interface for debug information providers (PDB readers).
+///
+public interface IDebugInfoProvider : IDisposable {
+ /// Load debug information from a PDB file.
+ bool LoadDebugInfo(string debugFilePath);
+
+ /// Unload debug information and release resources.
+ void Unload();
+
+ /// Enumerate all functions defined in the PDB.
+ IEnumerable EnumerateFunctions();
+
+ /// Get a sorted list of all functions (sorted by RVA).
+ List GetSortedFunctions();
+
+ /// Find a function by name.
+ FunctionDebugInfo? FindFunction(string functionName);
+
+ /// Find the function containing the given RVA.
+ FunctionDebugInfo? FindFunctionByRVA(long rva);
+
+ /// Populate source line mappings for a function.
+ bool PopulateSourceLines(FunctionDebugInfo funcInfo);
+
+ /// Find the source file path for a function by name.
+ SourceFileDebugInfo FindFunctionSourceFilePath(string functionName);
+
+ /// Find the source file path for a function by RVA.
+ SourceFileDebugInfo FindSourceFilePathByRVA(long rva);
+
+ /// Find the source line for a specific RVA, optionally including inlinee info.
+ SourceLineDebugInfo FindSourceLineByRVA(long rva, bool includeInlinees = false);
+}
+
+///
+/// Identifies a PDB file on a symbol server (GUID + Age + FileName).
+///
+[ProtoContract(SkipConstructor = true)]
+public class SymbolFileDescriptor : IEquatable {
+ public SymbolFileDescriptor(string fileName, Guid id, int age) {
+ FileName = fileName;
+ Id = id;
+ Age = age;
+ }
+
+ public SymbolFileDescriptor(string fileName) {
+ FileName = fileName;
+ }
+
+ [ProtoMember(1)] public string FileName { get; set; }
+ [ProtoMember(2)] public Guid Id { get; set; }
+ [ProtoMember(3)] public int Age { get; set; }
+
+ public string SymbolName => Path.GetFileName(FileName);
+
+ public bool Equals(SymbolFileDescriptor? other) {
+ if (other is null) return false;
+ return string.Equals(FileName, other.FileName, StringComparison.OrdinalIgnoreCase) &&
+ Id == other.Id &&
+ Age == other.Age;
+ }
+
+ public override bool Equals(object? obj) => obj is SymbolFileDescriptor other && Equals(other);
+ public override int GetHashCode() => HashCode.Combine(FileName?.GetHashCode(StringComparison.OrdinalIgnoreCase), Id, Age);
+ public override string ToString() => $"{Id}:{FileName}";
+
+ public static bool operator ==(SymbolFileDescriptor? left, SymbolFileDescriptor? right) => Equals(left, right);
+ public static bool operator !=(SymbolFileDescriptor? left, SymbolFileDescriptor? right) => !Equals(left, right);
+}
diff --git a/src/ProfileExplorer.Profiling/Symbols/PdbSymbolProvider.cs b/src/ProfileExplorer.Profiling/Symbols/PdbSymbolProvider.cs
new file mode 100644
index 00000000..98a26301
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Symbols/PdbSymbolProvider.cs
@@ -0,0 +1,329 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using System.Runtime.InteropServices;
+using Dia2Lib;
+
+namespace ProfileExplorer.Profiling.Symbols;
+
+///
+/// PDB symbol reader using the DIA SDK (msdia140.dll) via Dia2Lib COM interop.
+/// Supports both registered COM and side-loaded DLL (no regsvr32 needed).
+/// Ported from ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs.
+///
+internal class PdbSymbolProvider : IDebugInfoProvider {
+ private const int MaxDemangledNameLength = 8192;
+ private const int FunctionCacheMissThreshold = 100;
+
+ private IDiaDataSource? diaSource_;
+ private IDiaSession? session_;
+ private IDiaSymbol? globalSymbol_;
+
+ private List? sortedFuncList_;
+ private Dictionary? functionsByName_;
+ private bool sortedFuncListOverlapping_;
+ private volatile int funcCacheMisses_;
+ private string? debugFilePath_;
+
+ private static bool diaRegistrationFailed_;
+ private static string? diaRegistrationError_;
+ private static readonly object undecorateLock_ = new();
+
+ public static bool DiaRegistrationFailed => diaRegistrationFailed_;
+ public static string? DiaRegistrationError => diaRegistrationError_;
+
+ /// Optional path to msdia140.dll for side-loading.
+ public static string? MsDiaPath { get; set; }
+
+ public bool LoadDebugInfo(string debugFilePath) {
+ if (!File.Exists(debugFilePath)) return false;
+ debugFilePath_ = debugFilePath;
+
+ try {
+ diaSource_ = CreateDiaSource();
+ if (diaSource_ == null) {
+ diaRegistrationError_ ??= "Failed to create DIA source.";
+ return false;
+ }
+
+ diaSource_.loadDataFromPdb(debugFilePath);
+ diaSource_.openSession(out session_);
+ if (session_ == null) return false;
+
+ session_.findChildren(null, SymTagEnum.SymTagExe, null, 0, out var exeEnum);
+ if (exeEnum != null) {
+ exeEnum.Next(1, out var exeSym, out uint fetched);
+ if (fetched > 0) globalSymbol_ = exeSym;
+ Marshal.ReleaseComObject(exeEnum);
+ }
+
+ LoadFunctionList();
+ return true;
+ }
+ catch (COMException ex) {
+ diaRegistrationFailed_ = true;
+ diaRegistrationError_ = $"DIA COM error: 0x{ex.HResult:X8} - {ex.Message}";
+ return false;
+ }
+ catch (Exception ex) {
+ diaRegistrationError_ = $"DIA load error: {ex.GetType().Name}: {ex.Message}";
+ return false;
+ }
+ }
+
+ public void Unload() {
+ if (globalSymbol_ != null) { Marshal.ReleaseComObject(globalSymbol_); globalSymbol_ = null; }
+ if (session_ != null) { Marshal.ReleaseComObject(session_); session_ = null; }
+ if (diaSource_ != null) { Marshal.ReleaseComObject(diaSource_); diaSource_ = null; }
+ sortedFuncList_ = null;
+ functionsByName_ = null;
+ }
+
+ public IEnumerable EnumerateFunctions() => sortedFuncList_ ?? [];
+ public List GetSortedFunctions() { if (sortedFuncList_ == null) LoadFunctionList(); return sortedFuncList_ ?? []; }
+
+ public FunctionDebugInfo? FindFunction(string functionName) {
+ if (functionsByName_?.TryGetValue(functionName, out var result) == true) return result;
+ return sortedFuncList_?.FirstOrDefault(f => string.Equals(f.Name, functionName, StringComparison.Ordinal));
+ }
+
+ public FunctionDebugInfo? FindFunctionByRVA(long rva) {
+ if (sortedFuncList_ != null) {
+ var result = FunctionDebugInfo.BinarySearch(sortedFuncList_, rva, sortedFuncListOverlapping_);
+ if (result != null) return result;
+ }
+
+ if (sortedFuncList_ == null && Interlocked.Increment(ref funcCacheMisses_) >= FunctionCacheMissThreshold) {
+ LoadFunctionList();
+ if (sortedFuncList_ != null) return FunctionDebugInfo.BinarySearch(sortedFuncList_, rva, sortedFuncListOverlapping_);
+ }
+
+ return FindFunctionByRVADirect(rva);
+ }
+
+ public bool PopulateSourceLines(FunctionDebugInfo funcInfo) {
+ if (session_ == null) return false;
+ try {
+ session_.findLinesByRVA((uint)funcInfo.StartRVA, (uint)funcInfo.Size, out var lineEnum);
+ if (lineEnum == null) return false;
+
+ funcInfo.SourceLines ??= [];
+ try {
+ while (true) {
+ lineEnum.Next(1, out var line, out uint fetched);
+ if (fetched == 0) break;
+ string? filePath = null;
+ try { filePath = line.sourceFile?.fileName; } catch { }
+ funcInfo.SourceLines.Add(new SourceLineDebugInfo((int)line.addressOffset, (int)line.lineNumber, (int)line.columnNumber, filePath));
+ }
+ }
+ finally { Marshal.ReleaseComObject(lineEnum); }
+
+ if (funcInfo.SourceLines.Count > 0) funcInfo.SourceFileName = funcInfo.SourceLines[0].FilePath;
+ return funcInfo.SourceLines.Count > 0;
+ }
+ catch { return false; }
+ }
+
+ public SourceFileDebugInfo FindFunctionSourceFilePath(string functionName) {
+ var func = FindFunction(functionName);
+ return func == null ? SourceFileDebugInfo.Unknown : FindSourceFilePathByRVA(func.RVA);
+ }
+
+ public SourceFileDebugInfo FindSourceFilePathByRVA(long rva) {
+ var lineInfo = FindSourceLineByRVA(rva);
+ return lineInfo.IsUnknown ? SourceFileDebugInfo.Unknown : new SourceFileDebugInfo(lineInfo.FilePath, lineInfo.FilePath, lineInfo.Line);
+ }
+
+ public SourceLineDebugInfo FindSourceLineByRVA(long rva, bool includeInlinees = false) {
+ if (session_ == null) return SourceLineDebugInfo.Unknown;
+ try {
+ session_.findLinesByRVA((uint)rva, 0, out var lineEnum);
+ if (lineEnum == null) return SourceLineDebugInfo.Unknown;
+ try {
+ lineEnum.Next(1, out var line, out uint fetched);
+ if (fetched == 0) return SourceLineDebugInfo.Unknown;
+ string? filePath = null;
+ try { filePath = line.sourceFile?.fileName; } catch { }
+ return new SourceLineDebugInfo((int)line.addressOffset, (int)line.lineNumber, (int)line.columnNumber, filePath);
+ }
+ finally { Marshal.ReleaseComObject(lineEnum); }
+ }
+ catch { return SourceLineDebugInfo.Unknown; }
+ }
+
+ public static string? UndecorateName(string decoratedName) {
+ lock (undecorateLock_) {
+ try {
+ var buffer = new char[MaxDemangledNameLength];
+ int result = NativeMethods.UnDecorateSymbolName(decoratedName, buffer, MaxDemangledNameLength, 0);
+ return result > 0 ? new string(buffer, 0, result) : decoratedName;
+ }
+ catch { return decoratedName; }
+ }
+ }
+
+ public void Dispose() => Unload();
+
+ // ── Private ──────────────────────────────────────────
+
+ private void LoadFunctionList() {
+ if (globalSymbol_ == null) return;
+ try {
+ var symbolMap = new Dictionary();
+ var symbolList = new List();
+
+ void Collect(SymTagEnum tag) {
+ globalSymbol_.findChildren(tag, null, 0, out var enumSymbols);
+ if (enumSymbols == null) return;
+ try {
+ while (true) {
+ enumSymbols.Next(1, out var sym, out uint fetched);
+ if (fetched == 0) break;
+ try {
+ string name = sym.name ?? "";
+ long rva = sym.relativeVirtualAddress;
+ uint size = (uint)sym.length;
+ if (tag == SymTagEnum.SymTagPublicSymbol && symbolMap.TryGetValue(rva, out var existing) && existing.Size == size) {
+ // Don't overwrite unmangled SymTagFunction names with mangled SymTagPublicSymbol names.
+ // Only use the public symbol name if the function name is missing.
+ if (string.IsNullOrEmpty(existing.Name)) {
+ existing.Name = name;
+ }
+ }
+ else if (!symbolMap.ContainsKey(rva)) {
+ var info = new FunctionDebugInfo(name, rva, size);
+ symbolList.Add(info);
+ symbolMap[rva] = info;
+ }
+ }
+ finally { Marshal.ReleaseComObject(sym); }
+ }
+ }
+ finally { Marshal.ReleaseComObject(enumSymbols); }
+ }
+
+ Collect(SymTagEnum.SymTagFunction);
+ Collect(SymTagEnum.SymTagPublicSymbol);
+ symbolList.Sort();
+
+ for (int i = 0; i < symbolList.Count - 1; i++) {
+ if (symbolList[i].EndRVA >= symbolList[i + 1].StartRVA) { sortedFuncListOverlapping_ = true; break; }
+ }
+
+ sortedFuncList_ = symbolList;
+ functionsByName_ = new Dictionary(symbolList.Count, StringComparer.Ordinal);
+ foreach (var func in symbolList) functionsByName_.TryAdd(func.Name, func);
+ }
+ catch { /* Function enumeration failed. */ }
+ }
+
+ private FunctionDebugInfo? FindFunctionByRVADirect(long rva) {
+ if (session_ == null) return null;
+ try {
+ session_.findSymbolByRVA((uint)rva, SymTagEnum.SymTagFunction, out var funcSym);
+ session_.findSymbolByRVA((uint)rva, SymTagEnum.SymTagPublicSymbol, out var pubSym);
+
+ IDiaSymbol? best = funcSym;
+ if (pubSym != null) {
+ if (funcSym == null) { best = pubSym; }
+ else if (funcSym.relativeVirtualAddress == pubSym.relativeVirtualAddress && funcSym.length == pubSym.length) {
+ best = pubSym; Marshal.ReleaseComObject(funcSym);
+ }
+ else { Marshal.ReleaseComObject(pubSym); }
+ }
+ if (best == null) return null;
+ try { return new FunctionDebugInfo(best.name ?? "", best.relativeVirtualAddress, (uint)best.length); }
+ finally { Marshal.ReleaseComObject(best); }
+ }
+ catch { return null; }
+ }
+
+ private static IDiaDataSource? CreateDiaSource() {
+ diaRegistrationError_ = null;
+ var source = TryCreateViaSideLoad();
+ if (source != null) return source;
+ string? sideErr = diaRegistrationError_;
+
+ diaRegistrationError_ = null;
+ source = TryCreateViaRegistry();
+ if (source != null) return source;
+ string? regErr = diaRegistrationError_;
+
+ diaRegistrationError_ = $"Side-load: [{sideErr ?? "no msdia140.dll"}]. Registry: [{regErr ?? "COM failed"}]";
+ return null;
+ }
+
+ private static IDiaDataSource? TryCreateViaSideLoad() {
+ string? dllPath = MsDiaPath;
+ if (string.IsNullOrEmpty(dllPath)) {
+ string dir = Path.GetDirectoryName(typeof(PdbSymbolProvider).Assembly.Location) ?? "";
+ string[] candidates = [
+ Path.Combine(dir, "msdia140.dll"),
+ Path.Combine(dir, "x64", "msdia140.dll"),
+ Path.Combine(dir, "amd64", "msdia140.dll"),
+ Path.Combine(dir, "runtimes", "win-x64", "native", "msdia140.dll"),
+ Path.Combine(dir, "..", "external", "msdia140.dll"),
+ Path.Combine(dir, "..", "..", "..", "..", "external", "msdia140.dll"),
+ Path.Combine(dir, "..", "..", "..", "..", "..", "external", "msdia140.dll"),
+ ];
+ dllPath = candidates.FirstOrDefault(File.Exists);
+ }
+ if (string.IsNullOrEmpty(dllPath) || !File.Exists(dllPath)) return null;
+
+ try {
+ nint hModule = NativeMethods.LoadLibrary(dllPath);
+ if (hModule == 0) { diaRegistrationError_ = $"LoadLibrary failed for {dllPath}"; return null; }
+
+ nint proc = NativeMethods.GetProcAddress(hModule, "DllGetClassObject");
+ if (proc == 0) { diaRegistrationError_ = "DllGetClassObject not found"; return null; }
+
+ var getClassObj = Marshal.GetDelegateForFunctionPointer(proc);
+ var clsid = new Guid("E6756135-1E65-4D17-8576-610761398C3C");
+ var iid = new Guid("00000001-0000-0000-C000-000000000046");
+ int hr = getClassObj(ref clsid, ref iid, out var factory);
+ if (hr != 0) { diaRegistrationError_ = $"DllGetClassObject HR=0x{hr:X8}"; return null; }
+
+ try {
+ var cf = (NativeMethods.IClassFactory)factory;
+ var iunknown = new Guid("00000000-0000-0000-C000-000000000046");
+ cf.CreateInstance(null, ref iunknown, out var instance);
+ return instance as IDiaDataSource;
+ }
+ finally { Marshal.ReleaseComObject(factory); }
+ }
+ catch (Exception ex) {
+ diaRegistrationError_ = $"Side-load: {ex.GetType().Name}: {ex.Message}";
+ return null;
+ }
+ }
+
+ private static IDiaDataSource? TryCreateViaRegistry() {
+ try { return new DiaSourceClass(); }
+ catch (COMException ex) {
+ diaRegistrationFailed_ = true;
+ diaRegistrationError_ = $"COM error: 0x{ex.HResult:X8} - {ex.Message}";
+ return null;
+ }
+ catch { return null; }
+ }
+
+ private static class NativeMethods {
+ [DllImport("dbghelp.dll", CharSet = CharSet.Ansi, SetLastError = true)]
+ public static extern int UnDecorateSymbolName(string name, [Out] char[] outputString, int maxStringLength, int flags);
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
+ public static extern nint LoadLibrary(string lpLibFileName);
+
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi)]
+ public static extern nint GetProcAddress(nint hModule, string lpProcName);
+
+ [UnmanagedFunctionPointer(CallingConvention.StdCall)]
+ public delegate int DllGetClassObjectDelegate(ref Guid rclsid, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out object ppv);
+
+ [ComImport, Guid("00000001-0000-0000-C000-000000000046"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
+ public interface IClassFactory {
+ void CreateInstance([MarshalAs(UnmanagedType.IUnknown)] object? pUnkOuter, ref Guid riid, [MarshalAs(UnmanagedType.IUnknown)] out object ppvObject);
+ void LockServer([MarshalAs(UnmanagedType.Bool)] bool fLock);
+ }
+ }
+}
diff --git a/src/ProfileExplorer.Profiling/Symbols/SourceDebugInfo.cs b/src/ProfileExplorer.Profiling/Symbols/SourceDebugInfo.cs
new file mode 100644
index 00000000..81f2a675
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Symbols/SourceDebugInfo.cs
@@ -0,0 +1,118 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using ProtoBuf;
+
+namespace ProfileExplorer.Profiling.Symbols;
+
+///
+/// Source line debug information for a single instruction offset range within a function.
+///
+[ProtoContract(SkipConstructor = true)]
+public struct SourceLineDebugInfo : IEquatable {
+ public static readonly SourceLineDebugInfo Unknown = new(-1, -1);
+
+ public SourceLineDebugInfo(int offsetStart, int line, int column = 0, string? filePath = null) {
+ OffsetStart = offsetStart;
+ OffsetEnd = offsetStart;
+ Line = line;
+ Column = column;
+ FilePath = filePath;
+ }
+
+ /// Offset in bytes from the function start (start of range).
+ [ProtoMember(1)] public int OffsetStart { get; set; }
+
+ /// Offset in bytes from the function start (end of range).
+ [ProtoMember(2)] public int OffsetEnd { get; set; }
+
+ /// Source line number.
+ [ProtoMember(3)] public int Line { get; set; }
+
+ /// Source column number.
+ [ProtoMember(4)] public int Column { get; set; }
+
+ /// Source file path.
+ [ProtoMember(5)] public string? FilePath { get; set; }
+
+ /// Inlined function frames at this offset.
+ public List? Inlinees { get; set; }
+
+ public bool IsUnknown => Line == -1;
+
+ public void AddInlinee(SourceStackFrame inlinee) {
+ Inlinees ??= [];
+ Inlinees.Add(inlinee);
+ }
+
+ public bool Equals(SourceLineDebugInfo other) {
+ return OffsetStart == other.OffsetStart && Line == other.Line &&
+ Column == other.Column &&
+ string.Equals(FilePath, other.FilePath, StringComparison.Ordinal);
+ }
+
+ public override bool Equals(object? obj) => obj is SourceLineDebugInfo other && Equals(other);
+ public override int GetHashCode() => HashCode.Combine(FilePath, Line, Column);
+}
+
+///
+/// Represents a frame in an inlinee call stack at a particular source location.
+///
+public struct SourceStackFrame : IEquatable {
+ public string FunctionName { get; set; }
+ public string? FilePath { get; set; }
+ public int Line { get; set; }
+ public int Column { get; set; }
+
+ public SourceStackFrame(string functionName, string? filePath, int line, int column = 0) {
+ FunctionName = functionName;
+ FilePath = filePath;
+ Line = line;
+ Column = column;
+ }
+
+ public bool HasSameFunction(SourceStackFrame other) {
+ return string.Equals(FunctionName, other.FunctionName, StringComparison.Ordinal);
+ }
+
+ public bool Equals(SourceStackFrame other) {
+ return string.Equals(FunctionName, other.FunctionName, StringComparison.Ordinal) &&
+ Line == other.Line;
+ }
+
+ public override bool Equals(object? obj) => obj is SourceStackFrame other && Equals(other);
+ public override int GetHashCode() => HashCode.Combine(FunctionName, Line);
+}
+
+///
+/// Source file debug information.
+///
+[ProtoContract(SkipConstructor = true)]
+public struct SourceFileDebugInfo : IEquatable {
+ public static readonly SourceFileDebugInfo Unknown = new(null, null, -1);
+
+ public SourceFileDebugInfo(string? filePath, string? originalFilePath, int startLine = 0,
+ bool hasChecksumMismatch = false) {
+ FilePath = filePath;
+ OriginalFilePath = originalFilePath;
+ StartLine = startLine;
+ HasChecksumMismatch = hasChecksumMismatch;
+ }
+
+ [ProtoMember(1)] public string? FilePath { get; set; }
+ [ProtoMember(2)] public string? OriginalFilePath { get; set; }
+ [ProtoMember(3)] public int StartLine { get; set; }
+ [ProtoMember(4)] public bool HasChecksumMismatch { get; set; }
+
+ public bool IsUnknown => FilePath == null;
+ public bool HasFilePath => !string.IsNullOrEmpty(FilePath);
+ public bool HasOriginalFilePath => !string.IsNullOrEmpty(OriginalFilePath);
+
+ public bool Equals(SourceFileDebugInfo other) {
+ return string.Equals(FilePath, other.FilePath, StringComparison.Ordinal) &&
+ string.Equals(OriginalFilePath, other.OriginalFilePath, StringComparison.Ordinal) &&
+ StartLine == other.StartLine;
+ }
+
+ public override bool Equals(object? obj) => obj is SourceFileDebugInfo other && Equals(other);
+ public override int GetHashCode() => HashCode.Combine(FilePath, OriginalFilePath, StartLine);
+}
diff --git a/src/ProfileExplorer.Profiling/Symbols/SymbolServerClient.cs b/src/ProfileExplorer.Profiling/Symbols/SymbolServerClient.cs
new file mode 100644
index 00000000..5e4db1ac
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/Symbols/SymbolServerClient.cs
@@ -0,0 +1,314 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT license.
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text.RegularExpressions;
+using Azure.Identity;
+
+namespace ProfileExplorer.Profiling.Symbols;
+
+///
+/// HTTP-based symbol server client for downloading PDBs and binaries.
+/// Vendored implementation — replaces TraceEvent's SymbolReader to avoid the large transitive dependency.
+/// Implements the standard symbol server protocol: GET /{name}/{hash}/{name}
+///
+public class SymbolServerClient : IDisposable {
+ private readonly HttpClient httpClient_;
+ private readonly List servers_ = [];
+ private readonly string? localCachePath_;
+ private readonly TimeSpan timeout_;
+ private readonly TimeSpan degradedTimeout_;
+ private bool isDegraded_;
+ private readonly HashSet negativeCache_ = new(StringComparer.OrdinalIgnoreCase);
+ private readonly object negativeCacheLock_ = new();
+ private readonly bool enableNegativeCache_;
+ private string? effectiveLocalCachePath_;
+ private readonly string? injectedBearerToken_;
+
+ public SymbolServerClient(ProfilerOptions options) {
+ timeout_ = TimeSpan.FromSeconds(options.SymbolTimeoutSeconds);
+ degradedTimeout_ = TimeSpan.FromSeconds(options.DegradedTimeoutSeconds);
+ enableNegativeCache_ = options.EnableNegativeCache;
+ injectedBearerToken_ = options.SymwebBearerToken;
+
+ httpClient_ = new HttpClient {
+ Timeout = timeout_
+ };
+
+ foreach (string path in options.SymbolPaths) {
+ ParseSymbolPath(path);
+ }
+
+ localCachePath_ = options.SymbolCacheDirectory;
+ }
+
+ ///
+ /// Download a PDB file from the symbol server.
+ ///
+ /// PDB file name (e.g., "ntdll.pdb").
+ /// PDB GUID from the CodeView debug directory.
+ /// PDB Age from the CodeView debug directory.
+ /// Cancellation token.
+ /// Local file path to the downloaded PDB, or null if not found.
+ public async Task FindSymbolFileAsync(string pdbName, Guid guid, int age, CancellationToken ct = default) {
+ string hash = $"{guid:N}{age}".ToUpperInvariant();
+ return await DownloadFileAsync(pdbName, hash, ct);
+ }
+
+ ///
+ /// Download a binary/executable from the symbol server.
+ ///
+ /// Binary file name (e.g., "ntdll.dll").
+ /// PE TimeDateStamp from the file header.
+ /// PE ImageSize (SizeOfImage).
+ /// Cancellation token.
+ /// Local file path to the downloaded binary, or null if not found.
+ public async Task FindBinaryFileAsync(string binaryName, int timeDateStamp, long imageSize,
+ CancellationToken ct = default) {
+ string hash = $"{timeDateStamp:X8}{imageSize:x}".ToUpperInvariant();
+ return await DownloadFileAsync(binaryName, hash, ct);
+ }
+
+ ///
+ /// Perform a bellwether test to check symbol server health.
+ ///
+ public async Task TestServerHealthAsync(string testPdbName, Guid testGuid, int testAge,
+ TimeSpan timeout, CancellationToken ct = default) {
+ string hash = $"{testGuid:N}{testAge}".ToUpperInvariant();
+ string key = $"{testPdbName}/{hash}";
+
+ foreach (var server in servers_.Where(s => s.IsRemote)) {
+ string url = $"{server.Url}/{testPdbName}/{hash}/{testPdbName}";
+ try {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(timeout);
+
+ using var request = new HttpRequestMessage(HttpMethod.Head, url);
+ await ApplyAuthAsync(request, server);
+
+ using var response = await httpClient_.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cts.Token);
+ if (response.IsSuccessStatusCode) return true;
+ }
+ catch {
+ // Server unreachable or timeout.
+ }
+ }
+
+ isDegraded_ = true;
+ return false;
+ }
+
+ private async Task DownloadFileAsync(string fileName, string hash, CancellationToken ct) {
+ string key = $"{fileName}/{hash}";
+
+ // Check negative cache.
+ if (enableNegativeCache_) {
+ lock (negativeCacheLock_) {
+ if (negativeCache_.Contains(key)) return null;
+ }
+ }
+
+ // Check local cache first.
+ string? cachedPath = FindInLocalCache(fileName, hash);
+ if (cachedPath != null) return cachedPath;
+
+ // Try each server.
+ foreach (var server in servers_) {
+ if (!server.IsRemote) {
+ // Local path server — check if file exists directly.
+ string localPath = Path.Combine(server.Url, fileName, hash, fileName);
+ if (File.Exists(localPath)) return localPath;
+ continue;
+ }
+
+ string url = $"{server.Url}/{fileName}/{hash}/{fileName}";
+ try {
+ var effectiveTimeout = isDegraded_ ? degradedTimeout_ : timeout_;
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(effectiveTimeout);
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, url);
+ await ApplyAuthAsync(request, server);
+
+ using var response = await httpClient_.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cts.Token);
+
+ if (response.StatusCode == HttpStatusCode.NotFound) {
+ continue; // Try next server.
+ }
+
+ if (!response.IsSuccessStatusCode) {
+ Console.Error.WriteLine($" Symbol download {response.StatusCode}: {url}");
+ continue;
+ }
+
+ // Save to local cache.
+ string targetDir = GetCachePath(fileName, hash);
+ Directory.CreateDirectory(targetDir);
+ string targetPath = Path.Combine(targetDir, fileName);
+
+ await using var sourceStream = await response.Content.ReadAsStreamAsync(cts.Token);
+ await using var fileStream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None);
+ await sourceStream.CopyToAsync(fileStream, cts.Token);
+
+ return targetPath;
+ }
+ catch (TaskCanceledException) {
+ // Timeout — mark as degraded and try next server.
+ isDegraded_ = true;
+ }
+ catch (HttpRequestException) {
+ // Network error — try next server.
+ }
+ }
+
+ // All servers failed — add to negative cache.
+ if (enableNegativeCache_) {
+ lock (negativeCacheLock_) {
+ negativeCache_.Add(key);
+ }
+ }
+
+ return null;
+ }
+
+ private string? FindInLocalCache(string fileName, string hash) {
+ string? cachePath = effectiveLocalCachePath_ ?? localCachePath_;
+ if (cachePath == null) return null;
+ string path = Path.Combine(cachePath, fileName, hash, fileName);
+ return File.Exists(path) ? path : null;
+ }
+
+ private string GetCachePath(string fileName, string hash) {
+ string basePath = effectiveLocalCachePath_ ?? localCachePath_ ?? Path.Combine(Path.GetTempPath(), "ProfileExplorer.Profiling", "symbols");
+ return Path.Combine(basePath, fileName, hash);
+ }
+
+ private static Azure.Core.AccessToken? cachedToken_;
+ private static bool authFailed_;
+ private static readonly SemaphoreSlim authLock_ = new(1, 1);
+
+ private static async Task ApplyAuthAsync(HttpRequestMessage request, SymbolServerInfo server) {
+ if (!server.RequiresAuth) return;
+ if (authFailed_) return; // Don't retry auth after permanent failure.
+
+ // Use injected token if available (from consumer via ProfilerOptions.SymwebBearerToken).
+ if (server.InjectedToken != null) {
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", server.InjectedToken);
+ return;
+ }
+
+ try {
+ // Use cached token if still valid (tokens last ~1 hour).
+ if (cachedToken_.HasValue && cachedToken_.Value.ExpiresOn > DateTimeOffset.UtcNow.AddMinutes(5)) {
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", cachedToken_.Value.Token);
+ return;
+ }
+
+ await authLock_.WaitAsync();
+ try {
+ // Double-check after acquiring lock.
+ if (cachedToken_.HasValue && cachedToken_.Value.ExpiresOn > DateTimeOffset.UtcNow.AddMinutes(5)) {
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", cachedToken_.Value.Token);
+ return;
+ }
+
+ if (authFailed_) return;
+
+ // Symweb uses Azure AD with resource https://microsoft.com.
+ // Try non-interactive creds first, then browser as last resort.
+ var credential = new ChainedTokenCredential(
+ new SharedTokenCacheCredential(new SharedTokenCacheCredentialOptions { TenantId = "72f988bf-86f1-41af-91ab-2d7cd011db47" }),
+ new VisualStudioCredential(),
+ new AzureCliCredential(),
+ new AzurePowerShellCredential(),
+ new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions {
+ TenantId = "72f988bf-86f1-41af-91ab-2d7cd011db47",
+ TokenCachePersistenceOptions = new TokenCachePersistenceOptions { Name = "ProfileExplorer.Profiling" }
+ }));
+
+ var tokenContext = new Azure.Core.TokenRequestContext(["https://microsoft.com/.default"]);
+ cachedToken_ = await credential.GetTokenAsync(tokenContext);
+ request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", cachedToken_.Value.Token);
+ }
+ finally {
+ authLock_.Release();
+ }
+ }
+ catch (Exception ex) {
+ authFailed_ = true;
+ Console.Error.WriteLine($" Symweb auth failed (will not retry): {ex.GetType().Name}: {ex.Message.Split('\n')[0]}");
+ }
+ }
+
+ ///
+ /// Parse a symbol path string into server entries.
+ /// Format: "srv*[localcache*]serverUrl" or "localPath"
+ /// Multiple entries separated by semicolons.
+ ///
+ internal void ParseSymbolPath(string symbolPath) {
+ // Split by semicolons for multiple entries.
+ foreach (string entry in symbolPath.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) {
+ if (entry.StartsWith("srv*", StringComparison.OrdinalIgnoreCase) ||
+ entry.StartsWith("SRV*", StringComparison.OrdinalIgnoreCase)) {
+ // srv*[cache*]url
+ string[] parts = entry[4..].Split('*');
+ if (parts.Length == 1) {
+ // srv*url — no local cache specified.
+ servers_.Add(SymbolServerInfo.Remote(parts[0], injectedBearerToken_));
+ }
+ else if (parts.Length >= 2) {
+ // srv*cache*url
+ servers_.Add(SymbolServerInfo.Remote(parts[^1], injectedBearerToken_));
+
+ // Also register the local cache as a search path.
+ if (Directory.Exists(parts[0]) || !parts[0].StartsWith("http", StringComparison.OrdinalIgnoreCase)) {
+ effectiveLocalCachePath_ ??= parts[0]; // Use first cache path as default.
+ servers_.Insert(0, SymbolServerInfo.Local(parts[0]));
+ }
+ }
+ }
+ else if (entry.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
+ entry.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) {
+ servers_.Add(SymbolServerInfo.Remote(entry, injectedBearerToken_));
+ }
+ else {
+ // Local directory path.
+ servers_.Insert(0, SymbolServerInfo.Local(entry));
+ }
+ }
+ }
+
+ internal IReadOnlyList Servers => servers_;
+
+ public void Dispose() {
+ httpClient_.Dispose();
+ }
+}
+
+///
+/// Describes a symbol server endpoint (local or remote).
+///
+internal class SymbolServerInfo {
+ public string Url { get; }
+ public bool IsRemote { get; }
+ public bool RequiresAuth { get; }
+ public string? InjectedToken { get; }
+
+ private SymbolServerInfo(string url, bool isRemote, bool requiresAuth, string? injectedToken = null) {
+ Url = url.TrimEnd('/');
+ IsRemote = isRemote;
+ RequiresAuth = requiresAuth;
+ InjectedToken = injectedToken;
+ }
+
+ public static SymbolServerInfo Remote(string url, string? injectedToken = null) {
+ bool requiresAuth = url.Contains("symweb", StringComparison.OrdinalIgnoreCase);
+ return new SymbolServerInfo(url, true, requiresAuth, requiresAuth ? injectedToken : null);
+ }
+
+ public static SymbolServerInfo Local(string path) {
+ return new SymbolServerInfo(path, false, false);
+ }
+
+ public override string ToString() => $"{(IsRemote ? "Remote" : "Local")}: {Url}";
+}
diff --git a/src/ProfileExplorer.Profiling/build/net8.0-windows/ProfileExplorer.Profiling.targets b/src/ProfileExplorer.Profiling/build/net8.0-windows/ProfileExplorer.Profiling.targets
new file mode 100644
index 00000000..9f3c131d
--- /dev/null
+++ b/src/ProfileExplorer.Profiling/build/net8.0-windows/ProfileExplorer.Profiling.targets
@@ -0,0 +1,16 @@
+
+
+
+ PreserveNewest
+ capstone.dll
+ false
+
+
+ PreserveNewest
+ msdia140.dll
+ false
+
+
+
diff --git a/src/ProfileExplorer.sln b/src/ProfileExplorer.sln
index e336098d..f9ba893a 100644
--- a/src/ProfileExplorer.sln
+++ b/src/ProfileExplorer.sln
@@ -30,6 +30,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PDBViewer", "PDBViewer\PDBV
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ProfileExplorer.Mcp", "ProfileExplorer.Mcp\ProfileExplorer.Mcp.csproj", "{B8E89A2F-3C4D-4A5B-9E1F-2A7B3C4D5E6F}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProfileExplorer.Profiling", "ProfileExplorer.Profiling\ProfileExplorer.Profiling.csproj", "{D312A98C-1949-44DF-A5C0-552183C5B8BC}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProfileExplorer.Profiling.Tests", "ProfileExplorer.Profiling.Tests\ProfileExplorer.Profiling.Tests.csproj", "{966D6685-1DE4-400F-A427-64E2342C0DE4}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -202,6 +206,38 @@ Global
{B8E89A2F-3C4D-4A5B-9E1F-2A7B3C4D5E6F}.Release|x64.Build.0 = Release|Any CPU
{B8E89A2F-3C4D-4A5B-9E1F-2A7B3C4D5E6F}.Release|x86.ActiveCfg = Release|Any CPU
{B8E89A2F-3C4D-4A5B-9E1F-2A7B3C4D5E6F}.Release|x86.Build.0 = Release|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Debug|x64.Build.0 = Debug|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Debug|x86.Build.0 = Debug|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Release|ARM64.Build.0 = Release|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Release|x64.ActiveCfg = Release|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Release|x64.Build.0 = Release|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Release|x86.ActiveCfg = Release|Any CPU
+ {D312A98C-1949-44DF-A5C0-552183C5B8BC}.Release|x86.Build.0 = Release|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Debug|x64.Build.0 = Debug|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Debug|x86.Build.0 = Debug|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Release|ARM64.Build.0 = Release|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Release|x64.ActiveCfg = Release|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Release|x64.Build.0 = Release|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Release|x86.ActiveCfg = Release|Any CPU
+ {966D6685-1DE4-400F-A427-64E2342C0DE4}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
From 8e439599e5c818b822dacd2b623f849ed14279a6 Mon Sep 17 00:00:00 2001
From: Tristan Gibeau <41077386+trgibeau@users.noreply.github.com>
Date: Fri, 12 Jun 2026 10:27:53 -0700
Subject: [PATCH 02/12] More work
---
src/ProfileExplorer.McpServer/Program.cs | 62 +-
.../Integration/DisassemblerTests.cs | 44 +-
.../FunctionProfilerEndToEndTests.cs | 42 +-
.../Integration/PdbDiagnosticTests.cs | 1 +
.../Integration/PdbSymbolProviderTests.cs | 1 +
.../Unit/AssemblyAnnotatorTests.cs | 1 +
.../Unit/CallTreeBuilderTests.cs | 47 +-
.../Unit/CounterAggregatorTests.cs | 16 +-
.../Unit/FunctionProfileBuilderTests.cs | 89 --
.../Unit/SampleAggregatorTests.cs | 21 +-
.../Unit/SymbolServerClientTests.cs | 3 +-
.../Binary/Disassembler.cs | 117 ++-
.../Binary/FunctionDebugInfo.cs | 0
.../Binary/IBinaryInfoProvider.cs | 0
.../Binary/ISymbolDebugInfo.cs | 26 +
.../Binary/PEBinaryInfoProvider.cs | 327 +++++++
.../Binary/SourceFileDebugInfo.cs | 0
.../Binary/SourceLineDebugInfo.cs | 0
.../Binary/SymbolFileDescriptor.cs | 66 ++
.../Collections/TinyList.cs | 0
.../Counters}/PerformanceCounters.cs | 0
.../Disassembly/AssemblyAnnotator.cs | 8 +-
.../Disassembly/Disassembler.cs | 223 -----
.../FunctionProfiler.cs | 212 ++---
.../IR/SourceStackFrame.cs | 50 +
.../Managed/ManagedDebugInfoProvider.cs | 132 +++
.../Managed/MethodCode.cs | 45 +
.../Models/CallTree.cs | 130 ---
.../Models/CounterDescriptions.cs | 43 +
.../Models/FunctionProfile.cs | 88 --
.../Models/PerformanceCounters.cs | 105 --
.../Models/ProfileReport.cs | 50 +
.../Profile/CallTree/IResolvedCallStack.cs | 44 +
.../Profile/CallTree/ProfileCallSite.cs | 2 +-
.../Profile/CallTree/ProfileCallTree.cs | 110 +--
.../Profile/CallTree/ProfileCallTreeNode.cs | 85 +-
.../Profile/Data/FunctionProfileData.cs | 103 ++
.../Profile/Data/ModuleProfileInfo.cs | 0
.../ProfileExplorer.Profiling.csproj | 9 +-
.../Profiling/CallTreeBuilder.cs | 118 +--
.../Profiling/CounterAggregator.cs | 22 +-
.../Profiling/FunctionProfileBuilder.cs | 80 --
.../Profiling/IpResolver.cs | 10 +-
.../Profiling/ProfileFunctionId.cs | 38 +
.../Profiling/SampleAggregator.cs | 69 +-
.../Providers/FunctionNameFormatter.cs | 9 +
.../Symbols/FunctionDebugInfo.cs | 102 --
.../Symbols/IDebugInfoProvider.cs | 76 --
.../Symbols/ISymbolFileResolver.cs | 38 +
.../Symbols/PdbSymbolProvider.cs | 201 +++-
.../Symbols/SourceDebugInfo.cs | 118 ---
.../Symbols/SymbolFileCache.cs | 83 ++
.../Symbols/SymbolServerClient.cs | 2 +-
.../ProfileExplorer.Profiling.targets | 0
...ryInfoProvider.cs => BinaryFileLocator.cs} | 894 ++++++------------
.../Binary/DotNetDebugInfoProvider.cs | 165 +---
.../Binary/IDebugInfoProvider.cs | 71 +-
.../Binary/IDisassembler.cs | 50 +
.../Binary/PDBDebugInfoProvider.cs | 244 +----
.../Binary/SymbolFileCache.cs | 92 --
.../Compilers/ASM/ASMBinaryFileFinder.cs | 2 +-
.../IR/Tags/SourceStackTrace.cs | 45 -
.../CallTree/ProfileCallTreeExtensions.cs | 32 +
...ta.cs => FunctionProfileDataProcessing.cs} | 585 +++++-------
.../Profile/Data/ManagedRawProfileData.cs | 4 +-
.../Profile/Data/ProfileData.cs | 57 +-
.../Profile/Data/ProfileModuleBuilder.cs | 11 +
.../Profile/Data/RawProfileData.cs | 4 +-
.../Profile/Data/ResolvedProfileStack.cs | 17 +-
.../Profile/ETW/ETWProfileDataProvider.cs | 14 +
.../Profile/Processing/CallTreeProcessor.cs | 2 +-
.../Processing/FunctionProfileProcessor.cs | 26 +-
.../Processing/FunctionSamplesProcessor.cs | 2 +-
.../Profile/Processing/ProfileSampleFilter.cs | 2 +-
.../Profile/ProfileFunctionIdExtensions.cs | 15 +
.../ProfileExplorerCore.csproj | 4 +
.../Providers/INameProvider.cs | 2 -
.../Utilities/ExtensionMethods.cs | 8 +
.../ETWUnmappedFrameResolutionTests.cs | 8 +-
.../EndToEndWorkflowTests.cs | 10 +-
src/ProfileExplorerUI/MainWindowProfiling.cs | 14 +-
.../Mcp/McpActionExecutor.cs | 5 +-
.../Profile/CallTreeNodePanel.xaml.cs | 15 +-
.../Profile/CallTreePanel.xaml.cs | 10 +-
.../Profile/Document/ProfileDocumentMarker.cs | 2 +-
.../Profile/Document/ProfilingUtils.cs | 17 +-
.../Profile/FlameGraph/FlameGraph.cs | 6 +-
.../Profile/FlameGraph/FlameGraphHost.xaml.cs | 15 +-
.../FlameGraph/FlameGraphPanel.xaml.cs | 2 +-
.../Profile/ProfileListView.xaml.cs | 11 +-
src/ProfileExplorerUI/ProfileStyles.xaml | 2 +-
.../SyntheticProfileTests.cs | 4 +-
92 files changed, 2580 insertions(+), 3157 deletions(-)
delete mode 100644 src/ProfileExplorer.Profiling.Tests/Unit/FunctionProfileBuilderTests.cs
rename src/{ProfileExplorerCore => ProfileExplorer.Profiling}/Binary/Disassembler.cs (91%)
rename src/{ProfileExplorerCore => ProfileExplorer.Profiling}/Binary/FunctionDebugInfo.cs (100%)
rename src/{ProfileExplorerCore => ProfileExplorer.Profiling}/Binary/IBinaryInfoProvider.cs (100%)
create mode 100644 src/ProfileExplorer.Profiling/Binary/ISymbolDebugInfo.cs
create mode 100644 src/ProfileExplorer.Profiling/Binary/PEBinaryInfoProvider.cs
rename src/{ProfileExplorerCore => ProfileExplorer.Profiling}/Binary/SourceFileDebugInfo.cs (100%)
rename src/{ProfileExplorerCore => ProfileExplorer.Profiling}/Binary/SourceLineDebugInfo.cs (100%)
create mode 100644 src/ProfileExplorer.Profiling/Binary/SymbolFileDescriptor.cs
rename src/{ProfileExplorerCore => ProfileExplorer.Profiling}/Collections/TinyList.cs (100%)
rename src/{ProfileExplorerCore/Profile/Data => ProfileExplorer.Profiling/Counters}/PerformanceCounters.cs (100%)
delete mode 100644 src/ProfileExplorer.Profiling/Disassembly/Disassembler.cs
create mode 100644 src/ProfileExplorer.Profiling/IR/SourceStackFrame.cs
create mode 100644 src/ProfileExplorer.Profiling/Managed/ManagedDebugInfoProvider.cs
create mode 100644 src/ProfileExplorer.Profiling/Managed/MethodCode.cs
delete mode 100644 src/ProfileExplorer.Profiling/Models/CallTree.cs
create mode 100644 src/ProfileExplorer.Profiling/Models/CounterDescriptions.cs
delete mode 100644 src/ProfileExplorer.Profiling/Models/FunctionProfile.cs
delete mode 100644 src/ProfileExplorer.Profiling/Models/PerformanceCounters.cs
create mode 100644 src/ProfileExplorer.Profiling/Models/ProfileReport.cs
create mode 100644 src/ProfileExplorer.Profiling/Profile/CallTree/IResolvedCallStack.cs
rename src/{ProfileExplorerCore => ProfileExplorer.Profiling}/Profile/CallTree/ProfileCallSite.cs (97%)
rename src/{ProfileExplorerCore => ProfileExplorer.Profiling}/Profile/CallTree/ProfileCallTree.cs (83%)
rename src/{ProfileExplorerCore => ProfileExplorer.Profiling}/Profile/CallTree/ProfileCallTreeNode.cs (81%)
create mode 100644 src/ProfileExplorer.Profiling/Profile/Data/FunctionProfileData.cs
rename src/{ProfileExplorerCore => ProfileExplorer.Profiling}/Profile/Data/ModuleProfileInfo.cs (100%)
delete mode 100644 src/ProfileExplorer.Profiling/Profiling/FunctionProfileBuilder.cs
create mode 100644 src/ProfileExplorer.Profiling/Profiling/ProfileFunctionId.cs
create mode 100644 src/ProfileExplorer.Profiling/Providers/FunctionNameFormatter.cs
delete mode 100644 src/ProfileExplorer.Profiling/Symbols/FunctionDebugInfo.cs
delete mode 100644 src/ProfileExplorer.Profiling/Symbols/IDebugInfoProvider.cs
create mode 100644 src/ProfileExplorer.Profiling/Symbols/ISymbolFileResolver.cs
delete mode 100644 src/ProfileExplorer.Profiling/Symbols/SourceDebugInfo.cs
create mode 100644 src/ProfileExplorer.Profiling/Symbols/SymbolFileCache.cs
rename src/ProfileExplorer.Profiling/build/{net8.0-windows => net8.0}/ProfileExplorer.Profiling.targets (100%)
rename src/ProfileExplorerCore/Binary/{PEBinaryInfoProvider.cs => BinaryFileLocator.cs} (55%)
create mode 100644 src/ProfileExplorerCore/Binary/IDisassembler.cs
delete mode 100644 src/ProfileExplorerCore/Binary/SymbolFileCache.cs
create mode 100644 src/ProfileExplorerCore/Profile/CallTree/ProfileCallTreeExtensions.cs
rename src/ProfileExplorerCore/Profile/Data/{FunctionProfileData.cs => FunctionProfileDataProcessing.cs} (67%)
create mode 100644 src/ProfileExplorerCore/Profile/ProfileFunctionIdExtensions.cs
diff --git a/src/ProfileExplorer.McpServer/Program.cs b/src/ProfileExplorer.McpServer/Program.cs
index 37e94e85..09452063 100644
--- a/src/ProfileExplorer.McpServer/Program.cs
+++ b/src/ProfileExplorer.McpServer/Program.cs
@@ -9,6 +9,7 @@
using ModelContextProtocol.Server;
using ProfileExplorer.Core;
using ProfileExplorer.Core.Binary;
+using ProfileExplorer.Core.Profile;
using ProfileExplorer.Core.Profile.CallTree;
using ProfileExplorer.Core.Profile.Data;
using ProfileExplorer.Core.Profile.ETW;
@@ -84,7 +85,7 @@ public static void Reset()
// Clear static resolution caches so each trace starts fresh.
PDBDebugInfoProvider.ClearResolvedCache();
- PEBinaryInfoProvider.ClearResolvedCache();
+ BinaryFileLocator.ClearResolvedCache();
}
}
@@ -386,7 +387,7 @@ public static string GetAvailableFunctions(
double totalPct = totalWeightMs > 0 ? data.Weight.TotalMilliseconds / totalWeightMs * 100 : 0;
return new
{
- Name = ResolveFunctionName(func),
+ Name = ResolveFunctionName(profile, func),
ModuleName = func.ModuleName ?? "Unknown",
SelfTimePercentage = Math.Round(selfPct, 2),
TotalTimePercentage = Math.Round(totalPct, 2),
@@ -496,7 +497,7 @@ public static async Task GetFunctionAssembly(string functionName)
if (match == null)
return Error("GetFunctionAssembly", $"Function '{functionName}' not found");
- var data = profile.FunctionProfiles[match];
+ var data = profile.GetFunctionProfile(match);
var debugInfo = data.FunctionDebugInfo;
// Try to resolve source lines via the provider's debug info
@@ -627,7 +628,7 @@ public static async Task GetFunctionAssembly(string functionName)
{
Action = "GetFunctionAssembly",
Status = "Success",
- FunctionName = ResolveFunctionName(match),
+ FunctionName = ResolveFunctionName(profile, new ProfileFunctionId(match.ModuleName, match.Name)),
ModuleName = match.ModuleName ?? "Unknown",
SelfTime = data.ExclusiveWeight.ToString(),
TotalTime = data.Weight.ToString(),
@@ -663,7 +664,7 @@ public static string GetFunctionCallerCallee(
if (match == null)
return Error("GetFunctionCallerCallee", $"Function '{functionName}' not found");
- var data = profile.FunctionProfiles[match];
+ var data = profile.GetFunctionProfile(match);
var totalWeightMs = ProfileSession.TotalWeight.TotalMilliseconds;
var functionWeightMs = data.Weight.TotalMilliseconds;
int callerLimit = maxCallers ?? 10;
@@ -671,7 +672,7 @@ public static string GetFunctionCallerCallee(
int backtraceLimit = maxBacktraces ?? 5;
// Get all call tree instances of this function, sorted by weight
- var instances = profile.CallTree.GetSortedCallTreeNodes(match);
+ var instances = profile.CallTree.GetSortedCallTreeNodes(match.ToProfileId());
if (instances == null || instances.Count == 0)
return Error("GetFunctionCallerCallee", $"Function '{functionName}' has no call tree nodes");
@@ -681,10 +682,10 @@ public static string GetFunctionCallerCallee(
{
foreach (var caller in inst.Callers)
{
- if (caller?.Function == null) continue;
- var key = $"{caller.Function.ModuleName}!{ResolveFunctionName(caller)}";
+ if (caller is not {HasFunction: true}) continue;
+ var key = $"{caller.ModuleName}!{ResolveFunctionName(caller)}";
if (!callerAgg.TryGetValue(key, out var existing))
- existing = (TimeSpan.Zero, TimeSpan.Zero, caller.Function.ModuleName ?? "Unknown");
+ existing = (TimeSpan.Zero, TimeSpan.Zero, caller.ModuleName ?? "Unknown");
callerAgg[key] = (existing.weight + caller.Weight, existing.exclusiveWeight + caller.ExclusiveWeight, existing.module);
}
}
@@ -701,17 +702,17 @@ public static string GetFunctionCallerCallee(
}).ToArray();
// Aggregate callees from the combined node's children
- var combined = profile.CallTree.GetCombinedCallTreeNode(match);
+ var combined = profile.CallTree.GetCombinedCallTreeNode(match.ToProfileId());
var callees = Array.Empty