Skip to content

Faster equality in generic contexts - #16615

Merged
psfinaki merged 2 commits into
dotnet:mainfrom
psfinaki:equality-3
Mar 4, 2024
Merged

Faster equality in generic contexts#16615
psfinaki merged 2 commits into
dotnet:mainfrom
psfinaki:equality-3

Conversation

@psfinaki

@psfinakipsfinaki commented Jan 30, 2024

Copy link
Copy Markdown
Contributor

What is this?

This is the first part of this effort to resurrect the awesome work on compiler performance by @manofstick.

How is this different?

Many things have changed, among other things we now have a bigger team & community to review things and a different release cadence allowing us to dogfood things a bit.

More importantly, the original PRs often contained multiple ideas (fixes, breaking, optimizations, experiments, ...), whereas I want to narrow PRs as much as possible to make things easier to review and control.

Is this PR breaking?

A tiny bit. This is mostly an optimization.

Here is an example of code where the behavior does change:

[<Struct; CustomEquality; NoComparison>]typeBlah=interface IEquatable<Blah>withmemberlhs.Equals rhs = failwith "bang"leteq x y = printfn "hello"// long code insuring no inlining...
x = y // generic equality contextletresult= eq (Blah())(Blah())// currently false, will be exception

We agreed that it's OK here.

What's the source of inspiration here?

