Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions global.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"sdk":{"version":"8.0.421","rollForward":"latestMinor"}}
74 changes: 40 additions & 34 deletions src/ProfileExplorer.McpServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, IRTextFunction>? DemangledFunctionLookup { get; set; }
public static Dictionary<string, ProfileFunctionId>? DemangledFunctionLookup { get; set; }

/// <summary>
/// Resets all session state and static caches for a clean trace load.
Expand All @@ -102,7 +103,7 @@ public static void Reset()

// Clear static resolution caches so each trace starts fresh.
PDBDebugInfoProvider.ClearResolvedCache();
PEBinaryInfoProvider.ClearResolvedCache();
BinaryFileLocator.ClearResolvedCache();
}
}

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -520,7 +521,7 @@ public static async Task<string> 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
Expand Down Expand Up @@ -651,7 +652,7 @@ public static async Task<string> 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(),
Expand Down Expand Up @@ -687,15 +688,15 @@ 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;
int calleeLimit = maxCallees ?? 10;
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");

Expand All @@ -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);
}
}
Expand All @@ -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<object>();
if (combined != null && combined.HasChildren)
{
callees = combined.Children
.Where(c => c?.Function != null)
.Where(c => c is {HasFunction: true})
Comment thread
trgibeau marked this conversation as resolved.
.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,
Expand All @@ -755,15 +756,15 @@ 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();

var result = new
{
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(),
Expand Down Expand Up @@ -818,11 +819,11 @@ public static string GetHelp()
/// <summary>
/// Returns the PDB-resolved name for a function, falling back to IRTextFunction.Name (hex placeholder).
/// </summary>
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;
}

/// <summary>
Expand All @@ -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<string, IRTextFunction>(StringComparer.OrdinalIgnoreCase);
var lookup = new Dictionary<string, ProfileFunctionId>(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);
Expand All @@ -861,22 +867,22 @@ 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
.FirstOrDefault(k => k.Contains(functionName, StringComparison.OrdinalIgnoreCase));
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)
Comment thread
trgibeau marked this conversation as resolved.
if (f.FunctionName.Contains(functionName, StringComparison.OrdinalIgnoreCase)) return f;
return null;
}

private static string Error(string action, string message)
Expand Down
148 changes: 148 additions & 0 deletions src/ProfileExplorer.Profiling.Tests/Helpers/SyntheticSampleBuilder.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
namespace ProfileExplorer.Profiling.Tests.Helpers;

/// <summary>
/// Builds synthetic IProfileSample and IProfileImage instances for unit tests.
/// </summary>
public static class SyntheticSampleBuilder {
/// <summary>
/// Create uniform samples — all at the same IP with the same weight.
/// </summary>
public static IReadOnlyList<IProfileSample> CreateUniform(
int count, string module, long baseIp, TimeSpan weight, int processId = 1, int threadId = 1) {
var samples = new List<IProfileSample>(count);
for (int i = 0; i < count; i++) {
samples.Add(new SyntheticSample(baseIp, weight, processId, threadId, module, baseIp - 0x1000));
}

return samples;
}

/// <summary>
/// Create a hotspot — most samples at one IP, the rest distributed.
/// </summary>
public static IReadOnlyList<IProfileSample> 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<IProfileSample>(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;
}

/// <summary>
/// Create samples across multiple functions.
/// </summary>
public static IReadOnlyList<IProfileSample> CreateMultiFunction(
params (string module, long baseIp, int sampleCount)[] functions) {
var samples = new List<IProfileSample>();
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;
}

/// <summary>
/// Create samples with stack frames for call tree testing.
/// </summary>
public static IReadOnlyList<IProfileSample> CreateWithStacks(
params (string module, long leafIp, long[] stackIps, int count)[] entries) {
var samples = new List<IProfileSample>();
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;
}

/// <summary>
/// Create fake image definitions.
/// </summary>
public static IReadOnlyList<IProfileImage> 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();
}

/// <summary>
/// Create fake image definitions with PDB identity.
/// </summary>
public static IReadOnlyList<IProfileImage> 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<long>? 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<long>? 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; }
}
Loading
Loading