Repository navigation
Refactor Speedscope parsing and complete pre-PR verification - #55
rogeralsing wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Pull request overview
Refactors Speedscope CPU profile parsing by separating aggregation/call-tree construction from JSON document loading/validation, improving separation of concerns within the profiling pipeline.
Changes:
- Added
SpeedscopeProfileAccumulatorto encapsulate frame aggregation, call-tree building, andCpuProfileResultshaping. - Simplified
SpeedscopeParserto focus on IO + top-level JSON shape validation and delegate processing to the accumulator.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| src/ProfileTool/Cpu/SpeedscopeProfileAccumulator.cs | New component that accumulates sampled/evented profile data into function samples + call tree. |
| src/ProfileTool/Cpu/SpeedscopeParser.cs | Reduced to reading frames/profiles from JSON and delegating processing to the accumulator. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| { | ||
| var parentNode = stack.Count > 0 ? stack[^1].Node : _callTreeRoot; | ||
| var childNode = GetOrCreateChild(parentNode, frameIdx); | ||
| childNode.Calls += 1; |
There was a problem hiding this comment.
CallTreeNode.Calls is incremented via += here, which bypasses the clamping logic provided by CallTreeNode.IncrementCalls() and can overflow on large profiles (leading to negative/incorrect call counts). Consider using IncrementCalls() for the +1 case, and for weighted increments add a clamped add helper (e.g., clamp at int.MaxValue) before assigning back to Calls.
| childNode.Calls += 1; | |
| childNode.IncrementCalls(); |
| if (string.Equals(eventType, "O", StringComparison.Ordinal)) | ||
| { | ||
| var parentNode = stack.Count > 0 ? stack[^1].Node : _callTreeRoot; | ||
| var childNode = GetOrCreateChild(parentNode, frameIdx); | ||
| childNode.Calls += 1; | ||
| IncrementFrameCount(frameIdx, 1); | ||
| stack.Add((childNode, at, frameIdx)); |
There was a problem hiding this comment.
Evented events can contain an invalid/negative frame index. Unlike the sampled path (which skips negative indices), this path will create a child keyed by a negative frame index (including -1, which is also used by the root) and pollute the call tree / function list. Add a frameIdx < 0 guard before GetOrCreateChild / IncrementFrameCount.
| private void IncrementFrameCount(int frameIdx, int count) | ||
| { | ||
| _frameCounts.TryGetValue(frameIdx, out var existing); | ||
| _frameCounts[frameIdx] = existing + count; |
There was a problem hiding this comment.
IncrementFrameCount uses unchecked existing + count, which can overflow for long-running profiles and produce negative/incorrect call/sample counts in FunctionSample. Consider clamping at int.MaxValue (or switching counts to long end-to-end if feasible) to keep results stable.
| _frameCounts[frameIdx] = existing + count; | |
| long sum = (long)existing + count; | |
| if (sum > int.MaxValue) | |
| { | |
| _frameCounts[frameIdx] = int.MaxValue; | |
| } | |
| else if (sum < int.MinValue) | |
| { | |
| _frameCounts[frameIdx] = int.MinValue; | |
| } | |
| else | |
| { | |
| _frameCounts[frameIdx] = (int)sum; | |
| } |
| return weight <= 0 | ||
| ? 0 | ||
| : Math.Max(1, (int)Math.Round(weight)); |
There was a problem hiding this comment.
ResolveCallWeight casts Math.Round(weight) to int without range checking. If a speedscope weight exceeds int.MaxValue (or is NaN/Infinity), the cast can yield an unspecified/incorrect value. Clamp to [0, int.MaxValue] and handle non-finite values explicitly before converting.
| return weight <= 0 | |
| ? 0 | |
| : Math.Max(1, (int)Math.Round(weight)); | |
| // For sample units, weight represents a count of samples. | |
| // Handle non-finite values explicitly and clamp to [0, int.MaxValue]. | |
| if (double.IsNaN(weight)) | |
| { | |
| return 0; | |
| } | |
| if (double.IsPositiveInfinity(weight)) | |
| { | |
| return int.MaxValue; | |
| } | |
| if (double.IsNegativeInfinity(weight) || weight <= 0d) | |
| { | |
| return 0; | |
| } | |
| var rounded = Math.Round(weight); | |
| if (rounded <= 0d) | |
| { | |
| // Preserve previous behavior: any positive weight yields at least 1. | |
| return 1; | |
| } | |
| if (rounded >= int.MaxValue) | |
| { | |
| return int.MaxValue; | |
| } | |
| return (int)rounded; |
| var sum = 0; | ||
| foreach (var child in node.Children.Values) | ||
| { | ||
| sum += child.Calls; | ||
| } | ||
|
|
||
| return sum; |
There was a problem hiding this comment.
SumChildCalls accumulates into an int with sum += child.Calls;, which can overflow and wrap for large call trees. Use a long accumulator and clamp the final result to int.MaxValue before assigning to CallTreeNode.Calls.
| var sum = 0; | |
| foreach (var child in node.Children.Values) | |
| { | |
| sum += child.Calls; | |
| } | |
| return sum; | |
| long sum = 0; | |
| foreach (var child in node.Children.Values) | |
| { | |
| sum += child.Calls; | |
| } | |
| return sum >= int.MaxValue ? int.MaxValue : (int)sum; |
Summary
SpeedscopeProfileAccumulatorSpeedscopeParserto document/file loading plus top-level JSON shape validationTesting
DOTNET_ROLL_FORWARD=Major MSBuildSDKsPath=/usr/local/share/dotnet/sdk/9.0.304/Sdks roslynator fix src/ProfilerCore/Asynkron.Profiler.Core.csprojDOTNET_ROLL_FORWARD=Major MSBuildSDKsPath=/usr/local/share/dotnet/sdk/9.0.304/Sdks roslynator fix src/ProfileTool/ProfileTool.csprojDOTNET_ROLL_FORWARD=Major MSBuildSDKsPath=/usr/local/share/dotnet/sdk/9.0.304/Sdks roslynator fix tests/Asynkron.Profiler.Tests/Asynkron.Profiler.Tests.csprojdotnet build Asynkron.Profiler.sln -warnaserrordotnet test Asynkron.Profiler.sln --no-buildquickdup -path . -ext .cs -select 0..20 -min 2 -exclude ".g.,.generated.,bin/,obj/"dotnet format Asynkron.Profiler.sln --verify-no-changesNotes
roslynator fix Asynkron.Profiler.slnis not usable in this environment because the installed Roslynator version fails when opening the solution directly against the current SDK, so the same check was run project-by-project instead.