This is essentially part of the #5112, with the following changes:

  • Optimizer is not touched (there wasn't a consensus on that)
  • tail calls are not touched (same, also partially was there to work around JIT issues that were resolved later)
  • => hence IL is not changed
  • some comparison optimizations are removed (I wasn't convinced by them)
  • refactoring is removed (might be done in a followup)
  • => hence diff is minimized
  • benchmarks are added

So where does this improve things?

More theory and motivation is in this document.

TL;DR: this improves things when HashIdentity.Structural<'T> comparison is used in non-inlined code.

Example optimization

Let's look at the following code:

typeMusician={
Name:string
Surname:string}letmusicians=[{ Name ="Dave"; Surname ="Gahan"}{ Name ="Jim"; Surname ="Morrisson"}{ Name ="Robert"; Surname ="Smith"}{ Name ="Dave"; Surname ="Grohl"}{ Name ="Johnny"; Surname ="Marr"}{ Name ="David"; Surname ="Gilmour"}]letgetInitials musician =struct(musician.Name |> Seq.head, musician.Surname |> Seq.head)letresult= musicians |> List.map getInitials
|> List.distinct

How does List.distinct work?

There are 3 important parts of the algorithm to talk about.

  1. Pick the comparer

The comparer is something implementing IEqualityComparer<'T> which means 2 methods: GetHashCode(x) and Equals(x, y).

List.distinct list calls List.distinctWithComparer HashIdentity.Structural<'T> and the logic for picking HashIdentity.Structural<'T> (the comparer) is written in prim-types.fs.

  1. Initialize the hash set for the distinct elements

This means calling let hashSet = HashSet<'T>(comparer) with the comparer picked above.

  1. Add the elements to the hash set

So in our case,

hashSet.Add('D','G')
hashSet.Add('J','M')
hashSet.Add('R','S')
hashSet.Add('D','G')
hashSet.Add('J','M')
hashSet.Add('D','G')

Now, the Add(element) operation works in the following way:

  1. Do all the bucket initialization stuff they teach about in the universities
  2. Execute GetHashCode to get the hash of the element
  3. Check if this hash is already present
  4. If yes, execute Equals to see if this is the same element and decide on adding it to the hash set
  5. Otherwise just add the element to the hash set

This means, in our case there will be:

  • 6 GetHashCode calls (since there are 6 elements altogether)
  • 3 Equals calls (since there are only 3 unique elements)

Now, what changes in this PR is that we become smarter at picking the faster comparer (step 1). This brings enormous benefit at doing all the things in the step 3.

Before, this is how the comparer picking would be executed:

List.distinct list
List.distinctWithComparer HashIdentity.Structural<'T> list
// check if this is a basic F# type - we optimize things for them
FastGenericEqualityComparerTable<'T>.Function
// no, this is not a basic type hence go the worst case - create generic equality comparer
MakeGenericEqualityComparer<'T>// this is what it creates - we'll get to the consequences later{new IEqualityComparer<'T>withmember_.GetHashCode(x)= GenericHash x member_.Equals(x,y)= GenericEquality x y }

Now, this is what is going on:

List.distinct list
List.distinctWithComparer HashIdentity.Structural<'T> list
// call "smart" `canUseDefaultEqualityComparer` to see if this is a type applicable for the default equality comparison
FastGenericEqualityComparerTable<'T>.Function
// yes it is
EqualityComparer<'T>.Default
// this is property but it basically creates kind of a "native" comparer for this type, like{new IEqualityComparer<(char, char)>withmember_.GetHashCode(x)= x.GetHashCode()member_.Equals(x,y)= x.Equals(y)}

Hence, this is the difference for each of the 6 GetHashCode calls in question (taking first element ('D', 'G') as an example).

Before, using the generic equality comparer:

GetHashCode ('D','G')
GenericHashIntrinsic ('D','G')
GenericHashParamObj (object ('D','G'))// boxing!(IStructuralEquatable (ValueTuple ('D','G'))).GetHashCode()
GetHashCodeCore ('D','G')
comparer.GetHashCode('D')
GenericHashParamObj (object ('D'))// boxing!(char 'D').GetHashCode()
comparer.GetHashCode('G')
GenericHashParamObj (object ('G'))// boxing!(char 'G').GetHashCode()
HashCode.Combine

Now, using the "native" comparer:

GetHashCode ('D','G')(ValueTuple ('D','G').GetHashCode()
GetHashCodeCore ('D','G')
comparer.GetHashCode('D')(char 'D').GetHashCode()
comparer.GetHashCode('G')(char 'G').GetHashCode()
HashCode.Combine

Now, this difference for each of the 3 Equals calls in question (taking the elements ('D', 'G') as an example).

Before, using the generic equality comparer:

Equals ('D','G')('D','G')
GenericEqualityIntrinsic ('D','G')('D','G')
GenericEqualityObj (object ('D','G'))(object ('D','G'))// boxing!(IStructuralEquatable (ValueTuple ('D','G'))).Equals(ValueTuple ('D','G'))
comparer.Equals('D','D'))
GenericEqualityObj (object ('D'))(object ('D'))// boxing!(char 'D').Equals(object 'D')'D'==(char)'D'
comparer.Equals('G','G'))
GenericEqualityObj (object ('G'))(object ('G'))// boxing!(char 'G').Equals(object 'G')'G'==(char)'G'

Now, using the "native" comparer:

Equals ('D','G')('D','G')(ValueTuple ('D','G')).Equals(ValueTuple ('D','G'))(char 'D').Equals(char 'D')'D'=='D'(char 'G').Equals(char 'G')'G'=='G'

We can see that here we remove all the boxing and use the optimal call chain. This brings huge benefits for the tiny cost of executing the "smart" function on deciding about the comparer once.

The benefits of this approach vary based on the concrete algorithm and the elements in question. See the details about improved call chains in the spec mentioned above.

If we modify the example to have 1000 elements with 10 unique ones, we get the following results:

Before:

MethodMeanErrorStdDevGen 0Gen 1Gen 2Allocated
TheBenchmark142.4 us3.75 us10.82 us34.1797--210.49 KB

After:

MethodMeanErrorStdDevGen 0Gen 1Gen 2Allocated
TheBenchmark24.90 us0.871 us2.569 us0.1526--984 B

Which means 6x faster and 213x less memory.

Benchmarks

Main targets: structs, enums, floats, and specia/l generic types:

Structs and enums

Before:

MethodMeanErrorStdDevGen 0Gen 1Gen 2Allocated
FSharpStruct503.7 us36.91 us107.67 us68.3594--420.61 KB
FSharpEnum142.7 us4.76 us13.73 us22.9492--140.65 KB
CSharpStruct148.2 us3.81 us10.94 us38.452112.8174-237.58 KB
CSharpEnum134.0 us8.43 us24.44 us22.8271--140.59 KB

After:

MethodMeanErrorStdDevMedianGen 0Gen 1Gen 2Allocated
FSharpStruct73.44 us5.158 us15.046 us68.59 us0.1221--792 B
FSharpEnum16.10 us0.396 us1.163 us16.35 us0.0458--336 B
CSharpStruct77.63 us2.285 us6.482 us79.15 us28.56459.4604-179312 B
CSharpEnum15.92 us0.638 us1.839 us16.21 us0.0916--656 B

Huge improvements in both execution time and allocs, with especially remarkable results for native F# constructs.

Value tuples

Before:

MethodMeanRatioGen 0Gen 1Gen 2Allocated
ValueTuple3673.4 us1.0061.523415.8691-378.13 KB
ValueTuple4812.2 us1.2269.091819.7754-424.98 KB
ValueTuple51,004.2 us1.5084.960924.4141-523.63 KB
ValueTuple61,100.7 us1.6592.773423.4375-570.48 KB
ValueTuple71,324.9 us1.97117.187557.617229.2969669.14 KB
ValueTuple81,461.9 us2.20117.187558.105529.2969762.85 KB

After:

MethodMeanRatioGen 0Gen 1Gen 2Allocated
ValueTuple3173.0 us1.0028.56459.3994-175.11 KB
ValueTuple4174.9 us1.0328.56459.4604-175.11 KB
ValueTuple5208.9 us1.2234.423811.3525-211.29 KB
ValueTuple6217.0 us1.2634.423811.3525-211.29 KB
ValueTuple7293.7 us1.7329.296929.296929.2969247.48 KB
ValueTuple8293.8 us1.7329.296929.296929.2969247.48 KB

~80% in speed and ~50% in memory reduction, also much steeper ratios' increase for both.

Options and co

Before:

MethodMeanGen 0Gen 1Gen 2Allocated
Option165.0 us16.35743.1738-101.74 KB
ValueOption157.1 us28.80864.3945-177.02 KB
Result186.3 us40.405310.0098-248.25 KB

After:

MethodMeanGen 0Gen 1Gen 2Allocated
Option82.13 us12.69533.0518-78.33 KB
ValueOption55.09 us9.76561.5869-59.98 KB
Result75.92 us22.58305.6152-138.93 KB

50-75% speed and 25-75% memory improvements.

Nullable<'T>

Before:

MethodMeanGen 0Gen 1Gen 2Allocated
Nullable443.7 us24.90233.9063-153.66 KB

After:

MethodMeanGen 0Gen 1Gen 2Allocated
Nullable60.16 us9.76561.5869-59.94 KB

About 7x speed and 3x memory improvements.

Floats

Before:

MethodMeanErrorStdDevGen 0Gen 1Gen 2Allocated
FloatER51.47 us2.228 us6.570 us7.08010.3662-43.68 KB
Float32ER56.39 us2.566 us7.525 us7.11060.3662-43.68 KB
FloatPER149.49 us2.952 us7.513 us38.33012.6855-231.34 KB
Float32PER136.09 us5.584 us16.201 us37.59772.4414-227.01 KB

After:

MethodMeanErrorStdDevMedianGen 0Gen 1Gen 2Allocated
FloatER15.26 us0.487 us1.397 us15.38 us3.28060.1678-20.1 KB
Float32ER15.80 us0.316 us0.666 us15.82 us3.28060.1678-20.1 KB
FloatPER82.88 us5.580 us16.452 us93.36 us14.95361.2817-90.46 KB
Float32PER94.22 us1.851 us3.904 us93.97 us14.16020.9766-86.43 KB

PER comparison still takes more time and memory but still the improvements are 2-3 times in all cases.


Also (positively) affected: basic types, arrays, reference types - due to shorter call chains and less casting:

Arrays

Before:

MethodMeanErrorStdDev
Int32974.3 us73.48 us214.3 us
Int641,090.7 us58.65 us172.9 us
Byte1,075.3 us41.56 us121.9 us
Obj1,451.8 us43.91 us128.8 us

After:

MethodMeanErrorStdDev
Int32253.3 us18.09 us52.78 us
Int64312.3 us14.06 us41.45 us
Byte246.5 us6.11 us17.82 us
Obj489.3 us17.69 us51.61 us

About 3x faster.

F# basic types

Before (countBy):

MethodMeanErrorStdDev
Bool39.06 us1.936 us5.709 us
SByte55.23 us2.032 us5.992 us
Byte50.62 us1.617 us4.766 us
Int1685.46 us4.668 us13.764 us
UInt1686.66 us3.189 us9.351 us
Int3286.21 us4.690 us13.827 us
UInt3287.69 us4.911 us14.480 us
Int64112.81 us3.962 us11.681 us
UInt64112.66 us4.003 us11.550 us
IntPtr114.61 us3.430 us10.114 us
UIntPtr109.40 us3.322 us9.796 us
Char98.99 us2.825 us8.330 us
String214.52 us5.968 us17.503 us
Decimal315.84 us12.545 us36.988 us

After (countBy):

MethodMeanErrorStdDev
Bool29.19 us1.608 us4.740 us
SByte50.72 us2.167 us6.390 us
Byte47.40 us1.719 us5.069 us
Int1683.50 us3.508 us10.342 us
UInt1684.48 us2.949 us8.649 us
Int3287.24 us2.670 us7.832 us
UInt3286.17 us3.630 us10.703 us
Int6495.78 us4.763 us14.044 us
UInt64113.86 us4.278 us12.479 us
IntPtr110.92 us3.192 us9.412 us
UIntPtr105.11 us3.219 us9.440 us
Char91.03 us4.164 us12.211 us
String214.23 us5.859 us17.276 us
Decimal150.39 us3.703 us10.683 us

Before (distinct):

MethodMeanErrorStdDev
Bool9.489 us0.7345 us2.142 us
SByte12.568 us0.4049 us1.168 us
Byte12.393 us0.4176 us1.231 us
Int1623.539 us0.8906 us2.626 us
UInt1622.311 us0.8351 us2.449 us
Int3222.302 us0.4448 us1.180 us
UInt3222.092 us0.4760 us1.319 us
Int6425.567 us0.8679 us2.518 us
UInt6426.200 us1.4150 us4.172 us
IntPtr25.528 us1.4250 us4.202 us
UIntPtr25.000 us0.8785 us2.590 us
Char22.883 us0.8124 us2.383 us
String47.659 us1.6451 us4.799 us
Decimal58.086 us4.7662 us13.828 us

After:

MethodMeanErrorStdDev
Bool9.408 us0.9121 us2.689 us
SByte11.795 us0.3516 us1.020 us
Byte11.225 us0.4078 us1.170 us
Int1623.211 us0.7982 us2.354 us
UInt1621.682 us1.0798 us3.184 us
Int3220.035 us0.6128 us1.787 us
UInt3220.123 us0.8883 us2.591 us
Int6422.967 us0.7908 us2.319 us
UInt6424.757 us1.3208 us3.832 us
IntPtr26.448 us1.3528 us3.989 us
UIntPtr24.596 us1.1292 us3.330 us
Char22.670 us0.8961 us2.642 us
String40.480 us1.3767 us3.994 us
Decimal35.401 us1.4948 us4.289 us

Note that decimals also show improvement in memory allocations:

Before (countBy)

MethodMeanErrorStdDevMedianGen 0Gen 1Gen 2Allocated
Decimal149.9 us13.45 us39.01 us136.1 us38.452112.8174-237.55 KB

After (countBy)

MethodMeanErrorStdDevGen 0Gen 1Gen 2Allocated
Decimal65.17 us5.013 us13.807 us28.56459.4604-175.09 KB

Before (distinct)

MethodMeanErrorStdDevGen 0Gen 1Gen 2Allocated
Decimal57.83 us6.097 us17.977 us26.30626.5308-162.35 KB

After (distinct)

MethodMeanErrorStdDevGen 0Gen 1Gen 2Allocated
Decimal34.79 us1.572 us4.585 us21.27085.3101-131.1 KB

These mostly stay the same (as expected), apart from decimal, which show ~50% speed and ~25% alloc improvements.

Records

Before:

MethodMeanErrorStdDev
Record157.8 us8.98 us25.61 us
RecordStruct163.5 us5.87 us17.31 us

After:

MethodMeanErrorStdDev
Record143.1 us7.13 us20.00 us
RecordStruct149.5 us3.68 us10.80 us

Which is about 10% improvement.

Generic unions

Before:

MethodMeanErrorStdDevGen 0Gen 1Gen 2Allocated
GenericUnion439.8 us17.85 us51.78 us41.992212.2070-260 KB

After:

MethodMeanErrorStdDevGen 0Gen 1Gen 2Allocated
GenericUnion167.1 us3.32 us7.22 us26.97758.9722-166.3 KB

About 60% and 30% improvements in speed and allocs.

Reference tuples

Before:

MethodMeanErrorStdDev
SmallNonGenericTuple306.0 us14.40 us42.45 us
SmallGenericTuple360.9 us10.43 us30.74 us
BigNonGenericTuple393.2 us13.33 us39.30 us
BigGenericTuple480.1 us27.41 us80.81 us
SmallNonGenericTupleStruct167.4 us5.07 us14.62 us
SmallGenericTupleStruct192.2 us3.55 us5.93 us
BigNonGenericTupleStruct409.2 us16.19 us44.33 us
BigGenericTupleStruct559.1 us52.00 us153.33 us

After:

MethodMeanErrorStdDev
SmallNonGenericTuple268.7 us8.63 us25.45 us
SmallGenericTuple349.7 us9.56 us28.20 us
BigNonGenericTuple356.2 us12.56 us37.03 us
BigGenericTuple451.0 us26.06 us76.83 us
SmallNonGenericTupleStruct132.3 us4.12 us11.96 us
SmallGenericTupleStruct185.4 us6.08 us17.45 us
BigNonGenericTupleStruct350.0 us15.47 us44.63 us
BigGenericTupleStruct405.4 us20.45 us54.58 us

~5-15% faster execution.


Some (positive) implications for F#.Core.

F# core functions in question

Before:

MethodMeanErrorStdDevMedian
ArrayCountBy230.20 us7.098 us20.929 us230.07 us
ArrayGroupBy125.80 us5.344 us15.674 us127.24 us
ArrayDistinct121.08 us8.648 us25.500 us127.72 us
ArrayDistinctBy113.45 us2.636 us7.732 us114.15 us
ArrayExcept92.28 us1.835 us5.354 us92.66 us
ListCountBy225.45 us11.730 us34.585 us230.79 us
ListGroupBy167.40 us12.814 us37.783 us151.49 us
ListDistinct125.20 us4.192 us12.229 us127.01 us
ListDistinctBy109.52 us3.480 us10.097 us110.94 us
ListExcept194.14 us20.467 us60.347 us164.68 us
SeqCountBy460.13 us13.224 us38.575 us459.04 us
SeqGroupBy354.81 us15.000 us43.992 us348.92 us
SeqDistinct359.59 us12.339 us36.381 us361.93 us
SeqDistinctBy128.8 us5.70 us15.97 us127.2 us
SeqExcept127.8 us5.03 us14.74 us128.1 us

After:

MethodMeanErrorStdDevMedian
ArrayCountBy137.30 us5.008 us14.767 us137.01 us
ArrayGroupBy78.80 us4.673 us13.778 us80.87 us
ArrayDistinct71.63 us3.698 us10.904 us73.49 us
ArrayDistinctBy69.93 us2.148 us6.299 us70.02 us
ArrayExcept68.39 us3.071 us9.008 us69.59 us
ListCountBy138.17 us6.708 us19.566 us142.05 us
ListGroupBy129.57 us12.765 us37.639 us110.78 us
ListDistinct85.99 us1.662 us3.683 us85.73 us
ListDistinctBy67.36 us3.001 us8.707 us67.95 us
ListExcept173.01 us21.536 us63.499 us138.14 us
SeqCountBy289.70 us12.109 us35.514 us295.43 us
SeqGroupBy245.97 us6.969 us20.329 us246.79 us
SeqDistinct254.17 us14.776 us43.336 us257.62 us
SeqDistinctBy101.7 us5.35 us14.91 us102.4 us
SeqExcept105.9 us5.20 us15.35 us102.1 us

The improvement varies 20-40% in speed here.


Other considerations.

>64 bit value types

Before:

MethodMeanErrorStdDev
BigStruct47.43 ms3.136 ms9.098 ms

After:

MethodMeanErrorStdDev
BigStruct44.57 ms3.243 ms9.562 ms

This became marginally faster - but more importantly, it's here to address concerns about 64 bit JIT. So likely the underlying JIT problem got fixed meanwhile.

TODO

  • AOT tests to see if startup performance is not too affected
  • Finish the design doc on equality
  • Describe the changed code flow

Followups

@github-actions

github-actionsBot commented Jan 30, 2024

Copy link
Copy Markdown
Contributor

❗ Release notes required


✅ Found changes and release notes in following paths:

Change pathRelease notes pathDescription
src/FSharp.Coredocs/release-notes/.FSharp.Core/8.0.300.md

Comment threadsrc/FSharp.Core/prim-types.fs Outdated
Comment threadsrc/FSharp.Core/prim-types.fs Outdated
@vzarytovskii

Copy link
Copy Markdown
Member

Benchmark results are weird, numbers before and after are within the statistical error. What about allocations? We shouldn't be boxing as much now?

@psfinaki

Copy link
Copy Markdown
ContributorAuthor

@vzarytovskii yeah those are not the right benchmarks for this PR. We had a session with Don today to figure out the right ones and I am already getting some 25-30% improvements there, will post soon - stay tuned.

Comment threadtests/benchmarks/CompiledCodeBenchmarks/MicroPerf/Equality.fs Outdated
@psfinaki

Copy link
Copy Markdown
ContributorAuthor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 2 pipeline(s).

Comment threadtests/benchmarks/CompiledCodeBenchmarks/MicroPerf/Equality.fs Outdated
@manofstick

Copy link
Copy Markdown
Contributor

Is this PR breaking?
No. This is (supposed to be just) an optimization.

Well... It'll be calling IEquatable.Equals<>, not object.Equals, which is hence a breaking change - albeit I feel in a fashion that should be part of an evolution...

...and really should be paired with the optimizer change (@TIHan I believe worked on one at some stage?) Otherwise you have the somewhat bizarre situation where using an comparison of an external struct type such as a NodaTime.Instant in a generic context doesn't box and is fast, vs in a non-generic context where it does box is an is slower... Unless that has been resolved already?

Anyway, glad to see this moving forward. My beard is somewhat grayer now that when I first started trying to improve comparison/equality in F#!!

@psfinaki

Copy link
Copy Markdown
ContributorAuthor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 2 pipeline(s).

@psfinakipsfinaki changed the title WIP: Faster equality in generic contextsFaster equality in generic contextsFeb 13, 2024
@psfinaki

Copy link
Copy Markdown
ContributorAuthor

Marking as ready to review since the CI is green finally :D

@psfinaki
psfinaki marked this pull request as ready for review February 13, 2024 15:08
@psfinaki
psfinaki requested a review from a team as a code ownerFebruary 13, 2024 15:08
@psfinaki

Copy link
Copy Markdown
ContributorAuthor

And glad to see you here, @manofstick :) BTW thanks for the clear commits and comments to the commits in your original PRs, that really helped me a lot.

As for this one, well, it's not breaking in terms of NaN behavior and in terms of IL. So far. That's probably what we care about the most.

Comment threadsrc/FSharp.Core/prim-types.fs Outdated
Comment threadsrc/FSharp.Core/prim-types.fs Outdated
Comment threadsrc/FSharp.Core/prim-types.fs Outdated
Comment threadsrc/FSharp.Core/prim-types.fs Outdated
Comment threadsrc/FSharp.Core/prim-types.fs Outdated
Comment threadsrc/FSharp.Core/prim-types.fs Outdated
Comment threadsrc/FSharp.Core/prim-types.fs Outdated
@vzarytovskii

vzarytovskii commented Feb 14, 2024

Copy link
Copy Markdown
Member

Since it touches an important part of the language, I would like to have three things:

  1. What changed, in the form of
    Given (generic) code: ...
    How it worked before: ...
    How does it work now: ...

    Unless it exists somewhere already. It should help everyone in understanding this change in future in case of any issues.

  2. Some AOT compilation tests involving changed equality. It can be a new project, like the trimming one we have. We should start having more AOT testing, and this is a good place to start, since we know AOT was working with equality before, it's crucial it's still working after the change.

  3. Can we have some compiler perf comparison/profiling of using compiler+fslib before and after changes on something like FCS? It would be nice to have it as reference.

@psfinaki

Copy link
Copy Markdown
ContributorAuthor

Thanks all for the reviews and the feedback.

This needs a thorough rereview with @dsyme, meanwhile I will be improving the description, clarifying the code and adding tests.

@dsyme

Copy link
Copy Markdown
Contributor

...and really should be paired with the optimizer change (@TIHan I believe worked on one at some stage?) Otherwise you have the somewhat bizarre situation where using an comparison of an external struct type such as a NodaTime.Instant in a generic context doesn't box and is fast, vs in a non-generic context where it does box is an is slower... Unless that has been resolved already?

Agreed that in principle these should go together, though can be done in a separate PR.

Well... It'll be calling IEquatable.Equals<>, not object.Equals, which is hence a breaking change - albeit I feel in a fashion that should be part of an evolution...

I think this specific change is OK - use IEquatable.Equals if it exists.

Comment threadsrc/FSharp.Core/prim-types.fs Outdated
@dsyme

dsyme commented Feb 14, 2024

Copy link
Copy Markdown
Contributor

Well... It'll be calling IEquatable.Equals<>, not object.Equals, which is hence a breaking change - albeit I feel in a fashion that should be part of an evolution...

@manofstick Could you remind me of the specific thing that causes the call to IEquatable.Equals<, and which types it applies to. I assume it comes from EqualityComparer<'T>.Default and thus only applies to types that pass canUseDefaultEqualityComparer?

I think that is OK as a change. It is vanishingly rare to explicitly implement IEquatable.Equals<> on struct types in F# code, (except the automatic implementations provided by the compiler) - and even then it would always be the desired behaviour to have that implementation invoked

@vzarytovskii

Copy link
Copy Markdown
Member

Also, does this need an addition to spec (rfc), since it changes how we do equality? Or rather shall the doc from the other PR live in the design repo?

@psfinaki

Copy link
Copy Markdown
ContributorAuthor

@vzarytovskii I think it's a good idea to have that doc (from the other PR) in the design repo. This PR is meant to be an optimization and hence focuses on the implementation. I am looking at the spec while working on this and my impression so far is that the spec doesn't go that deep in the implementation to make this change anyhow misalign with it. If we discover anything of that nature, we can make the RFC or further discuss it.

@psfinaki

Copy link
Copy Markdown
ContributorAuthor

I added benchmarks for the value tuples, options and that stuff - the results are very convincing so far.
More to come soon!

@manofstick

Copy link
Copy Markdown
Contributor

@manofstick Could you remind me of the specific thing that causes the call to IEquatable.Equals<, and which types it applies to. I assume it comes from EqualityComparer<'T>.Default and thus only applies to types that pass canUseDefaultEqualityComparer?

Yep, simple as that.

I think that is OK as a change. It is vanishingly rare to explicitly implement IEquatable.Equals<> on struct types in F# code, (except the automatic implementations provided by the compiler) - and even then it would always be the desired behaviour to have that implementation invoked

Oh, completely agree. It was just that this was (as far as I understand it) the main reason why this change never went through in the past.

And just confirming there, given your comment, this change, as implemented, would be all types (that pass the check) using IEquatable<>.Equals in preference to object.Equals not just value types (obviously struct types get the most benefit, as they avoid the boxing step, but ref types can also get a slight improvement as they, don't need to cast input). But the check includes IsSealed, hence negating any potential inheritance issues. (Once again, from memory, don't have a compiler here to test, but F# records are created as Sealed always)

@psfinaki

Copy link
Copy Markdown
ContributorAuthor

@manofstick just to be sure we're on the same page - which input cast do you mean? :)

@manofstick

manofstick commented Feb 15, 2024

Copy link
Copy Markdown
Contributor

@psfinaki

...forgive my github comment coding, I think memory serves, but it's more than enough for the gist of things if it's incorrect...

[<Sealed>]// only for sealedtypeBlah(...)=...overridelhs.Equals(rhsObj:obj)=// (1)if obj.ReferenceEquals (lhs, rhsObj)thentrueelsematch rhsObj with|:? Blah as rhs ->// (2)(lhs:>IEquatable<Blah>).Equals rhs // (4)|_->falseinterface IEquatable<Blah>withmemberlhs.Equals rhs =// (3)// actually do the check

Well (1) would of been the entry point, where at (2) would of been the cast (well type check), but EqualityComparer<Blah>.Default will call (3) directly.

Most likely the actually equality check in these cases will be the significant component of the cost, so savings is minimal, but as mentioned, this is just for complete understanding of what's going on here...

(4) ... just because I'm showing off, the IL that F# used to generate actually caused this operation to have a cost, but way back at the dawn of history I fixed that... ;-)

@dsyme

dsyme commented Feb 15, 2024

Copy link
Copy Markdown
Contributor

@psfinaki I believe it should be possible to write a test case that detects the change, e.g.

[<Sealed, Struct>]typeBlah()=overridelhs.Equals(rhsObj:obj)=trueinterface IEquatable<Blah>withmemberlhs.Equals rhs = failwith "bang"leteq x y = printfn "hello"// do this enough times to make sure no inlining
printfn "hello"// do this enough times to make sure no inlining
printfn "hello"// do this enough times to make sure no inlining
printfn "hello"// do this enough times to make sure no inlining(x = y)// generic equality context
eq (Blah())(Blah())// true prior to this, exception now

I don't really think this needs a runtime library switch to disable, and we don't currently have such a mechanism to do those kinds of flags anyway

The equality spec should probably be updated to mention this case

@psfinaki

psfinaki commented Feb 16, 2024

Copy link
Copy Markdown
ContributorAuthor

Yes, correct. For reference, I guess this is the minimal code to demo the behavior difference:

[<Struct; CustomEquality; NoComparison>]typeBlah=interface IEquatable<Blah>withmemberlhs.Equals rhs = failwith "bang"leteq x y = printfn "hello"// do this enough times to make sure no inlining...
x = y // generic equality contextletresult= eq (Blah())(Blah())// false prior to this, exception now

Alright, so we've identified what breaks here and blessed it at the same time. Indeed, this is quite an esoteric scenario.

Good stuff, I will update the spec and the description to reflect this.

@vzarytovskii

Copy link
Copy Markdown
Member

Non-inlined stacktrace examples before and after could be helpful for reviewers as well, could you add it pelase, if it's not too much work.

@psfinaki

Copy link
Copy Markdown
ContributorAuthor

Yep I am going to add it to the PR description.

@psfinaki

Copy link
Copy Markdown
ContributorAuthor

Okay so hopefully getting to the finish line here, I think I have added enough micro benchmarks and grouped them in the PR description.

Next week will add AOT testing, refresh the equality design doc, finalize the PR description and give this a final review with Don :)

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

Looking good!

Comment threadtests/AheadOfTime/Equality/Equality.fsproj Outdated
Comment threadazure-pipelines.yml Outdated
@psfinaki

Copy link
Copy Markdown
ContributorAuthor

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 2 pipeline(s).

@KevinRansom

Copy link
Copy Markdown
Contributor

Nice

@psfinaki
psfinaki merged commit 9877cfe into dotnet:mainMar 4, 2024
@psfinaki
psfinaki deleted the equality-3 branch March 4, 2024 19:01
@manofstick

Copy link
Copy Markdown
Contributor

64 bit value types
This became marginally faster

I'm just relying on your words here, as I have not built this, but they should be significantly faster, and non boxing (I'm not talking about my concern re tail calls here). I'm guessing that because they implement 'IStructuralEquality' they are using that path, and hence not being sped up. I thought in my original PR I handled these too, maybe I didn't, or maybe you didn't carry that code across, I don't know.

Anyway, what I would suggest is that the code for 'canUseDefault...' is used at compile time, and if default can be used then the 'IStructuralEquals' interface isn't implemented. This, once again, is a breaking change, but I think it's even lesser than the one introduced by this PR.

Another follow up, if it hasn't been started yet, would be used user Compare<>.Default for inequalities....

@psfinaki

Copy link
Copy Markdown
ContributorAuthor

Yes, I think we will get to those as well. Next bigger one will be devirt equality, then we'll probably look into comparison. There is a lot of useful stuff to pull out of your contributions :)

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

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

7 participants

@psfinaki@vzarytovskii@manofstick@dsyme@KevinRansom@brianrourkeboll@T-Gro