Skip to content

Improved Map performance - #10768

Closed
krauthaufen wants to merge 6 commits into
dotnet:mainfrom
krauthaufen:main
Closed

Improved Map performance#10768
krauthaufen wants to merge 6 commits into
dotnet:mainfrom
krauthaufen:main

Conversation

@krauthaufen

Copy link
Copy Markdown
Contributor

PR for fslang-suggestion/940 with a faster Map<'Key, 'Value> implementation. The original code can be found here.

Creating this PR raised two questions from my side:

  • How shall we proceed with additional functionality I implemented? (test and expose vs. drop vs. keep and expose at a later time)
  • Shall I add the new Set implementation to this PR or create a separate one?

Cheers

@krauthaufenkrauthaufen changed the title Improved Map performance [WIP][WIP] Improved Map performanceDec 21, 2020
@krauthaufen

Copy link
Copy Markdown
ContributorAuthor

Oh and I had to reference System.ValueTuple in FSharp.Core.fsproj since I used struct-tuples in several places. Shall we just keep it that way or work around that problem?

@KevinRansomKevinRansom left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There is a huge amount here, it's going to take a while to work through everything. I wonder is there a way that you can think of where we can have the old and the new implementations in FSharp.Core at the same time and and switch between them, whether using a preview switch or #defines or some other mechanism? It feels like a big risk taking it in one gulp without a preview.

</ItemGroup>

<ItemGroup>
<PackageReference Include="System.ValueTuple" Version="4.4.0" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This shouldn't be necessary. Netstandard2.0 contains System.ValueTuple.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It still makes me a bit nervous to take a dependence on it, we have been avoiding it for so long in FSharp.Core but it is absolutely right to do so.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

I got compiler errors when not referencing it, so I simply added it, we could certainly use KeyValuePair or some other struct for the implementation but I saw several other suggestions that would require struct-tuples so I didn't go through n removing it but I can certainly do so if you think it's the way to go.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The compile errors is surprising ... reference it, if you need to. I will take a look at why we don't get it vie netstandard2.0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Within the iDE (VS) I don't get compile-time errors when using a struct tuple, but maybe something is wrong in the build process?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I will take a look, sometime.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Hey, this may have been a false alarm, i accidentally pulled master first instead of main, then switched and cherry-picked my map changes, so it might work without the reference after all.
I can look into that tomorrow (european tomorrow that is 😆)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

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

Okay it really was useless, so I removed it again (would have been necessary for the net45 build)

@cartermp

Copy link
Copy Markdown
Contributor

@KevinRansom There's a lot of discussion to wade through here: fsharp/fslang-suggestions#940

But in general we should consider taking this and carefully review with @dsyme. The CPU and memory gains are amazing, and it would quite benefit our own perf scenarios since the compiler makes such heavy use of maps and sets. I profiled our compiler last Friday and it bubbled in the profiling session (as opposed to before when other things simply dominated traces).

@KevinRansom

Copy link
Copy Markdown
Contributor

@KevinRansom There's a lot of discussion to wade through here: fsharp/fslang-suggestions#940

But in general we should consider taking this and carefully review with @dsyme. The CPU and memory gains are amazing, and it would quite benefit our own perf scenarios since the compiler makes such heavy use of maps and sets. I profiled our compiler last Friday and it bubbled in the profiling session (as opposed to before when other things simply dominated traces).

@cartermp , yeah it looks great, I was wondering if there was a way to reduce the risk, it may well be that there is not.

@krauthaufen

Copy link
Copy Markdown
ContributorAuthor

@KevinRansom please note that there's quite a lot of unused code in this PR (mostly due to new combinators) and I'll definetly clean that up when you consider merging that. I can also do that sooner if you think it makes reviewing the code easier. Nonetheless I think combinators like tryAt and especially GetSlice / Union(With) would greatly improve the usefulness of this datasrructure.

@KevinRansom

Copy link
Copy Markdown
Contributor

@krauthaufen --- It would certainly make the code review easier, if there was just the code we want to merge. Keep the extra code somewhere safe though, I'm sure it's important :-)

krauthaufen added a commit to krauthaufen/MapNew that referenced this pull request Dec 22, 2020
* removed System.ValueTuple reference
@krauthaufen

Copy link
Copy Markdown
ContributorAuthor

@KevinRansom i reduced the file by ~1000 lines.
The remaining things are more or less necessary for the current Map API to work.

