Uh oh!
There was an error while loading. Please reload this page.
Improve SortedSet DeepCopy performance - #56561
Conversation
ghost
commented
Jul 29, 2021
Tagging subscribers to this area: @eiriktsarpalis Issue DetailsI noticed that SortedDictionary copying was allocation more memory than iterating the initial dictionary and adding items one at a time. I realized that the optimization done as part of #45659 was not completely successful and that the SortedSet deep copy could be improved. For SortedSet prefer recursion through the tree over allocating multiple stacks. dotnet/performance@main...johnthcall:johncall/SortedSetDeepCopy
|
There was a problem hiding this comment.
We generally avoid using recursion out of concern for potential stack overflows. Theoretically speaking we should be fine since RB trees have O(log n) depth, but I'm not familiar enough with the implementation to know if it can admit unbalanced instances.
eiriktsarpalis
commented
Jul 30, 2021
The performance improvements are impressive, but I'd be curious to know how much of that was contributed by the |
johnthcall
commented
Jul 30, 2021
@eiriktsarpalis Below is the performance of main vs just the kvp comparer change vs both changes. If we decide to avoid the recursion in DeepCopy I'll make a separate PR for just the KVP comparer change.
|
eiriktsarpalis
commented
Jul 30, 2021
Introducing one change per PR is good practice in general (easier to revert etc). The KVP change is low risk with great perf improvements in its own right, so it certainly meets the bar for .NET 6 RC1. @layomia@stephentoub thoughts? |
stephentoub
commented
Jul 30, 2021
Separating them sounds good. |
johnthcall
commented
Jul 30, 2021
I've reverted SortedDictionary changes from here and separated it to the following #56634 |
eiriktsarpalis
commented
Aug 4, 2021
I moved the milestone to 7.0.0 given that we're very close to the .NET 6 release date. I will be following up with a more thorough review once .NET 7 development commences. |
eiriktsarpalis
commented
Sep 20, 2021
Hi @johnthcall, looking at this PR again it seems like using recursion in this context is safe, since depth is always bounded by 2 log(n + 1) + 1. The existing implementation does contain a few inefficiencies (e.g. allocating two stacks and traversing twice). I'm wondering though if we could get some of the perf benefits of your approach while still avoiding recursion. For example, consider the following (untested) implementation: publicNodeDeepClone(intcount){
#if DEBUGDebug.Assert(count==GetCount());
#endif
NodenewRoot=ShallowClone();varpendingNodes=newStack<(Nodesource,Nodetarget)>(2*Log2(count)+2);pendingNodes.Push((this,newRoot));while(pendingNodes.TryPop(outvarnext)){NodeclonedNode;if(next.source.LeftisNodeleft){clonedNode=left.ShallowClone();next.target.Left=clonedNode;pendingNodes.Push((left,clonedNode));}if(next.source.RightisNoderight){clonedNode=right.ShallowClone();next.target.Right=clonedNode;pendingNodes.Push((right,clonedNode));}}returnnewRoot;}I'd be curious to see what performance of something like the above could be, compared to the recursive approach. |
johnthcall
commented
Sep 25, 2021
@eiriktsarpalis Your code does pass all UT and has a perf improvement and 56 byte allocation improvement however the recursive change does outperform it. I understand because of the possibly imbalanced tree that we may want to avoid recursion, let me know what you'd like to do here.
|
Thanks for running the benchmarks. Theoretically speaking RB tree depths are bounded by O(log N), but I would need to spend time studying the actual implementation to see whether linear depth is possible in certain cases (for example it might be possible that a maliciously crafted BinaryFormatter payload or similar could result in an imbalanced set being hydrated and triggering SO when attempting to clone). cc @GrabYourPitchforks who might provide a security angle on using recursion in general. |
eiriktsarpalis
commented
Oct 6, 2021
I took a closer look at the type's Still, I don't think the performance benefits justify the potential of introducing stack overflows. I therefore conclude that we should not be taking this change. Would be happy to consider performance optimizations that don't involve recursion over the tree. |
johnthcall
commented
Oct 6, 2021
@eiriktsarpalis I've made the change use iteration instead. I tried changing the original implementation to use a single Stack like in your sample code as below but it's performance did not improve from main in the N=1000 benchmark so I've gone forward with your code change. publicNodeDeepClone(intcount){
#if DEBUGDebug.Assert(count==GetCount());
#endif
// Breadth-first traversal to recreate nodes, preorder traversal to replicate nodes.varpendingNodes=newStack<(Nodesource,Nodetarget)>(2*Log2(count)+2);NodenewRoot=ShallowClone();Node?originalCurrent=this;NodenewCurrent=newRoot;while(originalCurrent!=null){pendingNodes.Push((originalCurrent,newCurrent));newCurrent.Left=originalCurrent.Left?.ShallowClone();originalCurrent=originalCurrent.Left;newCurrent=newCurrent.Left!;}while(pendingNodes.TryPop(outvarnext)){Node?originalRight=next.source.Right;Node?newRight=originalRight?.ShallowClone();next.target.Right=newRight;while(originalRight!=null){pendingNodes.Push((originalRight,newRight!));newRight!.Left=originalRight.Left?.ShallowClone();originalRight=originalRight.Left;newRight=newRight.Left;}}returnnewRoot;} |
eiriktsarpalis
commented
Oct 12, 2021
Test failures seem related to #60151. |
I noticed that SortedDictionary copying was allocation more memory than iterating the initial dictionary and adding items one at a time. I realized that the optimization done as part of #45659 was not completely successful and that the SortedSet deep copy could be improved.
For SortedSet prefer recursion through the tree over allocating multiple stacks.
For SortedDictionary override Equals for KeyValuePairComparer so that SortedSets HasEqualComparer will pass to allow efficient deep copy.
dotnet/performance@main...johnthcall:johncall/SortedSetDeepCopy