Skip to content

Improve SortedSet DeepCopy performance - #56561

Merged
eiriktsarpalis merged 3 commits into
dotnet:mainfrom
johnthcall:SortedSetCopy
Oct 12, 2021
Merged

Improve SortedSet DeepCopy performance#56561
eiriktsarpalis merged 3 commits into
dotnet:mainfrom
johnthcall:SortedSetCopy

Conversation

@johnthcall

Copy link
Copy Markdown
Contributor

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

MethodToolchainNMeanErrorStdDevRatioAllocated
SortedDictionaryCopymain1123.74 ns3.476 ns4.002 ns1.00392 B
SortedDictionaryCopypr154.46 ns0.877 ns0.777 ns0.44168 B
SortedSetCopymain170.85 ns0.821 ns0.768 ns1.00272 B
SortedSetCopypr129.43 ns0.661 ns0.618 ns0.4296 B
SortedDictionaryCopymain10699.64 ns13.457 ns11.929 ns1.001,136 B
SortedDictionaryCopypr10218.58 ns2.036 ns1.904 ns0.31672 B
SortedSetCopymain10297.74 ns2.702 ns2.110 ns1.00800 B
SortedSetCopypr10150.63 ns1.824 ns1.617 ns0.51528 B
SortedDictionaryCopymain10010,101.19 ns175.585 ns187.874 ns1.007,664 B
SortedDictionaryCopypr1001,896.56 ns36.241 ns41.735 ns0.195,712 B
SortedSetCopymain1002,755.01 ns41.014 ns36.358 ns1.005,216 B
SortedSetCopypr1001,438.86 ns20.528 ns18.197 ns0.524,848 B
SortedDictionaryCopymain1000140,422.29 ns1,514.264 ns1,264.479 ns1.0072,512 B
SortedDictionaryCopypr100021,330.74 ns233.688 ns195.140 ns0.1556,112 B
SortedSetCopymain100028,099.61 ns244.839 ns217.043 ns1.0048,512 B
SortedSetCopypr100017,571.99 ns154.552 ns144.568 ns0.6348,048 B

@ghostghost added community-contribution Indicates that the PR has been added by a community member area-System.Collections labels Jul 29, 2021
@ghost

Copy link
Copy Markdown

Tagging subscribers to this area: @eiriktsarpalis
See info in area-owners.md if you want to be subscribed.

Issue Details

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

MethodToolchainNMeanErrorStdDevRatioAllocated
SortedDictionaryCopymain1123.74 ns3.476 ns4.002 ns1.00392 B
SortedDictionaryCopypr154.46 ns0.877 ns0.777 ns0.44168 B
SortedSetCopymain170.85 ns0.821 ns0.768 ns1.00272 B
SortedSetCopypr129.43 ns0.661 ns0.618 ns0.4296 B
SortedDictionaryCopymain10699.64 ns13.457 ns11.929 ns1.001,136 B
SortedDictionaryCopypr10218.58 ns2.036 ns1.904 ns0.31672 B
SortedSetCopymain10297.74 ns2.702 ns2.110 ns1.00800 B
SortedSetCopypr10150.63 ns1.824 ns1.617 ns0.51528 B
SortedDictionaryCopymain10010,101.19 ns175.585 ns187.874 ns1.007,664 B
SortedDictionaryCopypr1001,896.56 ns36.241 ns41.735 ns0.195,712 B
SortedSetCopymain1002,755.01 ns41.014 ns36.358 ns1.005,216 B
SortedSetCopypr1001,438.86 ns20.528 ns18.197 ns0.524,848 B
SortedDictionaryCopymain1000140,422.29 ns1,514.264 ns1,264.479 ns1.0072,512 B
SortedDictionaryCopypr100021,330.74 ns233.688 ns195.140 ns0.1556,112 B
SortedSetCopymain100028,099.61 ns244.839 ns217.043 ns1.0048,512 B
SortedSetCopypr100017,571.99 ns154.552 ns144.568 ns0.6348,048 B
Author:johnthcall
Assignees:-
Labels:

area-System.Collections, community-contribution

Milestone:-

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