@KevinRansom

Copy link
Copy Markdown
Contributor

@krauthaufen , thank you my friend.

@KevinRansom

Copy link
Copy Markdown
Contributor

@krauthaufen , there is a small conflict.

# Conflicts:
#	src/fsharp/FSharp.Core/map.fs
@krauthaufen

krauthaufen commented Dec 23, 2020

Copy link
Copy Markdown
ContributorAuthor

@KevinRansom working on it, btw. I noticed that I didn't include proper error messages in my exceptions, will do so after the whole christmas-thing...

@krauthaufen

Copy link
Copy Markdown
ContributorAuthor

Hey, I fixed the remaining problems and the implementation is now ready for reviews. I'm of course available for questions/suggestions/etc.

@forki

forki commented Jan 4, 2021

Copy link
Copy Markdown
Contributor

maybe remove the WIP then.

@krauthaufenkrauthaufen changed the title [WIP] Improved Map performanceImproved Map performanceJan 4, 2021
@krauthaufen

Copy link
Copy Markdown
ContributorAuthor

Good point 😆

@TIHanTIHan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A lot has changed, but this is implementation details and I'm confident the existing tests cover everything. From my view, this looks really good. The perf benchmarks look great too.

@cartermpcartermp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is great!

Once this is in we'll also want to update the TaggedCollections module similar to #10192 - yes, unfortunately, the compiler itself actually uses a different set of types and APIs as per #10188 (comment)

Would you be interested in making that update in a separate PR? If not it's fine, we can take care of it.

@cartermp

Copy link
Copy Markdown
Contributor

Regarding this:

Shall I add the new Set implementation to this PR or create a separate one

A separate PR would be fantastic, yes

@krauthaufen

Copy link
Copy Markdown
ContributorAuthor

Would you be interested in making that update in a separate PR? If not it's fine, we can take care of it.

@cartermp I can certainly update the TaggedCollections too, but maybe we should wait until Set/Map are merged?

A separate PR would be fantastic, yes

I'll start to put a Set-PR together then, but first I'd like to validate that also the Set implementation is really faster.

@forki

forki commented Jan 5, 2021

Copy link
Copy Markdown
Contributor

Hey @buybackoff since you were the one who optimized this last time: could you please take a look?

@buybackoff

buybackoff commented Jan 5, 2021

Copy link
Copy Markdown
Contributor

Hi @forki

Hey @buybackoff since you were the one who optimized this last time: could you please take a look?

I could only repeat one of the top comments: @KevinRansom

There is a huge amount here, it's going to take a while to work through everything.

The huge single file changes make me scary. My changes were one-evening (map only) free lunch with keeping even code layout with pipes. There were some optimization left, e.g. devirtualizing comparer calls (comparer is always the default one for the main public Map/Set).

But initial thoughts:

  • It would be nice to see the most important benchmarks for Add/TryGet/Remove/Contains for 10K and 1M values.
  • If ofSeq/ofArray are important probably nothing will beat System.Collections.Immutable. Actually just wrapping over it may be the right solution (yet not politically correct).

These changes will take a while just to understand what's going on.

I have a question:

In the benchmarks here on a 4Hz machine contains key takes 22ns. In my PR on 3.2Hz (i8700, turbo boost off, using netcore3.1), after was 35ns, or 28ns if we adjust only for CPU frequency. In the benchmark for these changes, the before number is 55ns, that would be 68ns on my machine. And in my table, the before line is 64ns. Same math applies to getItem/tryFind.

So do we really measure the performance of the version 5.0 here?

Why not to use the exact same benchmark setup and extend it with the additional items?

Update: I linked to server GC results, the math for workstation GC is even closer.

@buybackoff

Copy link
Copy Markdown
Contributor

Well... I have just downloaded https://www.nuget.org/packages/FSharp.Core/5.0.0 and opened it with dotPeek 🤔🤦‍♂️

Actually my changes didn't make it to 5.0.

image

@krauthaufen

Copy link
Copy Markdown
ContributorAuthor

Actually my changes didn't make it to 5.0.

Oh my, so we actually know nothing about that, do we? 🤦‍♂️

I think most of my code will perform more or less equal to yours but I think ofArray, etc. will still be significantly faster. (due to its completely different approach)
Of course we would need to test that.
Sorry for the confusion, will do some benchmarks tomorrow.

Cheers

