Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using ProfileExplorer.Core.Binary; // FunctionDebugInfo
using ProfileExplorer.Core.Profile; // ProfileFunctionId
using ProfileExplorer.Core.Profile.Data; // FunctionProfileData
using ProfileExplorer.Profiling.Profiling; // IpResolver, SampleAggregator (InternalsVisibleTo)
using ProfileExplorer.Profiling.Tests.Helpers; // SyntheticSample

namespace ProfileExplorer.Profiling.Tests.Unit;

/// <summary>
/// Verifies the library aggregator reproduces Core's per-instruction attribution, so Core can
/// delegate to the library without changing observable behavior (library = single source of truth).
///
/// Scenario: one CPU sample taken inside <c>Leaf</c> (offset 0x10), whose stack shows it was called
/// from <c>Caller</c> (return address at offset 0x20). Both engines credit a per-instruction sample
/// at the caller's call-site offset (0x20) for EVERY unique function on the stack — this is what
/// makes a `call` instruction show the inclusive time of what it invoked.
///
/// * Core (FunctionProfileProcessor.ProcessSample): credits AddInstructionSample per unique frame.
/// * Library (SampleAggregator.AddSamples): reconciled to do the same for caller frames.
///
/// The library side below runs the REAL <see cref="SampleAggregator"/>. The Core side is a
/// line-for-line transcription of FunctionProfileProcessor.ProcessSample
/// (src/ProfileExplorerCore/Profile/Processing/FunctionProfileProcessor.cs, the per-frame loop),
/// using the REAL <see cref="FunctionProfileData"/> type and the same AddInstructionSample/Weight/
/// ExclusiveWeight operations, so the comparison is faithful without standing up the full ETW
/// resolved-stack machinery.
/// </summary>
[TestClass]
public class AggregatorSemanticsCharacterizationTests {
private const string Module = "app.dll";
private const long ModuleBase = 0x10000;
private const int ModuleSize = 0x10000;
private const long LeafRva = 0x1000;
private const long CallerRva = 0x2000;
private const long LeafOffset = 0x10; // sample IP within Leaf
private const long CallerOffset = 0x20; // return address within Caller

public TestContext TestContext { get; set; }

[TestMethod]
[TestCategory("Characterization")]
public void CallerInstructionAttribution_LibraryVsCore_Matches() {
var weight = TimeSpan.FromMilliseconds(1);
long leafIp = ModuleBase + LeafRva + LeafOffset; // 0x11010
long callerReturnIp = ModuleBase + CallerRva + CallerOffset; // 0x12020

var leafId = new ProfileFunctionId(Module, "Leaf");
var callerId = new ProfileFunctionId(Module, "Caller");

// ---- LIBRARY: real SampleAggregator over a leaf-first stack [leaf, caller-return] ----
var ipResolver = new IpResolver();
ipResolver.AddImage(Module, ModuleBase, ModuleSize);
ipResolver.SetFunctions(Module, new List<FunctionDebugInfo> {
new FunctionDebugInfo("Leaf", LeafRva, 0x100),
new FunctionDebugInfo("Caller", CallerRva, 0x100)
});

var aggregator = new SampleAggregator(ipResolver);
var sample = new SyntheticSample(leafIp, weight, 1, 1, Module, ModuleBase,
new long[] { leafIp, callerReturnIp });
aggregator.AddSamples(new[] { sample });
var lib = aggregator.Build();
var libLeaf = lib[leafId];
var libCaller = lib[callerId];

// ---- CORE: faithful transcription of FunctionProfileProcessor.ProcessSample ----
var core = CoreProcessSampleTranscription(weight, new[] {
// leaf-first: (funcId, funcRva, frameRva=module-relative RVA of the frame's IP, debugInfo)
(leafId, LeafRva, LeafRva + LeafOffset, new FunctionDebugInfo("Leaf", LeafRva, 0x100)),
(callerId, CallerRva, CallerRva + CallerOffset, new FunctionDebugInfo("Caller", CallerRva, 0x100))
});
var coreLeaf = core[leafId];
var coreCaller = core[callerId];

TestContext.WriteLine(Describe("LIBRARY", libLeaf, libCaller));
TestContext.WriteLine(Describe("CORE ", coreLeaf, coreCaller));

// ---- Agreements: exclusive + inclusive weight, and leaf per-instruction attribution ----
Assert.AreEqual(coreLeaf.ExclusiveWeight, libLeaf.ExclusiveWeight, "leaf exclusive weight should match");
Assert.AreEqual(coreLeaf.Weight, libLeaf.Weight, "leaf inclusive weight should match");
Assert.AreEqual(coreCaller.ExclusiveWeight, libCaller.ExclusiveWeight, "caller exclusive weight should match (both 0)");
Assert.AreEqual(coreCaller.Weight, libCaller.Weight, "caller inclusive weight should match");
CollectionAssert.AreEquivalent(coreLeaf.InstructionWeight.Keys.ToList(),
libLeaf.InstructionWeight.Keys.ToList(),
"leaf per-instruction offsets should match");

// ---- Caller per-instruction attribution now matches (reconciled) ----
Assert.AreEqual(weight, coreCaller.InstructionWeight[CallerOffset],
"CORE attributes a per-instruction sample at the caller call-site offset 0x20");
Assert.AreEqual(weight, libCaller.InstructionWeight[CallerOffset],
"LIBRARY now attributes the same caller call-site sample (behavior-preserving)");
CollectionAssert.AreEquivalent(coreCaller.InstructionWeight.Keys.ToList(),
libCaller.InstructionWeight.Keys.ToList(),
"caller per-instruction offsets should match");
}

/// <summary>
/// Line-for-line transcription of the per-frame loop in
/// FunctionProfileProcessor.ProcessSample (Core). Uses the real FunctionProfileData type and the
/// identical AddInstructionSample / Weight / ExclusiveWeight operations. Frames are leaf-first;
/// the first frame is the top (leaf) frame.
/// </summary>
private static Dictionary<ProfileFunctionId, FunctionProfileData> CoreProcessSampleTranscription(
TimeSpan weight,
(ProfileFunctionId funcId, long funcRva, long frameRva, FunctionDebugInfo debugInfo)[] framesLeafFirst) {
var functionProfiles = new Dictionary<ProfileFunctionId, FunctionProfileData>();
var stackFunctions = new HashSet<ProfileFunctionId>();
bool isTopFrame = true;

foreach (var frame in framesLeafFirst) {
if (!functionProfiles.TryGetValue(frame.funcId, out var funcProfile)) {
funcProfile = new FunctionProfileData(frame.debugInfo);
functionProfiles[frame.funcId] = funcProfile;
}

long offset = frame.frameRva - frame.funcRva;

// Don't count the inclusive time / instruction sample for recursive functions multiple times.
if (stackFunctions.Add(frame.funcId)) {
funcProfile.AddInstructionSample(offset, weight);
funcProfile.Weight += weight;
}

// Exclusive time only for the top (leaf) frame.
if (isTopFrame) {
funcProfile.ExclusiveWeight += weight;
}

isTopFrame = false;
}

return functionProfiles;
}

private static string Describe(string label, FunctionProfileData leaf, FunctionProfileData caller) {
string InstrMap(FunctionProfileData d) =>
d.InstructionWeight.Count == 0
? "{}"
: "{" + string.Join(", ", d.InstructionWeight.OrderBy(p => p.Key)
.Select(p => $"0x{p.Key:X}:{p.Value.TotalMilliseconds}ms")) + "}";

return $"{label} | Leaf excl={leaf.ExclusiveWeight.TotalMilliseconds}ms incl={leaf.Weight.TotalMilliseconds}ms instr={InstrMap(leaf)}" +
$"\n{new string(' ', label.Length)} | Caller excl={caller.ExclusiveWeight.TotalMilliseconds}ms incl={caller.Weight.TotalMilliseconds}ms instr={InstrMap(caller)}";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using ProfileExplorer.Core.Binary; // FunctionDebugInfo
using ProfileExplorer.Core.Profile; // ProfileFunctionId
using ProfileExplorer.Profiling; // FunctionProfiler, ProfilerOptions, IProfileSample
using ProfileExplorer.Profiling.Symbols; // ISymbolFileLocator
using ProfileExplorer.Profiling.Tests.Helpers;

namespace ProfileExplorer.Profiling.Tests.Unit;

/// <summary>
/// Verifies the host-symbol-injection path (Stage 3): when the host (e.g. Profile Explorer, which
/// owns symbol acquisition via TraceEvent) provides pre-read function lists via
/// <see cref="FunctionProfiler.AddResolvedFunctions"/>, the engine resolves and aggregates against
/// them with no symbol download of its own.
/// </summary>
[TestClass]
public class FunctionProfilerInjectionTests {
/// <summary>Symbol locator that never resolves anything — proves no download path is taken.</summary>
private sealed class NoSymbolLocator : ISymbolFileLocator {
public Task<string?> FindSymbolFileAsync(string pdbName, Guid guid, int age, CancellationToken ct = default) =>
Task.FromResult<string?>(null);

public Task<string?> FindBinaryFileAsync(string binaryName, int timeDateStamp, long imageSize,
CancellationToken ct = default) =>
Task.FromResult<string?>(null);
}

[TestMethod]
public void AddResolvedFunctions_ResolvesAndAggregates_WithoutSymbolDownload() {
const string module = "app.dll";
const long baseAddr = 0x140000000;
const int size = 0x100000;

var options = new ProfilerOptions {
SymbolPaths = new[] { "srv*https://symbols.invalid" }, // only needs to be non-empty for Validate()
IncludeManagedCode = false,
IncludePerformanceCounters = false
};

using var profiler = new FunctionProfiler(options, new NoSymbolLocator());

profiler.AddImages(SyntheticSampleBuilder.CreateImages((module, baseAddr, size)));
profiler.AddResolvedFunctions(module, new List<FunctionDebugInfo> {
new("Main", 0x1000, 0x800),
new("Foo", 0x2000, 0x800),
new("Bar", 0x3000, 0x800)
});

// One 10ms sample, stack leaf-first: Bar(@0x40) -> Foo(@0x80) -> Main(@0x100).
long leafIp = baseAddr + 0x3000 + 0x40;
long fooIp = baseAddr + 0x2000 + 0x80;
long mainIp = baseAddr + 0x1000 + 0x100;
var sample = new SyntheticSample(leafIp, TimeSpan.FromMilliseconds(10), 1, 1, module, baseAddr,
new long[] { leafIp, fooIp, mainIp });
profiler.AddSamples(new IProfileSample[] { sample });

var report = profiler.GetReport();

var bar = report.Functions[new ProfileFunctionId(module, "Bar")];
var foo = report.Functions[new ProfileFunctionId(module, "Foo")];
var main = report.Functions[new ProfileFunctionId(module, "Main")];

// Leaf gets exclusive + inclusive; callers get inclusive; per-instruction attributed at call sites.
Assert.AreEqual(TimeSpan.FromMilliseconds(10), bar.ExclusiveWeight, "Bar exclusive (leaf)");
Assert.AreEqual(TimeSpan.FromMilliseconds(10), bar.Weight, "Bar inclusive");
Assert.AreEqual(TimeSpan.Zero, foo.ExclusiveWeight, "Foo exclusive (caller)");
Assert.AreEqual(TimeSpan.FromMilliseconds(10), foo.Weight, "Foo inclusive");
Assert.AreEqual(TimeSpan.Zero, main.ExclusiveWeight, "Main exclusive (caller)");
Assert.AreEqual(TimeSpan.FromMilliseconds(10), main.Weight, "Main inclusive");
Assert.AreEqual(TimeSpan.FromMilliseconds(10), report.TotalWeight, "total sampled weight");

Assert.AreEqual(TimeSpan.FromMilliseconds(10), bar.InstructionWeight[0x40], "Bar leaf @0x40");
Assert.AreEqual(TimeSpan.FromMilliseconds(10), foo.InstructionWeight[0x80], "Foo call-site @0x80");
Assert.AreEqual(TimeSpan.FromMilliseconds(10), main.InstructionWeight[0x100], "Main call-site @0x100");
}

[TestMethod]
public void AddResolvedFunctions_NullArguments_Throw() {
var options = new ProfilerOptions { SymbolPaths = new[] { "srv*https://symbols.invalid" } };
using var profiler = new FunctionProfiler(options, new NoSymbolLocator());

Assert.ThrowsException<ArgumentException>(() =>
profiler.AddResolvedFunctions("", new List<FunctionDebugInfo>()));
Assert.ThrowsException<ArgumentNullException>(() =>
profiler.AddResolvedFunctions("m.dll", null!));
}

[TestMethod]
public void AddSamples_WithInstancePath_FocusesOnMatchingStacks() {
const string module = "app.dll";
const long baseAddr = 0x140000000;
const int size = 0x100000;

var options = new ProfilerOptions {
SymbolPaths = new[] { "srv*https://symbols.invalid" },
IncludeManagedCode = false,
IncludePerformanceCounters = false
};

using var profiler = new FunctionProfiler(options, new NoSymbolLocator());
profiler.AddImages(SyntheticSampleBuilder.CreateImages((module, baseAddr, size)));
profiler.AddResolvedFunctions(module, new List<FunctionDebugInfo> {
new("Main", 0x1000, 0x800),
new("Foo", 0x2000, 0x800),
new("Bar", 0x3000, 0x800),
new("Baz", 0x4000, 0x800)
});

long Ip(long rva, long off) => baseAddr + rva + off;

// Stack 1 (10ms): Bar <- Foo <- Main → matches instance path Main -> Foo.
var s1 = new SyntheticSample(Ip(0x3000, 0x40), TimeSpan.FromMilliseconds(10), 1, 1, module, baseAddr,
new long[] { Ip(0x3000, 0x40), Ip(0x2000, 0x80), Ip(0x1000, 0x100) });
// Stack 2 (20ms): Baz <- Main → does NOT match (Main then Baz, not Foo).
var s2 = new SyntheticSample(Ip(0x4000, 0x50), TimeSpan.FromMilliseconds(20), 1, 1, module, baseAddr,
new long[] { Ip(0x4000, 0x50), Ip(0x1000, 0x104) });

// Root-first instance path: focus on Main -> Foo.
var instancePath = new List<ProfileFunctionId> {
new(module, "Main"),
new(module, "Foo")
};

profiler.AddSamples(new IProfileSample[] { s1, s2 }, instancePath);
var report = profiler.GetReport();

Assert.AreEqual(TimeSpan.FromMilliseconds(10), report.TotalWeight, "only the matching stack is counted");
Assert.IsTrue(report.Functions.ContainsKey(new ProfileFunctionId(module, "Bar")), "Bar (in focused path)");
Assert.IsTrue(report.Functions.ContainsKey(new ProfileFunctionId(module, "Foo")), "Foo (in focused path)");
Assert.IsTrue(report.Functions.ContainsKey(new ProfileFunctionId(module, "Main")), "Main (in focused path)");
Assert.IsFalse(report.Functions.ContainsKey(new ProfileFunctionId(module, "Baz")), "Baz stack filtered out");
Assert.AreEqual(TimeSpan.FromMilliseconds(10),
report.Functions[new ProfileFunctionId(module, "Bar")].ExclusiveWeight, "Bar exclusive from matching stack");

// Call tree is focused the same way: single root Main -> Foo -> Bar.
Assert.AreEqual(1, report.CallTree.RootNodes.Count, "single focused root");
Assert.AreEqual("Main", report.CallTree.RootNodes[0].FunctionName);
}
}
22 changes: 22 additions & 0 deletions src/ProfileExplorer.Profiling/Abstractions/ResolvedFrame.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
using ProfileExplorer.Core.Binary;
using ProfileExplorer.Core.Profile;

namespace ProfileExplorer.Profiling;

/// <summary>
/// A single pre-resolved stack frame (leaf-first order) supplied by a host that owns symbol
/// resolution (e.g. Profile Explorer). Lets the library aggregate over already-resolved stacks
/// without re-resolving IPs, so host-side managed / JIT / unknown-frame resolution flows through
/// unchanged. Frames the host could not resolve should be omitted before aggregation.
/// </summary>
public readonly record struct ResolvedFrame(
ProfileFunctionId FunctionId,
FunctionDebugInfo DebugInfo,
long FrameRva,
bool IsKernel = false,
bool IsManaged = false) {
/// <summary>Instruction offset within the owning function (frame RVA minus function RVA).</summary>
public long InstructionOffset => FrameRva - (DebugInfo?.RVA ?? 0);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
namespace ProfileExplorer.Profiling;

/// <summary>
/// Projects the host's sample at <paramref name="index"/> into the library's neutral resolved form.
/// Invoked CONCURRENTLY by <see cref="FunctionProfiler.AddResolvedSamplesParallel"/> worker threads,
/// so implementations MUST be thread-safe. Write the sample's resolved frames (leaf-first, with the
/// host omitting any frame it could not resolve) into <paramref name="frames"/>, which is supplied
/// already cleared. Return <c>false</c> to skip the sample (filtered out, or nothing resolvable).
/// </summary>
/// <param name="index">Sample index in the host's collection.</param>
/// <param name="frames">Destination for the leaf-first resolved frames (pre-cleared, reused per worker).</param>
/// <param name="weight">The sample's CPU weight.</param>
/// <param name="threadId">The sample's thread id (used for per-thread call-tree weights).</param>
public delegate bool ResolvedSampleProjector(int index, List<ResolvedFrame> frames,
out TimeSpan weight, out int threadId);
Loading
Loading