-
Notifications
You must be signed in to change notification settings - Fork 7.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix slow execution when many breakpoints are used #14953
Conversation
@@ -482,9 +482,6 @@ internal bool TrySetBreakpoint(string scriptFile, FunctionContext functionContex | |||
{ | |||
Diagnostics.Assert(SequencePointIndex == -1, "shouldn't be trying to set on a pending breakpoint"); | |||
|
|||
if (!scriptFile.Equals(this.Script, StringComparison.OrdinalIgnoreCase)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is only called from a single place after we took them out from collection that linked to the current file. So we are sure those belong to the current file. So this is just unnecessary overhead.
{ | ||
if (item.IsScriptBreakpoint && item.Script.Equals(functionContext._file, StringComparison.OrdinalIgnoreCase)) | ||
if (dictionary.Count > 0) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not sure why IsScriptBreakpoint
was checked here, but it was not re-checked anywhere else. SetPendingBreakpoints
below is called without the list of breakpoints to set, and internally it only checks the filepath from the function context. So I skipped the check to avoid looping in case there are thousands of breakpoints in one file.
|
||
breakpoints = TriggerBreakpoints(breakpoints); | ||
if (breakpoints.Count > 0) | ||
if (functionContext._boundBreakpoints.TryGetValue(functionContext._currentSequencePointIndex, out var bps)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the meat of the improvement when looking up BP, instead of looping over all in the file we get it from the map based on sequence point.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you say it is a single place where we get a performance improvement?
If so I wonder did you try to unroll the Linq?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is not the single place where you get the improvement. The improvement is caused by:
- Saving the breakpoints mapped by path, and then by sequence point, because that is how the break points are queried. This avoids unnecessary looping.
- Not moving breakpoints into a new dictionary every time we inspect them. This avoids unnecessary array allocation. https://github.com/PowerShell/PowerShell/pull/14953/files#diff-0a4e4bd42dcf35b5e74e88bce4adba02f6d6f823b698647e3ee706d007b1915bL2051
} | ||
} | ||
|
||
_pendingBreakpoints = new ConcurrentDictionary<int, LineBreakpoint>(newPendingBreakpoints); | ||
// Here could check if all breakpoints for the current functionContext were bound, but because there is no atomic |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How would you want this solved? This should happen rarely so I might lock here. Or just keep it as is and don't clean up the dictionary of files.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't fully understand the problem we're trying to solve here, but if you/@PaulHigin is able to explain it to me, I can try to weigh in
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is pending breakpoints:
_pendingBreakpoints = new ConcurrentDictionary<string, ConcurrentDictionary<int, LineBreakpoint>>()
Pending breakpoints is a dictionary keyed by filepath, that contains a dictionary keyed by sequence point. When all pending breakpoints were bound, it would be nice to remove the key from the _pendingBreakpoints dictionary.
Something like this:
if (_pendingBreakpoints.TryGetValue(currentScriptFile, out var bpsInThisScript) && bpsInThisScript.IsEmpty) {
_pendingBreakpoints.TryRemoveValue(currentScriptFile, out _);
}
Unfortunately the first line is not atomic, so there is a race condition between the first line and the second. If someone added breakpoint right after we checked the count, in theory we could lose breakpoints.
This seems like a rare condition and can be solved in few ways.
What I did here is that I simply leave the key in the dictionary. This means 1 extra string + empty concurrent dictionary is left in memory, for each file that had breakpoints. I am guessing there are rarely more than 100 distinct files with breakpoints per powershell session, so this seems okay-ish. But still dirty.
The race condition seems very rare, and simply checking if we removed a dictionary in which an item was added and they try to merge it back in might reduce the possibility of removing a breakpoint even further. We would add another race condition, but the possibility of timing both of them exactly right seems infinitely small. Something like this:
if (_pendingBreakpoints.TryGetValue(currentScriptFile, out var bpsInThisScript) && bpsInThisScript.IsEmpty) {
if (_pendingBreakpoints.TryRemoveValue(currentScriptFile, out var removedBps) && !removedBps.IsEmpty) {
// someone added a breakpoint after we counted but before we removed
// merge it back into _pendingBreakpoints
// this would happen extremely rarely
}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The race condition needs to be better defined here. How is setting a breakpoint subject to a race here, via API? PowerShell scripts run on a single thread.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That is why I was asking how you want this solved because I don't know enough about the interaction of pending breakpoints and code execution. Maybe there is no way that pending breakpoints collection would be changed while this code is running, because it all runs on a single thread, or maybe adding a breakpoint in vscode UI calls into the PowerShell process and sets the breakpoint from a different thread.
I just assumed it is the latter, which is why ConcurrentDictionary was used in the original code and also in the new code.
In my measurements, running all my Pester tests runs ~40s without Code Coverage and ~300s with Code Coverage, which is 7 times more. Code Coverage sets around 7k breakpoints for my codebase. With the fix, it runs ~40s without CC and ~42s with CC, including all the overhead of setting up breakpoints, calculating and printing the coverage report, so the execution is probably <1% slower with 7000 breakpoints enabled. |
@PaulHigin Polite nudge :) Could I get a review please? This would be a huge step forward for Pester users. Code coverage performance was always a pain point. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please change any instances of var
where the variable type isn't on the same line to the explicit type
|
||
breakpoints = TriggerBreakpoints(breakpoints); | ||
if (breakpoints.Count > 0) | ||
if (functionContext._boundBreakpoints.TryGetValue(functionContext._currentSequencePointIndex, out var bps)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would change the var
here
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also please rename bps
to something like breakpoints
if (breakpoints.Count > 0) | ||
{ | ||
breakpoints = TriggerBreakpoints(breakpoints); | ||
if (breakpoints.Count > 0) | ||
{ | ||
StopOnSequencePoint(functionContext, breakpoints); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know it's just a style thing and it's not your fault @nohwnd, but I don't love this double count check.
Ideally we could just put the check inside TriggerBreakpoints
.
If I were really trying to make this suit my desired style, I'd make them into extension methods:
breakpoints.Trigger().StopOnSequencePoint(functionContext);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't love it either, but it prevents creating another list in TriggerBreakpoints when we call it with empty list of breakpoints. And moving the check into TriggerBreakpoints makes this code path less obvious.
I also don't love that the breakpoints variable is reused, but I went for the minimal amount of changes in this PR. If you insist on changing it I can do it. As you say it's style related. Should I make the change?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's fine as is for now
} | ||
} | ||
|
||
_pendingBreakpoints = new ConcurrentDictionary<int, LineBreakpoint>(newPendingBreakpoints); | ||
// Here could check if all breakpoints for the current functionContext were bound, but because there is no atomic |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't fully understand the problem we're trying to solve here, but if you/@PaulHigin is able to explain it to me, I can try to weigh in
I'll try and look at this later this week. The problem with changing debugging code is that it is a interactive activity and our tests don't cover everything. So I am concerned about introducing regressions. But I should have time to look later this week. |
Could we add more xUnit tests? for which methods? |
It looks like I won't be able to get to this this week. Sorry for the delay, and I'll make it a higher priority for next week. |
@PowerShell/powershell-committee Marking this for committee review, as this is a significant change to the debugging code. Debugging is interactive and our tests don't cover many scenarios, and my main concern is regressions. |
@PaulHigin This is implemented in #13673 |
@PowerShell/powershell-committee reviewed this, we understand that Pester may be depending on using the debugger for compatibility reasons with older PowerShell. We recommend looking at the profiling work as a means to hook into PowerShell for a future Pester. For this PR, we ask that it gets wrapped as an ExperimentalFeature and try to get this in early to verify there are no unintended side-effects. |
I want to get understanding what tests we should add to avoid regressions? |
This pull request has been automatically marked as Review Needed because it has been there has not been any activity for 7 days. |
This pull request has been automatically marked as Review Needed because it has been there has not been any activity for 7 days. |
@nohwnd Sorry for the long delay ... I forgot all about this PR. I reviewed these changes and feel the perf inspired changes are good and that we should take them. I'd like to get the changes in so that they can bake for a while. I am not concerned about deallocating an empty sequence point dictionary and agree with you it is not that impactful. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am fine with these changes. But a rebase is probably needed since this PR is quite old.
9ac2752
to
d8010bf
Compare
This PR has Quantification details
Why proper sizing of changes matters
Optimal pull request sizes drive a better predictable PR flow as they strike a
What can I do to optimize my changes
How to interpret the change counts in git diff output
Was this comment helpful? 👍 :ok_hand: :thumbsdown: (Email) |
Thanks @nohwnd for your contribution! |
Oh, nice. :) Thanks for getting it merged. |
🎉 Handy links: |
Something in this has broken the VS Code extension's debugger, and I'm not yet sure what. It doesn't look like the APIs we're using have changed, we're just calling |
This reverts commit d8decdc. This commit broke the VS Code extension's debugger, and should be reverted until such time that the root cause is found and a fix applied.
PR Summary
PR Context
In Pester we use breakpoints in CodeCoverage and can set thousands of them. This makes execution of scripts really slow. This is because on every sequence point, every breakpoint is inspected to see if it should be bound. This PR uses dictionaries to split breakpoints by path, and by sequence point index, to make the lookup fast.
PR Checklist
.h
,.cpp
,.cs
,.ps1
and.psm1
files have the correct copyright headerWIP:
or[ WIP ]
to the beginning of the title (theWIP
bot will keep its status check atPending
while the prefix is present) and remove the prefix when the PR is ready.(which runs in a different PS Host).