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.McpServer/Program.cs b/src/ProfileExplorer.McpServer/Program.cs index c3619f2f..473e24c1 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; @@ -77,9 +78,9 @@ public static class ProfileSession // Concurrency guard — only one trace load at a time. public static readonly SemaphoreSlim LoadSemaphore = new(1, 1); - // Lazy-built lookup: demangled function name → IRTextFunction. + // Lazy-built lookup: demangled function name → ProfileFunctionId. // Populated on first FindFunction call after a trace loads. - public static Dictionary? DemangledFunctionLookup { get; set; } + public static Dictionary? DemangledFunctionLookup { get; set; } /// /// Resets all session state and static caches for a clean trace load. @@ -102,7 +103,7 @@ public static void Reset() // Clear static resolution caches so each trace starts fresh. PDBDebugInfoProvider.ClearResolvedCache(); - PEBinaryInfoProvider.ClearResolvedCache(); + BinaryFileLocator.ClearResolvedCache(); } } @@ -410,7 +411,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), @@ -520,7 +521,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 @@ -651,7 +652,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(), @@ -687,7 +688,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; @@ -695,7 +696,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"); @@ -705,10 +706,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); } } @@ -725,17 +726,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(); if (combined != null && combined.HasChildren) { callees = combined.Children - .Where(c => c?.Function != null) + .Where(c => c is {HasFunction: true}) .OrderByDescending(c => c.Weight) .Take(calleeLimit) .Select(c => new { - Function = $"{c.Function.ModuleName}!{ResolveFunctionName(c)}", + Function = $"{c.ModuleName}!{ResolveFunctionName(c)}", InclusiveTimeMs = Math.Round(c.Weight.TotalMilliseconds, 2), InclusivePct = totalWeightMs > 0 ? Math.Round(c.Weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, FunctionPct = functionWeightMs > 0 ? Math.Round(c.Weight.TotalMilliseconds / functionWeightMs * 100, 2) : 0, @@ -755,7 +756,7 @@ public static string GetFunctionCallerCallee( WeightMs = Math.Round(inst.Weight.TotalMilliseconds, 2), WeightPct = totalWeightMs > 0 ? Math.Round(inst.Weight.TotalMilliseconds / totalWeightMs * 100, 2) : 0, FunctionPct = functionWeightMs > 0 ? Math.Round(inst.Weight.TotalMilliseconds / functionWeightMs * 100, 2) : 0, - Stack = bt.Select(n => $"{n.Function?.ModuleName}!{ResolveFunctionName(n)}").ToArray() + Stack = bt.Select(n => $"{n.ModuleName}!{ResolveFunctionName(n)}").ToArray() }; }).ToArray(); @@ -763,7 +764,7 @@ public static string GetFunctionCallerCallee( { Action = "GetFunctionCallerCallee", 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(), @@ -818,11 +819,11 @@ public static string GetHelp() /// /// Returns the PDB-resolved name for a function, falling back to IRTextFunction.Name (hex placeholder). /// - private static string ResolveFunctionName(IRTextFunction func) + private static string ResolveFunctionName(ProfileData profile, ProfileFunctionId func) { - var data = ProfileSession.LoadedProfile?.FunctionProfiles.GetValueOrDefault(func); + var data = profile?.GetFunctionProfile(func); var debugName = data?.FunctionDebugInfo?.Name; - return !string.IsNullOrEmpty(debugName) ? debugName : func.Name; + return !string.IsNullOrEmpty(debugName) ? debugName : func.FunctionName; } /// @@ -831,24 +832,29 @@ private static string ResolveFunctionName(IRTextFunction func) private static string ResolveFunctionName(ProfileCallTreeNode node) { var debugName = node.FunctionDebugInfo?.Name; - return !string.IsNullOrEmpty(debugName) ? debugName : node.Function?.Name ?? "Unknown"; + return !string.IsNullOrEmpty(debugName) ? debugName : node.FunctionName ?? "Unknown"; } private static IRTextFunction? FindFunction(ProfileData profile, string functionName) + { + var id = FindFunctionId(profile, functionName); + return id.HasValue ? profile.ResolveFunction(id.Value) : null; + } + + private static ProfileFunctionId? FindFunctionId(ProfileData profile, string functionName) { // 1. Exact match on PDB-resolved (possibly decorated) name. - var exactMatch = profile.FunctionProfiles.Keys - .FirstOrDefault(f => ResolveFunctionName(f).Equals(functionName, StringComparison.OrdinalIgnoreCase)); - if (exactMatch != null) return exactMatch; + foreach (var f in profile.FunctionProfiles.Keys) + if (ResolveFunctionName(profile, f).Equals(functionName, StringComparison.OrdinalIgnoreCase)) return f; // 2. Exact match on demangled name (callers pass human-readable names; PE stores decorated MSVC names). if (ProfileSession.DemangledFunctionLookup == null) { // Build once per trace load — demangling is not thread-safe so do it lazily here. - var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); + var lookup = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var f in profile.FunctionProfiles.Keys) { - var raw = ResolveFunctionName(f); + var raw = ResolveFunctionName(profile, f); var demangled = PDBDebugInfoProvider.DemangleFunctionName(raw, FunctionNameDemanglingOptions.OnlyName | FunctionNameDemanglingOptions.NoReturnType | FunctionNameDemanglingOptions.NoSpecialKeywords); @@ -861,10 +867,9 @@ private static string ResolveFunctionName(ProfileCallTreeNode node) if (ProfileSession.DemangledFunctionLookup.TryGetValue(functionName, out var demangledMatch)) return demangledMatch; - // 3. Exact match on IRTextFunction.Name (hex placeholder when no symbols). - var hexMatch = profile.FunctionProfiles.Keys - .FirstOrDefault(f => f.Name.Equals(functionName, StringComparison.OrdinalIgnoreCase)); - if (hexMatch != null) return hexMatch; + // 3. Exact match on neutral function name (hex placeholder when no symbols). + foreach (var f in profile.FunctionProfiles.Keys) + if (f.FunctionName.Equals(functionName, StringComparison.OrdinalIgnoreCase)) return f; // 4. Contains on demangled name (partial match). var containsMatch = ProfileSession.DemangledFunctionLookup.Keys @@ -872,11 +877,12 @@ private static string ResolveFunctionName(ProfileCallTreeNode node) if (containsMatch != null && ProfileSession.DemangledFunctionLookup.TryGetValue(containsMatch, out var partialMatch)) return partialMatch; - // 5. Contains on decorated or hex placeholder name. - return profile.FunctionProfiles.Keys - .FirstOrDefault(f => ResolveFunctionName(f).Contains(functionName, StringComparison.OrdinalIgnoreCase)) - ?? profile.FunctionProfiles.Keys - .FirstOrDefault(f => f.Name.Contains(functionName, StringComparison.OrdinalIgnoreCase)); + // 5. Contains on decorated name, then on neutral/hex placeholder name. + foreach (var f in profile.FunctionProfiles.Keys) + if (ResolveFunctionName(profile, f).Contains(functionName, StringComparison.OrdinalIgnoreCase)) return f; + foreach (var f in profile.FunctionProfiles.Keys) + if (f.FunctionName.Contains(functionName, StringComparison.OrdinalIgnoreCase)) return f; + return null; } private static string Error(string action, string message) 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..585d542c --- /dev/null +++ b/src/ProfileExplorer.Profiling.Tests/Helpers/TestDataHelper.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using ProfileExplorer.Core.Binary; +using ProfileExplorer.Profiling.Symbols; + +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; + } + } + + /// + /// Returns functions whose RVA appears exactly once in the symbol list (i.e. not affected by + /// ICF/COMDAT folding, where several names share the same RVA). Folded functions have no stable + /// RVA->name mapping, so tests that need a deterministic round-trip should select from these. + /// + public static List GetUniqueRvaFunctions(PdbSymbolProvider provider) { + var functions = provider.GetSortedFunctions(); + var rvaCounts = new Dictionary(functions.Count); + + foreach (var func in functions) { + rvaCounts.TryGetValue(func.RVA, out int count); + rvaCounts[func.RVA] = count + 1; + } + + return functions.Where(f => f.Size > 0 && rvaCounts[f.RVA] == 1).ToList(); + } + + // 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..6fff3729 --- /dev/null +++ b/src/ProfileExplorer.Profiling.Tests/Integration/DisassemblerTests.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ProfileExplorer.Core.Binary; +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) && + File.Exists(PdbPath); + } + + 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 = Disassembler.CreateForBinary(DllPath, provider, null); + Assert.IsNotNull(disassembler, "Should create a disassembler for a valid binary."); + var instructions = disassembler.DisassembleToList(func.RVA, (int)func.Size); + + 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 = Disassembler.CreateForBinary(DllPath, provider, null); + Assert.IsNotNull(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.DisassembleToList(bigFunc.RVA, (int)bigFunc.Size); + + // 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) { + using var disassembler = Disassembler.CreateForBinary(DllPath, provider, null); + Assert.IsNotNull(disassembler); + var instructions = disassembler.DisassembleToList(func.RVA, (int)func.Size); + + 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. + // Use unique-RVA functions and try several candidates: some short symbols are import + // thunks / folded entries that don't disassemble into instructions. + var shortFuncs = TestDataHelper.GetUniqueRvaFunctions(provider) + .Where(f => f.Size is > 4 and <= 32) + .Take(20).ToList(); + + if (shortFuncs.Count == 0) { Assert.Inconclusive("No short function found."); return; } + + using var disassembler = Disassembler.CreateForBinary(DllPath, provider, null); + Assert.IsNotNull(disassembler); + + bool produced = false; + + foreach (var shortFunc in shortFuncs) { + var instructions = disassembler.DisassembleToList(shortFunc.RVA, (int)shortFunc.Size); + if (instructions.Count > 0) { + produced = true; + break; + } + } + + // At least one short function should produce instructions. + Assert.IsTrue(produced, "At least one short function should disassemble into instructions."); + } + } + + [TestMethod] + public void Disassemble_InvalidBinary_ReturnsEmpty() { + // CreateForBinary returns null when the PE binary can't be opened. + using var disassembler = Disassembler.CreateForBinary(@"C:\nonexistent\fake.dll", null, null); + Assert.IsNull(disassembler); + } +} diff --git a/src/ProfileExplorer.Profiling.Tests/Integration/FunctionProfilerEndToEndTests.cs b/src/ProfileExplorer.Profiling.Tests/Integration/FunctionProfilerEndToEndTests.cs new file mode 100644 index 00000000..a25675c3 --- /dev/null +++ b/src/ProfileExplorer.Profiling.Tests/Integration/FunctionProfilerEndToEndTests.cs @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ProfileExplorer.Profiling.Disassembly; +using ProfileExplorer.Core.Binary; +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.Key.FunctionName.Contains("SortByParameterGroups")); + Assert.IsNotNull(profile.Value, "Should have a profile for SortByParameterGroups."); + Assert.IsTrue(profile.Value.ExclusiveWeight.TotalMilliseconds >= sampleCount * 0.9, + $"Most samples should be attributed to this function. Got {profile.Value.ExclusiveWeight.TotalMilliseconds}ms, expected ~{sampleCount}ms."); + + // Step 4: Disassemble the function with the mature capstone disassembler. + using var disassembler = Disassembler.CreateForBinary(DllPath, pdbProvider, null); + + if (disassembler == null) { + // Binary couldn't be opened — verify profiling worked and skip assembly steps. + Assert.IsTrue(profile.Value.InstructionWeight.Count > 0, "Should have instruction weights."); + return; + } + + long targetRva = profile.Value.FunctionDebugInfo.RVA; + var instructions = disassembler.DisassembleToList(targetRva, (int)profile.Value.FunctionDebugInfo.Size); + + Assert.IsTrue(instructions.Count > 0, + $"Should disassemble {profile.Key.FunctionName} (Size={profile.Value.FunctionDebugInfo.Size})."); + + // Step 5: Annotate with timing data. + var funcDebugInfo = pdbProvider.FindFunctionByRVA(targetRva); + var annotated = AssemblyAnnotator.Annotate( + instructions, profile.Value.InstructionWeight, targetRva, + 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; + + // Pick two functions whose RVA is unique (not ICF-folded) and round-trips through the + // resolver, so each sample is attributed deterministically to the expected name. + var usable = TestDataHelper.GetUniqueRvaFunctions(provider) + .Where(f => provider.FindFunctionByRVA(f.RVA)?.RVA == f.RVA) + .Take(2).ToList(); + if (usable.Count < 2) { Assert.Inconclusive("Need at least 2 unique-RVA functions."); return; } + + var func1 = usable[0]; + var func2 = usable[1]; + + 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."); + + double total = aggregator.TotalWeight.TotalMilliseconds; + var p1 = profiles.First(p => p.Key.FunctionName == func1.Name); + var p2 = profiles.First(p => p.Key.FunctionName == func2.Name); + + Assert.AreEqual(30.0, p1.Value.ExclusiveWeight.TotalMilliseconds / total * 100, 0.1); + Assert.AreEqual(70.0, p2.Value.ExclusiveWeight.TotalMilliseconds / total * 100, 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..74c00f39 --- /dev/null +++ b/src/ProfileExplorer.Profiling.Tests/Integration/PdbDiagnosticTests.cs @@ -0,0 +1,66 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ProfileExplorer.Core.Binary; +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; + } + + // Locate msdia140.dll at src/external/msdia140.dll by walking up from the test + // output directory until the repo layout marker is found (robust to bin// depth). + string? msdiaPath = FindRepoFile(Path.Combine("src", "external", "msdia140.dll")); + if (msdiaPath != null) + 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"); + } + + // Walk up from the test output directory to find a repo-relative file, avoiding a hard-coded + // "../../.." depth that breaks when the build output layout changes. + private static string? FindRepoFile(string relativePath) + { + for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent) + { + string candidate = Path.Combine(dir.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + } + + return null; + } +} diff --git a/src/ProfileExplorer.Profiling.Tests/Integration/PdbSymbolProviderTests.cs b/src/ProfileExplorer.Profiling.Tests/Integration/PdbSymbolProviderTests.cs new file mode 100644 index 00000000..db2f5c9a --- /dev/null +++ b/src/ProfileExplorer.Profiling.Tests/Integration/PdbSymbolProviderTests.cs @@ -0,0 +1,212 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ProfileExplorer.Core.Binary; +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; + } + + // Use a function whose RVA is unique (not ICF-folded), so the RVA -> name lookup is + // deterministic. Folded functions share an RVA with other names and have no stable mapping. + var functions = TestDataHelper.GetUniqueRvaFunctions(provider); + if (functions.Count == 0) { Assert.Inconclusive("No unique-RVA functions found."); return; } + + bool verified = false; + + foreach (var func in functions.Take(50)) { + var found = provider.FindFunctionByRVA(func.RVA); + if (found == null) continue; + + // Skip cases where the RVA falls inside a larger overlapping function (assembly with + // multiple entry points) — the resolver intentionally returns the outer function there. + if (found.RVA != func.RVA) continue; + + Assert.AreEqual(func.Name, found.Name); + Assert.AreEqual(func.RVA, found.RVA); + verified = true; + break; + } + + Assert.IsTrue(verified, "Should round-trip at least one unique-RVA function by RVA."); + } + + [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..6bd0f525 --- /dev/null +++ b/src/ProfileExplorer.Profiling.Tests/Unit/AssemblyAnnotatorTests.cs @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ProfileExplorer.Core.Binary; +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..174b7159 --- /dev/null +++ b/src/ProfileExplorer.Profiling.Tests/Unit/CallTreeBuilderTests.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.Core.Binary; +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 roots = builder.Build().RootNodes; + + Assert.AreEqual(1, roots.Count); // One root function. + Assert.AreEqual("Root", roots[0].FunctionName); + Assert.AreEqual(1, roots[0].Children.Count); + Assert.AreEqual("Mid", roots[0].Children[0].FunctionName); + Assert.AreEqual(1, roots[0].Children[0].Children.Count); + Assert.AreEqual("Leaf", roots[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 roots = builder.Build().RootNodes; + + Assert.AreEqual(1, roots.Count); // Single root. + Assert.AreEqual("Root", roots[0].FunctionName); + Assert.AreEqual(2, roots[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 rootFunc = builder.Build().RootNodes[0]; + + Assert.AreEqual(3.0, rootFunc.Weight.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 rootFunc = builder.Build().RootNodes[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 func = builder.Build().RootNodes[0]; + + Assert.AreEqual(2, func.ThreadWeights.Count); + Assert.AreEqual(3.0, func.ThreadWeights[10].Weight.TotalMilliseconds, 0.01); + Assert.AreEqual(7.0, func.ThreadWeights[20].Weight.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) + ]); + + Assert.AreEqual(0, builder.Build().RootNodes.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 roots = builder.Build().RootNodes; + + Assert.AreEqual(10.0, roots[0].Weight.TotalMilliseconds, 0.01); + Assert.AreEqual(10.0, roots[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..7d673f7f --- /dev/null +++ b/src/ProfileExplorer.Profiling.Tests/Unit/CounterAggregatorTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ProfileExplorer.Profiling.Profiling; +using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.Profile; +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(new ProfileFunctionId("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(new ProfileFunctionId("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].FindCounterValue(1)); + Assert.AreEqual(1, counters[offset].FindCounterValue(2)); + Assert.AreEqual(1, counters[offset].FindCounterValue(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(new ProfileFunctionId("missing", "missing")); + Assert.IsNull(counters); + } + + [TestMethod] + public void NoCountersRegistered_ReturnsNull() { + var (_, aggregator) = Setup(); + var counters = aggregator.GetCounters(new ProfileFunctionId("test.dll", "HotFunc")); + Assert.IsNull(counters); + } +} 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..eac3a5f6 --- /dev/null +++ b/src/ProfileExplorer.Profiling.Tests/Unit/SampleAggregatorTests.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.Core.Binary; +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); + var foo = profiles.First(); + Assert.AreEqual("Foo", foo.Key.FunctionName); + Assert.AreEqual(1.0, foo.Value.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.First().Value.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.First().Value.InstructionWeight.Count); + Assert.AreEqual(50.0, profiles.First().Value.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(); + double total = aggregator.TotalWeight.TotalMilliseconds; + + var foo = profiles.First(p => p.Key.FunctionName == "Foo"); + var bar = profiles.First(p => p.Key.FunctionName == "Bar"); + + Assert.AreEqual(75.0, foo.Value.ExclusiveWeight.TotalMilliseconds / total * 100, 0.1); + Assert.AreEqual(25.0, bar.Value.ExclusiveWeight.TotalMilliseconds / total * 100, 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..66d10264 --- /dev/null +++ b/src/ProfileExplorer.Profiling.Tests/Unit/SymbolServerClientTests.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using Microsoft.VisualStudio.TestTools.UnitTesting; +using ProfileExplorer.Core.Binary; +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://symweb.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/ProfileExplorerCore/Binary/Disassembler.cs b/src/ProfileExplorer.Profiling/Binary/Disassembler.cs similarity index 91% rename from src/ProfileExplorerCore/Binary/Disassembler.cs rename to src/ProfileExplorer.Profiling/Binary/Disassembler.cs index a213ae77..e1d6819b 100644 --- a/src/ProfileExplorerCore/Binary/Disassembler.cs +++ b/src/ProfileExplorer.Profiling/Binary/Disassembler.cs @@ -9,55 +9,14 @@ using System.Text; using System.Threading.Tasks; using Microsoft.Win32.SafeHandles; -using ProfileExplorer.Core.Compilers.ASM; -using ProfileExplorer.Core.IR; using ProfileExplorer.Core.Providers; -using ProfileExplorer.Core.Utilities; namespace ProfileExplorer.Core.Binary; -public delegate void DisassemblerProgressHandler(DisassemblerProgress info); - -public enum DisassemblerStage { - Disassembling, - PostProcessing -} - -public interface IDisassembler { - DisassemberResult Disassemble(string imagePath, ICompilerInfoProvider compilerInfo, - DisassemblerProgressHandler progressCallback = null, - CancelableTask cancelableTask = null); - - Task DisassembleAsync(string imagePath, ICompilerInfoProvider compilerInfo, - DisassemblerProgressHandler progressCallback = null, - CancelableTask cancelableTask = null); - - bool EnsureDisassemblerAvailable(); -} - -public class DisassemblerOptions { - public bool IncludeBytes { get; set; } -} - -public class DisassemblerProgress { - public DisassemblerProgress(DisassemblerStage stage) { - Stage = stage; - } - - public DisassemblerStage Stage { get; set; } - public int Total { get; set; } - public int Current { get; set; } -} - -public class DisassemberResult { - public DisassemberResult(string disassemblyPath, string debugInfoFilePath) { - DisassemblyPath = disassemblyPath; - DebugInfoFilePath = debugInfoFilePath; - } - - public string DisassemblyPath { get; set; } - public string DebugInfoFilePath { get; set; } -} +/// +/// A single disassembled instruction with resolved operand text. +/// +public record DisassembledInstruction(long Address, long Rva, string Text, int Size); public class Disassembler : IDisposable { public delegate string SymbolNameResolverDelegate(long address); @@ -65,7 +24,7 @@ public class Disassembler : IDisposable { private List<(ReadOnlyMemory Data, long StartRVA)> codeSectionData_; private long baseAddress_; private Machine architecture_; - private IDebugInfoProvider debugInfo_; + private ISymbolDebugInfo debugInfo_; private Interop.DisassemblerHandle disasmHandle_; private bool checkValidCallAddress_; private SymbolNameResolverDelegate symbolNameResolver_; @@ -76,7 +35,7 @@ public class Disassembler : IDisposable { private Disassembler(Machine architecture, PEBinaryInfoProvider peInfo, long baseAddress = 0, - IDebugInfoProvider debugInfo = null, + ISymbolDebugInfo debugInfo = null, FunctionNameFormatter funcNameFormatter = null, SymbolNameResolverDelegate symbolNameResolver = null) { peInfo_ = peInfo; @@ -94,7 +53,7 @@ public void Dispose() { GC.SuppressFinalize(this); } - public static Disassembler CreateForBinary(string binaryFilePath, IDebugInfoProvider debugInfo, + public static Disassembler CreateForBinary(string binaryFilePath, ISymbolDebugInfo debugInfo, FunctionNameFormatter funcNameFormatter) { var peInfo = new PEBinaryInfoProvider(binaryFilePath); @@ -107,7 +66,7 @@ public static Disassembler CreateForBinary(string binaryFilePath, IDebugInfoProv binaryInfo.ImageBase, debugInfo, funcNameFormatter); } - public static Disassembler CreateForMachine(IDebugInfoProvider debugInfo, + public static Disassembler CreateForMachine(ISymbolDebugInfo debugInfo, FunctionNameFormatter funcNameFormatter) { return new Disassembler(debugInfo.Architecture.Value, null, 0, debugInfo, funcNameFormatter); } @@ -177,6 +136,40 @@ public string DisassembleToText(long startRVA, long size) { return builder.ToString(); } + /// + /// Disassemble a function into a list of individual instructions, with operand text + /// (call/jump targets resolved to symbol names when debug info is available). + /// + public List DisassembleToList(FunctionDebugInfo funcInfo) { + return DisassembleToList(funcInfo.StartRVA, funcInfo.Size); + } + + public List DisassembleToList(long startRVA, long size) { + var list = new List(); + + if (startRVA == 0 || size == 0) { + return list; + } + + try { + DisassembleInstructions(startRVA, size, startRVA + baseAddress_, (instr) => { + var sb = new StringBuilder(); + AppendMnemonic(instr, sb); + sb.Append(" "); + AppendOperands(instr, startRVA, size, sb); + list.Add(new DisassembledInstruction(instr.Address, instr.Address - baseAddress_, + sb.ToString(), instr.Size)); + }); + } + catch (Exception ex) { +#if DEBUG + Trace.TraceError($"Failed to disassemble code list at RVA {startRVA}, size {size}: {ex.Message}"); +#endif + } + + return list; + } + private void Initialize(bool checkValidCallAddress) { checkValidCallAddress_ = checkValidCallAddress; disasmHandle_ = Interop.Create(architecture_); @@ -448,21 +441,23 @@ private bool ShouldLookupAddressByName(Interop.Instruction instr, ref bool isJum switch (architecture_) { case Machine.I386: case Machine.Amd64: { - if (x86Opcodes.GetOpcodeInfo(instr.MnemonicString, out var info)) { - isJump = info.Kind == InstructionKind.Goto; - return info.Kind == InstructionKind.Call || isJump; - } - - return false; + // Resolve operand symbol names for direct calls and unconditional jumps + // (matches x86Opcodes Call/Goto classification: CALL, SYSCALL, JMP). + string mnemonic = instr.MnemonicString; + isJump = mnemonic.Equals("jmp", StringComparison.OrdinalIgnoreCase); + return isJump || + mnemonic.Equals("call", StringComparison.OrdinalIgnoreCase) || + mnemonic.Equals("syscall", StringComparison.OrdinalIgnoreCase); } case Machine.Arm: case Machine.Arm64: { - if (ARM64Opcodes.GetOpcodeInfo(instr.MnemonicString, out var info)) { - isJump = info.Kind == InstructionKind.Goto; - return info.Kind == InstructionKind.Call || isJump; - } - - return false; + // Matches ARM64Opcodes Call/Goto classification: B, BR (Goto), BL, BLR (Call). + string mnemonic = instr.MnemonicString; + isJump = mnemonic.Equals("b", StringComparison.OrdinalIgnoreCase) || + mnemonic.Equals("br", StringComparison.OrdinalIgnoreCase); + return isJump || + mnemonic.Equals("bl", StringComparison.OrdinalIgnoreCase) || + mnemonic.Equals("blr", StringComparison.OrdinalIgnoreCase); } } diff --git a/src/ProfileExplorerCore/Binary/FunctionDebugInfo.cs b/src/ProfileExplorer.Profiling/Binary/FunctionDebugInfo.cs similarity index 100% rename from src/ProfileExplorerCore/Binary/FunctionDebugInfo.cs rename to src/ProfileExplorer.Profiling/Binary/FunctionDebugInfo.cs diff --git a/src/ProfileExplorerCore/Binary/IBinaryInfoProvider.cs b/src/ProfileExplorer.Profiling/Binary/IBinaryInfoProvider.cs similarity index 100% rename from src/ProfileExplorerCore/Binary/IBinaryInfoProvider.cs rename to src/ProfileExplorer.Profiling/Binary/IBinaryInfoProvider.cs diff --git a/src/ProfileExplorer.Profiling/Binary/ISymbolDebugInfo.cs b/src/ProfileExplorer.Profiling/Binary/ISymbolDebugInfo.cs new file mode 100644 index 00000000..bfc6cd4a --- /dev/null +++ b/src/ProfileExplorer.Profiling/Binary/ISymbolDebugInfo.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System; +using System.Collections.Generic; +using System.Reflection.PortableExecutable; + +namespace ProfileExplorer.Core.Binary; + +/// +/// Decoupled debug-info reader surface: RVA/name-based symbol and source-line lookups +/// with no dependency on Profile Explorer's IR/document model. This is the interface the +/// library's binary/disassembly code depends on. Profile Explorer's IR-aware +/// derives from this and adds IR-coupled members. +/// +public interface ISymbolDebugInfo : IDisposable { + Machine? Architecture { get; } + void Unload(); + IEnumerable EnumerateFunctions(); + List GetSortedFunctions(); + FunctionDebugInfo FindFunction(string functionName); + FunctionDebugInfo FindFunctionByRVA(long rva); + bool PopulateSourceLines(FunctionDebugInfo funcInfo); + SourceFileDebugInfo FindFunctionSourceFilePath(string functionName); + SourceFileDebugInfo FindSourceFilePathByRVA(long rva); + SourceLineDebugInfo FindSourceLineByRVA(long rva, bool includeInlinees = false); +} diff --git a/src/ProfileExplorer.Profiling/Binary/PEBinaryInfoProvider.cs b/src/ProfileExplorer.Profiling/Binary/PEBinaryInfoProvider.cs new file mode 100644 index 00000000..66703924 --- /dev/null +++ b/src/ProfileExplorer.Profiling/Binary/PEBinaryInfoProvider.cs @@ -0,0 +1,327 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Reflection.PortableExecutable; +using System.Runtime.InteropServices; + +namespace ProfileExplorer.Core.Binary; + +/// +/// Contains version information extracted from a PE file's version resource. +/// +public sealed class PEVersionInfo { + public string CompanyName { get; init; } + public string ProductName { get; init; } + public string FileDescription { get; init; } + public string LegalCopyright { get; init; } + public string OriginalFilename { get; init; } + + /// + /// Checks if any of the version info fields contain the specified text (case-insensitive). + /// + public bool ContainsText(string text) { + if (string.IsNullOrEmpty(text)) { + return false; + } + + return ContainsTextInternal(CompanyName, text) || + ContainsTextInternal(ProductName, text) || + ContainsTextInternal(FileDescription, text) || + ContainsTextInternal(LegalCopyright, text); + } + + /// + /// Checks if any of the version info fields contain any of the specified texts (case-insensitive). + /// + public bool ContainsAnyText(IEnumerable texts) { + foreach (var text in texts) { + if (ContainsText(text)) { + return true; + } + } + + return false; + } + + private static bool ContainsTextInternal(string field, string text) { + return !string.IsNullOrEmpty(field) && + field.Contains(text, StringComparison.OrdinalIgnoreCase); + } + + public override string ToString() { + return $"Company: {CompanyName ?? "N/A"}, Product: {ProductName ?? "N/A"}, Description: {FileDescription ?? "N/A"}"; + } +} + +public sealed class PEBinaryInfoProvider : IBinaryInfoProvider, IDisposable { + private static ConcurrentDictionary versionInfoCache_ = new(); + + /// + /// Clears the static version info cache. Call between trace loads to ensure a clean state. + /// + public static void ClearVersionInfoCache() { + versionInfoCache_.Clear(); + } + + private string filePath_; + private PEReader reader_; + + public PEBinaryInfoProvider(string filePath) { + filePath_ = filePath; + } + + public List CodeSectionHeaders { + get { + var list = new List(); + + if (reader_.PEHeaders.PEHeader == null) { + return list; + } + + foreach (var section in reader_.PEHeaders.SectionHeaders) { + if (section.SectionCharacteristics.HasFlag(SectionCharacteristics.MemExecute) || + section.SectionCharacteristics.HasFlag(SectionCharacteristics.ContainsCode)) { + list.Add(section); + } + } + + return list; + } + } + + public SymbolFileDescriptor SymbolFileInfo { + get { + foreach (var entry in reader_.ReadDebugDirectory()) { + if (entry.Type == DebugDirectoryEntryType.CodeView) { + try { + var dir = reader_.ReadCodeViewDebugDirectoryData(entry); + return new SymbolFileDescriptor(dir.Path, dir.Guid, dir.Age); + } + catch (BadImageFormatException) { + // PE reader has problems with some old binaries. + } + + break; + } + } + + return null; + } + } + + public BinaryFileDescriptor BinaryFileInfo { + get { + if (reader_.PEHeaders.PEHeader == null) { + return null; + } + + var fileKind = BinaryFileKind.Native; + + if (reader_.HasMetadata && reader_.PEHeaders.CorHeader != null) { + if (reader_.PEHeaders.CorHeader.Flags.HasFlag(CorFlags.ILOnly)) { + fileKind = BinaryFileKind.DotNet; + } + else if (reader_.PEHeaders.CorHeader.Flags.HasFlag(CorFlags.ILLibrary)) { + fileKind = BinaryFileKind.DotNetR2R; + } + } + + // For AMR64 EC binaries, they may show up as AMD64, but have the hybrid metadata table set, + // consider them ARM64 binaries instead so that disassembly works as expected. + var architecture = reader_.PEHeaders.CoffHeader.Machine; + + if (architecture == Machine.Amd64 && IsARM64ECBinary()) { + architecture = Machine.Arm64; + } + + return new BinaryFileDescriptor { + ImageName = string.IsNullOrEmpty(filePath_) ? "" : Path.GetFileName(filePath_), + ImagePath = filePath_, + Architecture = architecture, + FileKind = fileKind, + Checksum = reader_.PEHeaders.PEHeader.CheckSum, + TimeStamp = reader_.PEHeaders.CoffHeader.TimeDateStamp, + ImageSize = reader_.PEHeaders.PEHeader.SizeOfImage, + CodeSize = reader_.PEHeaders.PEHeader.SizeOfCode, + ImageBase = (long)reader_.PEHeaders.PEHeader.ImageBase, + BaseOfCode = reader_.PEHeaders.PEHeader.BaseOfCode, + MajorVersion = reader_.PEHeaders.PEHeader.MajorImageVersion, + MinorVersion = reader_.PEHeaders.PEHeader.MinorImageVersion + }; + } + } + + public void Dispose() { + reader_?.Dispose(); + } + + public static BinaryFileDescriptor GetBinaryFileInfo(string filePath) { + using var binaryInfo = new PEBinaryInfoProvider(filePath); + + if (binaryInfo.Initialize()) { + return binaryInfo.BinaryFileInfo; + } + + return null; + } + + public static SymbolFileDescriptor GetSymbolFileInfo(string filePath) { + using var binaryInfo = new PEBinaryInfoProvider(filePath); + + if (binaryInfo.Initialize()) { + return binaryInfo.SymbolFileInfo; + } + + return null; + } + + /// + /// Gets version information (Company, Product, Description, Copyright) from a PE file. + /// Uses FileVersionInfo which reads the version resource from the PE file. + /// + public static PEVersionInfo GetVersionInfo(string filePath) { + if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) { + return null; + } + + // Check cache first + if (versionInfoCache_.TryGetValue(filePath, out var cached)) { + return cached; + } + + try { + var fileVersionInfo = FileVersionInfo.GetVersionInfo(filePath); + var versionInfo = new PEVersionInfo { + CompanyName = fileVersionInfo.CompanyName, + ProductName = fileVersionInfo.ProductName, + FileDescription = fileVersionInfo.FileDescription, + LegalCopyright = fileVersionInfo.LegalCopyright, + OriginalFilename = fileVersionInfo.OriginalFilename + }; + + versionInfoCache_.TryAdd(filePath, versionInfo); + return versionInfo; + } + catch (Exception ex) { + Trace.WriteLine($"Failed to read version info from {filePath}: {ex.Message}"); + return null; + } + } + + /// + /// Checks if a PE file's version info matches any of the specified company filter strings. + /// Returns true if any filter string is found in Company, Product, Description, or Copyright fields. + /// Returns true if no filters are specified (empty/null list). + /// Returns true if the file doesn't exist or version info cannot be read (fail-open for safety). + /// + public static bool MatchesCompanyFilter(string filePath, IReadOnlyList companyFilters) { + // No filter specified - accept all + if (companyFilters == null || companyFilters.Count == 0) { + return true; + } + + var versionInfo = GetVersionInfo(filePath); + + // If we can't read version info, accept the file (fail-open) + if (versionInfo == null) { + return true; + } + + return versionInfo.ContainsAnyText(companyFilters); + } + + public bool Initialize() { + if (!File.Exists(filePath_)) { + return false; + } + + try { + var stream = File.OpenRead(filePath_); + reader_ = new PEReader(stream); + return reader_.PEHeaders != null; // Throws BadImageFormatException on invalid file. + } + catch (Exception ex) { + Trace.WriteLine($"Failed to read PE binary file: {filePath_}"); + return false; + } + } + + public ReadOnlyMemory GetSectionData(SectionHeader header) { + var data = reader_.GetSectionData(header.VirtualAddress); + var array = data.GetContent(); + return array.AsMemory(); + } + + private bool IsARM64ECBinary() { + if (reader_.PEHeaders.PEHeader == null || + reader_.PEHeaders.PEHeader.LoadConfigTableDirectory.Size <= 0 || + !reader_.PEHeaders.TryGetDirectoryOffset(reader_.PEHeaders.PEHeader.LoadConfigTableDirectory, out int offset)) { + return false; + } + + var imageData = reader_.GetEntireImage(); + var configTableData = imageData.GetContent(offset, reader_.PEHeaders.PEHeader.LoadConfigTableDirectory.Size); + var span = MemoryMarshal.Cast(configTableData.AsSpan()); + return span.Length > 0 && span[0].CHPEMetadataPointer != 0; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + public struct IMAGE_LOAD_CONFIG_DIRECTORY64 { + public uint Size; + public uint TimeDateStamp; + public ushort MajorVersion; + public ushort MinorVersion; + public uint GlobalFlagsClear; + public uint GlobalFlagsSet; + public uint CriticalSectionDefaultTimeout; + public ulong DeCommitFreeBlockThreshold; + public ulong DeCommitTotalFreeThreshold; + public ulong LockPrefixTable; + public ulong MaximumAllocationSize; + public ulong VirtualMemoryThreshold; + public ulong ProcessAffinityMask; + public uint ProcessHeapFlags; + public ushort CSDVersion; + public ushort DependentLoadFlags; + public ulong EditList; + public ulong SecurityCookie; + public ulong SEHandlerTable; + public ulong SEHandlerCount; + public ulong GuardCFCheckFunctionPointer; + public ulong GuardCFDispatchFunctionPointer; + public ulong GuardCFFunctionTable; + public ulong GuardCFFunctionCount; + public uint GuardFlags; + public IMAGE_LOAD_CONFIG_CODE_INTEGRITY CodeIntegrity; + public ulong GuardAddressTakenIatEntryTable; + public ulong GuardAddressTakenIatEntryCount; + public ulong GuardLongJumpTargetTable; + public ulong GuardLongJumpTargetCount; + public ulong DynamicValueRelocTable; + public ulong CHPEMetadataPointer; + public ulong GuardRFFailureRoutine; + public ulong GuardRFFailureRoutineFunctionPointer; + public uint DynamicValueRelocTableOffset; + public ushort DynamicValueRelocTableSection; + public ushort Reserved2; + public ulong GuardRFVerifyStackPointerFunctionPointer; + public uint HotPatchTableOffset; + public uint Reserved3; + public ulong EnclaveConfigurationPointer; + public ulong VolatileMetadataPointer; + public ulong GuardEHContinuationTable; + public ulong GuardEHContinuationCount; + } + + [StructLayout(LayoutKind.Sequential)] + public struct IMAGE_LOAD_CONFIG_CODE_INTEGRITY { + public ushort Flags; + public ushort Catalog; + public uint CatalogOffset; + public uint Reserved; + } +} \ No newline at end of file diff --git a/src/ProfileExplorerCore/Binary/SourceFileDebugInfo.cs b/src/ProfileExplorer.Profiling/Binary/SourceFileDebugInfo.cs similarity index 100% rename from src/ProfileExplorerCore/Binary/SourceFileDebugInfo.cs rename to src/ProfileExplorer.Profiling/Binary/SourceFileDebugInfo.cs diff --git a/src/ProfileExplorerCore/Binary/SourceLineDebugInfo.cs b/src/ProfileExplorer.Profiling/Binary/SourceLineDebugInfo.cs similarity index 100% rename from src/ProfileExplorerCore/Binary/SourceLineDebugInfo.cs rename to src/ProfileExplorer.Profiling/Binary/SourceLineDebugInfo.cs diff --git a/src/ProfileExplorer.Profiling/Binary/SymbolFileDescriptor.cs b/src/ProfileExplorer.Profiling/Binary/SymbolFileDescriptor.cs new file mode 100644 index 00000000..86634fc5 --- /dev/null +++ b/src/ProfileExplorer.Profiling/Binary/SymbolFileDescriptor.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System; +using System.IO; +using ProtoBuf; + +namespace ProfileExplorer.Core.Binary; + +[ProtoContract(SkipConstructor = true)] +public class SymbolFileDescriptor : IEquatable { + public SymbolFileDescriptor(string fileName, Guid id, int age) { + FileName = fileName != null ? string.Intern(fileName) : null; + Id = id; + Age = age; + } + + public SymbolFileDescriptor(string fileName) { + FileName = fileName != null ? string.Intern(fileName) : null; + } + + [ProtoMember(1)] + public string FileName { get; set; } + [ProtoMember(2)] + public Guid Id { get; set; } + [ProtoMember(3)] + public int Age { get; set; } + public string SymbolName => string.IsNullOrEmpty(FileName) ? "" : Path.GetFileName(FileName); + + public bool Equals(SymbolFileDescriptor other) { + if (ReferenceEquals(null, other)) { + return false; + } + + return string.Equals(FileName, other.FileName, StringComparison.OrdinalIgnoreCase) && + Id == other.Id && + Age == other.Age; + } + + public static bool operator ==(SymbolFileDescriptor left, SymbolFileDescriptor right) { + return Equals(left, right); + } + + public static bool operator !=(SymbolFileDescriptor left, SymbolFileDescriptor right) { + return !Equals(left, right); + } + + public override string ToString() { + return $"{Id}:{FileName}"; + } + + public override bool Equals(object obj) { + if (ReferenceEquals(null, obj)) { + return false; + } + + if (ReferenceEquals(this, obj)) { + return true; + } + + if (obj.GetType() != GetType()) { + return false; + } + + return Equals((SymbolFileDescriptor)obj); + } + + public override int GetHashCode() { + return HashCode.Combine(StringComparer.OrdinalIgnoreCase.GetHashCode(FileName ?? string.Empty), Id, Age); + } +} diff --git a/src/ProfileExplorer.Profiling/Collections/CollectionExtensions.cs b/src/ProfileExplorer.Profiling/Collections/CollectionExtensions.cs new file mode 100644 index 00000000..e56738ec --- /dev/null +++ b/src/ProfileExplorer.Profiling/Collections/CollectionExtensions.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System.Collections.Generic; + +namespace ProfileExplorer.Profiling.Collections; + +// Small dictionary projection helpers ported into the library so call sites can +// materialize entries/keys/values without depending on ProfileExplorerCore utilities. +public static class CollectionExtensions { + public static List<(K Key, V Value)> ToList(this IDictionary dict) { + var list = new List<(K, V)>(dict.Count); + + foreach (var item in dict) { + list.Add((item.Key, item.Value)); + } + + return list; + } + + public static List ToKeyList(this IDictionary dict) { + var list = new List(dict.Count); + + foreach (var item in dict) { + list.Add(item.Key); + } + + return list; + } + + public static List ToValueList(this IDictionary dict) { + var list = new List(dict.Count); + + foreach (var item in dict) { + list.Add(item.Value); + } + + return list; + } +} diff --git a/src/ProfileExplorerCore/Collections/TinyList.cs b/src/ProfileExplorer.Profiling/Collections/TinyList.cs similarity index 100% rename from src/ProfileExplorerCore/Collections/TinyList.cs rename to src/ProfileExplorer.Profiling/Collections/TinyList.cs diff --git a/src/ProfileExplorerCore/Profile/Data/PerformanceCounters.cs b/src/ProfileExplorer.Profiling/Counters/PerformanceCounters.cs similarity index 100% rename from src/ProfileExplorerCore/Profile/Data/PerformanceCounters.cs rename to src/ProfileExplorer.Profiling/Counters/PerformanceCounters.cs diff --git a/src/ProfileExplorer.Profiling/Disassembly/AssemblyAnnotator.cs b/src/ProfileExplorer.Profiling/Disassembly/AssemblyAnnotator.cs new file mode 100644 index 00000000..cd48ab95 --- /dev/null +++ b/src/ProfileExplorer.Profiling/Disassembly/AssemblyAnnotator.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System.Reflection; +using System.Text; +using ProfileExplorer.Core.Binary; +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, + ISymbolDebugInfo? 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; + } +} diff --git a/src/ProfileExplorer.Profiling/FunctionProfiler.cs b/src/ProfileExplorer.Profiling/FunctionProfiler.cs new file mode 100644 index 00000000..b1e7198f --- /dev/null +++ b/src/ProfileExplorer.Profiling/FunctionProfiler.cs @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.Profile; +using ProfileExplorer.Core.Profile.CallTree; +using ProfileExplorer.Core.Profile.Data; +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 ISymbolFileLocator symbolResolver_; + private readonly bool ownsSymbolResolver_; + 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 ProfileReport? cachedReport_; + private bool symbolsLoaded_; + + public FunctionProfiler(ProfilerOptions options) + : this(options, symbolResolver: null) { + } + + /// + /// Creates a profiler with a custom symbol/binary resolver. When + /// is null, the library's vendored is used (and owned/disposed + /// by this instance). When a resolver is supplied, the caller retains ownership of its lifetime. + /// + public FunctionProfiler(ProfilerOptions options, ISymbolFileLocator? symbolResolver) { + options.Validate(); + options_ = options; + + if (symbolResolver != null) { + symbolResolver_ = symbolResolver; + ownsSymbolResolver_ = false; + } + else { + symbolResolver_ = new SymbolServerClient(options); + ownsSymbolResolver_ = true; + } + + 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); + } + + InvalidateReport(); + } + + /// + /// 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); + InvalidateReport(); + } + + /// + /// Add hardware performance counter events (PMU/PMC). + /// Only processed if is true. + /// + public void AddPerformanceCounterEvents(IEnumerable events) { + counterAggregator_?.AddEvents(events); + InvalidateReport(); + } + + /// + /// 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); + } + + InvalidateReport(); + } + + /// + /// 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 symbolResolver_.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. + // Cache the enumerated PDB function list to avoid re-enumeration on subsequent loads. + var provider = new PdbSymbolProvider(); + var cacheKey = new SymbolFileDescriptor(pdbName, image.PdbGuid, image.PdbAge); + string cacheDir = !string.IsNullOrEmpty(options_.SymbolCacheDirectory) + ? Path.Combine(options_.SymbolCacheDirectory, "symcache") + : SymbolFileCache.DefaultCacheDirectoryPath; + + if (provider.LoadDebugInfo(pdbPath, cacheKey, cacheDir)) { + debugInfoByModule_[moduleName] = provider; + var sortedFunctions = provider.GetSortedFunctions(); + if (sortedFunctions.Count > 0) { + ipResolver_.SetFunctions(moduleName, sortedFunctions); + } + else { + Log($"PDB loaded but 0 functions: {moduleName} ({pdbPath})"); + } + } + else { + Log($"PDB load FAILED: {moduleName} - {PdbSymbolProvider.DiaRegistrationError}"); + provider.Dispose(); + } + } + catch (Exception) { + // Symbol loading failure for this module — continue with others. + } + } + + symbolsLoaded_ = true; + InvalidateReport(); + } + + /// + /// Invalidate the memoized report so the next call rebuilds it. + /// Called whenever new data (images, samples, counters, managed methods, symbols) is added. + /// + private void InvalidateReport() { + cachedReport_ = null; + } + + /// + /// Build the aggregated profiling report (per-function profiles + call tree + totals) from + /// added samples. Requires StackFrames on for the call tree. + /// + public ProfileReport GetReport( + string? processName = null, + int? processId = null) { + if (cachedReport_ != null) return cachedReport_; + + var functions = sampleAggregator_.Build(); + var totalWeight = sampleAggregator_.TotalWeight; + + // Merge per-instruction performance counter data into the function profiles. + if (counterAggregator_ != null) { + foreach (var (id, data) in functions) { + var counters = counterAggregator_.GetCounters(id); + if (counters != null) { + data.InstructionCounters = new Dictionary(counters); + } + } + } + + // Filter by minimum self percent. + if (options_.MinSelfPercent > 0 && totalWeight.Ticks > 0) { + double totalMs = totalWeight.TotalMilliseconds; + var filtered = new Dictionary(); + + foreach (var (id, data) in functions) { + double exclusivePercent = data.ExclusiveWeight.TotalMilliseconds / totalMs * 100; + + if (exclusivePercent >= options_.MinSelfPercent) { + filtered[id] = data; + } + } + + functions = filtered; + } + + var callTree = callTreeBuilder_.Build(); + cachedReport_ = new ProfileReport(functions, callTree, totalWeight); + return cachedReport_; + } + + /// + /// Get annotated disassembly for a specific function. + /// Downloads the binary on-demand, disassembles via Capstone, and annotates with timing data. + /// + public async Task GetAnnotatedAssemblyAsync( + ProfileFunctionId functionId, + FunctionProfileData function, + CancellationToken ct = default) { + string moduleName = functionId.ModuleName; + long functionRva = function.FunctionDebugInfo.RVA; + int functionSize = (int)function.FunctionDebugInfo.Size; + + // Download binary if not already cached. + if (!binaryPathByModule_.TryGetValue(moduleName, out var binaryPath)) { + if (imagesByModule_.TryGetValue(moduleName, out var image)) { + binaryPath = await symbolResolver_.FindBinaryFileAsync( + image.ImageName, image.TimeDateStamp, image.Size, ct); + + if (binaryPath != null) { + binaryPathByModule_[moduleName] = binaryPath; + } + } + } + + if (binaryPath == null) { + // Binary not available — try to return hot lines from instruction weights + // without disassembly. Avoids DIA COM calls (AccessViolationException risk). + Log($"Binary not found for {moduleName}, falling back to instruction weights ({function.InstructionWeight.Count} offsets, {function.ExclusiveWeight.TotalMilliseconds:F1}ms)"); + try { + var result = GetHotLinesWithoutBinary(function, functionRva); + Log($"GetHotLinesWithoutBinary: {(result != null ? $"{result.HotLines.Count} hot lines" : "null")}"); + return result; + } + catch (Exception ex) { + Log($"GetHotLinesWithoutBinary failed for {functionId}: {ex.GetType().Name}: {ex.Message}"); + return null; + } + } + + // Get debug info for source line + call-target annotation. + debugInfoByModule_.TryGetValue(moduleName, out var debugInfoProvider); + + // Disassemble using the mature capstone disassembler (reads architecture and image base + // from the PE binary itself). Resolves call/jump targets via the debug info provider. + using var disassembler = Disassembler.CreateForBinary(binaryPath, debugInfoProvider, null); + + if (disassembler == null) return null; + + var instructions = disassembler.DisassembleToList(functionRva, functionSize); + + if (instructions.Count == 0) return null; + + FunctionDebugInfo? funcDebugInfo = debugInfoProvider?.FindFunctionByRVA(functionRva) ?? function.FunctionDebugInfo; + + // Annotate. + return AssemblyAnnotator.Annotate( + instructions, + function.InstructionWeight, + functionRva, + debugInfoProvider, + funcDebugInfo, + options_.Architecture, + options_.MinHotLinePercent, + options_.MaxHotLines); + } + + public void Dispose() { + if (ownsSymbolResolver_ && symbolResolver_ is IDisposable disposableResolver) { + disposableResolver.Dispose(); + } + + foreach (var (_, provider) in debugInfoByModule_) { + provider.Dispose(); + } + + debugInfoByModule_.Clear(); + } + + // Forward a diagnostic message to the consumer-provided sink (no-op when none is configured). + private void Log(string message) => options_.LogCallback?.Invoke(message); + + /// + /// 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 the function's debug-info source file name if available. + /// + private AnnotatedAssembly? GetHotLinesWithoutBinary(FunctionProfileData function, long functionRva) { + if (function.InstructionWeight.Count == 0) return null; + + var totalWeight = function.InstructionWeight.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 the function's debug info if available. + string? sourceFile = function.FunctionDebugInfo.SourceFileName; + + foreach (var (offset, weight) in function.InstructionWeight.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: functionRva + offset, + rva: functionRva + offset, + instructionText: text, + weight: weight, + percent: percent, + sourceFile: sourceFile, + sourceLine: null); + lines.Add(line); + + sb.AppendLine($"{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/IR/SourceStackFrame.cs b/src/ProfileExplorer.Profiling/IR/SourceStackFrame.cs new file mode 100644 index 00000000..ffaa970d --- /dev/null +++ b/src/ProfileExplorer.Profiling/IR/SourceStackFrame.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System; + +namespace ProfileExplorer.Core.IR.Tags; + +public sealed class SourceStackFrame : IEquatable { + public SourceStackFrame(string function, string filePath, int line, int column) { + Function = function; + FilePath = filePath; + Line = line; + Column = column; + } + + public string Function { get; set; } + public string FilePath { get; set; } + public int Line { get; set; } + public int Column { get; set; } + + public bool Equals(SourceStackFrame other) { + if (ReferenceEquals(null, other)) + return false; + if (ReferenceEquals(this, other)) + return true; + return Line == other.Line && Column == other.Column && + Function.Equals(other.Function, StringComparison.OrdinalIgnoreCase) && + FilePath.Equals(other.FilePath, StringComparison.OrdinalIgnoreCase); + } + + public static bool operator ==(SourceStackFrame left, SourceStackFrame right) { + return Equals(left, right); + } + + public static bool operator !=(SourceStackFrame left, SourceStackFrame right) { + return !Equals(left, right); + } + + public override bool Equals(object obj) { + return ReferenceEquals(this, obj) || obj is SourceStackFrame other && Equals(other); + } + + public override int GetHashCode() { + return HashCode.Combine(Function, FilePath, Line, Column); + } + + public bool HasSameFunction(SourceStackFrame inlinee) { + return Function.Equals(inlinee.Function, StringComparison.OrdinalIgnoreCase) && + FilePath.Equals(inlinee.FilePath, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/ProfileExplorer.Profiling/Managed/ManagedDebugInfoProvider.cs b/src/ProfileExplorer.Profiling/Managed/ManagedDebugInfoProvider.cs new file mode 100644 index 00000000..8e122e58 --- /dev/null +++ b/src/ProfileExplorer.Profiling/Managed/ManagedDebugInfoProvider.cs @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System.Collections.Generic; +using System.Reflection.PortableExecutable; + +namespace ProfileExplorer.Core.Binary; + +/// +/// Managed (.NET) debug-info reader core: holds the JIT-compiled method registry, IL-to-native +/// offset maps, and method code, and answers RVA/name lookups. Source-line resolution from a +/// managed (portable) PDB is delegated to , which Profile +/// Explorer overrides with its TraceEvent-based loader (kept out of this TraceEvent-free library). +/// +public class ManagedDebugInfoProvider : ISymbolDebugInfo { + protected readonly Dictionary functionMap_; + protected readonly List functions_; + protected readonly Dictionary> methodILNativeMap_; + protected Dictionary methodCodeMap_; + protected Machine architecture_; + + public ManagedDebugInfoProvider(Machine architecture) { + architecture_ = architecture; + functionMap_ = new Dictionary(); + functions_ = new List(); + methodILNativeMap_ = new Dictionary>(); + } + + public Machine? Architecture => architecture_; + + public FunctionDebugInfo FindFunction(string functionName) { + return functionMap_.TryGetValue(functionName, out var func) ? func : FunctionDebugInfo.Unknown; + } + + public IEnumerable EnumerateFunctions() { + return functions_; + } + + public List GetSortedFunctions() { + return functions_; + } + + public FunctionDebugInfo FindFunctionByRVA(long rva) { + return FunctionDebugInfo.BinarySearch(functions_, rva); + } + + public SourceFileDebugInfo FindFunctionSourceFilePath(string functionName) { + if (functionMap_.TryGetValue(functionName, out var funcInfo)) { + return GetSourceFileInfo(funcInfo); + } + + return SourceFileDebugInfo.Unknown; + } + + public SourceFileDebugInfo FindSourceFilePathByRVA(long rva) { + var funcInfo = FindFunctionByRVA(rva); + + if (EnsureHasSourceLines(funcInfo)) { + return GetSourceFileInfo(funcInfo); + } + + return SourceFileDebugInfo.Unknown; + } + + public SourceLineDebugInfo FindSourceLineByRVA(long rva, bool includeInlinees) { + var funcInfo = FindFunctionByRVA(rva); + + if (EnsureHasSourceLines(funcInfo)) { + long offset = rva - funcInfo.StartRVA; + return funcInfo.FindNearestLine(offset); + } + + return SourceLineDebugInfo.Unknown; + } + + public void Unload() { + } + + public void Dispose() { + } + + public bool PopulateSourceLines(FunctionDebugInfo funcInfo) { + return true; + } + + public void UpdateArchitecture(Machine architecture) { + if (architecture_ == Machine.Unknown) { + architecture_ = architecture; + } + } + + public MethodCode FindMethodCode(FunctionDebugInfo funcInfo) { + if (methodCodeMap_ != null && methodCodeMap_.TryGetValue(funcInfo.RVA, out var code)) { + return code; + } + + return null; + } + + public void AddFunctionInfo(FunctionDebugInfo funcInfo) { + functions_.Add(funcInfo); + functionMap_[funcInfo.Name] = funcInfo; + } + + public void AddMethodILToNativeMap(FunctionDebugInfo functionDebugInfo, + List<(int ILOffset, int NativeOffset)> ilOffsets) { + methodILNativeMap_[functionDebugInfo] = ilOffsets; + } + + public void LoadingCompleted() { + functions_.Sort(); + } + + public void AddMethodCode(long codeAddress, MethodCode code) { + methodCodeMap_ ??= new Dictionary(); + methodCodeMap_[codeAddress] = code; + } + + /// + /// Loads source-line mappings for the given function from a managed (portable) PDB. + /// The base library implementation has no PDB reader and returns false; Profile Explorer + /// overrides this with a TraceEvent-based loader. + /// + protected virtual bool EnsureHasSourceLines(FunctionDebugInfo functionDebugInfo) { + return functionDebugInfo is { IsUnknown: false, HasSourceLines: true }; + } + + protected static SourceFileDebugInfo GetSourceFileInfo(FunctionDebugInfo info) { + return new SourceFileDebugInfo(info.SourceFileName, + info.OriginalSourceFileName, + info.FirstSourceLine.Line); + } +} diff --git a/src/ProfileExplorer.Profiling/Managed/MethodCode.cs b/src/ProfileExplorer.Profiling/Managed/MethodCode.cs new file mode 100644 index 00000000..fb9f94fd --- /dev/null +++ b/src/ProfileExplorer.Profiling/Managed/MethodCode.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System.Collections.Generic; + +namespace ProfileExplorer.Core.Binary; + +/// +/// A resolved call target within JIT-compiled managed code (address -> symbol name). +/// +public struct AddressNamePair { + public long Address { get; set; } + public string Name { get; set; } + + public AddressNamePair(long address, string name) { + Address = address; + Name = name; + } +} + +/// +/// JIT-compiled native code for a managed method: the code bytes and resolved call targets. +/// +public class MethodCode { + public MethodCode(long address, int size, byte[] code) { + Address = address; + Size = size; + Code = code; + CallTargets = new List(); + } + + public long Address { get; set; } + public int Size { get; set; } + public byte[] Code { get; set; } + public List CallTargets { get; set; } + + public string? FindCallTarget(long address) { + int index = CallTargets.FindIndex(item => item.Address == address); + + if (index != -1) { + return CallTargets[index].Name; + } + + return null; + } +} 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/CounterDescriptions.cs b/src/ProfileExplorer.Profiling/Models/CounterDescriptions.cs new file mode 100644 index 00000000..19e38a8d --- /dev/null +++ b/src/ProfileExplorer.Profiling/Models/CounterDescriptions.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace ProfileExplorer.Profiling; + +/// +/// 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/Models/ProfileReport.cs b/src/ProfileExplorer.Profiling/Models/ProfileReport.cs new file mode 100644 index 00000000..23543a87 --- /dev/null +++ b/src/ProfileExplorer.Profiling/Models/ProfileReport.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.Profile; +using ProfileExplorer.Core.Profile.CallTree; +using ProfileExplorer.Core.Profile.Data; + +namespace ProfileExplorer.Profiling; + +/// +/// Aggregated result of a profiling run: the per-function profile map, the call tree, and the +/// total sampled weight. Functions are keyed by their neutral +/// (module + function name); per-function detail lives on . +/// +public sealed class ProfileReport { + public ProfileReport(IReadOnlyDictionary functions, + ProfileCallTree callTree, TimeSpan totalWeight) { + Functions = functions; + CallTree = callTree; + TotalWeight = totalWeight; + } + + /// Per-function aggregated profile, keyed by neutral function identity. + public IReadOnlyDictionary Functions { get; } + + /// Call tree built from the sample stacks. + public ProfileCallTree CallTree { get; } + + /// Total weight (CPU time) across all samples. + public TimeSpan TotalWeight { get; } + + /// Returns as a fraction (0..1) of the total sampled weight. + public double ScaleWeight(TimeSpan weight) { + return TotalWeight.Ticks > 0 ? weight.Ticks / (double)TotalWeight.Ticks : 0.0; + } + + /// Functions sorted by self (exclusive) weight, descending. + public List> FunctionsBySelfWeight() { + var list = new List>(Functions); + list.Sort((a, b) => b.Value.ExclusiveWeight.CompareTo(a.Value.ExclusiveWeight)); + return list; + } + + /// Functions sorted by total (inclusive) weight, descending. + public List> FunctionsByTotalWeight() { + var list = new List>(Functions); + list.Sort((a, b) => b.Value.Weight.CompareTo(a.Value.Weight)); + return list; + } +} diff --git a/src/ProfileExplorer.Profiling/Profile/CallTree/IResolvedCallStack.cs b/src/ProfileExplorer.Profiling/Profile/CallTree/IResolvedCallStack.cs new file mode 100644 index 00000000..dbcbad34 --- /dev/null +++ b/src/ProfileExplorer.Profiling/Profile/CallTree/IResolvedCallStack.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using ProfileExplorer.Core.Binary; + +namespace ProfileExplorer.Core.Profile.CallTree; + +/// +/// Neutral, resolved CPU call stack consumed by . +/// Frames are indexed leaf-first (index 0 = innermost/leaf frame); the call-tree build walks +/// them in reverse to form root-to-leaf paths. Implemented by the trace provider (e.g. +/// ProfileExplorerCore's ResolvedProfileStack) so the call-tree build logic stays free of any +/// trace-format or IR-specific types. +/// +public interface IResolvedCallStack { + /// Number of frames in the stack. + int FrameCount { get; } + + /// Thread that produced the sample. + int ThreadId { get; } + + /// Returns the resolved frame at the given index (0 = leaf). + ResolvedCallStackFrame GetFrame(int index); +} + +/// +/// A single resolved stack frame: neutral function identity plus the data the call-tree build +/// needs. A lightweight value type to avoid per-sample heap allocations. +/// +public readonly struct ResolvedCallStackFrame { + public ResolvedCallStackFrame(long frameRva, FunctionDebugInfo debugInfo, ProfileFunctionId functionId, + bool isKernelCode, bool isManagedCode) { + FrameRva = frameRva; + DebugInfo = debugInfo; + FunctionId = functionId; + IsKernelCode = isKernelCode; + IsManagedCode = isManagedCode; + } + + public long FrameRva { get; } + public FunctionDebugInfo DebugInfo { get; } + public ProfileFunctionId FunctionId { get; } + public bool IsKernelCode { get; } + public bool IsManagedCode { get; } +} diff --git a/src/ProfileExplorerCore/Profile/CallTree/ProfileCallSite.cs b/src/ProfileExplorer.Profiling/Profile/CallTree/ProfileCallSite.cs similarity index 97% rename from src/ProfileExplorerCore/Profile/CallTree/ProfileCallSite.cs rename to src/ProfileExplorer.Profiling/Profile/CallTree/ProfileCallSite.cs index 76641c67..66a690bb 100644 --- a/src/ProfileExplorerCore/Profile/CallTree/ProfileCallSite.cs +++ b/src/ProfileExplorer.Profiling/Profile/CallTree/ProfileCallSite.cs @@ -60,7 +60,7 @@ public void AddTarget(ProfileCallTreeNode node, TimeSpan weight) { // Don't use FindIndex because it allocates a lambda on each invocation. for (int i = 0; i < Targets.Count; i++) { - if (Targets[i].Node.Equals(node.Function)) { + if (Targets[i].Node.FunctionId == node.FunctionId) { index = i; break; } diff --git a/src/ProfileExplorerCore/Profile/CallTree/ProfileCallTree.cs b/src/ProfileExplorer.Profiling/Profile/CallTree/ProfileCallTree.cs similarity index 84% rename from src/ProfileExplorerCore/Profile/CallTree/ProfileCallTree.cs rename to src/ProfileExplorer.Profiling/Profile/CallTree/ProfileCallTree.cs index 6142e6aa..5f660376 100644 --- a/src/ProfileExplorerCore/Profile/CallTree/ProfileCallTree.cs +++ b/src/ProfileExplorer.Profiling/Profile/CallTree/ProfileCallTree.cs @@ -8,21 +8,14 @@ using System.Text; using System.Threading; using ProfileExplorer.Core.Binary; +using ProfileExplorer.Profiling.Collections; using ProfileExplorer.Core.Profile.Data; -using ProfileExplorer.Core.Utilities; namespace ProfileExplorer.Core.Profile.CallTree; -public enum ProfileCallTreeNodeKind { - Unset = 0, - NativeUser = 1, - NativeKernel = 2, - Managed = 3 -} - public sealed class ProfileCallTree { - private ConcurrentDictionary rootNodes_; - private Dictionary> funcToNodesMap_; + private ConcurrentDictionary rootNodes_; + private Dictionary> funcToNodesMap_; private Dictionary nodeIdMap_; private int nextNodeId_; @@ -45,40 +38,40 @@ public TimeSpan TotalRootNodesWeight { } } - public void UpdateCallTree(ref ProfileSample sample, ResolvedProfileStack resolvedStack) { + public void UpdateCallTree(TimeSpan sampleWeight, IResolvedCallStack resolvedStack) { // Build call tree. Note that the call tree methods themselves are thread-safe. bool isRootFrame = true; ProfileCallTreeNode prevNode = null; - ResolvedProfileStackFrame prevFrame = null; - var sampleWeight = sample.Weight; + long prevFrameRVA = 0; + int threadId = resolvedStack.ThreadId; for (int k = resolvedStack.FrameCount - 1; k >= 0; k--) { - var resolvedFrame = resolvedStack.StackFrames[k]; + var resolvedFrame = resolvedStack.GetFrame(k); - if (resolvedFrame.FrameRVA == 0 && resolvedFrame.FrameDetails.DebugInfo == null) { + if (resolvedFrame.FrameRva == 0 && resolvedFrame.DebugInfo == null) { continue; } ProfileCallTreeNode node = null; if (isRootFrame) { - node = AddRootNode(resolvedFrame.FrameDetails.DebugInfo, resolvedFrame.FrameDetails.Function); + node = AddRootNode(resolvedFrame.DebugInfo, resolvedFrame.FunctionId); isRootFrame = false; } else { - node = AddChildNode(prevNode, resolvedFrame.FrameDetails.DebugInfo, resolvedFrame.FrameDetails.Function); - prevNode.AddCallSite(node, prevFrame.FrameRVA, sampleWeight); + node = AddChildNode(prevNode, resolvedFrame.DebugInfo, resolvedFrame.FunctionId); + prevNode.AddCallSite(node, prevFrameRVA, sampleWeight); } node.AccumulateWeight(sampleWeight); - node.AccumulateWeight(sampleWeight, TimeSpan.Zero, resolvedStack.Context.ThreadId); + node.AccumulateWeight(sampleWeight, TimeSpan.Zero, threadId); // Set the user/kernel-mode context of the function. if (node.Kind == ProfileCallTreeNodeKind.Unset) { - if (resolvedFrame.FrameDetails.IsKernelCode) { + if (resolvedFrame.IsKernelCode) { node.Kind = ProfileCallTreeNodeKind.NativeKernel; } - else if (resolvedFrame.FrameDetails.IsManagedCode) { + else if (resolvedFrame.IsManagedCode) { node.Kind = ProfileCallTreeNodeKind.Managed; } else { @@ -86,31 +79,30 @@ public void UpdateCallTree(ref ProfileSample sample, ResolvedProfileStack resolv } } - //node.RecordSample(sample, resolvedFrame); //? Remove prevNode = node; - prevFrame = resolvedFrame; + prevFrameRVA = resolvedFrame.FrameRva; } // Last function on the stack gets the exclusive weight. if (prevNode != null) { prevNode.AccumulateExclusiveWeight(sampleWeight); - prevNode.AccumulateWeight(TimeSpan.Zero, sampleWeight, resolvedStack.Context.ThreadId); + prevNode.AccumulateWeight(TimeSpan.Zero, sampleWeight, threadId); } } - private ProfileCallTreeNode AddRootNode(FunctionDebugInfo funcInfo, IRTextFunction function) { - if (rootNodes_.TryGetValue(function, out var existingNode)) { + private ProfileCallTreeNode AddRootNode(FunctionDebugInfo funcInfo, ProfileFunctionId id) { + if (rootNodes_.TryGetValue(id, out var existingNode)) { return existingNode; } - var node = rootNodes_.GetOrAdd(function, static (func, info) => new ProfileCallTreeNode(info, func), funcInfo); + var node = rootNodes_.GetOrAdd(id, _ => new ProfileCallTreeNode(funcInfo, id)); RegisterFunctionTreeNode(node); return node; } private ProfileCallTreeNode AddChildNode(ProfileCallTreeNode node, FunctionDebugInfo funcInfo, - IRTextFunction function) { - (var childNode, bool isNewNode) = node.AddChild(funcInfo, function); + ProfileFunctionId id) { + (var childNode, bool isNewNode) = node.AddChild(funcInfo, id); if (isNewNode) { RegisterFunctionTreeNode(childNode); @@ -122,7 +114,7 @@ private ProfileCallTreeNode AddChildNode(ProfileCallTreeNode node, FunctionDebug private void RegisterFunctionTreeNode(ProfileCallTreeNode node) { // Add an unique instance of the node for a function. node.Id = Interlocked.Increment(ref nextNodeId_); - ref var nodeList = ref CollectionsMarshal.GetValueRefOrAddDefault(funcToNodesMap_, node.Function, out bool exists); + ref var nodeList = ref CollectionsMarshal.GetValueRefOrAddDefault(funcToNodesMap_, node.FunctionId, out bool exists); if (!exists) { nodeList = new List(); @@ -145,7 +137,7 @@ public ProfileCallTreeNode FindNode(long nodeId) { } } - return nodeIdMap_.GetValueOrNull(nodeId); + return nodeIdMap_.TryGetValue(nodeId, out var foundNode) ? foundNode : null; } public ProfileCallTreeNode FindMatchingNode(ProfileCallTreeNode queryNode) { @@ -155,7 +147,7 @@ public ProfileCallTreeNode FindMatchingNode(ProfileCallTreeNode queryNode) { return null; } - if (!funcToNodesMap_.TryGetValue(queryNode.Function, out var nodeList)) { + if (!funcToNodesMap_.TryGetValue(queryNode.FunctionId, out var nodeList)) { return null; } @@ -171,7 +163,7 @@ public ProfileCallTreeNode FindMatchingNode(ProfileCallTreeNode queryNode) { var nodeB = queryNode; while (nodeA != null && nodeB != null) { - if (!nodeA.Function.Equals(nodeB.Function)) { + if (nodeA.FunctionId != nodeB.FunctionId) { break; } @@ -187,16 +179,16 @@ public ProfileCallTreeNode FindMatchingNode(ProfileCallTreeNode queryNode) { return null; } - public List GetCallTreeNodes(IRTextFunction function) { - if (funcToNodesMap_.TryGetValue(function, out var nodeList)) { + public List GetCallTreeNodes(ProfileFunctionId functionId) { + if (funcToNodesMap_.TryGetValue(functionId, out var nodeList)) { return nodeList; } return new List(); } - public List GetSortedCallTreeNodes(IRTextFunction function) { - var nodeList = GetCallTreeNodes(function); + public List GetSortedCallTreeNodes(ProfileFunctionId functionId) { + var nodeList = GetCallTreeNodes(functionId); if (nodeList.Count < 2) { return nodeList; @@ -210,8 +202,8 @@ public List GetSortedCallTreeNodes(IRTextFunction function) return nodeListCopy; } - public ProfileCallTreeNode GetCombinedCallTreeNode(IRTextFunction function, ProfileCallTreeNode parentNode = null) { - var nodes = GetSortedCallTreeNodes(function); + public ProfileCallTreeNode GetCombinedCallTreeNode(ProfileFunctionId functionId, ProfileCallTreeNode parentNode = null) { + var nodes = GetSortedCallTreeNodes(functionId); return CombinedCallTreeNodesImpl(nodes, true, parentNode); } @@ -284,16 +276,19 @@ private static ProfileCallTreeNode CombinedCallTreeNodesImpl(List GetTopModules(ProfileCallTreeNode node) { public (List Functions, List Modules) GetTopFunctionsAndModules(ProfileCallTreeNode node) { var moduleMap = new Dictionary(); - var funcMap = new Dictionary(); + var funcMap = new Dictionary(); if (node is ProfileCallTreeGroupNode groupNode) { foreach (var nestedNode in groupNode.Nodes) { @@ -453,19 +448,19 @@ public List GetTopModules(ProfileCallTreeNode node) { } moduleList.Sort((a, b) => b.Weight.CompareTo(a.Weight)); - var funcList = funcMap.ToValueList(); + var funcList = new List(funcMap.Values); funcList.Sort((a, b) => b.ExclusiveWeight.CompareTo(a.ExclusiveWeight)); return (funcList, moduleList); } private void CollectFunctionsAndModules(ProfileCallTreeNode node, - Dictionary funcMap, + Dictionary funcMap, Dictionary moduleMap) { // Combine all instances of a function under the node. - ref var entry = ref CollectionsMarshal.GetValueRefOrAddDefault(funcMap, node.Function, out bool exists); + ref var entry = ref CollectionsMarshal.GetValueRefOrAddDefault(funcMap, node.FunctionId, out bool exists); if (!exists) { - entry = new ProfileCallTreeGroupNode(node.FunctionDebugInfo, node.Function, node.Kind); + entry = new ProfileCallTreeGroupNode(node.FunctionDebugInfo, node.FunctionId, node.Kind); } var groupEntry = (ProfileCallTreeGroupNode)entry; @@ -505,7 +500,7 @@ public void MergeWith(ProfileCallTree otherTree) { // Merge the other data structures. if (otherTree.funcToNodesMap_ != null) { - funcToNodesMap_ ??= new Dictionary>(); + funcToNodesMap_ ??= new Dictionary>(); var existingNodesSet = new HashSet(); foreach (var list in funcToNodesMap_.Values) { @@ -642,8 +637,8 @@ public override string ToString() { } private void InitializeReferenceMembers() { - rootNodes_ ??= new ConcurrentDictionary(); - funcToNodesMap_ ??= new Dictionary>(); + rootNodes_ ??= new ConcurrentDictionary(); + funcToNodesMap_ ??= new Dictionary>(); } public void ResetTags() { @@ -654,8 +649,8 @@ public void ResetTags() { } } - public ProfileCallTreeNode FindRootNode(IRTextFunction func) { - if (rootNodes_.TryGetValue(func, out var node)) { + public ProfileCallTreeNode FindRootNode(ProfileFunctionId funcId) { + if (rootNodes_.TryGetValue(funcId, out var node)) { return node; } diff --git a/src/ProfileExplorerCore/Profile/CallTree/ProfileCallTreeNode.cs b/src/ProfileExplorer.Profiling/Profile/CallTree/ProfileCallTreeNode.cs similarity index 84% rename from src/ProfileExplorerCore/Profile/CallTree/ProfileCallTreeNode.cs rename to src/ProfileExplorer.Profiling/Profile/CallTree/ProfileCallTreeNode.cs index 9872facf..e189b90d 100644 --- a/src/ProfileExplorerCore/Profile/CallTree/ProfileCallTreeNode.cs +++ b/src/ProfileExplorer.Profiling/Profile/CallTree/ProfileCallTreeNode.cs @@ -6,14 +6,21 @@ using System.Text; using ProfileExplorer.Core.Binary; using ProfileExplorer.Core.Collections; -using ProfileExplorer.Core.Utilities; +using ProfileExplorer.Profiling.Collections; namespace ProfileExplorer.Core.Profile.CallTree; +public enum ProfileCallTreeNodeKind { + Unset = 0, + NativeUser = 1, + NativeKernel = 2, + Managed = 3 +} + public class ProfileCallTreeNode : IEquatable { private static readonly object MergedNodeTag = new(); public int Id { get; set; } - public IRTextFunction Function { get; set; } + public ProfileFunctionId FunctionId { get; set; } // Neutral (module, name) identity; decoupled from IRTextFunction. public ProfileCallTreeNodeKind Kind { get; set; } private TinyList children_; private ProfileCallTreeNode caller_; // Can't be serialized, reconstructed. @@ -41,9 +48,9 @@ public class ProfileCallTreeNode : IEquatable { public virtual bool HasCallers => caller_ != null; public bool HasCallSites => CallSites != null && CallSites.Count > 0; public bool HasThreadWeights => ThreadWeights != null && ThreadWeights.Count > 0; - public bool HasFunction => Function != null; - public string FunctionName => Function.Name; - public string ModuleName => Function.ModuleName; + public bool HasFunction => !FunctionId.IsUnknown; + public string FunctionName => FunctionId.FunctionName; + public string ModuleName => FunctionId.ModuleName; public double ScaleWeight(TimeSpan relativeWeigth) { return relativeWeigth.Ticks / (double)Weight.Ticks; @@ -69,13 +76,13 @@ public double ScaleWeight(TimeSpan relativeWeigth) { protected ProfileCallTreeNode() { } - public ProfileCallTreeNode(FunctionDebugInfo funcInfo, IRTextFunction function, + public ProfileCallTreeNode(FunctionDebugInfo funcInfo, ProfileFunctionId functionId, List children = null, ProfileCallTreeNode caller = null, Dictionary callSites = null, Dictionary threadWeights = null) { FunctionDebugInfo = funcInfo; - Function = function; + FunctionId = functionId; ThreadWeights = threadWeights ?? new Dictionary(); children_ = new TinyList(children); caller_ = caller; @@ -87,7 +94,12 @@ public void AccumulateWeight(TimeSpan weight) { } public void AccumulateWeight(TimeSpan weight, TimeSpan exclusiveWeight, int threadId) { - ThreadWeights.AccumulateValue(threadId, weight, exclusiveWeight); + ref var currentValue = ref CollectionsMarshal.GetValueRefOrAddDefault(ThreadWeights, threadId, out bool exists); + + // The TimeSpan + operator does an overflow check that is not relevant + // (and an exception undesirable), avoid it for some speedup. + currentValue.Weight = TimeSpan.FromTicks(currentValue.Weight.Ticks + weight.Ticks); + currentValue.ExclusiveWeight = TimeSpan.FromTicks(currentValue.ExclusiveWeight.Ticks + exclusiveWeight.Ticks); } public List<(int ThreadId, (TimeSpan Weight, TimeSpan ExclusiveWeight) Values)> @@ -112,16 +124,16 @@ public void AccumulateExclusiveWeight(TimeSpan weight) { ExclusiveWeight += weight; } - public (ProfileCallTreeNode, bool) AddChild(FunctionDebugInfo functionDebugInfo, IRTextFunction function) { - return GetOrCreateChildNode(functionDebugInfo, function); + public (ProfileCallTreeNode, bool) AddChild(FunctionDebugInfo functionDebugInfo, ProfileFunctionId functionId) { + return GetOrCreateChildNode(functionDebugInfo, functionId); } public bool HasChild(ProfileCallTreeNode node) { return children_.Contains(node); } - public ProfileCallTreeNode FindChildNode(IRTextFunction function) { - return children_.Find(node => node.Function == function); + public ProfileCallTreeNode FindChildNode(ProfileFunctionId functionId) { + return children_.Find(node => node.FunctionId == functionId); } internal void SetChildrenNoLock(List children) { @@ -139,14 +151,14 @@ public bool HasParent(ProfileCallTreeNode parentNode, ProfileCallTreeNodeCompare } private (ProfileCallTreeNode, bool) - GetOrCreateChildNode(FunctionDebugInfo functionDebugInfo, IRTextFunction function) { - var childNode = FindExistingNode(functionDebugInfo, function); + GetOrCreateChildNode(FunctionDebugInfo functionDebugInfo, ProfileFunctionId functionId) { + var childNode = FindExistingNode(functionDebugInfo, functionId); if (childNode != null) { return (childNode, false); } - childNode = new ProfileCallTreeNode(functionDebugInfo, function, null, this); + childNode = new ProfileCallTreeNode(functionDebugInfo, functionId, null, this); children_.Add(childNode); return (childNode, true); } @@ -162,11 +174,11 @@ public void AddCallSite(ProfileCallTreeNode childNode, long rva, TimeSpan weight callsite.AddTarget(childNode, weight); } - private ProfileCallTreeNode FindExistingNode(FunctionDebugInfo functionDebugInfo, IRTextFunction function) { + private ProfileCallTreeNode FindExistingNode(FunctionDebugInfo functionDebugInfo, ProfileFunctionId functionId) { for (int i = 0; i < children_.Count; i++) { var child = children_[i]; - if (child.Equals(function)) { + if (child.FunctionId == functionId) { return child; } } @@ -208,7 +220,7 @@ public void MergeWith(ProfileCallTreeNode otherNode) { if (otherNode.HasChildren) { foreach (var child in otherNode.children_) { - var existingChild = FindChildNode(child.Function); + var existingChild = FindChildNode(child.FunctionId); if (existingChild != null) { // Recursively merge child nodes. @@ -247,10 +259,6 @@ internal void Print(StringBuilder builder, int level = 0, bool caller = false) { } } - public bool Equals(IRTextFunction function) { - return Function.Equals(function); - } - public bool Equals(ProfileCallTreeNode other) { if (ReferenceEquals(null, other)) { return false; @@ -302,7 +310,7 @@ public ProfileCallTreeNode Clone() { return new ProfileCallTreeNode { Id = Id, Kind = Kind, - Function = Function, + FunctionId = FunctionId, FunctionDebugInfo = FunctionDebugInfo, Weight = Weight, ExclusiveWeight = ExclusiveWeight, @@ -320,26 +328,26 @@ public sealed class ProfileCallTreeGroupNode : ProfileCallTreeNode { public ProfileCallTreeGroupNode() { } - public ProfileCallTreeGroupNode(FunctionDebugInfo funcInfo, IRTextFunction function, + public ProfileCallTreeGroupNode(FunctionDebugInfo funcInfo, ProfileFunctionId functionId, List nodes = null, List children = null, List callers = null, Dictionary callSites = null, Dictionary threadWeights = null) : - base(funcInfo, function, children, null, callSites, threadWeights) { + base(funcInfo, functionId, children, null, callSites, threadWeights) { nodes_ = nodes ?? new List(); callers_ = callers ?? new List(); } - public ProfileCallTreeGroupNode(FunctionDebugInfo funcInfo, IRTextFunction function, + public ProfileCallTreeGroupNode(FunctionDebugInfo funcInfo, ProfileFunctionId functionId, ProfileCallTreeNodeKind kind) : - base(funcInfo, function) { + base(funcInfo, functionId) { nodes_ = new List(); Kind = kind; } public ProfileCallTreeGroupNode(ProfileCallTreeNode baseNode, TimeSpan weight) : - this(baseNode.FunctionDebugInfo, baseNode.Function) { + this(baseNode.FunctionDebugInfo, baseNode.FunctionId) { nodes_.Add(baseNode); Weight = weight; } @@ -357,10 +365,10 @@ public override string ToString() { // Comparer used for the root nodes in order to ignore the ID part. public class ProfileCallTreeNodeComparer : IEqualityComparer { public bool Equals(ProfileCallTreeNode x, ProfileCallTreeNode y) { - return x.Equals(y.Function); + return x.FunctionId == y.FunctionId; } public int GetHashCode(ProfileCallTreeNode obj) { - return HashCode.Combine(obj.Function); + return obj.FunctionId.GetHashCode(); } } \ No newline at end of file diff --git a/src/ProfileExplorer.Profiling/Profile/Data/FunctionProfileData.cs b/src/ProfileExplorer.Profiling/Profile/Data/FunctionProfileData.cs new file mode 100644 index 00000000..80936b96 --- /dev/null +++ b/src/ProfileExplorer.Profiling/Profile/Data/FunctionProfileData.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using ProfileExplorer.Core.Binary; + +namespace ProfileExplorer.Core.Profile.Data; + +public class FunctionProfileData { + public FunctionProfileData() { + InstructionWeight = new Dictionary(); + SampleStartIndex = int.MaxValue; + SampleEndIndex = int.MinValue; + } + + public FunctionProfileData(FunctionDebugInfo debugInfo) : this() { + FunctionDebugInfo = debugInfo; + } + + public TimeSpan Weight { get; set; } + public TimeSpan ExclusiveWeight { get; set; } + public Dictionary InstructionWeight { get; set; } // Instr. offset mapping + public Dictionary InstructionCounters { get; set; } + public FunctionDebugInfo FunctionDebugInfo { get; set; } + public int SampleStartIndex { get; set; } + public int SampleEndIndex { get; set; } + public bool HasPerformanceCounters => InstructionCounters is {Count: > 0}; + + public void MergeWith(FunctionProfileData otherData) { + Weight += otherData.Weight; + ExclusiveWeight += otherData.ExclusiveWeight; + SampleStartIndex = Math.Min(SampleStartIndex, otherData.SampleStartIndex); + SampleEndIndex = Math.Max(SampleEndIndex, otherData.SampleEndIndex); + + foreach (var pair in otherData.InstructionWeight) { + ref var existingValue = + ref CollectionsMarshal.GetValueRefOrAddDefault(InstructionWeight, pair.Key, out bool exists); + existingValue += pair.Value; + } + + if (otherData.HasPerformanceCounters) { + InstructionCounters ??= new Dictionary(); + + foreach (var pair in otherData.InstructionCounters) { + ref var existingValue = + ref CollectionsMarshal.GetValueRefOrAddDefault(InstructionCounters, pair.Key, out bool exists); + + if (exists) { + existingValue.Add(pair.Value); + } + else { + existingValue = pair.Value; + } + } + } + } + + public void AddCounterSample(long instrOffset, int perfCounterId, long value) { + InstructionCounters ??= new Dictionary(); + + if (!InstructionCounters.TryGetValue(instrOffset, out var counterSet)) { + counterSet = new PerformanceCounterValueSet(); + InstructionCounters[instrOffset] = counterSet; + } + + counterSet.AddCounterSample(perfCounterId, value); + } + + public void AddInstructionSample(long instrOffset, TimeSpan weight) { + if (InstructionWeight.TryGetValue(instrOffset, out var currentWeight)) { + InstructionWeight[instrOffset] = currentWeight + weight; + } + else { + InstructionWeight[instrOffset] = weight; + } + } + + public double ScaleWeight(TimeSpan weight) { + return weight.Ticks / (double)Weight.Ticks; + } + + public PerformanceCounterValueSet ComputeFunctionTotalCounters() { + var result = new PerformanceCounterValueSet(); + + if (HasPerformanceCounters) { + foreach (var pair in InstructionCounters) { + result.Add(pair.Value); + } + } + + return result; + } + + public void Reset() { + Weight = TimeSpan.Zero; + ExclusiveWeight = TimeSpan.Zero; + SampleStartIndex = int.MaxValue; + SampleEndIndex = int.MinValue; + InstructionWeight?.Clear(); + InstructionCounters?.Clear(); + } +} \ No newline at end of file diff --git a/src/ProfileExplorerCore/Profile/Data/ModuleProfileInfo.cs b/src/ProfileExplorer.Profiling/Profile/Data/ModuleProfileInfo.cs similarity index 100% rename from src/ProfileExplorerCore/Profile/Data/ModuleProfileInfo.cs rename to src/ProfileExplorer.Profiling/Profile/Data/ModuleProfileInfo.cs diff --git a/src/ProfileExplorer.Profiling/ProfileExplorer.Profiling.csproj b/src/ProfileExplorer.Profiling/ProfileExplorer.Profiling.csproj new file mode 100644 index 00000000..c8a99832 --- /dev/null +++ b/src/ProfileExplorer.Profiling/ProfileExplorer.Profiling.csproj @@ -0,0 +1,87 @@ + + + + net8.0 + enable + enable + true + + $(NoWarn);CA1416 + 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 + + + + + + <_TlbImpCandidate Include="C:\Program Files (x86)\Microsoft SDKs\Windows\*\bin\**\TlbImp.exe" /> + <_TlbImpCandidate Include="C:\Program Files (x86)\Windows Kits\10\bin\**\TlbImp.exe" /> + + + <_TlbImpExe>@(_TlbImpCandidate) + <_TlbImpExe>$(_TlbImpExe.Split(';')[0]) + + + + + + + + diff --git a/src/ProfileExplorer.Profiling/ProfilerOptions.cs b/src/ProfileExplorer.Profiling/ProfilerOptions.cs new file mode 100644 index 00000000..a82feb3d --- /dev/null +++ b/src/ProfileExplorer.Profiling/ProfilerOptions.cs @@ -0,0 +1,104 @@ +// 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; + + /// How long a failed-download entry stays in the negative cache, in seconds. + public int NegativeCacheTtlSeconds { get; set; } = 300; + + /// 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; } + + /// + /// Optional sink for diagnostic messages (symbol-load failures, fallbacks, etc.). + /// When null (the default) the library emits no output; consumers can route messages to + /// an ILogger, the console, or a trace listener. Invoked on arbitrary threads. + /// + public Action? LogCallback { get; set; } + + /// + /// Validate options, throwing for invalid values. Percent thresholds are additionally clamped + /// into the [0, 100] range as a convenience (they are normalized in place). + /// + 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."); + } + + if (DegradedTimeoutSeconds < 0) { + throw new ArgumentOutOfRangeException(nameof(DegradedTimeoutSeconds), "Timeout must be non-negative."); + } + + if (NegativeCacheTtlSeconds < 0) { + throw new ArgumentOutOfRangeException(nameof(NegativeCacheTtlSeconds), "TTL must be non-negative."); + } + + if (MaxHotLines < 0) { + throw new ArgumentOutOfRangeException(nameof(MaxHotLines), "Max hot lines 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..92c60667 --- /dev/null +++ b/src/ProfileExplorer.Profiling/Profiling/CallTreeBuilder.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using ProfileExplorer.Core.Profile; +using ProfileExplorer.Core.Profile.CallTree; + +namespace ProfileExplorer.Profiling.Profiling; + +/// +/// Builds a mature from resolved stack samples. +/// Stacks are expected to be leaf-first (index 0 = leaf, last index = root). +/// +internal class CallTreeBuilder { + private readonly ProfileCallTree callTree_ = new(); + private readonly IpResolver ipResolver_; + private readonly object lock_ = 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) { + foreach (var sample in samples) { + if (sample.StackFrames is not { Count: > 0 }) continue; + + // Resolve frames, preserving leaf-first order expected by ProfileCallTree.UpdateCallTree. + var frames = new List(sample.StackFrames.Count); + + foreach (long ip in sample.StackFrames) { + var resolved = ipResolver_.Resolve(ip); + if (resolved == null) continue; + + string funcName = resolved.FunctionName ?? $""; + var id = new ProfileFunctionId(resolved.ModuleName, funcName); + frames.Add(new ResolvedCallStackFrame(resolved.Rva + resolved.InstructionOffset, resolved.DebugInfo, id, + isKernelCode: false, resolved.IsManaged)); + } + + if (frames.Count == 0) continue; + + var stack = new ResolvedSampleStack(frames, sample.ThreadId); + + lock (lock_) { + callTree_.UpdateCallTree(sample.Weight, stack); + } + } + } + + /// + /// Returns the built call tree (a forest of root nodes). + /// + public ProfileCallTree Build() { + return callTree_; + } + + // Adapter exposing resolved frames (leaf-first) to ProfileCallTree.UpdateCallTree. + private sealed class ResolvedSampleStack : IResolvedCallStack { + private readonly List frames_; + + public ResolvedSampleStack(List frames, int threadId) { + frames_ = frames; + ThreadId = threadId; + } + + public int FrameCount => frames_.Count; + public int ThreadId { get; } + + public ResolvedCallStackFrame GetFrame(int index) { + return frames_[index]; + } + } +} diff --git a/src/ProfileExplorer.Profiling/Profiling/CounterAggregator.cs b/src/ProfileExplorer.Profiling/Profiling/CounterAggregator.cs new file mode 100644 index 00000000..26c7e391 --- /dev/null +++ b/src/ProfileExplorer.Profiling/Profiling/CounterAggregator.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using ProfileExplorer.Core.Profile; +using ProfileExplorer.Core.Profile.Data; + +namespace ProfileExplorer.Profiling.Profiling; + +/// +/// Aggregates hardware performance counter events into per-function/instruction counter values, +/// keyed by neutral function identity. +/// +internal class CounterAggregator { + private readonly IpResolver ipResolver_; + private readonly Dictionary> countersByFunction_ = new(); + 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; + + var id = new ProfileFunctionId(resolved.ModuleName, resolved.FunctionName); + + lock (lock_) { + if (!countersByFunction_.TryGetValue(id, out var instrCounters)) { + instrCounters = []; + countersByFunction_[id] = instrCounters; + } + + if (!instrCounters.TryGetValue(resolved.InstructionOffset, out var counterSet)) { + counterSet = new PerformanceCounterValueSet(); + instrCounters[resolved.InstructionOffset] = counterSet; + } + + counterSet.AddCounterSample(evt.CounterId, 1); + } + } + } + + /// + /// Get the per-instruction counter values for a specific function. + /// + public IReadOnlyDictionary? GetCounters(ProfileFunctionId functionId) { + lock (lock_) { + return countersByFunction_.TryGetValue(functionId, out var counters) + ? new Dictionary(counters) + : null; + } + } +} 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..169142da --- /dev/null +++ b/src/ProfileExplorer.Profiling/Profiling/IpResolver.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using ProfileExplorer.Core.Binary; +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; + var managedInfo = new FunctionDebugInfo(managed.MethodName, 0, (uint)managed.NativeSize); + return new ResolvedIp(managed.ModuleName ?? "[managed]", rva, managed.MethodName, ip, managedInfo, + 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, func, + moduleRva - func.RVA, + (int)func.Size); + } + } + + // Module found but function not resolved. + return new ResolvedIp(image.Name, moduleRva, null, ip, new FunctionDebugInfo(null, moduleRva, 0)); + } + + 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, + FunctionDebugInfo DebugInfo, + 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/ProfileFunctionId.cs b/src/ProfileExplorer.Profiling/Profiling/ProfileFunctionId.cs new file mode 100644 index 00000000..9f81d85e --- /dev/null +++ b/src/ProfileExplorer.Profiling/Profiling/ProfileFunctionId.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace ProfileExplorer.Core.Profile; + +/// +/// Neutral, UI-agnostic identity for a profiled function: the (module, function-name) pair. +/// +/// This mirrors the effective identity that IRTextFunction provides today +/// (interned name + owning module summary), so re-keying the profiling model from +/// IRTextFunction to is behavior-preserving: functions +/// that share a name within a module are still treated as the same entity. +/// +/// +/// FunctionDebugInfo (RVA/size) is carried as payload on the model, not used as identity, +/// because its equality (RVA + size + id) would split same-name functions that the existing +/// pipeline merges. +/// +/// +public readonly record struct ProfileFunctionId { + public ProfileFunctionId(string moduleName, string functionName) { + ModuleName = moduleName ?? string.Empty; + FunctionName = functionName ?? string.Empty; + } + + /// Owning module/image name (e.g., "ntdll.dll"). + public string ModuleName { get; } + + /// Function name (as it appears in the module's symbols). + public string FunctionName { get; } + + /// True when this identity is empty/unresolved. + public bool IsUnknown => string.IsNullOrEmpty(FunctionName); + + public static ProfileFunctionId Unknown => default; + + public override string ToString() => $"{ModuleName}!{FunctionName}"; +} diff --git a/src/ProfileExplorer.Profiling/Profiling/SampleAggregator.cs b/src/ProfileExplorer.Profiling/Profiling/SampleAggregator.cs new file mode 100644 index 00000000..0a447fc1 --- /dev/null +++ b/src/ProfileExplorer.Profiling/Profiling/SampleAggregator.cs @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System.Collections.Concurrent; +using ProfileExplorer.Core.Profile; +using ProfileExplorer.Core.Profile.Data; + +namespace ProfileExplorer.Profiling.Profiling; + +/// +/// Aggregates CPU samples into per-function profiles keyed by neutral function identity. +/// The per-function entries live in a +/// and each is mutated under its own lock, so multiple calls may run +/// concurrently from different threads. A single call processes its batch +/// sequentially. +/// +internal class SampleAggregator { + private readonly IpResolver ipResolver_; + private readonly ConcurrentDictionary functions_ = new(); + 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; + + // Reused across samples to track which functions already received inclusive weight for the + // current stack, so recursive functions (appearing multiple times on one stack) are only + // credited inclusive time once per sample. + var creditedThisStack = new HashSet(); + + foreach (var sample in samples) { + if (string.IsNullOrEmpty(sample.ImageName)) continue; + + var resolved = ipResolver_.Resolve(sample.InstructionPointer); + if (resolved == null) continue; + + creditedThisStack.Clear(); + + // Leaf frame: self (exclusive) + inclusive + per-instruction weight. + var leaf = GetOrAddFunction(resolved, out var leafId); + creditedThisStack.Add(leafId); + + lock (leaf) { + leaf.ExclusiveWeight += sample.Weight; + leaf.Weight += sample.Weight; + leaf.AddInstructionSample(resolved.InstructionOffset, sample.Weight); + } + + batchWeight += sample.Weight; + + // Caller frames contribute inclusive weight only. + // Stack is leaf-first; skip index 0 (leaf — already counted above). + if (sample.StackFrames is { Count: > 1 }) { + for (int i = 1; i < sample.StackFrames.Count; i++) { + var callerResolved = ipResolver_.Resolve(sample.StackFrames[i]); + if (callerResolved == null) continue; + + var caller = GetOrAddFunction(callerResolved, out var callerId); + + // Skip recursive re-entry of a function already credited inclusive time on this stack. + if (!creditedThisStack.Add(callerId)) continue; + + lock (caller) { + caller.Weight += sample.Weight; + } + } + } + } + + lock (totalWeightLock_) { + totalWeight_ += batchWeight; + } + } + + /// + /// Build the final per-function profile map (snapshot). + /// + public Dictionary Build() { + return new Dictionary(functions_); + } + + public TimeSpan TotalWeight => totalWeight_; + + private FunctionProfileData GetOrAddFunction(ResolvedIp resolved, out ProfileFunctionId id) { + string funcName = resolved.FunctionName ?? $""; + id = new ProfileFunctionId(resolved.ModuleName, funcName); + return functions_.GetOrAdd(id, _ => new FunctionProfileData(resolved.DebugInfo)); + } +} diff --git a/src/ProfileExplorer.Profiling/Providers/FunctionNameFormatter.cs b/src/ProfileExplorer.Profiling/Providers/FunctionNameFormatter.cs new file mode 100644 index 00000000..d969f46c --- /dev/null +++ b/src/ProfileExplorer.Profiling/Providers/FunctionNameFormatter.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace ProfileExplorer.Core.Providers; + +/// +/// Formats a raw (possibly mangled) function name into a display name. +/// +public delegate string FunctionNameFormatter(string name); diff --git a/src/ProfileExplorer.Profiling/Symbols/ISymbolFileLocator.cs b/src/ProfileExplorer.Profiling/Symbols/ISymbolFileLocator.cs new file mode 100644 index 00000000..45f16cfa --- /dev/null +++ b/src/ProfileExplorer.Profiling/Symbols/ISymbolFileLocator.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace ProfileExplorer.Profiling.Symbols; + +/// +/// Abstraction over locating symbol (PDB) and binary files for a module, given its +/// debug identity. This inverts the download dependency: the library's readers call +/// through this interface instead of owning the transport. +/// +/// External consumers can rely on the library's vendored +/// (the default implementation). The Profile Explorer host instead implements this over its +/// existing TraceEvent SymbolReader/BinaryFileLocator so the mature download, +/// authentication, caching, and filtering behavior is preserved. +/// +/// +public interface ISymbolFileLocator { + /// + /// Locate a PDB file for the given debug identity, returning a local file path or + /// null if it cannot be found. + /// + /// PDB file name (e.g., "ntdll.pdb"). + /// PDB GUID from the CodeView debug directory. + /// PDB Age from the CodeView debug directory. + /// Cancellation token. + Task FindSymbolFileAsync(string pdbName, Guid guid, int age, CancellationToken ct = default); + + /// + /// Locate a binary/executable for the given PE identity, returning a local file path or + /// null if it cannot be found. + /// + /// Binary file name (e.g., "ntdll.dll"). + /// PE TimeDateStamp from the file header. + /// PE ImageSize (SizeOfImage). + /// Cancellation token. + Task FindBinaryFileAsync(string binaryName, int timeDateStamp, long imageSize, + CancellationToken ct = default); +} diff --git a/src/ProfileExplorer.Profiling/Symbols/PdbSymbolProvider.cs b/src/ProfileExplorer.Profiling/Symbols/PdbSymbolProvider.cs new file mode 100644 index 00000000..c36f5070 --- /dev/null +++ b/src/ProfileExplorer.Profiling/Symbols/PdbSymbolProvider.cs @@ -0,0 +1,504 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System.Reflection.PortableExecutable; +using System.Runtime.InteropServices; +using Dia2Lib; +using ProfileExplorer.Core.Binary; + +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. +/// +public class PdbSymbolProvider : ISymbolDebugInfo { + private const int MaxDemangledNameLength = 8192; + private const int FunctionCacheMissThreshold = 100; + + // UnDecorateSymbolName (undname) flags — mirror PE's NativeMethods.UnDecorateFlags subset. + private const int UndnameNoAllocationModel = 0x0008; + private const int UndnameNoAccessSpecifiers = 0x0080; + private const int UndnameNoMemberType = 0x0200; + private const int UndnameNameOnly = 0x1000; + + 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 SymbolFileDescriptor? cacheKey_; + private string? cacheDirectory_; + + 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; } + + /// Processor architecture is not derived from the PDB; resolved elsewhere. + public Machine? Architecture => null; + + public bool LoadDebugInfo(string debugFilePath, SymbolFileDescriptor? cacheKey = null, + string? cacheDirectory = null, bool enumerateImmediately = true) { + if (!File.Exists(debugFilePath)) return false; + debugFilePath_ = debugFilePath; + cacheKey_ = cacheKey; + cacheDirectory_ = cacheDirectory; + + 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); + } + + // When enumerateImmediately is false, defer reading the function list until the first + // GetSortedFunctions/EnumerateFunctions call or the FindFunctionByRVA cache-miss threshold + // (mirrors PE's lazy enumeration to avoid eagerly parsing PDBs that get few queries). + if (enumerateImmediately) { + EnsureFunctionListLoaded(); + } + + 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() { EnsureFunctionListLoaded(); return sortedFuncList_ ?? []; } + public List GetSortedFunctions() { EnsureFunctionListLoaded(); return sortedFuncList_ ?? []; } + + public FunctionDebugInfo? FindFunction(string functionName) { + if (functionsByName_?.TryGetValue(functionName, out var result) == true) return result; + + var listMatch = sortedFuncList_?.FirstOrDefault(f => string.Equals(f.Name, functionName, StringComparison.Ordinal)); + if (listMatch != null) return listMatch; + + // Fall back to a DIA query using the demangled name (mirrors PE's FindFunctionSymbol): + // the cached list stores mangled public-symbol names, so a demangled query name + // won't match by string — ask DIA directly, preferring the public symbol's mangled name. + var sym = FindFunctionSymbolByName(functionName); + if (sym == null) return null; + + try { return new FunctionDebugInfo(sym.name ?? "", sym.relativeVirtualAddress, (uint)sym.length); } + finally { Marshal.ReleaseComObject(sym); } + } + + 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) { + EnsureFunctionListLoaded(); + + if (sortedFuncList_ != null) { + var result = FunctionDebugInfo.BinarySearch(sortedFuncList_, rva, sortedFuncListOverlapping_); + if (result != null) return result; + } + } + + // List search missed (or list not yet loaded): fall back to a direct DIA query, which also + // resolves addresses inside PGO-split function chunks that the contiguous list doesn't cover. + 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) { + return DemangleFunctionName(decoratedName); + } + + /// + /// Demangle an MSVC C++ symbol name. Mirrors PE's PDBDebugInfoProvider.DemangleFunctionName: + /// only names starting with '?' are mangled; others are returned unchanged. Uses the same + /// undname flag set and a global lock (UnDecorateSymbolName is not thread-safe). + /// + public static string DemangleFunctionName(string name, bool onlyName = false) { + // Mangled MSVC C++ names always start with a '?' char. + if (string.IsNullOrEmpty(name) || !name.StartsWith('?')) { + return name; + } + + int flags = UndnameNoAccessSpecifiers | UndnameNoAllocationModel | UndnameNoMemberType; + if (onlyName) flags |= UndnameNameOnly; + + // DbgHelp UnDecorateSymbolName is not thread safe and can return bogus + // function names if not called under a global lock. + lock (undecorateLock_) { + try { + var buffer = new char[MaxDemangledNameLength]; + int result = NativeMethods.UnDecorateSymbolName(name, buffer, MaxDemangledNameLength, flags); + return result > 0 ? new string(buffer, 0, result) : name; + } + catch { return name; } + } + } + + public void Dispose() => Unload(); + + // ── Private ────────────────────────────────────────── + + /// + /// Load the function list on demand (from cache when available, else enumerate the PDB and + /// persist the cache). Idempotent — a no-op once the list is populated. + /// + private void EnsureFunctionListLoaded() { + if (sortedFuncList_ != null) return; + if (!TryLoadFunctionListFromCache(cacheKey_, cacheDirectory_)) { + LoadFunctionList(); + TrySaveFunctionListToCache(cacheKey_, cacheDirectory_); + } + } + + private bool TryLoadFunctionListFromCache(SymbolFileDescriptor? cacheKey, string? cacheDirectory) { + if (cacheKey == null || string.IsNullOrEmpty(cacheDirectory)) return false; + + try { + var cached = SymbolFileCache.DeserializeAsync(cacheKey, cacheDirectory).GetAwaiter().GetResult(); + + if (cached?.FunctionList is { Count: > 0 }) { + InitializeFunctionList(cached.FunctionList); + return true; + } + } + catch { + // Cache miss/corruption — fall back to DIA enumeration. + } + + return false; + } + + private void TrySaveFunctionListToCache(SymbolFileDescriptor? cacheKey, string? cacheDirectory) { + if (cacheKey == null || string.IsNullOrEmpty(cacheDirectory) || sortedFuncList_ is not { Count: > 0 }) { + return; + } + + try { + var cache = new SymbolFileCache { SymbolFile = cacheKey, FunctionList = sortedFuncList_ }; + SymbolFileCache.SerializeAsync(cache, cacheDirectory).GetAwaiter().GetResult(); + } + catch { + // Best-effort cache write. + } + } + + private void InitializeFunctionList(List symbolList) { + symbolList.Sort(); + sortedFuncListOverlapping_ = false; + + 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); + } + + private void LoadFunctionList() { + if (globalSymbol_ == null) return; + try { + var symbolMap = new Dictionary(); + var symbolList = new List(); + + void Enumerate(SymTagEnum tag, Action handle) { + 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 { + handle(sym.name ?? "", sym.relativeVirtualAddress, (uint)sym.length); + } + finally { Marshal.ReleaseComObject(sym); } + } + } + finally { Marshal.ReleaseComObject(enumSymbols); } + } + + // Mirror PE's legacy CollectFunctionDebugInfo exactly so function naming matches the + // historical behavior, including ICF-folded addresses that map to multiple symbol names: + // add every function symbol (the last one at a given RVA wins the map slot), then let a + // public symbol with the same RVA+size overwrite the name (public symbols carry the + // mangled name). Public symbols at an RVA with no function symbol are added as-is. + Enumerate(SymTagEnum.SymTagFunction, (name, rva, size) => { + var info = new FunctionDebugInfo(name, rva, size); + symbolList.Add(info); + symbolMap[rva] = info; + }); + + Enumerate(SymTagEnum.SymTagPublicSymbol, (name, rva, size) => { + if (symbolMap.TryGetValue(rva, out var existing)) { + if (existing.Size == size) { + existing.Name = name; + } + } + else { + symbolList.Add(new FunctionDebugInfo(name, rva, size)); + } + }); + + InitializeFunctionList(symbolList); + } + catch { /* Function enumeration failed. */ } + } + + /// + /// Locate a function/public symbol by name via DIA, demangling the query name the way PE does + /// (mirrors FindFunctionSymbol/FindFunctionSymbolImpl). Tries SymTagFunction first, then + /// SymTagPublicSymbol; among class::method matches, prefers the one whose full undecorated + /// name matches, else returns the first candidate. + /// + private IDiaSymbol? FindFunctionSymbolByName(string functionName) { + if (globalSymbol_ == null) return null; + + string demangledName = DemangleFunctionName(functionName); + string queryDemangledName = DemangleFunctionName(functionName, onlyName: true); + + return FindFunctionSymbolByNameImpl(SymTagEnum.SymTagFunction, demangledName, queryDemangledName) + ?? FindFunctionSymbolByNameImpl(SymTagEnum.SymTagPublicSymbol, demangledName, queryDemangledName); + } + + private IDiaSymbol? FindFunctionSymbolByNameImpl(SymTagEnum symbolType, string demangledName, + string queryDemangledName) { + IDiaEnumSymbols? symbolEnum = null; + try { + globalSymbol_.findChildren(symbolType, queryDemangledName, 0, out symbolEnum); + if (symbolEnum == null) return null; + + IDiaSymbol? candidateSymbol = null; + while (true) { + symbolEnum.Next(1, out var symbol, out uint retrieved); + if (retrieved == 0) break; + + // Class::function matches; check the full unmangled name to pick the right overload. + try { + symbol.get_undecoratedNameEx(UndnameNoAccessSpecifiers, out string symbolDemangledName); + if (symbolDemangledName == demangledName) { + // Exact match — release any earlier first-match candidate before returning. + if (candidateSymbol != null && !ReferenceEquals(symbol, candidateSymbol)) { + Marshal.ReleaseComObject(candidateSymbol); + } + + return symbol; + } + } + catch { /* ignore and keep first candidate */ } + + // Keep the first symbol as a fallback candidate; release any others. + if (candidateSymbol == null) { + candidateSymbol = symbol; + } + else if (!ReferenceEquals(symbol, candidateSymbol)) { + Marshal.ReleaseComObject(symbol); + } + } + + return candidateSymbol; // First match (PE returns first match). + } + catch { return null; } + finally { + if (symbolEnum != null) Marshal.ReleaseComObject(symbolEnum); + } + } + + 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/SymbolFileCache.cs b/src/ProfileExplorer.Profiling/Symbols/SymbolFileCache.cs new file mode 100644 index 00000000..db82fd0c --- /dev/null +++ b/src/ProfileExplorer.Profiling/Symbols/SymbolFileCache.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Threading.Tasks; +using ProtoBuf; + +namespace ProfileExplorer.Core.Binary; + +[ProtoContract] +public class SymbolFileCache { + private static int CurrentFileVersion = 3; + private static int MinSupportedFileVersion = 3; + [ProtoMember(1)] + public int Version { get; set; } + [ProtoMember(2)] + public SymbolFileDescriptor SymbolFile { get; set; } + [ProtoMember(3)] + public List FunctionList { get; set; } + public static string DefaultCacheDirectoryPath => Path.Combine(Path.GetTempPath(), "ProfileExplorer", "symcache"); + + public static Task SerializeAsync(SymbolFileCache symCache, string directoryPath) { + try { + symCache.Version = CurrentFileVersion; + + if (!Directory.Exists(directoryPath)) { + Directory.CreateDirectory(directoryPath); + } + + string cachePath = Path.Combine(directoryPath, MakeCacheFilePath(symCache.SymbolFile)); + + using var fileStream = File.Create(cachePath); + using var gzipStream = new GZipStream(fileStream, CompressionLevel.Fastest); + Serializer.Serialize(gzipStream, symCache); + return Task.FromResult(true); + } + catch (Exception ex) { + Trace.WriteLine($"Failed to save symbol file cache: {ex.Message}"); + return Task.FromResult(false); + } + } + + public static Task DeserializeAsync(SymbolFileDescriptor symbolFile, string directoryPath) { + try { + string cachePath = Path.Combine(directoryPath, MakeCacheFilePath(symbolFile)); + + if (!File.Exists(cachePath)) { + return Task.FromResult(null); + } + + using var fileStream = File.OpenRead(cachePath); + using var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress); + var symCache = Serializer.Deserialize(gzipStream); + + if (symCache.Version < MinSupportedFileVersion) { + Trace.WriteLine($"File version mismatch in deserialized symbol file cache"); + Trace.WriteLine($" actual: {symCache.Version} vs min supported {MinSupportedFileVersion}"); + return Task.FromResult(null); + } + + // Ensure it's a cache for the same symbol file. + if (symCache.SymbolFile.Equals(symbolFile)) { + return Task.FromResult(symCache); + } + + Trace.WriteLine($"Symbol file mismatch in deserialized symbol file cache"); + Trace.WriteLine($" actual: {symCache.SymbolFile} vs expected {symbolFile}"); + } + catch (Exception ex) { + Trace.WriteLine($"Failed to load symbol file cache: {ex.Message}"); + } + + return Task.FromResult(null); + } + + private static string MakeCacheFilePath(SymbolFileDescriptor symbolFile) { + string name = string.IsNullOrEmpty(symbolFile.FileName) ? "" : Path.GetFileName(symbolFile.FileName); + return $"{name}-{symbolFile.Id}-{symbolFile.Age}.cache"; + } +} \ No newline at end of file diff --git a/src/ProfileExplorer.Profiling/Symbols/SymbolServerClient.cs b/src/ProfileExplorer.Profiling/Symbols/SymbolServerClient.cs new file mode 100644 index 00000000..3dd9bf69 --- /dev/null +++ b/src/ProfileExplorer.Profiling/Symbols/SymbolServerClient.cs @@ -0,0 +1,342 @@ +// 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, ISymbolFileLocator { + // Well-known Microsoft (corp) Azure AD tenant used for Symweb authentication. + private const string MicrosoftTenantId = "72f988bf-86f1-41af-91ab-2d7cd011db47"; + // Azure AD resource scope for the Symweb symbol server. + private const string SymwebResourceScope = "https://microsoft.com/.default"; + + 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 Dictionary negativeCache_ = new(StringComparer.OrdinalIgnoreCase); + private readonly object negativeCacheLock_ = new(); + private readonly bool enableNegativeCache_; + private readonly TimeSpan negativeCacheTtl_; + private string? effectiveLocalCachePath_; + private readonly string? injectedBearerToken_; + private readonly Action? log_; + + public SymbolServerClient(ProfilerOptions options) { + timeout_ = TimeSpan.FromSeconds(options.SymbolTimeoutSeconds); + degradedTimeout_ = TimeSpan.FromSeconds(options.DegradedTimeoutSeconds); + enableNegativeCache_ = options.EnableNegativeCache; + negativeCacheTtl_ = TimeSpan.FromSeconds(Math.Max(0, options.NegativeCacheTtlSeconds)); + injectedBearerToken_ = options.SymwebBearerToken; + log_ = options.LogCallback; + + 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_ && IsInNegativeCache(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, SafeName(fileName), hash, SafeName(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) { + log_?.Invoke($"Symbol download {response.StatusCode}: {url}"); + continue; + } + + // Save to local cache. + string targetDir = GetCachePath(fileName, hash); + Directory.CreateDirectory(targetDir); + string targetPath = Path.Combine(targetDir, SafeName(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); + + // A successful response means the server is responsive again. + isDegraded_ = false; + 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_[key] = DateTime.UtcNow + negativeCacheTtl_; + } + } + + return null; + } + + private bool IsInNegativeCache(string key) { + lock (negativeCacheLock_) { + if (!negativeCache_.TryGetValue(key, out var expiry)) { + return false; + } + + if (DateTime.UtcNow >= expiry) { + negativeCache_.Remove(key); // Entry expired — allow a retry. + return false; + } + + return true; + } + } + + private string? FindInLocalCache(string fileName, string hash) { + string? cachePath = effectiveLocalCachePath_ ?? localCachePath_; + if (cachePath == null) return null; + string path = Path.Combine(cachePath, SafeName(fileName), hash, SafeName(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, SafeName(fileName), hash); + } + + // Strip any directory components from a server-/trace-supplied name before using it to build + // a local filesystem path, preventing path traversal outside the symbol cache directory. + private static string SafeName(string fileName) => Path.GetFileName(fileName); + + private static Azure.Core.AccessToken? cachedToken_; + private static readonly SemaphoreSlim authLock_ = new(1, 1); + private bool authFailed_; + + private 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 = MicrosoftTenantId }), + new VisualStudioCredential(), + new AzureCliCredential(), + new AzurePowerShellCredential(), + new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions { + TenantId = MicrosoftTenantId, + TokenCachePersistenceOptions = new TokenCachePersistenceOptions { Name = "ProfileExplorer.Profiling" } + })); + + var tokenContext = new Azure.Core.TokenRequestContext([SymwebResourceScope]); + cachedToken_ = await credential.GetTokenAsync(tokenContext); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", cachedToken_.Value.Token); + } + finally { + authLock_.Release(); + } + } + catch (Exception ex) { + authFailed_ = true; + log_?.Invoke($"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/ProfileExplorer.Profiling.targets b/src/ProfileExplorer.Profiling/build/net8.0/ProfileExplorer.Profiling.targets new file mode 100644 index 00000000..9f3c131d --- /dev/null +++ b/src/ProfileExplorer.Profiling/build/net8.0/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 diff --git a/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs b/src/ProfileExplorerCore/Binary/BinaryFileLocator.cs similarity index 55% rename from src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs rename to src/ProfileExplorerCore/Binary/BinaryFileLocator.cs index 93338282..f4ad8728 100644 --- a/src/ProfileExplorerCore/Binary/PEBinaryInfoProvider.cs +++ b/src/ProfileExplorerCore/Binary/BinaryFileLocator.cs @@ -1,599 +1,295 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Reflection.PortableExecutable; -using System.Runtime.InteropServices; -using System.Threading.Tasks; -using Microsoft.Diagnostics.Symbols; -using ProfileExplorer.Core.Providers; -using ProfileExplorer.Core.Settings; -using ProfileExplorer.Core.Utilities; - -namespace ProfileExplorer.Core.Binary; - -/// -/// Contains version information extracted from a PE file's version resource. -/// -public sealed class PEVersionInfo { - public string CompanyName { get; init; } - public string ProductName { get; init; } - public string FileDescription { get; init; } - public string LegalCopyright { get; init; } - public string OriginalFilename { get; init; } - - /// - /// Checks if any of the version info fields contain the specified text (case-insensitive). - /// - public bool ContainsText(string text) { - if (string.IsNullOrEmpty(text)) { - return false; - } - - return ContainsTextInternal(CompanyName, text) || - ContainsTextInternal(ProductName, text) || - ContainsTextInternal(FileDescription, text) || - ContainsTextInternal(LegalCopyright, text); - } - - /// - /// Checks if any of the version info fields contain any of the specified texts (case-insensitive). - /// - public bool ContainsAnyText(IEnumerable texts) { - foreach (var text in texts) { - if (ContainsText(text)) { - return true; - } - } - - return false; - } - - private static bool ContainsTextInternal(string field, string text) { - return !string.IsNullOrEmpty(field) && - field.Contains(text, StringComparison.OrdinalIgnoreCase); - } - - public override string ToString() { - return $"Company: {CompanyName ?? "N/A"}, Product: {ProductName ?? "N/A"}, Description: {FileDescription ?? "N/A"}"; - } -} - -public sealed class PEBinaryInfoProvider : IBinaryInfoProvider, IDisposable { - private static ConcurrentDictionary resolvedBinariesCache_ = new(); - private static ConcurrentDictionary versionInfoCache_ = new(); - - /// - /// Clears the static resolved binaries and version info caches. - /// Call between trace loads to ensure a clean resolution state. - /// - public static void ClearResolvedCache() { - resolvedBinariesCache_.Clear(); - versionInfoCache_.Clear(); - } - - private string filePath_; - private PEReader reader_; - - public PEBinaryInfoProvider(string filePath) { - filePath_ = filePath; - } - - public List CodeSectionHeaders { - get { - var list = new List(); - - if (reader_.PEHeaders.PEHeader == null) { - return list; - } - - foreach (var section in reader_.PEHeaders.SectionHeaders) { - if (section.SectionCharacteristics.HasFlag(SectionCharacteristics.MemExecute) || - section.SectionCharacteristics.HasFlag(SectionCharacteristics.ContainsCode)) { - list.Add(section); - } - } - - return list; - } - } - - public SymbolFileDescriptor SymbolFileInfo { - get { - foreach (var entry in reader_.ReadDebugDirectory()) { - if (entry.Type == DebugDirectoryEntryType.CodeView) { - try { - var dir = reader_.ReadCodeViewDebugDirectoryData(entry); - return new SymbolFileDescriptor(dir.Path, dir.Guid, dir.Age); - } - catch (BadImageFormatException) { - // PE reader has problems with some old binaries. - } - - break; - } - } - - return null; - } - } - - public BinaryFileDescriptor BinaryFileInfo { - get { - if (reader_.PEHeaders.PEHeader == null) { - return null; - } - - var fileKind = BinaryFileKind.Native; - - if (reader_.HasMetadata && reader_.PEHeaders.CorHeader != null) { - if (reader_.PEHeaders.CorHeader.Flags.HasFlag(CorFlags.ILOnly)) { - fileKind = BinaryFileKind.DotNet; - } - else if (reader_.PEHeaders.CorHeader.Flags.HasFlag(CorFlags.ILLibrary)) { - fileKind = BinaryFileKind.DotNetR2R; - } - } - - // For AMR64 EC binaries, they may show up as AMD64, but have the hybrid metadata table set, - // consider them ARM64 binaries instead so that disassembly works as expected. - var architecture = reader_.PEHeaders.CoffHeader.Machine; - - if (architecture == Machine.Amd64 && IsARM64ECBinary()) { - architecture = Machine.Arm64; - } - - return new BinaryFileDescriptor { - ImageName = Utils.TryGetFileName(filePath_), - ImagePath = filePath_, - Architecture = architecture, - FileKind = fileKind, - Checksum = reader_.PEHeaders.PEHeader.CheckSum, - TimeStamp = reader_.PEHeaders.CoffHeader.TimeDateStamp, - ImageSize = reader_.PEHeaders.PEHeader.SizeOfImage, - CodeSize = reader_.PEHeaders.PEHeader.SizeOfCode, - ImageBase = (long)reader_.PEHeaders.PEHeader.ImageBase, - BaseOfCode = reader_.PEHeaders.PEHeader.BaseOfCode, - MajorVersion = reader_.PEHeaders.PEHeader.MajorImageVersion, - MinorVersion = reader_.PEHeaders.PEHeader.MinorImageVersion - }; - } - } - - public void Dispose() { - reader_?.Dispose(); - } - - public static BinaryFileDescriptor GetBinaryFileInfo(string filePath) { - using var binaryInfo = new PEBinaryInfoProvider(filePath); - - if (binaryInfo.Initialize()) { - return binaryInfo.BinaryFileInfo; - } - - return null; - } - - public static SymbolFileDescriptor GetSymbolFileInfo(string filePath) { - using var binaryInfo = new PEBinaryInfoProvider(filePath); - - if (binaryInfo.Initialize()) { - return binaryInfo.SymbolFileInfo; - } - - return null; - } - - /// - /// Gets version information (Company, Product, Description, Copyright) from a PE file. - /// Uses FileVersionInfo which reads the version resource from the PE file. - /// - public static PEVersionInfo GetVersionInfo(string filePath) { - if (string.IsNullOrEmpty(filePath) || !File.Exists(filePath)) { - return null; - } - - // Check cache first - if (versionInfoCache_.TryGetValue(filePath, out var cached)) { - return cached; - } - - try { - var fileVersionInfo = FileVersionInfo.GetVersionInfo(filePath); - var versionInfo = new PEVersionInfo { - CompanyName = fileVersionInfo.CompanyName, - ProductName = fileVersionInfo.ProductName, - FileDescription = fileVersionInfo.FileDescription, - LegalCopyright = fileVersionInfo.LegalCopyright, - OriginalFilename = fileVersionInfo.OriginalFilename - }; - - versionInfoCache_.TryAdd(filePath, versionInfo); - return versionInfo; - } - catch (Exception ex) { - Trace.WriteLine($"Failed to read version info from {filePath}: {ex.Message}"); - return null; - } - } - - /// - /// Checks if a PE file's version info matches any of the specified company filter strings. - /// Returns true if any filter string is found in Company, Product, Description, or Copyright fields. - /// Returns true if no filters are specified (empty/null list). - /// Returns true if the file doesn't exist or version info cannot be read (fail-open for safety). - /// - public static bool MatchesCompanyFilter(string filePath, IReadOnlyList companyFilters) { - // No filter specified - accept all - if (companyFilters == null || companyFilters.Count == 0) { - return true; - } - - var versionInfo = GetVersionInfo(filePath); - - // If we can't read version info, accept the file (fail-open) - if (versionInfo == null) { - return true; - } - - return versionInfo.ContainsAnyText(companyFilters); - } - - public static async Task LocateBinaryFileAsync(BinaryFileDescriptor binaryFile, - SymbolFileSourceSettings settings) { - // Check if the binary was requested before. - if (resolvedBinariesCache_.TryGetValue(binaryFile, out var searchResult)) { - return searchResult; - } - - return await Task.Run(() => { - return LocateBinaryFile(binaryFile, settings); - }).ConfigureAwait(false); - } - - public static BinaryFileSearchResult LocateBinaryFile(BinaryFileDescriptor binaryFile, - SymbolFileSourceSettings settings) { - var sw = Stopwatch.StartNew(); - - // Check if the binary was requested before. - if (resolvedBinariesCache_.TryGetValue(binaryFile, out var searchResult)) { - DiagnosticLogger.LogDebug($"[BinarySearch] Cache hit for {binaryFile.ImageName}"); - return searchResult; - } - - DiagnosticLogger.LogInfo($"[BinarySearch] Starting binary search for {binaryFile.ImageName} (Size: {binaryFile.ImageSize}, Timestamp: {binaryFile.TimeStamp})"); - - // Check if this binary was previously rejected (failed lookup in a prior session). - if (settings.IsRejectedBinaryFile(binaryFile)) { - DiagnosticLogger.LogInfo($"[BinarySearch] SKIPPED - previously rejected: {binaryFile.ImageName}"); - searchResult = BinaryFileSearchResult.Failure(binaryFile, "Previously rejected"); - resolvedBinariesCache_.TryAdd(binaryFile, searchResult); - return searchResult; - } - - // Resolve NT kernel paths like \SystemRoot\system32\ntoskrnl.exe - // to actual file system paths like C:\Windows\system32\ntoskrnl.exe. - string resolvedPath = ResolveNtKernelPath(binaryFile.ImagePath); - if (resolvedPath != binaryFile.ImagePath) { - DiagnosticLogger.LogInfo($"[BinarySearch] Resolved NT path for {binaryFile.ImageName}: {binaryFile.ImagePath} -> {resolvedPath}"); - binaryFile.ImagePath = resolvedPath; - } - - // Quick check if trace was recorded on local machine. - string approximateMatchPath = null; - long correctedImageSize = 0; - string result = FindExactLocalBinaryFile(binaryFile, out approximateMatchPath, out correctedImageSize); - - if (result != null) { - DiagnosticLogger.LogInfo($"[BinarySearch] Found exact local binary for {binaryFile.ImageName} at {result} ({sw.ElapsedMilliseconds}ms)"); - binaryFile = GetBinaryFileInfo(result); - searchResult = BinaryFileSearchResult.Success(binaryFile, result, ""); - resolvedBinariesCache_.TryAdd(binaryFile, searchResult); - return searchResult; - } - - // Correct ImageSize if the file exists locally with matching timestamp but - // different size. The symbol server indexes binaries by TimeDateStamp+SizeOfImage - // from the PE header, but the ETW kernel event may report a larger mapped view size - // (the kernel mapping can include extra slack pages). Using the wrong size causes - // 404s on the symbol server even when the binary IS indexed there. - if (correctedImageSize > 0 && correctedImageSize != binaryFile.ImageSize) { - DiagnosticLogger.LogInfo( - $"[BinarySearch] Correcting ImageSize for {binaryFile.ImageName} from ETW value {binaryFile.ImageSize} " + - $"to PE SizeOfImage {correctedImageSize} for symbol server lookup"); - binaryFile.ImageSize = correctedImageSize; - } - - using var logWriter = new StringWriter(); - - try { - // Try to use symbol server to download binary. - // Add local binary path directly instead of using WithSymbolPaths (which clones - // settings again and resets runtime state like timeout overrides). - if (File.Exists(binaryFile.ImagePath)) { - settings.InsertSymbolPath(binaryFile.ImagePath); - } - - string userSearchPath = PDBDebugInfoProvider.ConstructSymbolSearchPath(settings); - - //? TODO: Making a new instance clears the "dead servers", - //? have a way to share the list between multiple instances. - using var symbolReader = - new SymbolReader(logWriter, userSearchPath, PDBDebugInfoProvider.CreateAuthHandler(settings)); - symbolReader.SecurityCheck += s => true; // Allow symbols from "unsafe" locations. - - // Set symbol server timeout from settings (default 10 seconds). - // Use EffectiveTimeoutSeconds which is reduced if bellwether test marked server as degraded. - int timeoutSeconds = settings.EffectiveTimeoutSeconds > 0 ? settings.EffectiveTimeoutSeconds : 10; - symbolReader.ServerTimeout = TimeSpan.FromSeconds(timeoutSeconds); - DiagnosticLogger.LogInfo($"[BinarySearch] ServerTimeout={timeoutSeconds}s, RejectPreviouslyFailedFiles={settings.RejectPreviouslyFailedFiles}, " + - $"RejectedBinaries={settings.RejectedBinaryFiles?.Count ?? 0} for {binaryFile.ImageName}"); - - //? TODO: Workaround for cases where the ETL file doesn't have a timestamp - //? and SymbolReader would reject the bin even on the same machine... - //? Better way to handle this is to have SymReader accept a func to check if PDB is valid to use - if (binaryFile.TimeStamp == 0 && File.Exists(binaryFile.ImagePath)) { - var binInfo = GetBinaryFileInfo(binaryFile.ImagePath); - binaryFile.TimeStamp = binInfo.TimeStamp; - } - - //Trace.WriteLine($"Start download of {Utils.TryGetFileName(binaryFile.ImageName)}"); - result = symbolReader.FindExecutableFilePath(binaryFile.ImageName, - binaryFile.TimeStamp, - (int)binaryFile.ImageSize); - - if (result == null) { - // Finally, try an approximate manual search. - DiagnosticLogger.LogDebug($"[BinarySearch] Symbol server search failed, trying approximate local search for {binaryFile.ImageName}"); - result = FindMatchingLocalBinaryFile(binaryFile, settings, ref approximateMatchPath); - } - } - catch (Exception ex) { - DiagnosticLogger.LogError($"[BinarySearch] Exception during binary search for {binaryFile.ImageName}: {ex.Message}", ex); - Trace.TraceError($"Failed FindExecutableFilePath: {ex.Message}"); - } - - var searchDuration = sw.Elapsed; - string searchLog = logWriter.ToString(); - -#if DEBUG - Trace.WriteLine($">> TraceEvent FindExecutableFilePath for {binaryFile.ImageName}"); - Trace.WriteLine(searchLog); - Trace.WriteLine("<< TraceEvent"); -#endif - - if (!string.IsNullOrEmpty(result) && File.Exists(result)) { - DiagnosticLogger.LogInfo($"[BinarySearch] Found binary for {binaryFile.ImageName} at {result} ({searchDuration.TotalMilliseconds:F0}ms)"); - // Read the binary info from the local file to fill in all fields. - binaryFile = GetBinaryFileInfo(result); - searchResult = BinaryFileSearchResult.Success(binaryFile, result, searchLog); - } - else if (settings.AllowApproximateBinaryMatch && - !string.IsNullOrEmpty(approximateMatchPath) && - File.Exists(approximateMatchPath)) { - // Fallback: use a binary with matching timestamp but different size. - // This handles cases where the kernel-reported ImageSize in the ETW trace - // differs from the PE header SizeOfImage, or the DLL was serviced. - long traceImageSize = binaryFile.ImageSize; - var originalDescriptor = binaryFile; - DiagnosticLogger.LogWarning( - $"[BinarySearch] Using APPROXIMATE match for {binaryFile.ImageName} " + - $"at {approximateMatchPath} ({searchDuration.TotalMilliseconds:F0}ms) - " + - $"timestamp matches but image size differs"); - binaryFile = GetBinaryFileInfo(approximateMatchPath); - string details = $"Approximate match: timestamp matches but image size differs " + - $"(trace: {traceImageSize}, on-disk: {binaryFile.ImageSize}). Disassembly may not be fully accurate.\n{searchLog}"; - searchResult = BinaryFileSearchResult.ApproximateSuccess(binaryFile, approximateMatchPath, details); - // Cache under the original trace descriptor so subsequent lookups for the - // same trace module hit the cache instead of repeating the search. - resolvedBinariesCache_.TryAdd(originalDescriptor, searchResult); - } - else { - DiagnosticLogger.LogWarning($"[BinarySearch] Failed to find binary for {binaryFile.ImageName} ({searchDuration.TotalMilliseconds:F0}ms)"); - searchResult = BinaryFileSearchResult.Failure(binaryFile, searchLog); - - // Record failed lookup to avoid retrying in future sessions, - // but skip transient failures (timeout, auth, server errors). - var reason = settings.ClassifySearchFailure(searchLog); - settings.RejectBinaryFile(binaryFile, reason, searchLog); - } - - resolvedBinariesCache_.TryAdd(binaryFile, searchResult); - return searchResult; - } - - private static string ResolveNtKernelPath(string path) { - if (string.IsNullOrEmpty(path)) { - return path; - } - - if (path.StartsWith(@"\SystemRoot", StringComparison.OrdinalIgnoreCase)) { - string systemRoot = Environment.GetEnvironmentVariable("SystemRoot") ?? @"C:\Windows"; - return systemRoot + path.Substring(@"\SystemRoot".Length); - } - - return path; - } - - private static string FindExactLocalBinaryFile(BinaryFileDescriptor binaryFile, - out string approximateMatchPath, - out long correctedImageSize) { - approximateMatchPath = null; - correctedImageSize = 0; - - if (File.Exists(binaryFile.ImagePath)) { - var fileInfo = GetBinaryFileInfo(binaryFile.ImagePath); - - if (fileInfo != null && fileInfo.TimeStamp == binaryFile.TimeStamp) { - if (fileInfo.ImageSize == binaryFile.ImageSize) { - return binaryFile.ImagePath; - } - - // Timestamp matches but size differs. This commonly happens because the - // kernel-reported ImageSize in ETW events is the mapped view size, which - // can exceed the PE header's SizeOfImage by a few pages of slack. - // Record the local file as an approximate match and save the PE SizeOfImage - // so we can correct the symbol server lookup key. - approximateMatchPath = binaryFile.ImagePath; - correctedImageSize = fileInfo.ImageSize; - DiagnosticLogger.LogInfo( - $"[BinarySearch] Local binary for {binaryFile.ImageName} at {binaryFile.ImagePath}: " + - $"timestamp matches ({fileInfo.TimeStamp}), size differs " + - $"(PE SizeOfImage: {fileInfo.ImageSize}, ETW ImageSize: {binaryFile.ImageSize})"); - } - } - - return null; - } - - private static string FindMatchingLocalBinaryFile(BinaryFileDescriptor binaryFile, - SymbolFileSourceSettings settings, - ref string approximateMatchPath) { - // Manually search in the provided directories. - // This helps in cases where the original fine name doesn't match - // the one on disk, like it seems to happen sometimes with the SPEC runner. - string winPath = Environment.GetFolderPath(Environment.SpecialFolder.Windows); - string sysPath = Environment.GetFolderPath(Environment.SpecialFolder.System); - string sysx86Path = Environment.GetFolderPath(Environment.SpecialFolder.SystemX86); - - // Don't search in the system dirs though, it's pointless - // and takes a long time checking thousands of binaries. - bool PathIsSubPath(string subPath, string basePath) { - string rel = Path.GetRelativePath(basePath, subPath); - return !rel.StartsWith('.') && !Path.IsPathRooted(rel); - } - - foreach (string path in settings.SymbolPaths) { - if (PathIsSubPath(path, winPath) || - PathIsSubPath(path, sysPath) || - PathIsSubPath(path, sysx86Path)) { - continue; - } - - try { - string searchPath = Utils.TryGetDirectoryName(path); - - foreach (string file in Directory.EnumerateFiles(searchPath, "*.*", SearchOption.AllDirectories)) { - if (!Utils.IsBinaryFile(file)) { - continue; - } - - var fileInfo = GetBinaryFileInfo(file); - - if (fileInfo != null && fileInfo.TimeStamp == binaryFile.TimeStamp) { - if (fileInfo.ImageSize == binaryFile.ImageSize) { - return file; - } - - // Track first approximate match (timestamp matches, size differs). - if (approximateMatchPath == null) { - approximateMatchPath = file; - DiagnosticLogger.LogInfo( - $"[BinarySearch] Approximate match for {binaryFile.ImageName} in search paths: " + - $"{file} (on-disk: {fileInfo.ImageSize}, trace: {binaryFile.ImageSize})"); - } - } - } - } - catch (Exception ex) { - Trace.TraceError($"Exception searching for binary {binaryFile.ImageName} in {path}: {ex.Message}"); - } - } - - return null; - } - - public bool Initialize() { - if (!File.Exists(filePath_)) { - return false; - } - - try { - var stream = File.OpenRead(filePath_); - reader_ = new PEReader(stream); - return reader_.PEHeaders != null; // Throws BadImageFormatException on invalid file. - } - catch (Exception ex) { - Trace.WriteLine($"Failed to read PE binary file: {filePath_}"); - return false; - } - } - - public ReadOnlyMemory GetSectionData(SectionHeader header) { - var data = reader_.GetSectionData(header.VirtualAddress); - var array = data.GetContent(); - return array.AsMemory(); - } - - private bool IsARM64ECBinary() { - if (reader_.PEHeaders.PEHeader == null || - reader_.PEHeaders.PEHeader.LoadConfigTableDirectory.Size <= 0 || - !reader_.PEHeaders.TryGetDirectoryOffset(reader_.PEHeaders.PEHeader.LoadConfigTableDirectory, out int offset)) { - return false; - } - - var imageData = reader_.GetEntireImage(); - var configTableData = imageData.GetContent(offset, reader_.PEHeaders.PEHeader.LoadConfigTableDirectory.Size); - var span = MemoryMarshal.Cast(configTableData.AsSpan()); - return span.Length > 0 && span[0].CHPEMetadataPointer != 0; - } - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] - public struct IMAGE_LOAD_CONFIG_DIRECTORY64 { - public uint Size; - public uint TimeDateStamp; - public ushort MajorVersion; - public ushort MinorVersion; - public uint GlobalFlagsClear; - public uint GlobalFlagsSet; - public uint CriticalSectionDefaultTimeout; - public ulong DeCommitFreeBlockThreshold; - public ulong DeCommitTotalFreeThreshold; - public ulong LockPrefixTable; - public ulong MaximumAllocationSize; - public ulong VirtualMemoryThreshold; - public ulong ProcessAffinityMask; - public uint ProcessHeapFlags; - public ushort CSDVersion; - public ushort DependentLoadFlags; - public ulong EditList; - public ulong SecurityCookie; - public ulong SEHandlerTable; - public ulong SEHandlerCount; - public ulong GuardCFCheckFunctionPointer; - public ulong GuardCFDispatchFunctionPointer; - public ulong GuardCFFunctionTable; - public ulong GuardCFFunctionCount; - public uint GuardFlags; - public IMAGE_LOAD_CONFIG_CODE_INTEGRITY CodeIntegrity; - public ulong GuardAddressTakenIatEntryTable; - public ulong GuardAddressTakenIatEntryCount; - public ulong GuardLongJumpTargetTable; - public ulong GuardLongJumpTargetCount; - public ulong DynamicValueRelocTable; - public ulong CHPEMetadataPointer; - public ulong GuardRFFailureRoutine; - public ulong GuardRFFailureRoutineFunctionPointer; - public uint DynamicValueRelocTableOffset; - public ushort DynamicValueRelocTableSection; - public ushort Reserved2; - public ulong GuardRFVerifyStackPointerFunctionPointer; - public uint HotPatchTableOffset; - public uint Reserved3; - public ulong EnclaveConfigurationPointer; - public ulong VolatileMetadataPointer; - public ulong GuardEHContinuationTable; - public ulong GuardEHContinuationCount; - } - - [StructLayout(LayoutKind.Sequential)] - public struct IMAGE_LOAD_CONFIG_CODE_INTEGRITY { - public ushort Flags; - public ushort Catalog; - public uint CatalogOffset; - public uint Reserved; - } -} \ No newline at end of file +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using Microsoft.Diagnostics.Symbols; +using ProfileExplorer.Core.Providers; +using ProfileExplorer.Core.Settings; +using ProfileExplorer.Core.Utilities; + +namespace ProfileExplorer.Core.Binary; + +/// +/// Locates binary files on disk or downloads them from a symbol server using TraceEvent's +/// . This is the Profile Explorer-side (TraceEvent-coupled) counterpart +/// to the TraceEvent-free reader that lives in the library. +/// +public static class BinaryFileLocator { + private static ConcurrentDictionary resolvedBinariesCache_ = new(); + + /// + /// Clears the static resolved binaries cache. + /// Call between trace loads to ensure a clean resolution state. + /// + public static void ClearResolvedCache() { + resolvedBinariesCache_.Clear(); + PEBinaryInfoProvider.ClearVersionInfoCache(); + } + + public static async Task LocateBinaryFileAsync(BinaryFileDescriptor binaryFile, + SymbolFileSourceSettings settings) { + // Check if the binary was requested before. + if (resolvedBinariesCache_.TryGetValue(binaryFile, out var searchResult)) { + return searchResult; + } + + return await Task.Run(() => { + return LocateBinaryFile(binaryFile, settings); + }).ConfigureAwait(false); + } + + public static BinaryFileSearchResult LocateBinaryFile(BinaryFileDescriptor binaryFile, + SymbolFileSourceSettings settings) { + var sw = Stopwatch.StartNew(); + + // Check if the binary was requested before. + if (resolvedBinariesCache_.TryGetValue(binaryFile, out var searchResult)) { + DiagnosticLogger.LogDebug($"[BinarySearch] Cache hit for {binaryFile.ImageName}"); + return searchResult; + } + + DiagnosticLogger.LogInfo($"[BinarySearch] Starting binary search for {binaryFile.ImageName} (Size: {binaryFile.ImageSize}, Timestamp: {binaryFile.TimeStamp})"); + + // Check if this binary was previously rejected (failed lookup in a prior session). + if (settings.IsRejectedBinaryFile(binaryFile)) { + DiagnosticLogger.LogInfo($"[BinarySearch] SKIPPED - previously rejected: {binaryFile.ImageName}"); + searchResult = BinaryFileSearchResult.Failure(binaryFile, "Previously rejected"); + resolvedBinariesCache_.TryAdd(binaryFile, searchResult); + return searchResult; + } + + // Resolve NT kernel paths like \SystemRoot\system32\ntoskrnl.exe + // to actual file system paths like C:\Windows\system32\ntoskrnl.exe. + string resolvedPath = ResolveNtKernelPath(binaryFile.ImagePath); + if (resolvedPath != binaryFile.ImagePath) { + DiagnosticLogger.LogInfo($"[BinarySearch] Resolved NT path for {binaryFile.ImageName}: {binaryFile.ImagePath} -> {resolvedPath}"); + binaryFile.ImagePath = resolvedPath; + } + + // Quick check if trace was recorded on local machine. + string approximateMatchPath = null; + long correctedImageSize = 0; + string result = FindExactLocalBinaryFile(binaryFile, out approximateMatchPath, out correctedImageSize); + + if (result != null) { + DiagnosticLogger.LogInfo($"[BinarySearch] Found exact local binary for {binaryFile.ImageName} at {result} ({sw.ElapsedMilliseconds}ms)"); + binaryFile = PEBinaryInfoProvider.GetBinaryFileInfo(result); + searchResult = BinaryFileSearchResult.Success(binaryFile, result, ""); + resolvedBinariesCache_.TryAdd(binaryFile, searchResult); + return searchResult; + } + + // Correct ImageSize if the file exists locally with matching timestamp but + // different size. The symbol server indexes binaries by TimeDateStamp+SizeOfImage + // from the PE header, but the ETW kernel event may report a larger mapped view size + // (the kernel mapping can include extra slack pages). Using the wrong size causes + // 404s on the symbol server even when the binary IS indexed there. + if (correctedImageSize > 0 && correctedImageSize != binaryFile.ImageSize) { + DiagnosticLogger.LogInfo( + $"[BinarySearch] Correcting ImageSize for {binaryFile.ImageName} from ETW value {binaryFile.ImageSize} " + + $"to PE SizeOfImage {correctedImageSize} for symbol server lookup"); + binaryFile.ImageSize = correctedImageSize; + } + + using var logWriter = new StringWriter(); + + try { + // Try to use symbol server to download binary. + // Add local binary path directly instead of using WithSymbolPaths (which clones + // settings again and resets runtime state like timeout overrides). + if (File.Exists(binaryFile.ImagePath)) { + settings.InsertSymbolPath(binaryFile.ImagePath); + } + + string userSearchPath = PDBDebugInfoProvider.ConstructSymbolSearchPath(settings); + + //? TODO: Making a new instance clears the "dead servers", + //? have a way to share the list between multiple instances. + using var symbolReader = + new SymbolReader(logWriter, userSearchPath, PDBDebugInfoProvider.CreateAuthHandler(settings)); + symbolReader.SecurityCheck += s => true; // Allow symbols from "unsafe" locations. + + // Set symbol server timeout from settings (default 10 seconds). + // Use EffectiveTimeoutSeconds which is reduced if bellwether test marked server as degraded. + int timeoutSeconds = settings.EffectiveTimeoutSeconds > 0 ? settings.EffectiveTimeoutSeconds : 10; + symbolReader.ServerTimeout = TimeSpan.FromSeconds(timeoutSeconds); + DiagnosticLogger.LogInfo($"[BinarySearch] ServerTimeout={timeoutSeconds}s, RejectPreviouslyFailedFiles={settings.RejectPreviouslyFailedFiles}, " + + $"RejectedBinaries={settings.RejectedBinaryFiles?.Count ?? 0} for {binaryFile.ImageName}"); + + //? TODO: Workaround for cases where the ETL file doesn't have a timestamp + //? and SymbolReader would reject the bin even on the same machine... + //? Better way to handle this is to have SymReader accept a func to check if PDB is valid to use + if (binaryFile.TimeStamp == 0 && File.Exists(binaryFile.ImagePath)) { + var binInfo = PEBinaryInfoProvider.GetBinaryFileInfo(binaryFile.ImagePath); + binaryFile.TimeStamp = binInfo.TimeStamp; + } + + //Trace.WriteLine($"Start download of {Utils.TryGetFileName(binaryFile.ImageName)}"); + result = symbolReader.FindExecutableFilePath(binaryFile.ImageName, + binaryFile.TimeStamp, + (int)binaryFile.ImageSize); + + if (result == null) { + // Finally, try an approximate manual search. + DiagnosticLogger.LogDebug($"[BinarySearch] Symbol server search failed, trying approximate local search for {binaryFile.ImageName}"); + result = FindMatchingLocalBinaryFile(binaryFile, settings, ref approximateMatchPath); + } + } + catch (Exception ex) { + DiagnosticLogger.LogError($"[BinarySearch] Exception during binary search for {binaryFile.ImageName}: {ex.Message}", ex); + Trace.TraceError($"Failed FindExecutableFilePath: {ex.Message}"); + } + + var searchDuration = sw.Elapsed; + string searchLog = logWriter.ToString(); + +#if DEBUG + Trace.WriteLine($">> TraceEvent FindExecutableFilePath for {binaryFile.ImageName}"); + Trace.WriteLine(searchLog); + Trace.WriteLine("<< TraceEvent"); +#endif + + if (!string.IsNullOrEmpty(result) && File.Exists(result)) { + DiagnosticLogger.LogInfo($"[BinarySearch] Found binary for {binaryFile.ImageName} at {result} ({searchDuration.TotalMilliseconds:F0}ms)"); + // Read the binary info from the local file to fill in all fields. + binaryFile = PEBinaryInfoProvider.GetBinaryFileInfo(result); + searchResult = BinaryFileSearchResult.Success(binaryFile, result, searchLog); + } + else if (settings.AllowApproximateBinaryMatch && + !string.IsNullOrEmpty(approximateMatchPath) && + File.Exists(approximateMatchPath)) { + // Fallback: use a binary with matching timestamp but different size. + // This handles cases where the kernel-reported ImageSize in the ETW trace + // differs from the PE header SizeOfImage, or the DLL was serviced. + long traceImageSize = binaryFile.ImageSize; + var originalDescriptor = binaryFile; + DiagnosticLogger.LogWarning( + $"[BinarySearch] Using APPROXIMATE match for {binaryFile.ImageName} " + + $"at {approximateMatchPath} ({searchDuration.TotalMilliseconds:F0}ms) - " + + $"timestamp matches but image size differs"); + binaryFile = PEBinaryInfoProvider.GetBinaryFileInfo(approximateMatchPath); + string details = $"Approximate match: timestamp matches but image size differs " + + $"(trace: {traceImageSize}, on-disk: {binaryFile.ImageSize}). Disassembly may not be fully accurate.\n{searchLog}"; + searchResult = BinaryFileSearchResult.ApproximateSuccess(binaryFile, approximateMatchPath, details); + // Cache under the original trace descriptor so subsequent lookups for the + // same trace module hit the cache instead of repeating the search. + resolvedBinariesCache_.TryAdd(originalDescriptor, searchResult); + } + else { + DiagnosticLogger.LogWarning($"[BinarySearch] Failed to find binary for {binaryFile.ImageName} ({searchDuration.TotalMilliseconds:F0}ms)"); + searchResult = BinaryFileSearchResult.Failure(binaryFile, searchLog); + + // Record failed lookup to avoid retrying in future sessions, + // but skip transient failures (timeout, auth, server errors). + var reason = settings.ClassifySearchFailure(searchLog); + settings.RejectBinaryFile(binaryFile, reason, searchLog); + } + + resolvedBinariesCache_.TryAdd(binaryFile, searchResult); + return searchResult; + } + + private static string ResolveNtKernelPath(string path) { + if (string.IsNullOrEmpty(path)) { + return path; + } + + if (path.StartsWith(@"\SystemRoot", StringComparison.OrdinalIgnoreCase)) { + string systemRoot = Environment.GetEnvironmentVariable("SystemRoot") ?? @"C:\Windows"; + return systemRoot + path.Substring(@"\SystemRoot".Length); + } + + return path; + } + + private static string FindExactLocalBinaryFile(BinaryFileDescriptor binaryFile, + out string approximateMatchPath, + out long correctedImageSize) { + approximateMatchPath = null; + correctedImageSize = 0; + + if (File.Exists(binaryFile.ImagePath)) { + var fileInfo = PEBinaryInfoProvider.GetBinaryFileInfo(binaryFile.ImagePath); + + if (fileInfo != null && fileInfo.TimeStamp == binaryFile.TimeStamp) { + if (fileInfo.ImageSize == binaryFile.ImageSize) { + return binaryFile.ImagePath; + } + + // Timestamp matches but size differs. This commonly happens because the + // kernel-reported ImageSize in ETW events is the mapped view size, which + // can exceed the PE header's SizeOfImage by a few pages of slack. + // Record the local file as an approximate match and save the PE SizeOfImage + // so we can correct the symbol server lookup key. + approximateMatchPath = binaryFile.ImagePath; + correctedImageSize = fileInfo.ImageSize; + DiagnosticLogger.LogInfo( + $"[BinarySearch] Local binary for {binaryFile.ImageName} at {binaryFile.ImagePath}: " + + $"timestamp matches ({fileInfo.TimeStamp}), size differs " + + $"(PE SizeOfImage: {fileInfo.ImageSize}, ETW ImageSize: {binaryFile.ImageSize})"); + } + } + + return null; + } + + private static string FindMatchingLocalBinaryFile(BinaryFileDescriptor binaryFile, + SymbolFileSourceSettings settings, + ref string approximateMatchPath) { + // Manually search in the provided directories. + // This helps in cases where the original fine name doesn't match + // the one on disk, like it seems to happen sometimes with the SPEC runner. + string winPath = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + string sysPath = Environment.GetFolderPath(Environment.SpecialFolder.System); + string sysx86Path = Environment.GetFolderPath(Environment.SpecialFolder.SystemX86); + + // Don't search in the system dirs though, it's pointless + // and takes a long time checking thousands of binaries. + bool PathIsSubPath(string subPath, string basePath) { + string rel = Path.GetRelativePath(basePath, subPath); + return !rel.StartsWith('.') && !Path.IsPathRooted(rel); + } + + foreach (string path in settings.SymbolPaths) { + if (PathIsSubPath(path, winPath) || + PathIsSubPath(path, sysPath) || + PathIsSubPath(path, sysx86Path)) { + continue; + } + + try { + string searchPath = Utils.TryGetDirectoryName(path); + + foreach (string file in Directory.EnumerateFiles(searchPath, "*.*", SearchOption.AllDirectories)) { + if (!Utils.IsBinaryFile(file)) { + continue; + } + + var fileInfo = PEBinaryInfoProvider.GetBinaryFileInfo(file); + + if (fileInfo != null && fileInfo.TimeStamp == binaryFile.TimeStamp) { + if (fileInfo.ImageSize == binaryFile.ImageSize) { + return file; + } + + // Track first approximate match (timestamp matches, size differs). + if (approximateMatchPath == null) { + approximateMatchPath = file; + DiagnosticLogger.LogInfo( + $"[BinarySearch] Approximate match for {binaryFile.ImageName} in search paths: " + + $"{file} (on-disk: {fileInfo.ImageSize}, trace: {binaryFile.ImageSize})"); + } + } + } + } + catch (Exception ex) { + Trace.TraceError($"Exception searching for binary {binaryFile.ImageName} in {path}: {ex.Message}"); + } + } + + return null; + } +} diff --git a/src/ProfileExplorerCore/Binary/DotNetDebugInfoProvider.cs b/src/ProfileExplorerCore/Binary/DotNetDebugInfoProvider.cs index 2313654c..b9e1f418 100644 --- a/src/ProfileExplorerCore/Binary/DotNetDebugInfoProvider.cs +++ b/src/ProfileExplorerCore/Binary/DotNetDebugInfoProvider.cs @@ -14,33 +14,26 @@ namespace ProfileExplorer.Core.Binary; -//? Provider ASM should return instance instead of JSONDebug -public class DotNetDebugInfoProvider : IDebugInfoProvider { - private Dictionary functionMap_; - private List functions_; - private Machine architecture_; - private Dictionary> methodILNativeMap_; - private Dictionary methodCodeMap_; +/// +/// Profile Explorer's managed (.NET) debug-info provider. Extends the TraceEvent-free +/// reading core (in ProfileExplorer.Profiling) with IR +/// source-location annotation and TraceEvent-based managed (portable) PDB source-line loading. +/// +public class DotNetDebugInfoProvider : ManagedDebugInfoProvider, IDebugInfoProvider { private bool hasManagedSymbolFileFailure_; - public DotNetDebugInfoProvider(Machine architecture) { - architecture_ = architecture; - functionMap_ = new Dictionary(); - functions_ = new List(); - methodILNativeMap_ = new Dictionary>(); + public DotNetDebugInfoProvider(Machine architecture) : base(architecture) { } public SymbolFileDescriptor ManagedSymbolFile { get; set; } public string ManagedAsmFilePath { get; set; } - public Machine? Architecture => architecture_; public SymbolFileSourceSettings SymbolSettings { get; set; } public bool AnnotateSourceLocations(FunctionIR function, IRTextFunction textFunc) { return AnnotateSourceLocations(function, textFunc.Name); } - public bool AnnotateSourceLocations(FunctionIR function, - FunctionDebugInfo funcInfo) { + public bool AnnotateSourceLocations(FunctionIR function, FunctionDebugInfo funcInfo) { var metadataTag = function.GetTag(); if (metadataTag == null) { @@ -65,113 +58,29 @@ public bool AnnotateSourceLocations(FunctionIR function, return true; } - public FunctionDebugInfo FindFunction(string functionName) { - return functionMap_.GetValueOr(functionName, FunctionDebugInfo.Unknown); - } - - public IEnumerable EnumerateFunctions() { - return functions_; - } + public bool AnnotateSourceLocations(FunctionIR function, string functionName) { + var funcInfo = FindFunction(functionName); - public List GetSortedFunctions() { - return functions_; - } + if (funcInfo == null) { + return false; + } - public FunctionDebugInfo FindFunctionByRVA(long rva) { - return FunctionDebugInfo.BinarySearch(functions_, rva); + return AnnotateSourceLocations(function, funcInfo); } public SourceFileDebugInfo FindFunctionSourceFilePath(IRTextFunction textFunc) { return FindFunctionSourceFilePath(textFunc.Name); } - public SourceFileDebugInfo FindFunctionSourceFilePath(string functionName) { - if (functionMap_.TryGetValue(functionName, out var funcInfo)) { - return GetSourceFileInfo(funcInfo); - } - - return SourceFileDebugInfo.Unknown; - } - - public SourceFileDebugInfo FindSourceFilePathByRVA(long rva) { - var funcInfo = FindFunctionByRVA(rva); - - if (EnsureHasSourceLines(funcInfo)) { - return GetSourceFileInfo(funcInfo); - } - - return SourceFileDebugInfo.Unknown; - } - - public SourceLineDebugInfo FindSourceLineByRVA(long rva, bool includeInlinees) { - var funcInfo = FindFunctionByRVA(rva); - - if (EnsureHasSourceLines(funcInfo)) { - long offset = rva - funcInfo.StartRVA; - return funcInfo.FindNearestLine(offset); - } - - return SourceLineDebugInfo.Unknown; - } - - public void Unload() { - } - public bool LoadDebugInfo(DebugFileSearchResult debugFile, IDebugInfoProvider other = null) { return true; } - public void Dispose() { - } - - public bool PopulateSourceLines(FunctionDebugInfo funcInfo) { - return true; - } - - public void UpdateArchitecture(Machine architecture) { - if (architecture_ == Machine.Unknown) { - architecture_ = architecture; - } - } - - public MethodCode FindMethodCode(FunctionDebugInfo funcInfo) { - return methodCodeMap_?.GetValueOrNull(funcInfo.RVA); - } - - public void AddFunctionInfo(FunctionDebugInfo funcInfo) { - functions_.Add(funcInfo); - functionMap_[funcInfo.Name] = funcInfo; - } - - public void AddMethodILToNativeMap(FunctionDebugInfo functionDebugInfo, - List<(int ILOffset, int NativeOffset)> ilOffsets) { - methodILNativeMap_[functionDebugInfo] = ilOffsets; - } - - public void LoadingCompleted() { - functions_.Sort(); - } - - public void AddMethodCode(long codeAddress, MethodCode code) { - methodCodeMap_ ??= new Dictionary(); - methodCodeMap_[codeAddress] = code; - } - - public bool AnnotateSourceLocations(FunctionIR function, string functionName) { - var funcInfo = FindFunction(functionName); - - if (funcInfo == null) { - return false; - } - - return AnnotateSourceLocations(function, funcInfo); - } - public bool LoadDebugInfo(string debugFilePath, IDebugInfoProvider other = null) { return true; } - private bool EnsureHasSourceLines(FunctionDebugInfo functionDebugInfo) { + protected override bool EnsureHasSourceLines(FunctionDebugInfo functionDebugInfo) { if (functionDebugInfo == null || functionDebugInfo.IsUnknown) { return false; } @@ -250,51 +159,9 @@ private bool EnsureHasSourceLines(FunctionDebugInfo functionDebugInfo) { } } - private SourceFileDebugInfo GetSourceFileInfo(FunctionDebugInfo info) { - return new SourceFileDebugInfo(info.SourceFileName, - info.OriginalSourceFileName, - info.FirstSourceLine.Line); - } - - public struct AddressNamePair { - public long Address { get; set; } - public string Name { get; set; } - - public AddressNamePair(long address, string name) { - Address = address; - Name = name; - } - } - - public class MethodCode { - public MethodCode(long address, int size, byte[] code) { - Address = address; - Size = size; - Code = code; - CallTargets = new List(); - } - - public long Address { get; set; } - public int Size { get; set; } - public byte[] Code { get; set; } - public List CallTargets { get; set; } - - public string FindCallTarget(long address) { - //? TODO: Map - - int index = CallTargets.FindIndex(item => item.Address == address); - - if (index != -1) { - return CallTargets[index].Name; - } - - return null; - } - } - private class ManagedProcessCode { public int ProcessId { get; set; } public int MachineType { get; set; } public List Methods { get; set; } } -} \ No newline at end of file +} diff --git a/src/ProfileExplorerCore/Binary/IDebugInfoProvider.cs b/src/ProfileExplorerCore/Binary/IDebugInfoProvider.cs index a5097703..e9907fb0 100644 --- a/src/ProfileExplorerCore/Binary/IDebugInfoProvider.cs +++ b/src/ProfileExplorerCore/Binary/IDebugInfoProvider.cs @@ -11,81 +11,12 @@ namespace ProfileExplorer.Core.Binary; -public interface IDebugInfoProvider : IDisposable { - public Machine? Architecture { get; } +public interface IDebugInfoProvider : ISymbolDebugInfo { public SymbolFileSourceSettings SymbolSettings { get; set; } //bool LoadDebugInfo(string debugFilePath, IDebugInfoProvider other = null); bool LoadDebugInfo(DebugFileSearchResult debugFile, IDebugInfoProvider other = null); - void Unload(); bool AnnotateSourceLocations(FunctionIR function, IRTextFunction textFunc); bool AnnotateSourceLocations(FunctionIR function, FunctionDebugInfo funcDebugInfo); - IEnumerable EnumerateFunctions(); - List GetSortedFunctions(); - FunctionDebugInfo FindFunction(string functionName); - FunctionDebugInfo FindFunctionByRVA(long rva); - bool PopulateSourceLines(FunctionDebugInfo funcInfo); SourceFileDebugInfo FindFunctionSourceFilePath(IRTextFunction textFunc); - SourceFileDebugInfo FindFunctionSourceFilePath(string functionName); - SourceFileDebugInfo FindSourceFilePathByRVA(long rva); - SourceLineDebugInfo FindSourceLineByRVA(long rva, bool includeInlinees = false); -} - -[ProtoContract(SkipConstructor = true)] -public class SymbolFileDescriptor : IEquatable { - public SymbolFileDescriptor(string fileName, Guid id, int age) { - FileName = fileName != null ? string.Intern(fileName) : null; - Id = id; - Age = age; - } - - public SymbolFileDescriptor(string fileName) { - FileName = fileName != null ? string.Intern(fileName) : null; - } - - [ProtoMember(1)] - public string FileName { get; set; } - [ProtoMember(2)] - public Guid Id { get; set; } - [ProtoMember(3)] - public int Age { get; set; } - public string SymbolName => Utils.TryGetFileName(FileName); - - public bool Equals(SymbolFileDescriptor other) { - return FileName.Equals(other.FileName, StringComparison.OrdinalIgnoreCase) && - Id == other.Id && - Age == other.Age; - } - - public static bool operator ==(SymbolFileDescriptor left, SymbolFileDescriptor right) { - return Equals(left, right); - } - - public static bool operator !=(SymbolFileDescriptor left, SymbolFileDescriptor right) { - return !Equals(left, right); - } - - public override string ToString() { - return $"{Id}:{FileName}"; - } - - public override bool Equals(object obj) { - if (ReferenceEquals(null, obj)) { - return false; - } - - if (ReferenceEquals(this, obj)) { - return true; - } - - if (obj.GetType() != GetType()) { - return false; - } - - return Equals((SymbolFileDescriptor)obj); - } - - public override int GetHashCode() { - return HashCode.Combine(FileName.GetHashCode(StringComparison.OrdinalIgnoreCase), Id, Age); - } } \ No newline at end of file diff --git a/src/ProfileExplorerCore/Binary/IDisassembler.cs b/src/ProfileExplorerCore/Binary/IDisassembler.cs new file mode 100644 index 00000000..01c269cb --- /dev/null +++ b/src/ProfileExplorerCore/Binary/IDisassembler.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System.Threading.Tasks; +using ProfileExplorer.Core.Providers; +using ProfileExplorer.Core.Utilities; + +namespace ProfileExplorer.Core.Binary; + +public delegate void DisassemblerProgressHandler(DisassemblerProgress info); + +public enum DisassemblerStage { + Disassembling, + PostProcessing +} + +public interface IDisassembler { + DisassemberResult Disassemble(string imagePath, ICompilerInfoProvider compilerInfo, + DisassemblerProgressHandler progressCallback = null, + CancelableTask cancelableTask = null); + + Task DisassembleAsync(string imagePath, ICompilerInfoProvider compilerInfo, + DisassemblerProgressHandler progressCallback = null, + CancelableTask cancelableTask = null); + + bool EnsureDisassemblerAvailable(); +} + +public class DisassemblerOptions { + public bool IncludeBytes { get; set; } +} + +public class DisassemblerProgress { + public DisassemblerProgress(DisassemblerStage stage) { + Stage = stage; + } + + public DisassemblerStage Stage { get; set; } + public int Total { get; set; } + public int Current { get; set; } +} + +public class DisassemberResult { + public DisassemberResult(string disassemblyPath, string debugInfoFilePath) { + DisassemblyPath = disassemblyPath; + DebugInfoFilePath = debugInfoFilePath; + } + + public string DisassemblyPath { get; set; } + public string DebugInfoFilePath { get; set; } +} diff --git a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs index 6427b776..03f3c374 100644 --- a/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs +++ b/src/ProfileExplorerCore/Binary/PDBDebugInfoProvider.cs @@ -22,13 +22,13 @@ using ProfileExplorer.Core.Providers; using ProfileExplorer.Core.Settings; using ProfileExplorer.Core.Utilities; +using ProfileExplorer.Profiling.Symbols; using StringWriter = System.IO.StringWriter; namespace ProfileExplorer.Core.Binary; public sealed class PDBDebugInfoProvider : IDebugInfoProvider { private const int MaxDemangledFunctionNameLength = 8192; - private const int FunctionCacheMissThreshold = 100; private const int MaxLogEntryLength = 10240; private static ConcurrentDictionary resolvedSymbolsCache_ = new(); @@ -42,10 +42,8 @@ public sealed class PDBDebugInfoProvider : IDebugInfoProvider { private ConcurrentDictionary sourceFileByNameCache_ = new(); private ConcurrentDictionary> inlineeByRvaCache_ = new(); private ConcurrentDictionary loggedRvas_ = new(); - private object cacheLock_ = new(); private SymbolFileDescriptor symbolFile_; private SymbolFileSourceSettings settings_; - private SymbolFileCache symbolCache_; private SymbolReader symbolReader_; private StringWriter symbolReaderLog_; private NativeSymbolModule symbolReaderPDB_; @@ -53,9 +51,7 @@ public sealed class PDBDebugInfoProvider : IDebugInfoProvider { private IDiaDataSource diaSource_; private IDiaSession session_; private IDiaSymbol globalSymbol_; - private List sortedFuncList_; - private bool sortedFuncListOverlapping_; - private volatile int funcCacheMisses_; + private PdbSymbolProvider libReader_; // Library reader: single source of truth for PDB function-list reading. private bool loadFailed_; private bool disposed_; private bool hasSourceInfo_; @@ -317,90 +313,21 @@ public SourceLineDebugInfo FindSourceLineByRVA(long rva, bool includeInlinees) { } public FunctionDebugInfo FindFunctionByRVA(long rva) { - bool shouldLog = loggedRvas_.TryAdd(rva, true); // Returns true if newly added - string binaryName = symbolFile_?.FileName ?? "Unknown"; - - try { - if (sortedFuncList_ != null) { - // Query the function list first. If not found, then still query the actual PDB - // because DIA has special lookup for functions split into multiple chunks by PGO for ex. - var result = FunctionDebugInfo.BinarySearch(sortedFuncList_, rva, sortedFuncListOverlapping_); - - if (result != null) { - if (shouldLog) { - DiagnosticLogger.LogInfo($"[PDBDebugInfo] Binary: {binaryName}, RVA: 0x{rva:X}, Function: {result.Name} (found in cache)"); - } - return result; - } - } - - // Preload the function list only when there are enough queries - // to justify the time spent in reading the entire PDB. - if (sortedFuncList_ == null && - Interlocked.Increment(ref funcCacheMisses_) >= FunctionCacheMissThreshold) { - if (shouldLog) { - DiagnosticLogger.LogInfo($"[PDBDebugInfo] Binary: {binaryName}, cache miss threshold reached, loading function list"); - } - GetSortedFunctions(); - - if (sortedFuncList_ != null) { - var result = FunctionDebugInfo.BinarySearch(sortedFuncList_, rva, sortedFuncListOverlapping_); - - if (result != null) { - if (shouldLog) { - DiagnosticLogger.LogInfo($"[PDBDebugInfo] Binary: {binaryName}, RVA: 0x{rva:X}, Function: {result.Name} (found after cache load)"); - } - return result; - } - } else if (shouldLog) { - DiagnosticLogger.LogWarning($"[PDBDebugInfo] Binary: {binaryName}, failed to load function list"); - } - } - - // Query the PDB file. - var symbol = FindFunctionSymbolByRVA(rva); - - if (symbol != null) { - if (shouldLog) { - DiagnosticLogger.LogInfo($"[PDBDebugInfo] Binary: {binaryName}, RVA: 0x{rva:X}, Function: {symbol.name} (found via PDB query)"); - } - return new FunctionDebugInfo(symbol.name, symbol.relativeVirtualAddress, (uint)symbol.length); - } else if (shouldLog) { - DiagnosticLogger.LogWarning($"[PDBDebugInfo] Binary: {binaryName}, RVA: 0x{rva:X}, Function: NOT_RESOLVED (no symbol found)"); - } - } - catch (Exception ex) { - if (shouldLog) { - DiagnosticLogger.LogError($"[PDBDebugInfo] Binary: {binaryName}, RVA: 0x{rva:X}, Function: ERROR ({ex.Message})", ex); - } - Trace.TraceError($"Failed to find function for RVA {rva}: {ex.Message}"); - } - - return null; + // Delegate to the library reader (faithful superset: binary search + cache-miss threshold + + // DIA PGO-split fallback). PE retains its own DIA session for IR annotation / source work. + return libReader_?.FindFunctionByRVA(rva); } public FunctionDebugInfo FindFunction(string functionName) { - var funcSym = FindFunctionSymbol(functionName); - return funcSym != null ? new FunctionDebugInfo(funcSym.name, funcSym.relativeVirtualAddress, (uint)funcSym.length) - : null; + return libReader_?.FindFunction(functionName); } public IEnumerable EnumerateFunctions() { - return GetSortedFunctions(); + return libReader_?.EnumerateFunctions() ?? Enumerable.Empty(); } public List GetSortedFunctions() { - if (sortedFuncList_ != null) { - return sortedFuncList_; - } - - lock (cacheLock_) { - var result = Utils.RunSync(GetSortedFunctionsAsync); -#if DEBUG - ValidateSortedList(result); -#endif - return result; - } + return libReader_?.GetSortedFunctions() ?? new List(); } public void Dispose() { @@ -742,13 +669,19 @@ private bool LoadDebugInfo(string debugFilePath, IDebugInfoProvider other = null return false; } - if (other is PDBDebugInfoProvider otherPdb) { - // Copy the already loaded function list from another PDB - // provider that was created on another thread and is unusable otherwise. - symbolCache_ = otherPdb.symbolCache_; - sortedFuncList_ = otherPdb.sortedFuncList_; + if (other is PDBDebugInfoProvider) { + // Cross-thread function-list sharing is no longer needed: reading is delegated to the + // library reader below, which manages its own cache. } + // Delegate PDB function-list reading to the library's PdbSymbolProvider (single source of + // truth). PE keeps its own DIA session (opened above) for IR annotation and source-server + // work. Lazy enumeration mirrors PE's deferred behavior (avoids parsing PDBs with few queries). + libReader_ = new PdbSymbolProvider(); + var readerCacheKey = settings_?.CacheSymbolFiles == true ? symbolFile_ : null; + var readerCacheDir = settings_?.CacheSymbolFiles == true ? settings_.SymbolCacheDirectoryPath : null; + libReader_.LoadDebugInfo(debugFilePath, readerCacheKey, readerCacheDir, enumerateImmediately: false); + // Check if PDB has source file information (not stripped) CheckForSourceInfo(); return true; @@ -820,97 +753,19 @@ private bool AnnotateSourceLocationsImpl(FunctionIR function, IDiaSymbol funcSym return true; } - private bool ValidateSortedList(List list) { - for (int i = 1; i < list.Count; i++) { - if (list[i].StartRVA < list[i - 1].StartRVA && - list[i].StartRVA != 0 && - list[i - 1].StartRVA != 0) { - Debug.Assert(false, "Function list is not sorted by RVA"); - return false; - } - } - - return true; - } - - private async Task> GetSortedFunctionsAsync() { - // This method assumes lock is taken by caller. - if (sortedFuncList_ != null) { - return sortedFuncList_; - } - - if (settings_.CacheSymbolFiles) { - // Try to load a previous cached function list file. - symbolCache_ = await SymbolFileCache.DeserializeAsync(symbolFile_, settings_.SymbolCacheDirectoryPath). - ConfigureAwait(false); - } - - if (symbolCache_ != null) { - Trace.WriteLine($"PDB cache loaded for {symbolFile_.FileName}"); - sortedFuncList_ = symbolCache_.FunctionList; - } - else { - // Create sorted list of functions and public symbols. - sortedFuncList_ = CollectFunctionDebugInfo(); - - if (sortedFuncList_ == null) { - return null; - } - - if (settings_.CacheSymbolFiles) { - // Save symbol cache file. - symbolCache_ = new SymbolFileCache() { - SymbolFile = symbolFile_, - FunctionList = sortedFuncList_ - }; - - await SymbolFileCache.SerializeAsync(symbolCache_, settings_.SymbolCacheDirectoryPath). - ConfigureAwait(false); - Trace.WriteLine($"PDB cache created for {symbolFile_.FileName}"); - } - } - - // Sorting needed for binary search later. - sortedFuncList_.Sort(); - sortedFuncListOverlapping_ = HasOverlappingFunctions(sortedFuncList_); - return sortedFuncList_; - } - - private bool HasOverlappingFunctions(List sortedFuncList) { - if (sortedFuncList == null || sortedFuncList.Count < 2) { - return false; - } - - for (int i = 1; i < sortedFuncList.Count; i++) { - if (sortedFuncList[i].StartRVA == 0) { - continue; - } - - for (int k = i - 1; k >= 0 && i - k < 10; k--) { - if (sortedFuncList[k].StartRVA != 0 && - sortedFuncList[k].StartRVA <= sortedFuncList[i].StartRVA && - sortedFuncList[k].EndRVA > sortedFuncList[i].EndRVA) { - return true; - } - } - } - - return false; - } - private void Dispose(bool disposing) { if (disposed_) { return; } if (disposing) { + libReader_?.Dispose(); + libReader_ = null; symbolReaderPDB_?.Dispose(); symbolReader_?.Dispose(); symbolReaderLog_?.Dispose(); symbolReaderPDB_ = null; symbolReader_ = null; - symbolCache_ = null; - sortedFuncList_ = null; } Unload(); @@ -1212,63 +1067,6 @@ private IEnumerable } } - private List CollectFunctionDebugInfo() { - if (!EnsureLoaded()) { - return null; - } - - IDiaEnumSymbols symbolEnum = null; - IDiaEnumSymbols publicSymbolEnum = null; - - try { - var symbolList = new List(); - var symbolMap = new Dictionary(); - globalSymbol_.findChildren(SymTagEnum.SymTagFunction, null, 0, out symbolEnum); - globalSymbol_.findChildren(SymTagEnum.SymTagPublicSymbol, null, 0, out publicSymbolEnum); - - foreach (IDiaSymbol sym in symbolEnum) { - //Trace.WriteLine($" FuncSym {sym.name}: RVA {sym.relativeVirtualAddress:X}, size {sym.length}"); - var funcInfo = new FunctionDebugInfo(sym.name, sym.relativeVirtualAddress, (uint)sym.length); - symbolList.Add(funcInfo); - symbolMap[funcInfo.RVA] = funcInfo; - } - - foreach (IDiaSymbol sym in publicSymbolEnum) { - //Trace.WriteLine($" PublicSym {sym.name}: RVA {sym.relativeVirtualAddress:X} size {sym.length}"); - var funcInfo = new FunctionDebugInfo(sym.name, sym.relativeVirtualAddress, (uint)sym.length); - - // Public symbols are preferred over function symbols if they have the same RVA and size. - // This ensures that the mangled name is saved, set only of public symbols. - // This tries to mirror the behavior from FindFunctionSymbolByRVA. - if (symbolMap.TryGetValue(funcInfo.RVA, out var existingFuncInfo)) { - if (existingFuncInfo.Size == funcInfo.Size) { - existingFuncInfo.Name = funcInfo.Name; - } - } - else { - // Consider the public sym if it doesn't overlap a function sym. - symbolList.Add(funcInfo); - } - } - - return symbolList; - } - catch (Exception ex) { - Trace.TraceError($"Failed to enumerate functions: {ex.Message}"); - } - finally { - if (symbolEnum != null) { - Marshal.ReleaseComObject(symbolEnum); - } - - if (publicSymbolEnum != null) { - Marshal.ReleaseComObject(publicSymbolEnum); - } - } - - return null; - } - private IDiaSymbol FindFunctionSymbol(string functionName) { string demangledName = DemangleFunctionName(functionName); string queryDemangledName = DemangleFunctionName(functionName, FunctionNameDemanglingOptions.OnlyName); diff --git a/src/ProfileExplorerCore/Binary/SymbolFileCache.cs b/src/ProfileExplorerCore/Binary/SymbolFileCache.cs deleted file mode 100644 index c79f1dac..00000000 --- a/src/ProfileExplorerCore/Binary/SymbolFileCache.cs +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Threading.Tasks; -using ProfileExplorer.Core.FileFormat; -using ProfileExplorer.Core.Utilities; -using ProtoBuf; - -namespace ProfileExplorer.Core.Binary; - -[ProtoContract] -public class SymbolFileCache { - private static int CurrentFileVersion = 2; - private static int MinSupportedFileVersion = 2; - [ProtoMember(1)] - public int Version { get; set; } - [ProtoMember(2)] - public SymbolFileDescriptor SymbolFile { get; set; } - [ProtoMember(3)] - public List FunctionList { get; set; } - public static string DefaultCacheDirectoryPath => Path.Combine(Path.GetTempPath(), "ProfileExplorer", "symcache"); - - public static async Task SerializeAsync(SymbolFileCache symCache, string directoryPath) { - try { - symCache.Version = CurrentFileVersion; - var outStream = new MemoryStream(); - Serializer.Serialize(outStream, symCache); - outStream.Position = 0; - - if (!Directory.Exists(directoryPath)) { - Directory.CreateDirectory(directoryPath); - } - - string cacheFile = MakeCacheFilePath(symCache.SymbolFile); - string cachePath = Path.Combine(directoryPath, cacheFile); - - //? TODO: Convert everything to RunSync or add the support in the FileArchive - return await FileArchive.CreateFromStreamAsync(outStream, cacheFile, cachePath).ConfigureAwait(false); - } - catch (Exception ex) { - Trace.WriteLine($"Failed to save symbol file cache: {ex.Message}"); - return false; - } - } - - public static async Task DeserializeAsync(SymbolFileDescriptor symbolFile, string directoryPath) { - try { - string cacheFile = MakeCacheFilePath(symbolFile); - string cachePath = Path.Combine(directoryPath, cacheFile); - - if (!File.Exists(cachePath)) { - return null; - } - - using var archive = await FileArchive.LoadAsync(cachePath).ConfigureAwait(false); - - if (archive != null) { - using var stream = await archive.ExtractFileToMemoryAsync(cacheFile); - - if (stream != null) { - var symCache = Serializer.Deserialize(stream); - - if (symCache.Version < MinSupportedFileVersion) { - Trace.WriteLine($"File version mismatch in deserialized symbol file cache"); - Trace.WriteLine($" actual: {symCache.Version} vs min supported {MinSupportedFileVersion}"); - return null; - } - - // Ensure it's a cache for the same symbol file. - if (symCache.SymbolFile.Equals(symbolFile)) { - return symCache; - } - - Trace.WriteLine($"Symbol file mismatch in deserialized symbol file cache"); - Trace.WriteLine($" actual: {symCache.SymbolFile} vs expected {symbolFile}"); - } - } - } - catch (Exception ex) { - Trace.WriteLine($"Failed to load symbol file cache: {ex.Message}"); - } - - return null; - } - - private static string MakeCacheFilePath(SymbolFileDescriptor symbolFile) { - return $"{Utils.TryGetFileName(symbolFile.FileName)}-{symbolFile.Id}-{symbolFile.Age}.cache"; - } -} \ No newline at end of file diff --git a/src/ProfileExplorerCore/Compilers/ASM/ASMBinaryFileFinder.cs b/src/ProfileExplorerCore/Compilers/ASM/ASMBinaryFileFinder.cs index e2e60a14..afbebf64 100644 --- a/src/ProfileExplorerCore/Compilers/ASM/ASMBinaryFileFinder.cs +++ b/src/ProfileExplorerCore/Compilers/ASM/ASMBinaryFileFinder.cs @@ -15,6 +15,6 @@ public async Task FindBinaryFileAsync(BinaryFileDescript settings = CoreSettingsProvider.SymbolSettings.Clone(); } - return await PEBinaryInfoProvider.LocateBinaryFileAsync(binaryFile, settings).ConfigureAwait(false); + return await BinaryFileLocator.LocateBinaryFileAsync(binaryFile, settings).ConfigureAwait(false); } } diff --git a/src/ProfileExplorerCore/IR/Tags/SourceStackTrace.cs b/src/ProfileExplorerCore/IR/Tags/SourceStackTrace.cs index 44eb4ebd..233697dc 100644 --- a/src/ProfileExplorerCore/IR/Tags/SourceStackTrace.cs +++ b/src/ProfileExplorerCore/IR/Tags/SourceStackTrace.cs @@ -7,51 +7,6 @@ namespace ProfileExplorer.Core.IR.Tags; -public sealed class SourceStackFrame : IEquatable { - public SourceStackFrame(string function, string filePath, int line, int column) { - Function = function; - FilePath = filePath; - Line = line; - Column = column; - } - - public string Function { get; set; } - public string FilePath { get; set; } - public int Line { get; set; } - public int Column { get; set; } - - public bool Equals(SourceStackFrame other) { - if (ReferenceEquals(null, other)) - return false; - if (ReferenceEquals(this, other)) - return true; - return Line == other.Line && Column == other.Column && - Function.Equals(other.Function, StringComparison.OrdinalIgnoreCase) && - FilePath.Equals(other.FilePath, StringComparison.OrdinalIgnoreCase); - } - - public static bool operator ==(SourceStackFrame left, SourceStackFrame right) { - return Equals(left, right); - } - - public static bool operator !=(SourceStackFrame left, SourceStackFrame right) { - return !Equals(left, right); - } - - public override bool Equals(object obj) { - return ReferenceEquals(this, obj) || obj is SourceStackFrame other && Equals(other); - } - - public override int GetHashCode() { - return HashCode.Combine(Function, FilePath, Line, Column); - } - - public bool HasSameFunction(SourceStackFrame inlinee) { - return Function.Equals(inlinee.Function, StringComparison.OrdinalIgnoreCase) && - FilePath.Equals(inlinee.FilePath, StringComparison.OrdinalIgnoreCase); - } -} - public sealed class SourceStackTrace { public SourceStackTrace() { Frames = new List(); diff --git a/src/ProfileExplorerCore/Profile/CallTree/ProfileCallTreeExtensions.cs b/src/ProfileExplorerCore/Profile/CallTree/ProfileCallTreeExtensions.cs new file mode 100644 index 00000000..873bcd9c --- /dev/null +++ b/src/ProfileExplorerCore/Profile/CallTree/ProfileCallTreeExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System; +using System.Collections.Generic; +using ProfileExplorer.Core.Profile; + +namespace ProfileExplorer.Core.Profile.CallTree; + +/// +/// IRTextFunction-accepting convenience overloads for query methods. +/// These live on the PE side as extensions so itself stays +/// IRTextFunction-free (and movable to the library), while existing UI call sites that hold an +/// IRTextFunction keep working by converting to the neutral . +/// +public static class ProfileCallTreeExtensions { + public static List GetCallTreeNodes(this ProfileCallTree tree, IRTextFunction function) { + return tree.GetCallTreeNodes(function.ToProfileId()); + } + + public static List GetSortedCallTreeNodes(this ProfileCallTree tree, IRTextFunction function) { + return tree.GetSortedCallTreeNodes(function.ToProfileId()); + } + + public static ProfileCallTreeNode GetCombinedCallTreeNode(this ProfileCallTree tree, IRTextFunction function, + ProfileCallTreeNode parentNode = null) { + return tree.GetCombinedCallTreeNode(function.ToProfileId(), parentNode); + } + + public static TimeSpan GetCombinedCallTreeNodeWeight(this ProfileCallTree tree, IRTextFunction function) { + return tree.GetCombinedCallTreeNodeWeight(function.ToProfileId()); + } +} diff --git a/src/ProfileExplorerCore/Profile/Data/FunctionProfileData.cs b/src/ProfileExplorerCore/Profile/Data/FunctionProfileDataProcessing.cs similarity index 67% rename from src/ProfileExplorerCore/Profile/Data/FunctionProfileData.cs rename to src/ProfileExplorerCore/Profile/Data/FunctionProfileDataProcessing.cs index 520407d9..2beca587 100644 --- a/src/ProfileExplorerCore/Profile/Data/FunctionProfileData.cs +++ b/src/ProfileExplorerCore/Profile/Data/FunctionProfileDataProcessing.cs @@ -1,333 +1,252 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT license. -using System; -using System.Collections.Generic; -using System.Runtime.InteropServices; -using ProfileExplorer.Core.Binary; -using ProfileExplorer.Core.IR; -using ProfileExplorer.Core.IR.Tags; -using ProfileExplorer.Core.Utilities; - -namespace ProfileExplorer.Core.Profile.Data; - -public class FunctionProfileData { - public FunctionProfileData() { - InstructionWeight = new Dictionary(); - SampleStartIndex = int.MaxValue; - SampleEndIndex = int.MinValue; - } - - public FunctionProfileData(FunctionDebugInfo debugInfo) : this() { - FunctionDebugInfo = debugInfo; - } - - public TimeSpan Weight { get; set; } - public TimeSpan ExclusiveWeight { get; set; } - public Dictionary InstructionWeight { get; set; } // Instr. offset mapping - public Dictionary InstructionCounters { get; set; } - public FunctionDebugInfo FunctionDebugInfo { get; set; } - public int SampleStartIndex { get; set; } - public int SampleEndIndex { get; set; } - public bool HasPerformanceCounters => InstructionCounters is {Count: > 0}; - - public void MergeWith(FunctionProfileData otherData) { - Weight += otherData.Weight; - ExclusiveWeight += otherData.ExclusiveWeight; - SampleStartIndex = Math.Min(SampleStartIndex, otherData.SampleStartIndex); - SampleEndIndex = Math.Max(SampleEndIndex, otherData.SampleEndIndex); - - foreach (var pair in otherData.InstructionWeight) { - ref var existingValue = - ref CollectionsMarshal.GetValueRefOrAddDefault(InstructionWeight, pair.Key, out bool exists); - existingValue += pair.Value; - } - - if (otherData.HasPerformanceCounters) { - InstructionCounters ??= new Dictionary(); - - foreach (var pair in otherData.InstructionCounters) { - ref var existingValue = - ref CollectionsMarshal.GetValueRefOrAddDefault(InstructionCounters, pair.Key, out bool exists); - - if (exists) { - existingValue.Add(pair.Value); - } - else { - existingValue = pair.Value; - } - } - } - } - - public static bool TryFindElementForOffset(AssemblyMetadataTag metadataTag, long offset, - ICompilerIRInfo ir, - out IRElement element) { - var offsetData = ir.InstructionOffsetData; - int multiplier = offsetData.InitialMultiplier; - - do { - long candidateOffset = Math.Max(0, offset - multiplier * offsetData.OffsetAdjustIncrement); - - if (metadataTag.OffsetToElementMap.TryGetValue(candidateOffset, out element)) { - return true; - } - - ++multiplier; - } while (multiplier * offsetData.OffsetAdjustIncrement < offsetData.MaxOffsetAdjust); - - return false; - } - - public void AddCounterSample(long instrOffset, int perfCounterId, long value) { - InstructionCounters ??= new Dictionary(); - var counterSet = InstructionCounters.GetOrAddValue(instrOffset); - counterSet.AddCounterSample(perfCounterId, value); - } - - public void AddInstructionSample(long instrOffset, TimeSpan weight) { - if (InstructionWeight.TryGetValue(instrOffset, out var currentWeight)) { - InstructionWeight[instrOffset] = currentWeight + weight; - } - else { - InstructionWeight[instrOffset] = weight; - } - } - - public double ScaleWeight(TimeSpan weight) { - return weight.Ticks / (double)Weight.Ticks; - } - - public FunctionProcessingResult Process(FunctionIR function, ICompilerIRInfo ir) { - var metadataTag = function.GetTag(); - bool hasInstrOffsetMetadata = metadataTag != null && metadataTag.OffsetToElementMap.Count > 0; - - if (!hasInstrOffsetMetadata) { - return null; - } - - var result = new FunctionProcessingResult(metadataTag.OffsetToElementMap.Count); - - foreach (var pair in InstructionWeight) { - if (TryFindElementForOffset(metadataTag, pair.Key, ir, out var element)) { - result.SampledElements.Add((element, pair.Value)); - result.BlockSampledElementsMap.AccumulateValue(element.ParentBlock, pair.Value); - } - } - - if (HasPerformanceCounters) { - foreach (var pair in InstructionCounters) { - if (TryFindElementForOffset(metadataTag, pair.Key, ir, out var element)) { - result.CounterElements.Add((element, pair.Value)); - } - - result.FunctionCountersValue.Add(pair.Value); - } - } - - result.BlockSampledElements = result.BlockSampledElementsMap.ToList(); - result.SortSampledElements(); - return result; - } - - public SourceLineProcessingResult ProcessSourceLines(IDebugInfoProvider debugInfo, - ICompilerIRInfo ir, - SourceStackFrame inlinee = null) { - var result = new SourceLineProcessingResult(); - int firstLine = int.MaxValue; - int lastLine = int.MinValue; - var offsetData = ir.InstructionOffsetData; - - var firstLineInfo = debugInfo.FindSourceLineByRVA(FunctionDebugInfo.RVA); - - if (!firstLineInfo.IsUnknown) { - firstLine = firstLineInfo.Line; - } - - var lastLineInfo = debugInfo.FindSourceLineByRVA(FunctionDebugInfo.EndRVA); - - if (!lastLineInfo.IsUnknown) { - lastLine = lastLineInfo.Line; - } - - foreach (var pair in InstructionWeight) { - long rva = pair.Key + FunctionDebugInfo.RVA - offsetData.InitialMultiplier; - var lineInfo = debugInfo.FindSourceLineByRVA(rva, inlinee != null); - - if (!lineInfo.IsUnknown) { - int line = lineInfo.Line; - - if (inlinee != null) { - // Map the instruction back to the function that got inlined - // at the call site, if filtering by an inlinee is used. - var matchingInlinee = lineInfo.FindSameFunctionInlinee(inlinee); - - if (matchingInlinee != null) { - line = matchingInlinee.Line; - } - else { - continue; // Don't count the instr. if not part of the inlinee. - } - } - - result.SourceLineWeight.AccumulateValue(line, pair.Value); - firstLine = Math.Min(line, firstLine); - lastLine = Math.Max(line, lastLine); - } - } - - if (HasPerformanceCounters) { - foreach (var pair in InstructionCounters) { - long rva = pair.Key + FunctionDebugInfo.RVA; - var lineInfo = debugInfo.FindSourceLineByRVA(rva, inlinee != null); - - if (!lineInfo.IsUnknown) { - int line = lineInfo.Line; - - if (inlinee != null) { - // Map the instruction back to the function that got inlined - // at the call site, if filtering by an inlinee is used. - var matchingInlinee = lineInfo.FindSameFunctionInlinee(inlinee); - - if (matchingInlinee != null) { - line = matchingInlinee.Line; - } - else { - continue; // Don't count the instr. if not part of the inlinee. - } - } - - result.SourceLineCounters.AccumulateValue(line, pair.Value); - firstLine = Math.Min(line, firstLine); - lastLine = Math.Max(line, lastLine); - } - - result.FunctionCountersValue.Add(pair.Value); - } - } - - result.FirstLineIndex = firstLine; - result.LastLineIndex = lastLine; - return result; - } - - public PerformanceCounterValueSet ComputeFunctionTotalCounters() { - var result = new PerformanceCounterValueSet(); - - if (HasPerformanceCounters) { - foreach (var pair in InstructionCounters) { - result.Add(pair.Value); - } - } - - return result; - } - - public void Reset() { - Weight = TimeSpan.Zero; - ExclusiveWeight = TimeSpan.Zero; - SampleStartIndex = int.MaxValue; - SampleEndIndex = int.MinValue; - InstructionWeight?.Clear(); - InstructionCounters?.Clear(); - } -} - -public class FunctionProcessingResult { - public FunctionProcessingResult(int capacity = 0) { - SampledElements = new List<(IRElement, TimeSpan)>(capacity); - BlockSampledElementsMap = new Dictionary(capacity); - BlockSampledElements = new List<(BlockIR, TimeSpan)>(); - CounterElements = new List<(IRElement, PerformanceCounterValueSet)>(capacity); - FunctionCountersValue = new PerformanceCounterValueSet(); - } - - public List<(IRElement, TimeSpan)> SampledElements { get; set; } - public Dictionary BlockSampledElementsMap { get; set; } - public List<(BlockIR, TimeSpan)> BlockSampledElements { get; set; } - public List<(IRElement, PerformanceCounterValueSet)> CounterElements { get; set; } - public List<(BlockIR, PerformanceCounterValueSet)> BlockCounterElements { get; set; } - public PerformanceCounterValueSet FunctionCountersValue { get; set; } - - public double ScaleCounterValue(long value, PerformanceCounter counter) { - long total = FunctionCountersValue.FindCounterValue(counter); - return total > 0 ? value / (double)total : 0; - } - - public void SortSampledElements() { - BlockSampledElements.Sort((a, b) => b.Item2.CompareTo(a.Item2)); - SampledElements.Sort((a, b) => b.Item2.CompareTo(a.Item2)); - } - - public SampledElementsToLineMapping BuildSampledElementsToLineMapping(FunctionProfileData profile, - ParsedIRTextSection parsedSection) { - var elementMap = BuildElementToWeightMap(); - var counterMap = BuildElementToCounterMap(); - var instrToLineMap = new SampledElementsToLineMapping(); - - // Build groups of instructions mapping to the same source line, - // with their associated sampled weight and perf. counters. - foreach (var instr in parsedSection.Function.AllInstructions) { - var tag = instr.GetTag(); - - if (tag != null) { - var weight = elementMap.GetValueOr(instr, TimeSpan.Zero); - var counters = counterMap.GetValueOrNull(instr); - var list = instrToLineMap.SampledElements.GetOrAddValue(tag.Line); - list.Add((instr, (weight, counters))); - } - } - - // Sort elements in each line group by text offset. - foreach (var linePair in instrToLineMap.SampledElements) { - linePair.Value.Sort((a, b) => a.Item1.TextLocation.CompareTo(b.Item1.TextLocation)); - } - - return instrToLineMap; - } - - public Dictionary BuildElementToWeightMap() { - var map = new Dictionary(); - - foreach (var pair in SampledElements) { - map[pair.Item1] = pair.Item2; - } - - return map; - } - - public Dictionary BuildElementToCounterMap() { - var map = new Dictionary(); - - foreach (var pair in CounterElements) { - map[pair.Item1] = pair.Item2; - } - - return map; - } - - // Mapping from a source line number to a list - // of associated instructions and their weight and/or perf. counters. - public record SampledElementsToLineMapping( - Dictionary> SampledElements) { - public SampledElementsToLineMapping() : - this(new Dictionary>()) { - } - } -} - -public class SourceLineProcessingResult { - public SourceLineProcessingResult() { - SourceLineWeight = new Dictionary(); - SourceLineCounters = new Dictionary(); - FunctionCountersValue = new PerformanceCounterValueSet(); - } - - public Dictionary SourceLineWeight { get; set; } // Line number mapping - public Dictionary SourceLineCounters { get; set; } // Line number mapping - public PerformanceCounterValueSet FunctionCountersValue { get; set; } - public List<(int LineNumber, TimeSpan Weight)> SourceLineWeightList => SourceLineWeight.ToList(); - public int FirstLineIndex { get; set; } - public int LastLineIndex { get; set; } -} \ No newline at end of file +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +using System; +using System.Collections.Generic; +using System.Linq; +using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.IR; +using ProfileExplorer.Core.IR.Tags; +using ProfileExplorer.Core.Utilities; + +namespace ProfileExplorer.Core.Profile.Data; + +/// +/// IR/document-coupled processing for : maps per-instruction-offset +/// profile data onto IR elements and source lines. Kept on the PE side (as extension methods) so that +/// itself stays free of the IR subsystem and can live in the library. +/// +public static class FunctionProfileDataProcessing { + public static bool TryFindElementForOffset(AssemblyMetadataTag metadataTag, long offset, + ICompilerIRInfo ir, + out IRElement element) { + var offsetData = ir.InstructionOffsetData; + int multiplier = offsetData.InitialMultiplier; + + do { + long candidateOffset = Math.Max(0, offset - multiplier * offsetData.OffsetAdjustIncrement); + + if (metadataTag.OffsetToElementMap.TryGetValue(candidateOffset, out element)) { + return true; + } + + ++multiplier; + } while (multiplier * offsetData.OffsetAdjustIncrement < offsetData.MaxOffsetAdjust); + + return false; + } + + public static FunctionProcessingResult Process(this FunctionProfileData profile, FunctionIR function, + ICompilerIRInfo ir) { + var metadataTag = function.GetTag(); + bool hasInstrOffsetMetadata = metadataTag != null && metadataTag.OffsetToElementMap.Count > 0; + + if (!hasInstrOffsetMetadata) { + return null; + } + + var result = new FunctionProcessingResult(metadataTag.OffsetToElementMap.Count); + + foreach (var pair in profile.InstructionWeight) { + if (TryFindElementForOffset(metadataTag, pair.Key, ir, out var element)) { + result.SampledElements.Add((element, pair.Value)); + result.BlockSampledElementsMap.AccumulateValue(element.ParentBlock, pair.Value); + } + } + + if (profile.HasPerformanceCounters) { + foreach (var pair in profile.InstructionCounters) { + if (TryFindElementForOffset(metadataTag, pair.Key, ir, out var element)) { + result.CounterElements.Add((element, pair.Value)); + } + + result.FunctionCountersValue.Add(pair.Value); + } + } + + result.BlockSampledElements = result.BlockSampledElementsMap.ToList(); + result.SortSampledElements(); + return result; + } + + public static SourceLineProcessingResult ProcessSourceLines(this FunctionProfileData profile, + IDebugInfoProvider debugInfo, + ICompilerIRInfo ir, + SourceStackFrame inlinee = null) { + var result = new SourceLineProcessingResult(); + int firstLine = int.MaxValue; + int lastLine = int.MinValue; + var offsetData = ir.InstructionOffsetData; + + var firstLineInfo = debugInfo.FindSourceLineByRVA(profile.FunctionDebugInfo.RVA); + + if (!firstLineInfo.IsUnknown) { + firstLine = firstLineInfo.Line; + } + + var lastLineInfo = debugInfo.FindSourceLineByRVA(profile.FunctionDebugInfo.EndRVA); + + if (!lastLineInfo.IsUnknown) { + lastLine = lastLineInfo.Line; + } + + foreach (var pair in profile.InstructionWeight) { + long rva = pair.Key + profile.FunctionDebugInfo.RVA - offsetData.InitialMultiplier; + var lineInfo = debugInfo.FindSourceLineByRVA(rva, inlinee != null); + + if (!lineInfo.IsUnknown) { + int line = lineInfo.Line; + + if (inlinee != null) { + // Map the instruction back to the function that got inlined + // at the call site, if filtering by an inlinee is used. + var matchingInlinee = lineInfo.FindSameFunctionInlinee(inlinee); + + if (matchingInlinee != null) { + line = matchingInlinee.Line; + } + else { + continue; // Don't count the instr. if not part of the inlinee. + } + } + + result.SourceLineWeight.AccumulateValue(line, pair.Value); + firstLine = Math.Min(line, firstLine); + lastLine = Math.Max(line, lastLine); + } + } + + if (profile.HasPerformanceCounters) { + foreach (var pair in profile.InstructionCounters) { + long rva = pair.Key + profile.FunctionDebugInfo.RVA; + var lineInfo = debugInfo.FindSourceLineByRVA(rva, inlinee != null); + + if (!lineInfo.IsUnknown) { + int line = lineInfo.Line; + + if (inlinee != null) { + // Map the instruction back to the function that got inlined + // at the call site, if filtering by an inlinee is used. + var matchingInlinee = lineInfo.FindSameFunctionInlinee(inlinee); + + if (matchingInlinee != null) { + line = matchingInlinee.Line; + } + else { + continue; // Don't count the instr. if not part of the inlinee. + } + } + + result.SourceLineCounters.AccumulateValue(line, pair.Value); + firstLine = Math.Min(line, firstLine); + lastLine = Math.Max(line, lastLine); + } + + result.FunctionCountersValue.Add(pair.Value); + } + } + + result.FirstLineIndex = firstLine; + result.LastLineIndex = lastLine; + return result; + } +} + +public class FunctionProcessingResult { + public FunctionProcessingResult(int capacity = 0) { + SampledElements = new List<(IRElement, TimeSpan)>(capacity); + BlockSampledElementsMap = new Dictionary(capacity); + BlockSampledElements = new List<(BlockIR, TimeSpan)>(); + CounterElements = new List<(IRElement, PerformanceCounterValueSet)>(capacity); + FunctionCountersValue = new PerformanceCounterValueSet(); + } + + public List<(IRElement, TimeSpan)> SampledElements { get; set; } + public Dictionary BlockSampledElementsMap { get; set; } + public List<(BlockIR, TimeSpan)> BlockSampledElements { get; set; } + public List<(IRElement, PerformanceCounterValueSet)> CounterElements { get; set; } + public List<(BlockIR, PerformanceCounterValueSet)> BlockCounterElements { get; set; } + public PerformanceCounterValueSet FunctionCountersValue { get; set; } + + public double ScaleCounterValue(long value, PerformanceCounter counter) { + long total = FunctionCountersValue.FindCounterValue(counter); + return total > 0 ? value / (double)total : 0; + } + + public void SortSampledElements() { + BlockSampledElements.Sort((a, b) => b.Item2.CompareTo(a.Item2)); + SampledElements.Sort((a, b) => b.Item2.CompareTo(a.Item2)); + } + + public SampledElementsToLineMapping BuildSampledElementsToLineMapping(FunctionProfileData profile, + ParsedIRTextSection parsedSection) { + var elementMap = BuildElementToWeightMap(); + var counterMap = BuildElementToCounterMap(); + var instrToLineMap = new SampledElementsToLineMapping(); + + // Build groups of instructions mapping to the same source line, + // with their associated sampled weight and perf. counters. + foreach (var instr in parsedSection.Function.AllInstructions) { + var tag = instr.GetTag(); + + if (tag != null) { + var weight = elementMap.GetValueOr(instr, TimeSpan.Zero); + var counters = counterMap.GetValueOrNull(instr); + var list = instrToLineMap.SampledElements.GetOrAddValue(tag.Line); + list.Add((instr, (weight, counters))); + } + } + + // Sort elements in each line group by text offset. + foreach (var linePair in instrToLineMap.SampledElements) { + linePair.Value.Sort((a, b) => a.Item1.TextLocation.CompareTo(b.Item1.TextLocation)); + } + + return instrToLineMap; + } + + public Dictionary BuildElementToWeightMap() { + var map = new Dictionary(); + + foreach (var pair in SampledElements) { + map[pair.Item1] = pair.Item2; + } + + return map; + } + + public Dictionary BuildElementToCounterMap() { + var map = new Dictionary(); + + foreach (var pair in CounterElements) { + map[pair.Item1] = pair.Item2; + } + + return map; + } + + // Mapping from a source line number to a list + // of associated instructions and their weight and/or perf. counters. + public record SampledElementsToLineMapping( + Dictionary> SampledElements) { + public SampledElementsToLineMapping() : + this(new Dictionary>()) { + } + } +} + +public class SourceLineProcessingResult { + public SourceLineProcessingResult() { + SourceLineWeight = new Dictionary(); + SourceLineCounters = new Dictionary(); + FunctionCountersValue = new PerformanceCounterValueSet(); + } + + public Dictionary SourceLineWeight { get; set; } // Line number mapping + public Dictionary SourceLineCounters { get; set; } // Line number mapping + public PerformanceCounterValueSet FunctionCountersValue { get; set; } + public List<(int LineNumber, TimeSpan Weight)> SourceLineWeightList => SourceLineWeight.ToList(); + public int FirstLineIndex { get; set; } + public int LastLineIndex { get; set; } +} diff --git a/src/ProfileExplorerCore/Profile/Data/ManagedRawProfileData.cs b/src/ProfileExplorerCore/Profile/Data/ManagedRawProfileData.cs index 3c6f700c..c081126c 100644 --- a/src/ProfileExplorerCore/Profile/Data/ManagedRawProfileData.cs +++ b/src/ProfileExplorerCore/Profile/Data/ManagedRawProfileData.cs @@ -13,7 +13,7 @@ public class ManagedRawProfileData { public Dictionary moduleDebugInfoMap_; public Dictionary moduleImageMap_; public Dictionary managedMethodIdMap_; - public Dictionary managedMethodCodeMap_; + public Dictionary managedMethodCodeMap_; public Dictionary managedMethodsMap_; public List managedMethods_; public List<(long ModuleId, ManagedMethodMapping Mapping)> patchedMappings_; @@ -24,7 +24,7 @@ public ManagedRawProfileData() { moduleImageMap_ = new Dictionary(); managedMethods_ = new List(); managedMethodsMap_ = new Dictionary(); - managedMethodCodeMap_ = new Dictionary(); + managedMethodCodeMap_ = new Dictionary(); managedMethodIdMap_ = new Dictionary(); patchedMappings_ = new List<(long ModuleId, ManagedMethodMapping Mapping)>(); } diff --git a/src/ProfileExplorerCore/Profile/Data/ProfileData.cs b/src/ProfileExplorerCore/Profile/Data/ProfileData.cs index b3fcd077..1e88778e 100644 --- a/src/ProfileExplorerCore/Profile/Data/ProfileData.cs +++ b/src/ProfileExplorerCore/Profile/Data/ProfileData.cs @@ -19,7 +19,8 @@ public ProfileData(TimeSpan profileWeight, TimeSpan totalWeight) : this() { public ProfileData() { ProfileWeight = TimeSpan.Zero; - FunctionProfiles = new Dictionary(); + FunctionProfiles = new Dictionary(); + FunctionResolver = new Dictionary(); ModuleWeights = new Dictionary(); PerformanceCounters = new Dictionary(); ModuleCounters = new Dictionary(); @@ -33,7 +34,10 @@ public ProfileData() { public TimeSpan ProfileWeight { get; set; } public TimeSpan TotalWeight { get; set; } - public Dictionary FunctionProfiles { get; set; } + public Dictionary FunctionProfiles { get; set; } + // Maps the neutral function identity back to its IRTextFunction (for UI/document navigation). + // Populated at the single add-path (GetOrCreateFunctionProfile). + public Dictionary FunctionResolver { get; set; } public Dictionary ModuleWeights { get; set; } public Dictionary ModuleCounters { get; set; } public Dictionary PerformanceCounters { get; set; } @@ -138,7 +142,11 @@ public double ScaleModuleWeight(TimeSpan weight) { } public FunctionProfileData GetFunctionProfile(IRTextFunction function) { - return FunctionProfiles.TryGetValue(function, out var profile) ? profile : null; + return GetFunctionProfile(Id(function)); + } + + public FunctionProfileData GetFunctionProfile(ProfileFunctionId functionId) { + return FunctionProfiles.TryGetValue(functionId, out var profile) ? profile : null; } public bool HasFunctionProfile(IRTextFunction function) { @@ -147,22 +155,51 @@ public bool HasFunctionProfile(IRTextFunction function) { public FunctionProfileData GetOrCreateFunctionProfile(IRTextFunction function, FunctionDebugInfo debugInfo) { + var id = Id(function); ref var funcProfile = - ref CollectionsMarshal.GetValueRefOrAddDefault(FunctionProfiles, function, out bool exists); + ref CollectionsMarshal.GetValueRefOrAddDefault(FunctionProfiles, id, out bool exists); if (!exists) { funcProfile = new FunctionProfileData(debugInfo); + FunctionResolver[id] = function; // Remember the IRTextFunction for navigation/resolution. } return funcProfile; } + public IRTextFunction ResolveFunction(ProfileFunctionId functionId) { + return FunctionResolver.GetValueOrNull(functionId); + } + + /// + /// Registers the neutral identity -> IRTextFunction mapping used for UI/document navigation. + /// Call single-threaded (FunctionResolver is a plain dictionary). Idempotent. + /// + public void RegisterFunction(IRTextFunction function) { + if (function != null) { + FunctionResolver[Id(function)] = function; + } + } + public List<(IRTextFunction, FunctionProfileData)> GetSortedFunctions() { - var list = FunctionProfiles.ToList(); + var list = new List<(IRTextFunction, FunctionProfileData)>(FunctionProfiles.Count); + + foreach (var pair in FunctionProfiles) { + var func = ResolveFunction(pair.Key); + + if (func != null) { + list.Add((func, pair.Value)); + } + } + list.Sort((a, b) => -a.Item2.ExclusiveWeight.CompareTo(b.Item2.ExclusiveWeight)); return list; } + private static ProfileFunctionId Id(IRTextFunction function) { + return function != null ? new ProfileFunctionId(function.ModuleName, function.Name) : default; + } + public void AddThreads(IEnumerable threads) { foreach (var thread in threads) { Threads[thread.ThreadId] = thread; @@ -211,6 +248,7 @@ public ProcessingResult FilterFunctionProfile(ProfileSampleFilter filter) { //? while the rest is more like a processing result similar to FuncProfileData var currentProfile = new ProcessingResult { FunctionProfiles = FunctionProfiles, + FunctionResolver = FunctionResolver, CallTree = CallTree, ModuleWeights = ModuleWeights, ProfileWeight = ProfileWeight, @@ -220,7 +258,8 @@ public ProcessingResult FilterFunctionProfile(ProfileSampleFilter filter) { CallTree?.ResetTags(); ModuleWeights = new Dictionary(); - FunctionProfiles = new Dictionary(); + FunctionProfiles = new Dictionary(); + FunctionResolver = new Dictionary(); ProfileWeight = TimeSpan.Zero; TotalWeight = TimeSpan.Zero; @@ -229,6 +268,7 @@ public ProcessingResult FilterFunctionProfile(ProfileSampleFilter filter) { ProfileWeight = profile.ProfileWeight; TotalWeight = profile.TotalWeight; FunctionProfiles = profile.FunctionProfiles; + FunctionResolver = profile.FunctionResolver; CallTree = profile.CallTree; Filter = filter; return currentProfile; @@ -237,6 +277,7 @@ public ProcessingResult FilterFunctionProfile(ProfileSampleFilter filter) { public ProcessingResult RestorePreviousProfile(ProcessingResult previousProfile) { var currentProfile = new ProcessingResult { FunctionProfiles = FunctionProfiles, + FunctionResolver = FunctionResolver, CallTree = CallTree, ModuleWeights = ModuleWeights, ProfileWeight = ProfileWeight, @@ -248,6 +289,7 @@ public ProcessingResult RestorePreviousProfile(ProcessingResult previousProfile) ProfileWeight = previousProfile.ProfileWeight; TotalWeight = previousProfile.TotalWeight; FunctionProfiles = previousProfile.FunctionProfiles; + FunctionResolver = previousProfile.FunctionResolver; CallTree = previousProfile.CallTree; Filter = previousProfile.Filter; return currentProfile; @@ -334,7 +376,8 @@ public ThreadSampleRanges ComputeThreadSampleRanges() { public class ProcessingResult { public ProfileSampleFilter Filter { get; set; } - public Dictionary FunctionProfiles { get; set; } + public Dictionary FunctionProfiles { get; set; } + public Dictionary FunctionResolver { get; set; } public ProfileCallTree CallTree { get; set; } public Dictionary ModuleWeights { get; set; } public TimeSpan ProfileWeight { get; set; } diff --git a/src/ProfileExplorerCore/Profile/Data/ProfileModuleBuilder.cs b/src/ProfileExplorerCore/Profile/Data/ProfileModuleBuilder.cs index 1f8b9016..d5c7215b 100644 --- a/src/ProfileExplorerCore/Profile/Data/ProfileModuleBuilder.cs +++ b/src/ProfileExplorerCore/Profile/Data/ProfileModuleBuilder.cs @@ -56,6 +56,17 @@ public ProfileModuleBuilder(ProfileDataReport report, ICompilerInfoProvider comp public bool Initialized { get; set; } public bool IsManaged { get; set; } + /// + /// Registers every IRTextFunction this builder has created into the profile's neutral-identity + /// resolver, so call-tree nodes (keyed by ProfileFunctionId) can resolve back for navigation. + /// Must be called single-threaded after profile processing completes. + /// + public void RegisterFunctionsInto(ProfileData profileData) { + foreach (var pair in functionMap_.Values) { + profileData.RegisterFunction(pair.Item1); + } + } + public async Task Initialize(BinaryFileDescriptor binaryInfo, SymbolFileSourceSettings symbolSettings, IDebugInfoProvider debugInfo, diff --git a/src/ProfileExplorerCore/Profile/Data/RawProfileData.cs b/src/ProfileExplorerCore/Profile/Data/RawProfileData.cs index b1416eda..561e2a58 100644 --- a/src/ProfileExplorerCore/Profile/Data/RawProfileData.cs +++ b/src/ProfileExplorerCore/Profile/Data/RawProfileData.cs @@ -156,7 +156,7 @@ public void AddManagedMethodMapping(long moduleId, long methodId, long rejitId, public void AddManagedMethodCode(long functionId, int rejitId, int processId, long address, int codeSize, byte[] codeBytes) { - var info = new DotNetDebugInfoProvider.MethodCode(address, codeSize, codeBytes); + var info = new MethodCode(address, codeSize, codeBytes); var data = GetOrCreateManagedData(processId); data.managedMethodCodeMap_[new ManagedMethodId(functionId, rejitId)] = info; } @@ -165,7 +165,7 @@ public void AddManagedMethodCallTarget(long functionId, int rejitId, int process var data = GetOrCreateManagedData(processId); if (data.managedMethodCodeMap_.TryGetValue(new ManagedMethodId(functionId, rejitId), out var code)) { - code.CallTargets.Add(new DotNetDebugInfoProvider.AddressNamePair(address, name)); + code.CallTargets.Add(new AddressNamePair(address, name)); } } diff --git a/src/ProfileExplorerCore/Profile/Data/ResolvedProfileStack.cs b/src/ProfileExplorerCore/Profile/Data/ResolvedProfileStack.cs index 39ed2472..498120f8 100644 --- a/src/ProfileExplorerCore/Profile/Data/ResolvedProfileStack.cs +++ b/src/ProfileExplorerCore/Profile/Data/ResolvedProfileStack.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Runtime.InteropServices; using ProfileExplorer.Core.Binary; +using ProfileExplorer.Core.Profile.CallTree; using ProfileExplorer.Core.Profile.ETW; namespace ProfileExplorer.Core.Profile.Data; @@ -77,7 +78,7 @@ public ResolvedProfileStackFrame32(uint frameRva, ResolvedProfileStackFrameDetai } [StructLayout(LayoutKind.Sequential, Pack = 1)] -public sealed class ResolvedProfileStack { +public sealed class ResolvedProfileStack : IResolvedCallStack { // Used to deduplicate stack frames for the same function running in the same context. public static ConcurrentDictionary uniqueFrames_ = new(); @@ -105,6 +106,16 @@ public ResolvedProfileStack(int frameCount, ProfileContext context) { public ProfileContext Context { get; set; } public int FrameCount => StackFrames.Count; + // IResolvedCallStack: neutral view consumed by ProfileCallTree.UpdateCallTree. + int IResolvedCallStack.ThreadId => Context.ThreadId; + + ResolvedCallStackFrame IResolvedCallStack.GetFrame(int index) { + var frame = StackFrames[index]; + var details = frame.FrameDetails; + return new ResolvedCallStackFrame(frame.FrameRVA, details.DebugInfo, details.FunctionId, + details.IsKernelCode, details.IsManagedCode); + } + public void AddFrame(IRTextFunction function, long frameIP, long frameRVA, int frameIndex, ResolvedProfileStackFrameKey frameDetails, ProfileStack stack, int pointerSize) { // Deduplicate the frame. @@ -140,6 +151,7 @@ public ResolvedProfileStackFrameDetails(FunctionDebugInfo debugInfo, IRTextFunct ProfileImage image, bool isManagedCode) { DebugInfo = debugInfo; Function = function; + FunctionId = function != null ? new ProfileFunctionId(function.ModuleName, function.Name) : default; Image = image; IsManagedCode = isManagedCode; } @@ -147,6 +159,9 @@ public ResolvedProfileStackFrameDetails(FunctionDebugInfo debugInfo, IRTextFunct private ResolvedProfileStackFrameDetails() { } public FunctionDebugInfo DebugInfo { get; set; } public IRTextFunction Function { get; set; } + // Neutral (module, name) identity precomputed from Function so the call-tree build + // path can remain free of IRTextFunction. + public ProfileFunctionId FunctionId { get; set; } public ProfileImage Image { get; set; } public bool IsKernelCode { get; set; } public bool IsManagedCode { get; set; } diff --git a/src/ProfileExplorerCore/Profile/ETW/ETWProfileDataProvider.cs b/src/ProfileExplorerCore/Profile/ETW/ETWProfileDataProvider.cs index a179107f..bd30c740 100644 --- a/src/ProfileExplorerCore/Profile/ETW/ETWProfileDataProvider.cs +++ b/src/ProfileExplorerCore/Profile/ETW/ETWProfileDataProvider.cs @@ -80,6 +80,16 @@ private sealed class UnknownModuleState(ProfileImage image, ProfileData profileD public ProfileImage Image { get; } = image; public ILoadedDocument Document => document_; + /// + /// Registers all synthetic per-thread functions into the profile's neutral-identity resolver, + /// so call-tree nodes for unmapped/JIT frames can resolve back to their IRTextFunction. + /// + public void RegisterFunctionsInto(ProfileData profileData) { + foreach (var pair in threadFunctions_.Values) { + profileData.RegisterFunction(pair.Function); + } + } + /// /// Gets or creates a synthetic function for samples with unmapped IPs /// on a specific thread. @@ -888,6 +898,9 @@ private ILoadedDocument FindSessionDocuments(string imageName, out List(); foreach (ProfileModuleBuilder module in imageModuleMap_.Values) { + // Register all created functions for neutral-identity -> IRTextFunction resolution (navigation). + module.RegisterFunctionsInto(profileData_); + var moduleDoc = module.ModuleDocument; if (moduleDoc == null) { @@ -917,6 +930,7 @@ private ILoadedDocument FindSessionDocuments(string imageName, out List> filterStackFuncts_; + private List> filterStackFuncts_; private List chunks_; private FunctionProfileProcessor(ProfileSampleFilter filter) { @@ -22,7 +22,7 @@ private FunctionProfileProcessor(ProfileSampleFilter filter) { if (filter_ != null && filter_.FunctionInstances is {Count: > 0}) { // Compute once the list of functions on the path // from call tree root to the function instance node. - filterStackFuncts_ = new List>(); + filterStackFuncts_ = new List>(); foreach (var instance in filter_.FunctionInstances) { if (instance is ProfileCallTreeGroupNode groupNode) { @@ -40,10 +40,10 @@ private FunctionProfileProcessor(ProfileSampleFilter filter) { public ProfileData Profile { get; } = new(); private void AddInstanceFilter(ProfileCallTreeNode node) { - var stackFuncts = new List(); + var stackFuncts = new List(); while (node != null) { - stackFuncts.Add(node.Function); + stackFuncts.Add(node.FunctionId); node = node.Caller; } @@ -90,7 +90,7 @@ protected override void ProcessSample(ref ProfileSample sample, ResolvedProfileS for (int i = 0; i < stackFuncts.Count; i++) { if (stackFuncts[i] != - stack.StackFrames[stack.FrameCount - i - 1].FrameDetails.Function) { + stack.StackFrames[stack.FrameCount - i - 1].FrameDetails.Function.ToProfileId()) { isMatch = false; break; } @@ -129,17 +129,19 @@ protected override void ProcessSample(ref ProfileSample sample, ResolvedProfileS long funcRva = frameDetails.DebugInfo.RVA; long frameRva = resolvedFrame.FrameRVA; var textFunction = frameDetails.Function; + var funcId = new ProfileFunctionId(textFunction?.ModuleName, textFunction?.Name); ref var funcProfile = - ref CollectionsMarshal.GetValueRefOrAddDefault(data.FunctionProfiles, frameDetails.Function, out bool exists); + ref CollectionsMarshal.GetValueRefOrAddDefault(data.FunctionProfiles, funcId, out bool exists); if (!exists) { funcProfile = new FunctionProfileData(frameDetails.DebugInfo); + data.FunctionResolver[funcId] = textFunction; } long offset = frameRva - funcRva; // Don't count the inclusive time for recursive functions multiple times. - if (data.StackFunctions.Add(textFunction)) { + if (data.StackFunctions.Add(funcId)) { funcProfile.AddInstructionSample(offset, sample.Weight); funcProfile.Weight += sample.Weight; @@ -209,6 +211,7 @@ protected override void Complete() { lock (Profile) { Profile.FunctionProfiles = chunks_[0].FunctionProfiles; + Profile.FunctionResolver = chunks_[0].FunctionResolver; } } } @@ -227,13 +230,18 @@ ref CollectionsMarshal.GetValueRefOrAddDefault(destChunk.FunctionProfiles, pair. existingValue = pair.Value; } } + + foreach (var pair in sourceChunk.FunctionResolver) { + destChunk.FunctionResolver[pair.Key] = pair.Value; + } } private class ChunkData { public HashSet StackModules = new(); - public HashSet StackFunctions = new(); + public HashSet StackFunctions = new(); public Dictionary ModuleWeights = new(); - public Dictionary FunctionProfiles = new(); + public Dictionary FunctionProfiles = new(); + public Dictionary FunctionResolver = new(); public TimeSpan TotalWeight = TimeSpan.Zero; public TimeSpan ProfileWeight = TimeSpan.Zero; } diff --git a/src/ProfileExplorerCore/Profile/Processing/FunctionSamplesProcessor.cs b/src/ProfileExplorerCore/Profile/Processing/FunctionSamplesProcessor.cs index d8a0815f..69f20df9 100644 --- a/src/ProfileExplorerCore/Profile/Processing/FunctionSamplesProcessor.cs +++ b/src/ProfileExplorerCore/Profile/Processing/FunctionSamplesProcessor.cs @@ -61,7 +61,7 @@ protected override void ProcessSample(ref ProfileSample sample, ResolvedProfileS continue; } - if (stackFrame.FrameDetails.Function.Equals(currentNode.Function)) { + if (currentNode.FunctionId == stackFrame.FrameDetails.Function.ToProfileId()) { // Continue checking if the callers show up on the stack trace // to make the search context-sensitive. match = true; diff --git a/src/ProfileExplorerCore/Profile/Processing/ProfileSampleFilter.cs b/src/ProfileExplorerCore/Profile/Processing/ProfileSampleFilter.cs index 1570ae87..ca0aa361 100644 --- a/src/ProfileExplorerCore/Profile/Processing/ProfileSampleFilter.cs +++ b/src/ProfileExplorerCore/Profile/Processing/ProfileSampleFilter.cs @@ -93,7 +93,7 @@ public ProfileSampleFilter CloneForCallTarget(IRTextFunction targetFunc) { foreach (var instance in FunctionInstances) { // Try to add the instance node that is a child // of the current instance in the profile filter. - var targetInstance = instance.FindChildNode(targetFunc); + var targetInstance = instance.FindChildNode(targetFunc.ToProfileId()); if (targetInstance != null) { targetFilter.AddInstance(targetInstance); diff --git a/src/ProfileExplorerCore/Profile/ProfileFunctionIdExtensions.cs b/src/ProfileExplorerCore/Profile/ProfileFunctionIdExtensions.cs new file mode 100644 index 00000000..e0cd3dc6 --- /dev/null +++ b/src/ProfileExplorerCore/Profile/ProfileFunctionIdExtensions.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +namespace ProfileExplorer.Core.Profile; + +/// +/// Helpers to derive the neutral from an IRTextFunction. +/// Kept on the PE side because itself is IRTextFunction-free +/// (so it can live in the library alongside the migrated profiling model). +/// +public static class ProfileFunctionIdExtensions { + public static ProfileFunctionId ToProfileId(this IRTextFunction function) { + return function != null ? new ProfileFunctionId(function.ModuleName, function.Name) : default; + } +} diff --git a/src/ProfileExplorerCore/ProfileExplorerCore.csproj b/src/ProfileExplorerCore/ProfileExplorerCore.csproj index c96ac884..f5d6e226 100644 --- a/src/ProfileExplorerCore/ProfileExplorerCore.csproj +++ b/src/ProfileExplorerCore/ProfileExplorerCore.csproj @@ -26,4 +26,8 @@ + + + + diff --git a/src/ProfileExplorerCore/Providers/INameProvider.cs b/src/ProfileExplorerCore/Providers/INameProvider.cs index c13214bb..ba2919f9 100644 --- a/src/ProfileExplorerCore/Providers/INameProvider.cs +++ b/src/ProfileExplorerCore/Providers/INameProvider.cs @@ -4,8 +4,6 @@ namespace ProfileExplorer.Core.Providers; -public delegate string FunctionNameFormatter(string name); - [Flags] public enum FunctionNameDemanglingOptions { Default = 0, diff --git a/src/ProfileExplorerCore/Utilities/ExtensionMethods.cs b/src/ProfileExplorerCore/Utilities/ExtensionMethods.cs index 605e6bef..6ba2ac04 100644 --- a/src/ProfileExplorerCore/Utilities/ExtensionMethods.cs +++ b/src/ProfileExplorerCore/Utilities/ExtensionMethods.cs @@ -346,6 +346,14 @@ public static string FormatFunctionName(this ProfileCallTreeNode node, ISession return FormatName(node.FunctionName, session.CompilerInfo.NameProvider.FormatFunctionName, maxLength); } + /// + /// Resolves the neutral call-tree node identity back to its IRTextFunction for document navigation, + /// using the session's profile resolver. Returns null if no IRTextFunction is associated. + /// + public static IRTextFunction ResolveFunction(this ProfileCallTreeNode node, ISession session) { + return node != null ? session?.ProfileData?.ResolveFunction(node.FunctionId) : null; + } + private static string FormatName(string name, FunctionNameFormatter nameFormatter, int maxLength) { if (string.IsNullOrEmpty(name)) { return name; diff --git a/src/ProfileExplorerCoreTests/ASMIRSectionReaderTests.cs b/src/ProfileExplorerCoreTests/ASMIRSectionReaderTests.cs index 1c65e266..104ac804 100644 --- a/src/ProfileExplorerCoreTests/ASMIRSectionReaderTests.cs +++ b/src/ProfileExplorerCoreTests/ASMIRSectionReaderTests.cs @@ -13,12 +13,13 @@ namespace ProfileExplorer.CoreTests; public class ASMIRSectionReaderTests { [TestMethod] public void GenerateSummary_CreatesOneTextInfoPerSection() { + // Build the input with explicit CRLF so the test is independent of this source file's + // line endings (a verbatim literal would otherwise capture LF or CRLF based on checkout). string data = - @"x264_plane_copy_c: - str lr,[sp] -x264_frame_init_lowres: - sub sp,sp,#0x40 -"; + "x264_plane_copy_c:\r\n" + + " str lr,[sp]\r\n" + + "x264_frame_init_lowres:\r\n" + + " sub sp,sp,#0x40\r\n"; byte[] bytes = Encoding.UTF8.GetBytes(data); var (_, capturedText, _1) = GenerateSummaryFor(bytes); Assert.AreEqual(2, capturedText.Count); @@ -31,11 +32,10 @@ public void GenerateSummary_CreatesOneTextInfoPerSection() { [TestMethod] public void GenerateSummary_GivesNPlus1ProgressUpdates() { string data = - @"x264_plane_copy_c: - str lr,[sp] -x264_frame_init_lowres: - sub sp,sp,#0x40 -"; + "x264_plane_copy_c:\r\n" + + " str lr,[sp]\r\n" + + "x264_frame_init_lowres:\r\n" + + " sub sp,sp,#0x40\r\n"; byte[] bytes = Encoding.UTF8.GetBytes(data); var (_, _1, capturedProgressInfo) = GenerateSummaryFor(bytes); // 1 progress for each section + 1 "done" @@ -51,11 +51,10 @@ public void GenerateSummary_GivesNPlus1ProgressUpdates() { [TestMethod] public void GenerateSummary_CreatesCorrectIROutputForFunctions() { string data = - @"x264_plane_copy_c: - str lr,[sp] -x264_frame_init_lowres: - sub sp,sp,#0x40 -"; + "x264_plane_copy_c:\r\n" + + " str lr,[sp]\r\n" + + "x264_frame_init_lowres:\r\n" + + " sub sp,sp,#0x40\r\n"; byte[] bytes = Encoding.UTF8.GetBytes(data); var (summary, _, _1) = GenerateSummaryFor(bytes); Assert.AreEqual(2, summary.Functions.Count); diff --git a/src/ProfileExplorerCoreTests/ETWUnmappedFrameResolutionTests.cs b/src/ProfileExplorerCoreTests/ETWUnmappedFrameResolutionTests.cs index c5acf5a1..59f22b89 100644 --- a/src/ProfileExplorerCoreTests/ETWUnmappedFrameResolutionTests.cs +++ b/src/ProfileExplorerCoreTests/ETWUnmappedFrameResolutionTests.cs @@ -221,13 +221,13 @@ public void FunctionProfileProcessor_ExclusiveWeightGoesToLeafNotCaller() { var result = FunctionProfileProcessor.Compute(profileData, new ProfileSampleFilter()); Assert.AreEqual(TimeSpan.FromMilliseconds(20), - result.FunctionProfiles[unknownFunc].ExclusiveWeight, + result.GetFunctionProfile(unknownFunc).ExclusiveWeight, "Leaf (JIT) should get exclusive weight"); Assert.AreEqual(TimeSpan.Zero, - result.FunctionProfiles[mainFunc].ExclusiveWeight, + result.GetFunctionProfile(mainFunc).ExclusiveWeight, "Caller (main) should NOT get exclusive weight — it's not the leaf"); Assert.AreEqual(TimeSpan.FromMilliseconds(20), - result.FunctionProfiles[mainFunc].Weight, + result.GetFunctionProfile(mainFunc).Weight, "Caller (main) should get inclusive weight"); } @@ -254,7 +254,7 @@ public void FunctionProfileProcessor_AllUnmappedStack_WeightPreserved() { Assert.AreEqual(TimeSpan.FromMilliseconds(5), result.ProfileWeight, "Profile weight must include unmapped-code-only samples"); Assert.AreEqual(TimeSpan.FromMilliseconds(5), - result.FunctionProfiles[unknownFunc].ExclusiveWeight); + result.GetFunctionProfile(unknownFunc).ExclusiveWeight); } [TestMethod] diff --git a/src/ProfileExplorerCoreTests/EndToEndWorkflowTests.cs b/src/ProfileExplorerCoreTests/EndToEndWorkflowTests.cs index d9a505c6..a2e857ef 100644 --- a/src/ProfileExplorerCoreTests/EndToEndWorkflowTests.cs +++ b/src/ProfileExplorerCoreTests/EndToEndWorkflowTests.cs @@ -527,12 +527,12 @@ private Dictionary> ExtractFunctionBaselineD // Group by function to avoid duplicates and combine weights var functionGroups = allFunctionNodes - .GroupBy(node => node.Function) + .GroupBy(node => node.FunctionId) .ToList(); foreach (var group in functionGroups) { - var function = group.Key; - if (function == null) continue; + var functionId = group.Key; + if (functionId.IsUnknown) continue; // Sum up weights from all instances of this function var totalWeight = TimeSpan.Zero; @@ -554,9 +554,9 @@ private Dictionary> ExtractFunctionBaselineD double totalTimePercentage = profileData.ScaleFunctionWeight(totalWeight) * 100; var functionEntry = new FunctionBaselineEntry { - Name = function.Name ?? "Unknown", + Name = functionId.FunctionName, Address = firstNode.FunctionDebugInfo?.RVA.ToString("X") ?? "Unknown", - Module = function.ModuleName ?? "Unknown", + Module = functionId.ModuleName, SelfTimePercentage = selfTimePercentage, SelfTimeMs = totalExclusiveWeight.TotalMilliseconds, TotalTimePercentage = totalTimePercentage, diff --git a/src/ProfileExplorerUI/MainWindowProfiling.cs b/src/ProfileExplorerUI/MainWindowProfiling.cs index 97f0a722..faad088b 100644 --- a/src/ProfileExplorerUI/MainWindowProfiling.cs +++ b/src/ProfileExplorerUI/MainWindowProfiling.cs @@ -183,7 +183,7 @@ public async Task OpenProfileFunction(ProfileCallTreeNode node, OpenSectio return false; } - return await OpenProfileFunction(node.Function, openMode, instanceFilter, targetDocument); + return await OpenProfileFunction(node.ResolveFunction(this), openMode, instanceFilter, targetDocument); } public async Task OpenProfileFunction(IRTextFunction function, OpenSectionKind openMode, @@ -204,7 +204,7 @@ public async Task SwitchActiveProfileFunction(ProfileCallTreeNode node) { return false; } - await SwitchActiveFunction(node.Function); + await SwitchActiveFunction(node.ResolveFunction(this)); return true; } @@ -213,7 +213,7 @@ public async Task OpenProfileSourceFile(ProfileCallTreeNode node, ProfileS return false; } - return await OpenProfileSourceFile(node.Function, profileFilter); + return await OpenProfileSourceFile(node.ResolveFunction(this), profileFilter); } public async Task OpenProfileSourceFile(IRTextFunction function, ProfileSampleFilter profileFilter = null) { @@ -357,12 +357,12 @@ public async Task ProfileFunctionSelected(ProfileCallTreeNode node, ToolPa } if (sourcePanelKind != ToolPanelKind.Section) { - await SwitchActiveFunction(node.Function, false); + await SwitchActiveFunction(node.ResolveFunction(this), false); } if (sourcePanelKind != ToolPanelKind.FlameGraph) { if (FindPanel(ToolPanelKind.FlameGraph) is FlameGraphPanel flameGraphPanel) { - await flameGraphPanel.SelectFunction(node.Function, false); + await flameGraphPanel.SelectFunction(node.ResolveFunction(this), false); } } @@ -374,7 +374,7 @@ public async Task ProfileFunctionSelected(ProfileCallTreeNode node, ToolPa if (sourcePanelKind != ToolPanelKind.CallerCallee) { if (FindPanel(ToolPanelKind.CallerCallee) is CallTreePanel callerCalleePanel) { //? TODO: Make it path-sensitive (show exact instance, not combined?) - await callerCalleePanel?.DisplayProfileCallerCalleeTree(node.Function); + await callerCalleePanel?.DisplayProfileCallerCalleeTree(node.ResolveFunction(this)); } } @@ -631,7 +631,7 @@ private List FindCallTreeNodesForSamples(HashSet GetFunctionAssemblyAsync(string functionName) foreach (var kvp in profileData.FunctionProfiles) { var func = kvp.Key; - string funcName = func.Name; + string funcName = func.FunctionName; string moduleName = func.ModuleName; // Match by function name @@ -916,7 +916,8 @@ private async Task GetFunctionAssemblyAsync(string functionName) ProfileExplorer.Core.Utilities.DiagnosticLogger.LogInfo($"[MCP] Found function in profile: {funcName} (module: {moduleName})"); // Create a temporary IRTextFunctionEx wrapper for the found function // We need to get the actual extension from the SectionPanel if possible - var funcExtension = sectionPanel.MainPanel.GetFunctionExtension(func); + var irFunc = profileData.ResolveFunction(func); + var funcExtension = irFunc != null ? sectionPanel.MainPanel.GetFunctionExtension(irFunc) : null; if (funcExtension != null) { return funcExtension; diff --git a/src/ProfileExplorerUI/Profile/CallTreeNodePanel.xaml.cs b/src/ProfileExplorerUI/Profile/CallTreeNodePanel.xaml.cs index ccacbef6..7828e780 100644 --- a/src/ProfileExplorerUI/Profile/CallTreeNodePanel.xaml.cs +++ b/src/ProfileExplorerUI/Profile/CallTreeNodePanel.xaml.cs @@ -21,6 +21,7 @@ using ProfileExplorer.UI.Controls; using PlotCommands = OxyPlot.PlotCommands; using VerticalAlignment = System.Windows.VerticalAlignment; +using ProfileExplorer.Core.Profile; using ProfileExplorer.Core.Profile.CallTree; using ProfileExplorer.Core.Profile.Data; using ProfileExplorer.Core.Profile.Processing; @@ -253,7 +254,7 @@ public bool EnableSingleNodeActions { if (((FrameworkElement)obj).DataContext is ThreadListItem threadItem && instancesNode_.CallTreeNode != null) { var filter = new ProfileSampleFilter(threadItem.ThreadId); - await IRDocumentPopupInstance.ShowPreviewPopup(instancesNode_.CallTreeNode.Function, "", + await IRDocumentPopupInstance.ShowPreviewPopup(instancesNode_.CallTreeNode.ResolveFunction(Session), "", ThreadsExpander, Session, filter); } }); @@ -418,11 +419,11 @@ private async Task SetupInstanceInfo(ProfileCallTreeNode node) { instanceNodes_ = await Task.Run(() => { // For a node group, combine the instances for each node. var instanceNodes = new List(); - var handledFuncts = new HashSet(); + var handledFuncts = new HashSet(); foreach (var n in groupNode.Nodes) { - if (handledFuncts.Add(n.Function)) { - instanceNodes.AddRange(callTree.GetSortedCallTreeNodes(n.Function)); + if (handledFuncts.Add(n.FunctionId)) { + instanceNodes.AddRange(callTree.GetSortedCallTreeNodes(n.FunctionId)); } } @@ -430,7 +431,7 @@ private async Task SetupInstanceInfo(ProfileCallTreeNode node) { }); } else { - instanceNodes_ = callTree.GetSortedCallTreeNodes(node.Function); + instanceNodes_ = callTree.GetSortedCallTreeNodes(node.FunctionId); } if (instanceNodes_.Count == 0) { @@ -447,7 +448,7 @@ private async Task SetupInstanceInfo(ProfileCallTreeNode node) { combinedNode = groupNode; } else { - combinedNode = await Task.Run(() => callTree.GetCombinedCallTreeNode(node.Function)); + combinedNode = await Task.Run(() => callTree.GetCombinedCallTreeNode(node.FunctionId)); } InstancesNode = SetupNodeExtension(combinedNode, Session); @@ -821,7 +822,7 @@ private void CopyFUnctionButton_OnClick(object sender, RoutedEventArgs e) { } private async void PreviewButton_OnClick(object sender, RoutedEventArgs e) { - await IRDocumentPopupInstance.ShowPreviewPopup(instancesNode_.CallTreeNode.Function, "", + await IRDocumentPopupInstance.ShowPreviewPopup(instancesNode_.CallTreeNode.ResolveFunction(Session), "", ThreadsExpander, Session); } diff --git a/src/ProfileExplorerUI/Profile/CallTreePanel.xaml.cs b/src/ProfileExplorerUI/Profile/CallTreePanel.xaml.cs index c8a39be1..ff515b59 100644 --- a/src/ProfileExplorerUI/Profile/CallTreePanel.xaml.cs +++ b/src/ProfileExplorerUI/Profile/CallTreePanel.xaml.cs @@ -94,7 +94,7 @@ public Brush ModuleBackColor { public List Children { get; set; } public long Time { get; set; } public CallTreeListItemKind Kind { get; set; } - public bool HasCallTreeNode => CallTreeNode?.Function != null; + public bool HasCallTreeNode => CallTreeNode is {HasFunction: true}; public override TimeSpan Weight => HasCallTreeNode ? CallTreeNode.Weight : TimeSpan.Zero; public override TimeSpan ExclusiveWeight => HasCallTreeNode ? CallTreeNode.ExclusiveWeight : TimeSpan.Zero; public override string ModuleName => @@ -600,7 +600,7 @@ private List GetCallTreeNodes(IRTextFunction function, Prof private ProfileCallTreeNode GetChildCallTreeNode(ProfileCallTreeNode childNode, ProfileCallTreeNode parentNode, ProfileCallTree callTree) { if (settings_.CombineInstances) { - return callTree.GetCombinedCallTreeNode(childNode.Function, parentNode); + return callTree.GetCombinedCallTreeNode(childNode.FunctionId, parentNode); } return childNode; @@ -622,7 +622,7 @@ private CallTreeListItem CreateProfileCallerCalleeTree(IRTextFunction function) foreach (var instance in nodeList) { bool isSelf = nodeList.Count == 1; string name = isSelf ? "Function" : $"Function instance {index++}"; - string funcName = Session.CompilerInfo.NameProvider.FormatFunctionName(instance.Function); + string funcName = Session.CompilerInfo.NameProvider.FormatFunctionName(instance.FunctionName); if (isSelf && !string.IsNullOrEmpty(funcName)) { name = funcName; @@ -784,7 +784,7 @@ private CallTreeListItem CreateProfileCallTreeChild(ProfileCallTreeNode node, Ca double exclusiveWeightPercentage = ComputeNodePercentage(node.ExclusiveWeight, percentageFunc); var result = new CallTreeListItem(kind, this, Session.CompilerInfo.NameProvider.FormatFunctionName) { - Function = node.Function, + Function = node.ResolveFunction(Session), ModuleName = node.ModuleName, Time = node.Weight.Ticks, CallTreeNode = node, @@ -805,7 +805,7 @@ private CallTreeListItem CreateProfileCallTreeHeader(string name, TimeSpan weigh double weightPercentage = ComputeNodePercentage(weight, percentageFunc); double exclusiveWeightPercentage = ComputeNodePercentage(exclusiveWeight, percentageFunc); return new CallTreeListItem(CallTreeListItemKind.Header, this) { - CallTreeNode = new ProfileCallTreeNode(null, null) {Weight = weight, ExclusiveWeight = exclusiveWeight}, + CallTreeNode = new ProfileCallTreeNode(null, default) {Weight = weight, ExclusiveWeight = exclusiveWeight}, Time = TimeSpan.MaxValue.Ticks - priority, FunctionName = name, Percentage = weightPercentage, diff --git a/src/ProfileExplorerUI/Profile/Document/ProfileDocumentMarker.cs b/src/ProfileExplorerUI/Profile/Document/ProfileDocumentMarker.cs index eff7b3cd..23d38c6c 100644 --- a/src/ProfileExplorerUI/Profile/Document/ProfileDocumentMarker.cs +++ b/src/ProfileExplorerUI/Profile/Document/ProfileDocumentMarker.cs @@ -463,7 +463,7 @@ private void MarkCallSites(IRDocument document, FunctionIR function, IRTextFunct } foreach (var callsite in node.CallSites.Values) { - if (!FunctionProfileData.TryFindElementForOffset(metadataTag, callsite.RVA - profile_.FunctionDebugInfo.RVA, + if (!FunctionProfileDataProcessing.TryFindElementForOffset(metadataTag, callsite.RVA - profile_.FunctionDebugInfo.RVA, irInfo_.IR, out var element)) { continue; } diff --git a/src/ProfileExplorerUI/Profile/Document/ProfilingUtils.cs b/src/ProfileExplorerUI/Profile/Document/ProfilingUtils.cs index 1bf92d99..69ee123a 100644 --- a/src/ProfileExplorerUI/Profile/Document/ProfilingUtils.cs +++ b/src/ProfileExplorerUI/Profile/Document/ProfilingUtils.cs @@ -17,6 +17,7 @@ using ProfileExplorer.UI.Profile.Document; using ProfileExplorer.Core.Session; using ProfileExplorer.Core.Profile.Processing; +using ProfileExplorer.Core.Profile; using ProfileExplorer.Core.Profile.CallTree; using ProfileExplorer.Core.Profile.Data; @@ -403,7 +404,7 @@ public static List CollectMarkedFunctions(List(); - var funcMap = new Dictionary>(); + var funcMap = new Dictionary>(); if (startNode == null) { CollectGlobalMarkedFunctions(marking, funcNodeList, funcMap, session); @@ -459,7 +460,7 @@ public static List CollectMarkedFunctions(List funcNodeList, - Dictionary> funcNodeMap, + Dictionary> funcNodeMap, ISession session) { var nameProvider = session.CompilerInfo.NameProvider; var visited = new HashSet(); @@ -474,9 +475,9 @@ private static void CollectCallTreeMarkedFunctions(FunctionMarkingStyle marking, visited.Add(node); #endif - if (marking.NameMatches(nameProvider.FormatFunctionName(node.Function.Name))) { + if (marking.NameMatches(nameProvider.FormatFunctionName(node.FunctionName))) { funcNodeList.Add(node); // Per-category list. - var instanceNodeList = funcNodeMap.GetOrAddValue(node.Function); + var instanceNodeList = funcNodeMap.GetOrAddValue(node.FunctionId); instanceNodeList.Add(node); } @@ -490,7 +491,7 @@ private static void CollectCallTreeMarkedFunctions(FunctionMarkingStyle marking, private static void CollectGlobalMarkedFunctions(FunctionMarkingStyle marking, List funcNodeList, - Dictionary> funcNodeMap, + Dictionary> funcNodeMap, ISession session) { var nameProvider = session.CompilerInfo.NameProvider; @@ -510,7 +511,7 @@ private static void CollectGlobalMarkedFunctions(FunctionMarkingStyle marking, funcNodeList.AddRange(nodeList); // Per-category list. } - funcNodeMap[func] = nodeList; + funcNodeMap[func.ToProfileId()] = nodeList; } } } @@ -877,7 +878,7 @@ private static void CreateFunctionsSubmenu(List functions, } var funcValue = new ProfileMenuItem(funcText, node.Weight.Ticks, funcWeightPercentage) { - PrefixText = node.Function.FormatFunctionName(session, 60), + PrefixText = node.FormatFunctionName(session, 60), ToolTip = tooltip, ShowPercentageBar = markerSettings.ShowPercentageBar(funcWeightPercentage), TextWeight = markerSettings.PickTextWeight(funcWeightPercentage), @@ -886,7 +887,7 @@ private static void CreateFunctionsSubmenu(List functions, var nodeItem = new MenuItem { Header = funcValue, - Tag = node.Function, + Tag = node.ResolveFunction(session), HeaderTemplate = valueTemplate, Style = menuStyle }; diff --git a/src/ProfileExplorerUI/Profile/FlameGraph/FlameGraph.cs b/src/ProfileExplorerUI/Profile/FlameGraph/FlameGraph.cs index 6ed0b24a..0840c9ad 100644 --- a/src/ProfileExplorerUI/Profile/FlameGraph/FlameGraph.cs +++ b/src/ProfileExplorerUI/Profile/FlameGraph/FlameGraph.cs @@ -6,6 +6,7 @@ using System.Windows; using System.Windows.Media; using ProfileExplorer.Core; +using ProfileExplorer.Core.Profile; using ProfileExplorer.Core.Profile.CallTree; using ProfileExplorer.Core.Profile.Data; using ProfileExplorer.Core.Providers; @@ -47,7 +48,6 @@ public FlameGraphNode(ProfileCallTreeNode callTreeNode, TimeSpan weight, int dep public bool IsHidden { get; set; } public bool HasFunction => CallTreeNode != null; public bool HasChildren => Children is {Count: > 0}; - public IRTextFunction Function => CallTreeNode?.Function; public TimeSpan StartTime { get; set; } public TimeSpan EndTime { get; set; } public TimeSpan Duration => EndTime - StartTime; @@ -323,7 +323,7 @@ private void AddSample(FlameGraphNode rootNode, ProfileSample sample, ResolvedPr for (int i = node.Children.Count - 1; i >= 0; i--) { var child = node.Children[i]; - if (!child.CallTreeNode.Function.Equals(resolvedFrame.FrameDetails.Function)) { + if (child.CallTreeNode.FunctionId != resolvedFrame.FrameDetails.Function.ToProfileId()) { break; // Last func is different, stop and start a new stack. } @@ -337,7 +337,7 @@ private void AddSample(FlameGraphNode rootNode, ProfileSample sample, ResolvedPr if (targetNode == null) { var callNode = new ProfileCallTreeNode(resolvedFrame.FrameDetails.DebugInfo, - resolvedFrame.FrameDetails.Function, + resolvedFrame.FrameDetails.Function.ToProfileId(), null, node.CallTreeNode); targetNode = new FlameGraphNode(callNode, TimeSpan.Zero, depth, nameFormatter_); node.Children ??= new List(); diff --git a/src/ProfileExplorerUI/Profile/FlameGraph/FlameGraphHost.xaml.cs b/src/ProfileExplorerUI/Profile/FlameGraph/FlameGraphHost.xaml.cs index cec181d9..ca8b08a1 100644 --- a/src/ProfileExplorerUI/Profile/FlameGraph/FlameGraphHost.xaml.cs +++ b/src/ProfileExplorerUI/Profile/FlameGraph/FlameGraphHost.xaml.cs @@ -14,6 +14,7 @@ using ProfileExplorer.Core; using ProfileExplorer.UI.Controls; using ProfileExplorer.Core.Profile.CallTree; +using ProfileExplorer.Core.Utilities; using ProfileExplorer.Core.Profile.Processing; using ProfileExplorer.Core.Profile.Data; @@ -86,7 +87,7 @@ public bool EnableSingleNodeActions { } if (node.HasFunction) { - text += Session.CompilerInfo.NameProvider.GetFunctionName(node.Function); + text += node.FunctionName; } } @@ -101,7 +102,7 @@ public bool EnableSingleNodeActions { } if (node.HasFunction) { - text += Session.CompilerInfo.NameProvider.FormatFunctionName(node.Function); + text += Session.CompilerInfo.NameProvider.FormatFunctionName(node.FunctionName); } } @@ -125,7 +126,7 @@ public bool EnableSingleNodeActions { }); public RelayCommand MarkAllInstancesCommand => new(async obj => { MarkSelectedNodes( - obj, (node, color) => MarkFunctionInstances(node.Function, GraphViewer.MarkedColoredNodeStyle(color))); + obj, (node, color) => MarkFunctionInstances(node.CallTreeNode.ResolveFunction(Session), GraphViewer.MarkedColoredNodeStyle(color))); }); public RelayCommand MarkModuleCommand => new(async obj => { var markingSettings = App.Settings.MarkingSettings; @@ -151,7 +152,7 @@ await Session.MarkProfileFunction(GraphViewer.SelectedNode.CallTreeNode, ToolPan public RelayCommand PreviewFunctionCommand => new(async obj => { if (GraphViewer.SelectedNode is {HasFunction: true}) { var brush = GetMarkedNodeColor(GraphViewer.SelectedNode); - await IRDocumentPopupInstance.ShowPreviewPopup(GraphViewer.SelectedNode.Function, "", + await IRDocumentPopupInstance.ShowPreviewPopup(GraphViewer.SelectedNode.CallTreeNode.ResolveFunction(Session), "", GraphViewer, Session, null, false, brush); } }); @@ -159,7 +160,7 @@ await IRDocumentPopupInstance.ShowPreviewPopup(GraphViewer.SelectedNode.Function if (GraphViewer.SelectedNode is {HasFunction: true}) { var filter = new ProfileSampleFilter(GraphViewer.SelectedNode.CallTreeNode); var brush = GetMarkedNodeColor(GraphViewer.SelectedNode); - await IRDocumentPopupInstance.ShowPreviewPopup(GraphViewer.SelectedNode.Function, "", + await IRDocumentPopupInstance.ShowPreviewPopup(GraphViewer.SelectedNode.CallTreeNode.ResolveFunction(Session), "", GraphViewer, Session, filter, false, brush); } }); @@ -675,7 +676,7 @@ private async Task OpenFunction(FlameGraphNode node) { } private async Task OpenFunction(ProfileCallTreeNode node) { - if (node is {HasFunction: true} && node.Function.HasSections) { + if (node is {HasFunction: true} && node.ResolveFunction(Session)?.HasSections == true) { var openMode = Utils.IsControlModifierActive() ? OpenSectionKind.NewTab : OpenSectionKind.ReplaceCurrent; await Session.OpenProfileFunction(node, openMode); } @@ -1196,7 +1197,7 @@ private async void ChangeRootNodeExecuted(object sender, ExecutedRoutedEventArgs private void MarkAllInstancesExecuted(object sender, ExecutedRoutedEventArgs e) { if (GraphViewer.SelectedNode != null && GraphViewer.SelectedNode.HasFunction) { - MarkFunctionInstances(GraphViewer.SelectedNode.Function, + MarkFunctionInstances(GraphViewer.SelectedNode.CallTreeNode.ResolveFunction(Session), GraphViewer.MarkedNodeStyle); } } diff --git a/src/ProfileExplorerUI/Profile/FlameGraph/FlameGraphPanel.xaml.cs b/src/ProfileExplorerUI/Profile/FlameGraph/FlameGraphPanel.xaml.cs index 44946b5a..ca7ed7a5 100644 --- a/src/ProfileExplorerUI/Profile/FlameGraph/FlameGraphPanel.xaml.cs +++ b/src/ProfileExplorerUI/Profile/FlameGraph/FlameGraphPanel.xaml.cs @@ -351,7 +351,7 @@ private async void NodeDetailsPanel_NodeDoubleClick(object sender, ProfileCallTr } private async Task OpenFunction(ProfileCallTreeNode node) { - if (node is {HasFunction: true} && node.Function.HasSections) { + if (node is {HasFunction: true} && node.ResolveFunction(Session)?.HasSections == true) { var openMode = Utils.IsControlModifierActive() ? OpenSectionKind.NewTab : OpenSectionKind.ReplaceCurrent; await Session.OpenProfileFunction(node, openMode); } diff --git a/src/ProfileExplorerUI/Profile/ProfileListView.xaml.cs b/src/ProfileExplorerUI/Profile/ProfileListView.xaml.cs index 64be9758..aa8704c0 100644 --- a/src/ProfileExplorerUI/Profile/ProfileListView.xaml.cs +++ b/src/ProfileExplorerUI/Profile/ProfileListView.xaml.cs @@ -13,6 +13,7 @@ using ProfileExplorer.UI.Controls; using ProfileExplorer.UI.Document; using ProfileExplorer.Core.Profile.CallTree; +using ProfileExplorer.Core.Utilities; using ProfileExplorer.Core.Profile.Data; using ProfileExplorer.Core.Profile.Processing; using ProfileExplorer.Core.Providers; @@ -164,7 +165,7 @@ public bool IsCategoriesList { public RelayCommand PreviewFunctionCommand => new(async obj => { if (ItemList.SelectedItem is ProfileListViewItem item && item.CallTreeNode != null) { var brush = GetMarkedNodeColor(item); - await IRDocumentPopupInstance.ShowPreviewPopup(item.CallTreeNode.Function, "", + await IRDocumentPopupInstance.ShowPreviewPopup(item.CallTreeNode.ResolveFunction(session_), "", ItemList, session_, null, false, brush); } }); @@ -178,7 +179,7 @@ await IRDocumentPopupInstance.ShowPreviewPopup(item.CallTreeNode.Function, "", public RelayCommand PreviewFunctionInstanceCommand => new(async obj => { if (ItemList.SelectedItem is ProfileListViewItem item && item.CallTreeNode != null) { var filter = new ProfileSampleFilter(item.CallTreeNode); - await IRDocumentPopupInstance.ShowPreviewPopup(item.CallTreeNode.Function, "", + await IRDocumentPopupInstance.ShowPreviewPopup(item.CallTreeNode.ResolveFunction(session_), "", ItemList, session_, filter); } }); @@ -206,14 +207,14 @@ await IRDocumentPopupInstance.ShowPreviewPopup(item.CallTreeNode.Function, "", }); public RelayCommand CopyFunctionNameCommand => new(async obj => { if (ItemList.SelectedItem is ProfileListViewItem item) { - string text = Session.CompilerInfo.NameProvider.GetFunctionName(item.CallTreeNode.Function); + string text = item.CallTreeNode.FunctionName; Clipboard.SetText(text); } }); public RelayCommand CopyDemangledFunctionNameCommand => new(async obj => { if (ItemList.SelectedItem is ProfileListViewItem item) { var options = FunctionNameDemanglingOptions.Default; - string text = Session.CompilerInfo.NameProvider.DemangleFunctionName(item.CallTreeNode.Function, options); + string text = Session.CompilerInfo.NameProvider.DemangleFunctionName(item.CallTreeNode.FunctionName, options); Clipboard.SetText(text); } }); @@ -420,7 +421,7 @@ private void SetupPreviewPopup() { var item = (ProfileListViewItem)hoveredItem.DataContext; if (item.CallTreeNode != null) { - return PreviewPopupArgs.ForFunction(item.CallTreeNode.Function, ItemList); + return PreviewPopupArgs.ForFunction(item.CallTreeNode.ResolveFunction(session_), ItemList); } return null; diff --git a/src/ProfileExplorerUI/ProfileStyles.xaml b/src/ProfileExplorerUI/ProfileStyles.xaml index 1a00562a..d4579328 100644 --- a/src/ProfileExplorerUI/ProfileStyles.xaml +++ b/src/ProfileExplorerUI/ProfileStyles.xaml @@ -3,7 +3,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:oxy="http://oxyplot.org/wpf" xmlns:profile="clr-namespace:ProfileExplorer.UI.Profile" - xmlns:callTree="clr-namespace:ProfileExplorer.Core.Profile.CallTree;assembly=ProfileExplorerCore" + xmlns:callTree="clr-namespace:ProfileExplorer.Core.Profile.CallTree;assembly=ProfileExplorer.Profiling" xmlns:system="clr-namespace:System;assembly=System.Runtime"> True diff --git a/src/ProfileExplorerUITests/SyntheticProfileTests.cs b/src/ProfileExplorerUITests/SyntheticProfileTests.cs index e552684a..957ecfdf 100644 --- a/src/ProfileExplorerUITests/SyntheticProfileTests.cs +++ b/src/ProfileExplorerUITests/SyntheticProfileTests.cs @@ -54,7 +54,7 @@ private void BuildStack(IReadOnlyList funcs, IReadOnlyList