@cartermpcartermp left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Putting up a block for now until we figure out FSharp.Core changes. Unfortunately, yes, it's a complicated process and we slip up sometimes.

@buybackoff

buybackoff commented Jan 5, 2021

Copy link
Copy Markdown
Contributor

Numbers vs current main branch. Using code from https://github.com/krauthaufen/MapNew

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
Intel Core i7-8700 CPU 3.20GHz (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=5.0.200-preview.20601.7
[Host] : .NET Core 5.0.1 (CoreCLR 5.0.120.57516, CoreFX 5.0.120.57516), X64 RyuJIT DEBUG
Main : .NET Core 5.0.1 (CoreCLR 5.0.120.57516, CoreFX 5.0.120.57516), X64 RyuJIT DEBUG
Job=Main MaxRelativeError=0.01 BuildConfiguration=LocalBuild IterationCount=10 WarmupCount=1 
MethodSizeMeanErrorStdDevRankCode SizeGen 0Gen 1Gen 2Allocated
getItem10035.381 ns0.8438 ns0.5581 ns1124 B----
getItem1000081.619 ns1.2875 ns0.8516 ns2124 B----
getItem1000000109.375 ns1.1262 ns0.6702 ns3124 B----
getItemNew10029.845 ns0.3784 ns0.2503 ns1282 B----
getItemNew1000061.976 ns0.3408 ns0.2028 ns2282 B----
getItemNew100000093.273 ns1.3122 ns0.8679 ns3282 B----
containsKey10030.116 ns0.5946 ns0.3538 ns1175 B----
containsKey1000056.167 ns1.1283 ns0.7463 ns2175 B----
containsKey100000094.005 ns2.1741 ns1.4381 ns3175 B----
containsKeyNew10031.732 ns0.2470 ns0.1470 ns1223 B----
containsKeyNew1000061.257 ns0.3315 ns0.2193 ns2223 B----
containsKeyNew100000092.901 ns1.1783 ns0.7794 ns3223 B----
itemCount100188.675 ns2.8392 ns1.8779 ns196 B----
itemCount1000034,833.115 ns1,329.9992 ns879.7121 ns296 B----
itemCount10000008,603,079.688 ns214,615.4686 ns141,954.8443 ns396 B----
itemCountNew1001.745 ns0.0415 ns0.0274 ns128 B----
itemCountNew100001.783 ns0.0677 ns0.0448 ns128 B----
itemCountNew10000002.201 ns0.0634 ns0.0419 ns228 B----
iterForeach1003,177.495 ns74.5034 ns49.2794 ns1283 B0.9727--6120 B
iterForeach10000324,238.965 ns4,494.5489 ns2,674.6339 ns2283 B95.2148--600120 B
iterForeach100000044,529,380.000 ns1,319,685.6302 ns872,890.3346 ns3283 B9500.0000--60000231 B
iterForeachNew1002,720.537 ns39.5423 ns26.1548 ns11192 B0.6409--4040 B
iterForeachNew10000282,560.364 ns4,493.2936 ns2,350.0794 ns21192 B63.4766--400040 B
iterForeachNew100000037,056,267.857 ns885,648.7498 ns585,801.8121 ns31192 B6357.1429--40000129 B
addItem100164.311 ns3.2224 ns2.1314 ns1603 B0.05860.0003-369 B
addItem1000037,396.420 ns749.3590 ns445.9315 ns2603 B11.01563.5156-69324 B
addItem10000006,349,347.900 ns89,281.6470 ns59,054.2815 ns3603 B1600.000090.0000-10096697 B
addItemNew100217.753 ns3.9022 ns2.5811 ns1178 B0.05860.0003-369 B
addItemNew1000047,099.414 ns820.5227 ns542.7249 ns2178 B11.01563.3594-69324 B
addItemNew10000007,763,597.333 ns119,464.6711 ns71,091.5095 ns3220 B1600.000090.0000-10096697 B
removeItem10012.204 ns0.3569 ns0.2361 ns1626 B0.0064--40 B
removeItem100001,188.044 ns13.2493 ns8.7636 ns2626 B0.6372--4000 B
removeItem1000000121,771.113 ns2,547.2179 ns1,684.8269 ns3626 B63.7500--400001 B
removeItemNew10011.795 ns0.3364 ns0.2225 ns1145 B0.0064--40 B
removeItemNew100001,146.194 ns18.3236 ns12.1199 ns2145 B0.6372--4000 B
removeItemNew1000000156,384.700 ns46,225.3597 ns30,575.2133 ns3145 B60.0000--400000 B

@krauthaufen

krauthaufen commented Jan 5, 2021

Copy link
Copy Markdown
ContributorAuthor

Hey, cool that you did some benchmarks, i will add mine as soon as i have time...
As far as i see the current implementation seems to be a little faster for some cases, nonetheless I think the ofArray performance will be significantly better.

So if that turns out to be true I'll just submit that as a PR.

@yatli

yatli commented Jan 6, 2021

Copy link
Copy Markdown

@buybackoff your benchmark job is running in DEBUG:

[Host] : .NET Core 5.0.1 (CoreCLR 5.0.120.57516, CoreFX 5.0.120.57516), X64 RyuJIT DEBUG
Main : .NET Core 5.0.1 (CoreCLR 5.0.120.57516, CoreFX 5.0.120.57516), X64 RyuJIT DEBUG

Job=Main MaxRelativeError=0.01 BuildConfiguration=LocalBuild

I'm not sure why that happens -- the job should've be run in a child process in release mode (no DEBUG at the end of the line)
Try dotnet run -c Release?

@buybackoff

Copy link
Copy Markdown
Contributor

@yatli

It's this dotnet/BenchmarkDotNet#1493.

Using the workaround doesn't change results more than a noise.

@krauthaufen

Copy link
Copy Markdown
ContributorAuthor

Hey, I finally got to run the benchmarks and the results (for count=100) seem consistent with what we've seen so far.
After all my implementation (New in the Benchmarks) is a tiny bit slower for some operations (add, remove, etc.), a little faster for some others (tryFind, etc.). The operations drastically faster are toArray (due to the known count), ofArray (due to the merge-sort implementation)

After all I see two options here:

  1. use the new Map implementation (and tweak it a little s.t. add/remove are on par with current)
  2. use the current implementation and improve at least ofArray/ofList/etc. (maybe also include the count in inner nodes?)

What do you think?

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
Intel Core i7-9750H CPU 2.60GHz, 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=5.0.200-preview.20601.7
[Host] : .NET Core 3.1.10 (CoreCLR 4.700.20.51601, CoreFX 4.700.20.51901), X64 RyuJIT DEBUG
Job-MJAPUR : .NET Core 3.1.10 (CoreCLR 4.700.20.51601, CoreFX 4.700.20.51901), X64 RyuJIT
Server=False IterationTime=100.0000 ms MaxIterationCount=20
MethodCountMeanErrorStdDevRatioRatioSDGen 0Gen 1Gen 2Allocated
Map_add10014,666.15 ns97.249 ns86.209 ns1.000.006.1047--38992 B
MapNew_add10016,628.96 ns135.702 ns120.296 ns1.130.015.9211--37360 B
Map_remove10014,483.32 ns151.238 ns134.069 ns1.000.005.3571--34368 B
MapNew_remove10015,675.43 ns208.992 ns174.518 ns1.080.024.6875--29608 B
Map_ofArray10011,584.76 ns150.759 ns125.890 ns1.000.004.2098--28360 B
MapNew_ofArray1005,507.70 ns64.988 ns54.268 ns0.480.010.8741--5600 B
Map_ofList10011,040.13 ns219.175 ns234.515 ns1.000.004.34780.1087-27304 B
MapNew_ofList1006,011.46 ns72.356 ns67.682 ns0.540.011.0664--6792 B
Map_ofSeq10012,125.45 ns115.617 ns102.491 ns1.000.004.51660.1221-28552 B
MapNew_ofSeq1006,193.12 ns102.929 ns96.280 ns0.510.011.0386--6792 B
Map_toArray1001,547.31 ns23.038 ns20.423 ns1.000.001.01100.0153-6424 B
MapNew_toArray100776.14 ns5.881 ns4.911 ns0.500.010.50760.0077-3224 B
Map_toList1001,074.89 ns13.688 ns12.804 ns1.000.000.89080.0220-5600 B
MapNew_toList1001,127.79 ns12.803 ns10.691 ns1.050.020.88520.0224-5600 B
Map_enumerate1003,105.46 ns48.375 ns45.250 ns1.000.001.1766--7800 B
MapNew_enumerate1002,975.47 ns22.260 ns20.822 ns0.960.010.6194--4000 B
Map_toSeq_enum1004,895.17 ns41.850 ns37.098 ns1.000.001.6563--10472 B
MapNew_toSeq_enum1004,999.20 ns55.518 ns49.215 ns1.020.011.0408--6600 B
Map_containsKey_all1002,417.70 ns17.477 ns15.493 ns1.000.00----
MapNew_containsKey_all1002,358.98 ns40.268 ns41.353 ns0.980.02----
Map_containsKey_nonexisting10027.02 ns0.216 ns0.180 ns1.000.00----
MapNew_containsKey_nonexisting10022.03 ns0.168 ns0.157 ns0.820.01----
Map_tryFind10031.46 ns0.288 ns0.255 ns1.000.000.0035--24 B
MapNew_tryFind10023.94 ns0.166 ns0.147 ns0.760.010.0038--24 B
Map_tryFind_nonexisting10024.36 ns0.379 ns0.336 ns1.000.00----
MapNew_tryFind_nonexisting10022.16 ns0.220 ns0.195 ns0.910.02----
Map_remove_all10010,491.85 ns49.821 ns38.897 ns1.000.004.4044--28216 B
MapNew_remove_all10013,698.76 ns66.820 ns55.797 ns1.310.013.8631--24712 B
Map_exists100308.65 ns1.629 ns1.444 ns1.000.000.0031--24 B
MapNew_exists100351.49 ns2.939 ns2.749 ns1.140.010.0035--24 B
Map_fold100258.62 ns3.153 ns2.633 ns1.000.000.0026--24 B
MapNew_fold100328.32 ns2.504 ns2.342 ns1.270.010.0033--24 B
Map_foldBack100264.86 ns2.028 ns1.797 ns1.000.000.0027--24 B
MapNew_foldBack100333.76 ns2.223 ns1.970 ns1.260.010.0034--24 B

@buybackoff

Copy link
Copy Markdown
Contributor

What do you think?

  • Virtual methods would be the first thing I would try to remove if I wanted to optimize a hot path. It's actually surprising for me that they do not kill the performance too much. Maybe devirtualization kicks in. Also there are two isinst checks on hot paths, instead of one checking for a sealed class and a null check.
  • It's more OOP rather than FP. If no one cares about that, then the best optimization would be really just to wrap over System.Collections.Immutable. Minus couple of thousands of code lines in total and faster performance. But concise functional recursive AVL tree implementation is nice on it's own.
  • In my view, get/contains/add/remove with structural sharing (aka immutability, not thread safety) is the most important usage of immutable maps/sets. If immutability is used only for thread-safety then it's wrong to use trees, ConcurrentDictionary is 10x faster. With that, bulk operations such as AddRange may be important. Serialization is a strong point. If one cares about performance, it's unlikely that built-in serialization is of any use ever. For latency, I would prefer to deserialize in a streaming manner, i.e. from IEnumerable. For throughput and big number of values creating a copy of array and sort it just takes too much memory (but less GC in the new implementation, see next point). With immutable collections event sourcing and serializing deltas is the best, so that one could have a snapshot of a map at multiple points.
  • System.Collections.Immutable has a clever way of optimizing bulk create operations. They have a field with IsFrozen state, and they mutate the nodes during bulk inserts. For the current case, we may treat all nodes as mutable for ofArray/ofSeq methods until those methods return. That should reduce GC a lot.
  • Size matters, both the line count (912 current vs 1705 new) and compiled code size. E.g. there are 3 different implementations for Add. In most cases the one from a node is used, then at the last step it's one from a leaf. I'm not sure what BDN reports as the code size, maybe just the size of the entry point, and it doesn't count all the code size that is touched during the execution.
  • Set and complier collections are already done with the current version.

Huge improvement here are only for bulk creation, at the cost of array creation/copying and pre-sorting data to keep a tree balanced during addition. That could be done using Array.Sort(keys[],values[],..,comparer) method, without special cases for small count, in a new method.

So, with the arguments above, I'm for option 2 not only because it's mine and have never had a daylight 😄

@krauthaufen

Copy link
Copy Markdown
ContributorAuthor
  1. Devirtualiuation can't kick here since the node types are unknown and i don't think theres any type-testing involved in the virtual calls (basically a pointer-fetch, an addition and an indirect call) i have several old benchmarks that virtual calls are way faster than type-test (also requiring some kind of null check) for large branching factors (obviously not for three though)
  2. The ImmutableDictionary, etc. weren't that awesome when i did my HashMap benchmarks but maybe that has changed since then.
  3. The mutable in-place add is actually implemented in my map and used in ofArray for small counts

Nonetheless i totally get your point and I'm absolutely fine with keeping the current implementation. I actually implemented it for having additional functions and then stumbled upon the performance gains.

Cheers

@buybackoff

Copy link
Copy Markdown
Contributor

Devirtualiuation can't kick here since the node types are unknown and i don't think theres any type-testing involved in the virtual calls

The virtual calls in many cases are after isinst check, maybe that works. Unfortunately all the new rules are scattered across dotnet/runtime repo, couldn't find a single place with a list of all cases after multiple improvements by @AndyAyersMS

The ImmutableDictionary, etc. weren't that awesome when i did my HashMap benchmarks but maybe that has changed since then.

From the very old discussion the benchmarks were faster: #5360 (comment)

The mutable in-place add is actually implemented in my map and used in ofArray for small counts

I mean methods like map.AddRange(pairs:IEnumerable<K,V>) -> map, i.e. optimize multiple operations with less GC but keep immutability.

@krauthaufen

Copy link
Copy Markdown
ContributorAuthor

Here's my (also very old) HashMap comparison https://github.com/krauthaufen/ImmutableHashCollections which also included the Map (which of course is a different datastructure) for reference which showed that add wasn't too good in the ImmutableDictionary, however the lookup was terribly fast..

@AndyAyersMS

Copy link
Copy Markdown
Member

find a single place with a list of all cases

Unfortunately there is no simple way to describe what can be devirtualized; it depends a lot on the generated IL, what other optimizations the jit can do, etc.

I can look if you like but it may be a day or two before I have time. If you know how to use a checked jit you can enable jit dumps and see what the jit itself has to say.

Also, we are always looking for good F# benchmarks, please consider contributing some to https://github.com/dotnet/performance.

@buybackoff

Copy link
Copy Markdown
Contributor

@AndyAyersMS
Thanks for confirming that I didn't miss anything.

I can look if you like but it may be a day or two before I have time. If you know how to use a checked jit you can enable jit dumps and see what the jit itself has to say.

I haven't even profiled the new implementation, so cannot even say if it was devirtualized or not. It's very easy to see just in dotTrace.

For this discussion a very relevant to your work is static readonly devirtualization. It's impossible to create a static readonly field and access it directly in F#. I think if that was possible, the default comparer for primitives could be inlined. Now we do an interface call even for int32 comparer. dotTrace showns 14% in Compare for tryGetValue.

@buybackoff

Copy link
Copy Markdown
Contributor

Improved the performance of the current version at small memory cost here: #10845

@cartermp

Copy link
Copy Markdown
Contributor

@krauthaufen in light of the discussion here, how possible is it to extract the improvements you've made (e.g. to ToArray) independently of the rest?

@buybackoffbuybackoff mentioned this pull request Jan 8, 2021
@krauthaufen

Copy link
Copy Markdown
ContributorAuthor

Hey, after doing yet another map implementation (Yam) with the insights gained here I managed to make most operations a bit faster (especially note the ofArray performance which now doesn't copy anything and therefore runs with O(1) scratch-memory) @cartermp this basically treats the tree as mutable during build (in ofArray, ofList and ofSeq) and reuses as much nodes as possible.

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19041.746 (2004/?/20H1)
Intel Core i7-4930K CPU 3.40GHz (Haswell), 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=5.0.100
[Host] : .NET Core 3.1.9 (CoreCLR 4.700.20.47201, CoreFX 4.700.20.47203), X64 RyuJIT DEBUG
Job-WQVKON : .NET Core 3.1.9 (CoreCLR 4.700.20.47201, CoreFX 4.700.20.47203), X64 RyuJIT
Server=False
MethodCountMeanErrorStdDevRatioRatioSDGen 0Gen 1Gen 2Allocated
Yam_add10015,401.56 ns243.887 ns216.199 ns0.800.025.8289--36608 B
Map_add10019,254.27 ns378.406 ns566.381 ns1.000.005.7373--36136 B
Yam_remove10015,461.79 ns307.595 ns554.657 ns0.790.044.9744--31232 B
Map_remove10019,426.96 ns381.377 ns546.959 ns1.000.005.4321--34144 B
Yam_ofArray1009,095.73 ns181.949 ns367.546 ns0.600.020.70190.0153-4416 B
Map_ofArray10015,010.79 ns194.285 ns162.237 ns1.000.004.45560.1373-28000 B
Yam_toArray1001,379.79 ns27.016 ns42.850 ns0.650.030.51310.0114-3224 B
Map_toArray1002,122.62 ns41.669 ns66.091 ns1.000.001.02230.0191-6424 B
Yam_containsKey_all1003,586.66 ns55.260 ns51.690 ns1.000.08----
Map_containsKey_all1003,622.26 ns71.927 ns176.438 ns1.000.00----
Yam_containsKey_nonexisting10028.18 ns0.570 ns0.610 ns0.850.02----
Map_containsKey_nonexisting10033.33 ns0.581 ns0.515 ns1.000.00----
Yam_exists100380.36 ns5.128 ns4.546 ns0.920.010.0038--24 B
Map_exists100413.72 ns3.673 ns3.436 ns1.000.000.0038--24 B

When maintaining a count per inner-node (allowing for O(1) count and O(log N) positional queries) the results are still relatively acceptable:

// * Summary *
BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19041.746 (2004/?/20H1)
Intel Core i7-4930K CPU 3.40GHz (Haswell), 1 CPU, 12 logical and 6 physical cores
.NET Core SDK=5.0.100
[Host] : .NET Core 3.1.9 (CoreCLR 4.700.20.47201, CoreFX 4.700.20.47203), X64 RyuJIT DEBUG
Job-NTZQCI : .NET Core 3.1.9 (CoreCLR 4.700.20.47201, CoreFX 4.700.20.47203), X64 RyuJIT
Server=False
MethodCountMeanErrorStdDevRatioRatioSDGen 0Gen 1Gen 2Allocated
Yam_add10017,137.13 ns338.754 ns527.398 ns0.880.035.7983--36424 B
Map_add10019,499.56 ns309.162 ns289.190 ns1.000.005.8594--36832 B
Yam_remove10017,596.50 ns344.689 ns423.309 ns0.980.035.8289--36672 B
Map_remove10017,981.42 ns359.338 ns413.814 ns1.000.005.4321--34112 B
Yam_ofArray10011,144.64 ns221.712 ns217.751 ns0.670.020.77820.0153-4912 B
Map_ofArray10016,669.08 ns331.241 ns622.151 ns1.000.004.57760.1221-28744 B
Yam_toArray1001,025.46 ns18.328 ns30.114 ns0.480.030.51310.0114-3224 B
Map_toArray1002,137.10 ns40.995 ns66.199 ns1.000.001.02230.0191-6424 B
Yam_containsKey_all1003,575.39 ns70.659 ns96.719 ns1.050.03----
Map_containsKey_all1003,428.17 ns67.072 ns74.550 ns1.000.00----
Yam_containsKey_nonexisting10033.52 ns0.708 ns1.038 ns1.080.03----
Map_containsKey_nonexisting10031.19 ns0.516 ns0.403 ns1.000.00----
Yam_exists100366.18 ns7.328 ns9.268 ns0.950.030.0038--24 B
Map_exists100385.18 ns5.914 ns6.328 ns1.000.000.0038--24 B

However note that the Map benchmarks differ between the two runs, so I'll investigate.

If you're still interested in the (now smaller) improvements I can create a new PR (or adapt this one). Note that I have something in mind for keeping the overall count (not per inner node but globally per map) that should be quite efficient.
Please let me know what you think. The implementation is here

@cartermp

Copy link
Copy Markdown
Contributor

Interesting. I think it'd be interesting to look at a fresh PR with your findings, with measurements against latest main.

cartermp pushed a commit that referenced this pull request Feb 26, 2021
The improvements to Map have only made it to 5.0.1 nuget package, not 5.0.0
see #10768 (comment)
cc @buybackoff@krauthaufen
@KevinRansom

Copy link
Copy Markdown
Contributor

@krauthaufen ,

Big changes like this scare us to death, there are a lot of conflicts, and not much traffic for 6 months. Could you possibly find a way to parcel up the changes into smaller PRs that are easier to digest and less scary.

Closing for now

Thanks

Kevin

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants

@krauthaufen@cartermp@KevinRansom@forki@buybackoff@yatli@AndyAyersMS@TIHan