Improve docs coverage and refresh profiling parser artifacts

Add domain-specific XML documentation across src server components to satisfy CommentChecker, and update dotTrace parsing outputs used for diagnostics.
This commit is contained in:
Joseph Doherty
2026-03-14 04:06:04 -04:00
parent 46ead5ea9f
commit 5de4962bd3
46 changed files with 761 additions and 10488 deletions

View File

@@ -26,29 +26,26 @@ static async Task<int> ProgramMain(string[] args)
{
try
{
if (args.Length == 0)
var options = ParseArguments(args);
if (options is null)
{
Console.Error.WriteLine("Usage: DtpSnapshotExtractor <snapshot.dtp>");
Console.Error.WriteLine("Usage: DtpSnapshotExtractor <snapshot.dtp> [--top N] [--filter text] [--flat] [--include-idle]");
return 2;
}
var snapshotPath = Path.GetFullPath(args[0]);
if (!File.Exists(snapshotPath))
if (!File.Exists(options.SnapshotPath))
{
Console.Error.WriteLine($"Snapshot not found: {snapshotPath}");
Console.Error.WriteLine($"Snapshot not found: {options.SnapshotPath}");
return 2;
}
var dotTraceAppDir = AppContext.GetData("APP_CONTEXT_DEPS_FILES") is not null
? Environment.GetEnvironmentVariable("DOTTRACE_APP_DIR")
: Environment.GetEnvironmentVariable("DOTTRACE_APP_DIR");
RegisterAssemblyResolver(dotTraceAppDir);
RegisterAssemblyResolver(Environment.GetEnvironmentVariable("DOTTRACE_APP_DIR"));
using var lifetimeDef = Lifetime.Define();
var lifetime = lifetimeDef.Lifetime;
var logger = Logger.GetLogger("DtpSnapshotExtractor");
var masks = new SnapshotMasksComponent();
var snapshotFile = FileSystemPath.Parse(snapshotPath);
var snapshotFile = FileSystemPath.Parse(options.SnapshotPath);
var container = new SnapshotStorageContainer(lifetime, logger, masks, snapshotFile);
var callTreeSections = new CallTreeSections(container, masks);
var headerSections = new HeaderSections(container);
@@ -67,27 +64,32 @@ static async Task<int> ProgramMain(string[] args)
var resolver = new SignatureResolver(masks, fuidConverter, assemblyProvider);
var headers = callTreeSections.AllHeaders().ToArray();
var totalNodeCount = headers.Sum(header =>
var expectedNodeCount = headers.Sum(header =>
checked((int)((header.HeaderFull.SectionSize - header.HeaderFull.SectionHeaderSize) / header.HeaderFull.RecordSize())));
var nodes = new DfsNode<CallTreeSectionOffset, FunctionUID>[totalNodeCount];
var totals = new DotTracePayload[totalNodeCount];
var owns = new DotTracePayload[totalNodeCount];
var nodes = new DfsNode<CallTreeSectionOffset, FunctionUID>[expectedNodeCount];
var totals = new DotTracePayload[expectedNodeCount];
var owns = new DotTracePayload[expectedNodeCount];
var readNodes = dfsReaders.GetNodesReaders(lifetime).ReadForwardOffsetsAscending(accessor.MinOffset, totalNodeCount, nodes, 0);
var readTotals = totalPayloadReader.ReadForwardOffsetsAscending(accessor.MinOffset, totalNodeCount, totals, 0);
var readOwns = ownPayloadReader.ReadForwardOffsetsAscending(accessor.MinOffset, totalNodeCount, owns, 0);
var readNodes = dfsReaders.GetNodesReaders(lifetime).ReadForwardOffsetsAscending(accessor.MinOffset, expectedNodeCount, nodes, 0);
var readTotals = totalPayloadReader.ReadForwardOffsetsAscending(accessor.MinOffset, expectedNodeCount, totals, 0);
var readOwns = ownPayloadReader.ReadForwardOffsetsAscending(accessor.MinOffset, expectedNodeCount, owns, 0);
if (readNodes != totalNodeCount || readTotals != totalNodeCount || readOwns != totalNodeCount)
throw new InvalidOperationException($"Snapshot read mismatch. nodes={readNodes}, totals={readTotals}, owns={readOwns}, expected={totalNodeCount}.");
if (readNodes != expectedNodeCount || readTotals != expectedNodeCount || readOwns != expectedNodeCount)
{
throw new InvalidOperationException(
$"Snapshot read mismatch. nodes={readNodes}, totals={readTotals}, owns={readOwns}, expected={expectedNodeCount}.");
}
var nodeMap = new Dictionary<CallTreeSectionOffset, MutableNode>(totalNodeCount);
foreach (var index in Enumerable.Range(0, totalNodeCount))
var nodeMap = new Dictionary<CallTreeSectionOffset, MutableNode>(expectedNodeCount);
foreach (var index in Enumerable.Range(0, expectedNodeCount))
{
var node = nodes[index];
var signature = resolver.Resolve(node.Key);
nodeMap[node.Offset] = new MutableNode
{
Offset = node.Offset,
ParentOffset = node.ParentOffset,
Id = node.Offset.ToString(),
Name = signature.Name,
Kind = signature.Kind,
@@ -98,7 +100,7 @@ static async Task<int> ProgramMain(string[] args)
};
}
foreach (var index in Enumerable.Range(0, totalNodeCount))
foreach (var index in Enumerable.Range(0, expectedNodeCount))
{
var node = nodes[index];
if (!node.ParentOffset.IsValid)
@@ -127,7 +129,7 @@ static async Task<int> ProgramMain(string[] args)
InclusiveTime = rootNode.InclusiveTime,
ExclusiveTime = 0,
CallCount = rootNode.CallCount,
Children = [CloneTree(header.Root, nodeMap, [])]
Children = [CloneTree(rootNode, [])]
});
}
@@ -143,24 +145,41 @@ static async Task<int> ProgramMain(string[] args)
Children = threadNodes
};
var hotspots = BuildHotspots(nodeMap.Values);
var filteredTree = string.IsNullOrWhiteSpace(options.NameFilter)
? syntheticRoot
: PruneTree(syntheticRoot, options.NameFilter) ?? CreateEmptyRoot();
var hotspotCandidates = FilterHotspotCandidates(nodeMap.Values, options);
var hotspots = BuildHotspots(hotspotCandidates, options.Top);
var summary = BuildSummary(syntheticRoot, hotspotCandidates, hotspots.Exclusive.FirstOrDefault());
var payload = new OutputDocument
{
Snapshot = new SnapshotInfo
{
Path = snapshotPath,
Path = options.SnapshotPath,
PayloadType = "time",
TimeUnit = "nanoseconds",
ThreadCount = threadNodes.Count,
NodeCount = nodeMap.Count
NodeCount = nodeMap.Count,
Diagnostics = new SnapshotDiagnostics
{
HeaderCount = headers.Length,
ExpectedNodeCount = expectedNodeCount,
ReadNodeCount = readNodes,
ReadTotalPayloadCount = readTotals,
ReadOwnPayloadCount = readOwns
}
},
ThreadRoots = threadNodes.Select(node => new ThreadRootInfo
Summary = summary,
ThreadRoots = filteredTree.Children.Select(node => new ThreadRootInfo
{
Id = node.Id,
Name = node.Name,
InclusiveTime = node.InclusiveTime
}).ToList(),
Hotspots = hotspots,
CallTree = syntheticRoot
HotPaths = options.FlatPaths ? BuildHotPaths(filteredTree, options) : null,
CallTree = filteredTree
};
await JsonSerializer.SerializeAsync(Console.OpenStandardOutput(), payload, CreateJsonOptions());
@@ -174,11 +193,54 @@ static async Task<int> ProgramMain(string[] args)
}
}
static ExtractorOptions? ParseArguments(string[] args)
{
if (args.Length == 0)
return null;
var snapshotPath = Path.GetFullPath(args[0]);
var top = 200;
string? nameFilter = null;
var flatPaths = false;
var excludeIdle = true;
for (var index = 1; index < args.Length; index++)
{
switch (args[index])
{
case "--top":
if (++index >= args.Length || !int.TryParse(args[index], out top) || top <= 0)
throw new ArgumentException("--top requires a positive integer value.");
break;
case "--filter":
if (++index >= args.Length)
throw new ArgumentException("--filter requires a value.");
nameFilter = args[index];
break;
case "--flat":
case "--paths":
flatPaths = true;
break;
case "--include-idle":
excludeIdle = false;
break;
case "--exclude-idle":
excludeIdle = true;
break;
default:
throw new ArgumentException($"Unknown argument: {args[index]}");
}
}
return new ExtractorOptions(snapshotPath, top, nameFilter, flatPaths, excludeIdle);
}
static JsonSerializerOptions CreateJsonOptions() => new()
{
MaxDepth = 4096,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
WriteIndented = true,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
static void RegisterAssemblyResolver(string? dotTraceAppDir)
@@ -202,10 +264,15 @@ static long GetTotalTime(DotTracePayload payload) => payload.PlusTime - payload.
static long GetCallCount(DotTracePayload payload) => payload.PlusCallCount - payload.MinusCallCount;
static HotspotLists BuildHotspots(IEnumerable<MutableNode> nodes)
static IEnumerable<MutableNode> FilterHotspotCandidates(IEnumerable<MutableNode> nodes, ExtractorOptions options) =>
nodes.Where(node =>
node.Kind == "method" &&
(!options.ExcludeIdle || !IsIdleMethod(node.Name)) &&
MatchesFilter(node.Name, options.NameFilter));
static HotspotLists BuildHotspots(IEnumerable<MutableNode> nodes, int top)
{
var candidates = nodes
.Where(node => node.Kind == "method")
.Select(node => new HotspotEntry
{
Id = node.Id,
@@ -222,32 +289,101 @@ static HotspotLists BuildHotspots(IEnumerable<MutableNode> nodes)
Inclusive = candidates
.OrderByDescending(node => node.InclusiveTime)
.ThenBy(node => node.Name, StringComparer.Ordinal)
.Take(50)
.Take(top)
.ToList(),
Exclusive = candidates
.OrderByDescending(node => node.ExclusiveTime)
.ThenBy(node => node.Name, StringComparer.Ordinal)
.Take(50)
.Take(top)
.ToList()
};
}
static SerializableNode CloneTree(
CallTreeSectionOffset offset,
IReadOnlyDictionary<CallTreeSectionOffset, MutableNode> nodeMap,
HashSet<CallTreeSectionOffset> ancestry)
static SummaryInfo BuildSummary(
SerializableNode callTree,
IEnumerable<MutableNode> hotspotCandidates,
HotspotEntry? topExclusive)
{
var source = nodeMap[offset];
var nextAncestry = new HashSet<CallTreeSectionOffset>(ancestry) { offset };
var candidateArray = hotspotCandidates.ToArray();
return new SummaryInfo
{
WallTimeMs = NanosecondsToMilliseconds(callTree.Children.Count == 0 ? 0 : callTree.Children.Max(node => node.InclusiveTime)),
ActiveTimeMs = NanosecondsToMilliseconds(candidateArray.Sum(node => node.ExclusiveTime)),
TotalSamples = candidateArray.Sum(node => node.CallCount),
TopExclusiveMethod = topExclusive?.Name,
TopExclusiveMs = NanosecondsToMilliseconds(topExclusive?.ExclusiveTime ?? 0)
};
}
static List<HotPathEntry> BuildHotPaths(SerializableNode root, ExtractorOptions options)
{
var collector = new List<HotPathAccumulator>();
foreach (var threadNode in root.Children)
{
CollectHotPaths(threadNode, [threadNode.Name], collector, options);
}
return collector
.OrderByDescending(path => path.InclusiveTime)
.ThenBy(path => path.Path, StringComparer.Ordinal)
.Take(options.Top)
.Select(path => new HotPathEntry
{
Path = path.Path,
InclusiveTime = path.InclusiveTime,
InclusiveMs = NanosecondsToMilliseconds(path.InclusiveTime),
LeafExclusiveTime = path.LeafExclusiveTime,
LeafExclusiveMs = NanosecondsToMilliseconds(path.LeafExclusiveTime)
})
.ToList();
}
static void CollectHotPaths(
SerializableNode node,
List<string> path,
List<HotPathAccumulator> collector,
ExtractorOptions options)
{
foreach (var child in node.Children)
{
if (child.Kind == "method")
{
path.Add(child.Name);
if ((!options.ExcludeIdle || !IsIdleMethod(child.Name)) && MatchesFilter(child.Name, options.NameFilter))
{
collector.Add(new HotPathAccumulator(
string.Join(" > ", path),
child.InclusiveTime,
child.ExclusiveTime));
}
CollectHotPaths(child, path, collector, options);
path.RemoveAt(path.Count - 1);
continue;
}
var appended = child.Kind is not ("root" or "special");
if (appended)
path.Add(child.Name);
CollectHotPaths(child, path, collector, options);
if (appended)
path.RemoveAt(path.Count - 1);
}
}
static SerializableNode CloneTree(MutableNode source, HashSet<CallTreeSectionOffset> ancestry)
{
var nextAncestry = new HashSet<CallTreeSectionOffset>(ancestry) { source.Offset };
var children = new List<SerializableNode>(source.Children.Count);
foreach (var child in source.Children)
{
var childOffset = ParseOffset(child.Id);
if (nextAncestry.Contains(childOffset))
if (nextAncestry.Contains(child.Offset))
continue;
children.Add(CloneTree(childOffset, nodeMap, nextAncestry));
children.Add(CloneTree(child, nextAncestry));
}
return new SerializableNode
@@ -263,12 +399,80 @@ static SerializableNode CloneTree(
};
}
static CallTreeSectionOffset ParseOffset(string value)
static SerializableNode? PruneTree(SerializableNode node, string? filter)
{
var parts = value.Split('/');
return new CallTreeSectionOffset(Convert.ToInt64(parts[0], 16), int.Parse(parts[1]));
if (string.IsNullOrWhiteSpace(filter))
return node;
if (MatchesFilter(node.Name, filter))
return node;
var prunedChildren = new List<SerializableNode>();
foreach (var child in node.Children)
{
var prunedChild = PruneTree(child, filter);
if (prunedChild is not null)
prunedChildren.Add(prunedChild);
}
if (prunedChildren.Count == 0)
return null;
return node with { Children = prunedChildren };
}
static SerializableNode CreateEmptyRoot() => new()
{
Id = "root",
Name = "<root>",
Kind = "root",
ThreadName = null,
InclusiveTime = 0,
ExclusiveTime = 0,
CallCount = 0,
Children = []
};
static bool MatchesFilter(string value, string? filter) =>
string.IsNullOrWhiteSpace(filter) ||
value.Contains(filter, StringComparison.OrdinalIgnoreCase);
static bool IsIdleMethod(string name) =>
GetIdleMethodPatterns().Any(pattern => name.Contains(pattern, StringComparison.Ordinal));
static double NanosecondsToMilliseconds(long value) => Math.Round(value / 1_000_000d, 3);
static string[] GetIdleMethodPatterns()
{
return
[
"WaitHandle.Wait",
"WaitOneNoCheck",
"SemaphoreSlim.Wait",
"LowLevelLifoSemaphore.Wait",
"LowLevelLifoSemaphore.WaitForSignal",
"LowLevelSpinWaiter.Wait",
"Monitor.Wait",
"SocketAsyncEngine.EventLoop",
"PollGC",
"Interop+Sys.WaitForSocketEvents",
"ManualResetEventSlim.Wait",
"Task.SpinThenBlockingWait",
"Thread.SleepInternal"
];
}
sealed record ExtractorOptions(
string SnapshotPath,
int Top,
string? NameFilter,
bool FlatPaths,
bool ExcludeIdle);
readonly record struct SignatureResult(string Name, string Kind);
readonly record struct HotPathAccumulator(string Path, long InclusiveTime, long LeafExclusiveTime);
sealed class SignatureResolver(
SnapshotMasksComponent masks,
FuidToMetadataIdConverter fuidConverter,
@@ -343,16 +547,18 @@ sealed class SignatureResolver(
}
}
readonly record struct SignatureResult(string Name, string Kind);
sealed class OutputDocument
{
public required SnapshotInfo Snapshot { get; init; }
public required SummaryInfo Summary { get; init; }
public required List<ThreadRootInfo> ThreadRoots { get; init; }
public required HotspotLists Hotspots { get; init; }
public List<HotPathEntry>? HotPaths { get; init; }
public required SerializableNode CallTree { get; init; }
}
@@ -362,9 +568,39 @@ sealed class SnapshotInfo
public required string PayloadType { get; init; }
public required string TimeUnit { get; init; }
public required int ThreadCount { get; init; }
public required int NodeCount { get; init; }
public required SnapshotDiagnostics Diagnostics { get; init; }
}
sealed class SnapshotDiagnostics
{
public required int HeaderCount { get; init; }
public required int ExpectedNodeCount { get; init; }
public required int ReadNodeCount { get; init; }
public required int ReadTotalPayloadCount { get; init; }
public required int ReadOwnPayloadCount { get; init; }
}
sealed class SummaryInfo
{
public required double WallTimeMs { get; init; }
public required double ActiveTimeMs { get; init; }
public required long TotalSamples { get; init; }
public string? TopExclusiveMethod { get; init; }
public required double TopExclusiveMs { get; init; }
}
sealed class ThreadRootInfo
@@ -398,8 +634,25 @@ sealed class HotspotEntry
public required long CallCount { get; init; }
}
class MutableNode
sealed class HotPathEntry
{
public required string Path { get; init; }
public required long InclusiveTime { get; init; }
public required double InclusiveMs { get; init; }
public required long LeafExclusiveTime { get; init; }
public required double LeafExclusiveMs { get; init; }
}
sealed class MutableNode
{
public required CallTreeSectionOffset Offset { get; init; }
public required CallTreeSectionOffset ParentOffset { get; init; }
public required string Id { get; init; }
public required string Name { get; init; }
@@ -415,7 +668,7 @@ class MutableNode
public required List<MutableNode> Children { get; init; }
}
sealed class SerializableNode
sealed record SerializableNode
{
public required string Id { get; init; }