The performance improvements are impressive, but I'd be curious to know how much of that was contributed by the KeyValuePairComparer changes versus the rewriting of DeepClone. Would it be possible to provide results measuring each change individually?

@johnthcall

Copy link
Copy Markdown
ContributorAuthor

@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.

MethodToolchainNMeanErrorStdDevRatioAllocated
SortedDictionaryCopymain1233.25 ns14.931 ns17.195 ns1.00392 B
SortedDictionaryCopykvpc1179.63 ns4.817 ns5.547 ns0.77344 B
SortedDictionaryCopypr195.33 ns3.673 ns4.229 ns0.41168 B
SortedDictionaryCopymain101,353.38 ns55.142 ns63.501 ns1.001,136 B
SortedDictionaryCopykvpc10600.19 ns30.233 ns34.817 ns0.44944 B
SortedDictionaryCopypr10323.45 ns15.388 ns17.720 ns0.24672 B
SortedDictionaryCopymain10016,490.14 ns785.823 ns904.955 ns1.007,664 B
SortedDictionaryCopykvpc1004,805.14 ns91.329 ns89.697 ns0.296,080 B
SortedDictionaryCopypr1002,782.22 ns68.882 ns76.562 ns0.175,712 B
SortedDictionaryCopymain1000212,509.48 ns10,768.851 ns11,969.549 ns1.0072,512 B
SortedDictionaryCopykvpc100048,069.80 ns1,324.823 ns1,525.668 ns0.2356,576 B
SortedDictionaryCopypr100028,287.56 ns1,260.076 ns1,451.105 ns0.1356,112 B

@eiriktsarpalis

Copy link
Copy Markdown
Member

If we decide to avoid the recursion in DeepCopy I'll make a separate PR for just the KVP comparer change.

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

Copy link
Copy Markdown
Member

Separating them sounds good.

@johnthcall

Copy link
Copy Markdown
ContributorAuthor

I've reverted SortedDictionary changes from here and separated it to the following #56634

@eiriktsarpaliseiriktsarpalis added this to the 7.0.0 milestone Aug 3, 2021
@eiriktsarpalis

Copy link
Copy Markdown
Member

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

Copy link
Copy Markdown
Member

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

Copy link
Copy Markdown
ContributorAuthor

@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.

ToolchainNMeanErrorStdDevRatioAllocated
main169.80 ns3.569 ns3.967 ns1.00240 B
eirik152.87 ns2.967 ns3.298 ns0.76184 B
pr132.39 ns2.562 ns2.847 ns0.4796 B
main10302.86 ns13.321 ns14.806 ns1.00768 B
eirik10242.44 ns12.855 ns14.804 ns0.81712 B
pr10169.16 ns8.750 ns8.986 ns0.56528 B
main1002,686.17 ns121.347 ns139.744 ns1.005,184 B
eirik1002,510.29 ns128.237 ns147.678 ns0.945,128 B
pr1001,548.30 ns68.639 ns76.292 ns0.584,848 B
main100029,168.67 ns1,340.231 ns1,543.412 ns1.0048,480 B
eirik100026,338.11 ns989.322 ns1,139.305 ns0.9148,424 B
pr100019,071.81 ns745.912 ns858.994 ns0.6648,048 B

@eiriktsarpalis

eiriktsarpalis commented Sep 27, 2021

Copy link
Copy Markdown
Member

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

Copy link
Copy Markdown
Member

I took a closer look at the type's ISerializable implementation: elements are serialized as flat arrays so there doesn't appear to be a way for a valid payload to influence the shape of the materialized tree. So excluding any implementation bugs, future regressions or reflection code modifying private fields it doesn't seem to be possible to materialize linked list-like trees.

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

Copy link
Copy Markdown
ContributorAuthor

@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;}

@eiriktsarpaliseiriktsarpalis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@eiriktsarpalis

Copy link
Copy Markdown
Member

Test failures seem related to #60151.

@eiriktsarpalis
eiriktsarpalis merged commit d4e4d33 into dotnet:mainOct 12, 2021
@ghostghost locked as resolved and limited conversation to collaborators Nov 11, 2021
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-System.Collectionscommunity-contributionIndicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@johnthcall@eiriktsarpalis@stephentoub