|
| 1 | +# Compiling Equality |
| 2 | + |
| 3 | +This spec covers how equality is compiled and executed by the F# compiler and library, based mainly on the types involved in the equality operation after all inlining, type specialization and other optimizations have been applied. |
| 4 | + |
| 5 | +## What do we mean by an equality operation? |
| 6 | + |
| 7 | +This spec is about the semantics and performance of the following coding constructs |
| 8 | + |
| 9 | +* `a = b` |
| 10 | +* `a <> b` |
| 11 | + |
| 12 | +It is also about the semantics and performance of uses of the following `FSharp.Core` constructs which, after inlining, generate code that contains an equality check at the specific `EQTYPE` |
| 13 | +* `HashIdentity.Structural<'T>` |
| 14 | +* `{Array,Seq,List}.contains` |
| 15 | +* `{Array,Seq,List}.countBy` |
| 16 | +* `{Array,Seq,List}.groupBy` |
| 17 | +* `{Array,Seq,List}.distinct` |
| 18 | +* `{Array,Seq,List}.distinctBy` |
| 19 | +* `{Array,Seq,List}.except` |
| 20 | + |
| 21 | +All of which have implied equality checks. Some of these operations are inlined, see below, which in turn affects the semantics and performance of the overall operation. |
| 22 | + |
| 23 | +## ER vs PER equality |
| 24 | + |
| 25 | +In math, a (binary) relation is a way to describe a relationship between the elements of sets. "Greater than" is a relation for numbers, "Subset of" is a relation for sets. |
| 26 | + |
| 27 | +Here we talk about 3 particular relations: |
| 28 | +1) **Reflexivity** - every element is related to itself |
| 29 | + - For integers, `=` is reflexive (`a = a` is always true) and `>` is not (`a > a` is never true) |
| 30 | +2) **Symmetry** - if `a` is related to `b`, then `b` is related to `a` |
| 31 | + - For integers, `=` is symmetric (`a = b` -> `b = a`) and `>` is not (if `a > b` then `b > a` is false) |
| 32 | +3) **Transitivity** - if `a` is related to `b`, and `b` is related to `c`, then `a` is also related `c` |
| 33 | + - For integers, `>` is transitive (`a > b` && `b > c` -> `a > c`) and `√` is not (`a = √b` && `b = √c` doesn't mean `a = √c`) |
| 34 | + |
| 35 | +If a relation has 1, 2, and 3, we talk about **Equivalence Relation (ER)**. If a relation only has 2 and 3, we talk about **Partial Equivalence Relation (PER)**. |
| 36 | + |
| 37 | +This matters in comparing floats since they include [NaN](https://en.wikipedia.org/wiki/NaN). Depending on if we consider `NaN = NaN` true or false, we talk about ER or PER comparison respectively. |
| 38 | + |
| 39 | +## What is the type known to the compiler and library for an equality operation? |
| 40 | + |
| 41 | +The static type known to the F# compiler is crucial to determining the performance of the operation. The runtime type of the equality check is also significant in some situations. |
| 42 | + |
| 43 | +Here we define the relevant static type `EQTYPE` for the different constructs above: |
| 44 | + |
| 45 | +### Basics |
| 46 | + |
| 47 | +* `a = b`: `EQTYPE` is the statically known type of `a` or `b` |
| 48 | +* `a <> b`: `EQTYPE` is the statically known type of `a` or `b` |
| 49 | + |
| 50 | +### Inlined constructs |
| 51 | + |
| 52 | +* `HashIdentity.Structural<'T>`, `EQTYPE` is the **inlined** `'T` (results in specialized equality) |
| 53 | +* `Array.contains<'T>`, `EQTYPE` is the **inlined** `'T` (results in specialized equality) |
| 54 | +* `List.contains<T>` likewise |
| 55 | +* `Seq.contains<T>` likewise |
| 56 | + |
| 57 | +These only result in naked generic equality if themselves used from a non-inlined generic context. |
| 58 | + |
| 59 | +### Non-inlined constructs always resulting in naked generic equality |
| 60 | + |
| 61 | +* `Array.groupBy<'Key, 'T> f array`, `EQTYPE` is non-inlined `'Key`, results in naked generic equality |
| 62 | +* `Array.countBy array` likewise for `'T` |
| 63 | +* `Array.distinct<'T> array` likewise |
| 64 | +* `Array.distinctBy array` likewise |
| 65 | +* `Array.except array` likewise |
| 66 | +* `List.groupBy` likewise |
| 67 | +* `List.countBy` likewise |
| 68 | +* `List.distinct` likewise |
| 69 | +* `List.distinctBy` likewise |
| 70 | +* `List.except` likewise |
| 71 | +* `Seq.groupBy` likewise |
| 72 | +* `Seq.countBy` likewise |
| 73 | +* `Seq.distinct` likewise |
| 74 | +* `Seq.distinctBy` likewise |
| 75 | +* `Seq.except` likewise |
| 76 | + |
| 77 | +These **always** result in naked generic equality checks. |
| 78 | + |
| 79 | +Example 1: |
| 80 | + |
| 81 | +```fsharp |
| 82 | +let x = HashIdentity.Structural<byte> // EQTYPE known to compiler is `byte` |
| 83 | +``` |
| 84 | + |
| 85 | +Example 2 (a non-inlined "naked" generic context): |
| 86 | + |
| 87 | +```fsharp |
| 88 | +let f2<'T> () = |
| 89 | + ... some long code |
| 90 | + // EQTYPE known to the compiler is `'T` |
| 91 | + // RUNTIME-EQTYPE known to the library is `byte` |
| 92 | + let x = HashIdentity.Structural<'T> |
| 93 | + ... some long code |
| 94 | +
|
| 95 | +f2<byte>() // performance of this is determined by EQTYPE<'T> and RUNTIME-EQTYPE<byte> |
| 96 | +``` |
| 97 | + |
| 98 | +Example 3 (an inlined generic context): |
| 99 | + |
| 100 | +```fsharp |
| 101 | +let f3<'T> () = |
| 102 | + ... some long code |
| 103 | + // EQTYPE known to the compiler is `byte` |
| 104 | + // RUNTIME-EQTYPE known to the library is `byte` |
| 105 | + let x = HashIdentity.Structural<'T> |
| 106 | + ... some long code |
| 107 | +
|
| 108 | +f3<byte>() // performance of this is determined by EQTYPE<byte> and RUNTIME-EQTYPE<byte> |
| 109 | +``` |
| 110 | + |
| 111 | +Example 4 (a generic struct type in a non-inline generic context): |
| 112 | + |
| 113 | +```fsharp |
| 114 | +let f4<'T> () = |
| 115 | + ... some long code |
| 116 | + // EQTYPE known to the compiler is `SomeStructType<'T>` |
| 117 | + // RUNTIME-EQTYPE known to the library is `SomeStructType<byte>` |
| 118 | + let x = HashIdentity.Structural<SomeStructType<'T>> |
| 119 | + ... some long code |
| 120 | +
|
| 121 | +f4<byte>() // performance of this determined by EQTYPE<SomeStructType<'T>> and RUNTIME-EQTYPE<SomeStructType<byte>> |
| 122 | +``` |
| 123 | + |
| 124 | +## How we compile equality "a = b" |
| 125 | + |
| 126 | +This very much depends on the `EQTYPE` involved in the equality as known by the compiler |
| 127 | + |
| 128 | +Aim here is to flesh these all out with: |
| 129 | +* **Semantics**: what semantics the user expects, and what the semantics actually is |
| 130 | +* **Perf expectation**: what perf the user expects |
| 131 | +* **Compilation today**: How we actually compile today |
| 132 | +* **Perf today**: What is the perf we achieve today |
| 133 | +* (Optional) sharplab.io link to how things are in whatever version is selected in sharplab |
| 134 | +* (Optional) notes |
| 135 | + |
| 136 | +### primitive integer types (`int32`, `int64`, ...) |
| 137 | + |
| 138 | +```fsharp |
| 139 | +let f (x: int) (y: int) = (x = y) |
| 140 | +``` |
| 141 | + |
| 142 | +* Semantics: equality on primitive |
| 143 | +* Perf: User expects full performance down to native |
| 144 | +* Compilation today: compiles to IL instruction ✅ |
| 145 | +* Perf today: good ✅ |
| 146 | +* [sharplab int32](https://sharplab.io/#v2:DYLgZgzgNAJiDUAfYBTALgAjBgFADxAwEsA7NASlwE9DSKMBeXPRjK8gWACgg===) |
| 147 | + |
| 148 | +### primitive floating point types (`float32`, `float64`) |
| 149 | + |
| 150 | +```fsharp |
| 151 | +let f (x: float32) (y: float32) = (x = y) |
| 152 | +``` |
| 153 | + |
| 154 | +* Semantics: IEEE floating point equality (respecting NaN etc.) |
| 155 | +* Perf: User expects full performance down to native |
| 156 | +* Compilation today: compiles to IL instruction ✅ |
| 157 | +* Perf today: good ✅ |
| 158 | +* [sharplab float32](https://sharplab.io/#v2:DYLgZgzgNAJiDUAfYBTALgAjBgFADxC2AHsBDNAZgCYBKXAT0LBPOroF5c8NP6aBYAFBA===) |
| 159 | + |
| 160 | +### primitive `string`, `decimal` |
| 161 | + |
| 162 | +* Semantics: .NET equivalent equality, non-localized for strings |
| 163 | +* Perf: User expects full performance down to native |
| 164 | +* Compilation today: compiles to `String.Equals` or `Decimal.op_Equality` call ✅ |
| 165 | +* Perf today: good ✅ |
| 166 | +* [sharplab decimal](https://sharplab.io/#v2:DYLgZgzgNALiCWwoBMQGoA+wCmMAEYeAFAB4h7LYDG8AtgIbACUxAnuZTQ83gLzEk+eVkwCwAKCA) |
| 167 | +* [sharplab string](https://sharplab.io/#v2:DYLgZgzgNALiCWwoBMQGoA+wCmMAEYeAFAB4h4QwBO8AdgOYCUxAnuZTQ8wLzEl68WjALAAoIA==) |
| 168 | + |
| 169 | +### reference tuple type (size <= 5) |
| 170 | + |
| 171 | +* Semantics: User expects structural |
| 172 | +* Perf: User expects flattening to constituent checks |
| 173 | +* Compilation today: tuple equality is flattened to constituent checks ✅ |
| 174 | +* Perf today: good ✅ |
| 175 | +* [sharplab (int * double * 'T), with example reductions/optimizations noted](https://sharplab.io/#v2:DYLgZgzgPgsAUMApgFwARlQCgB4iwSwDs0AqVAEwHsBXAIyVTIHIAVASjdQE9UBeLbH25t48TCVFxB/LpIC0cosCJEA5goB8kgOKJCiAE74AxgFEAjtQCGy5D0Gy48BUpWF1crU7gAJKxAALAGFKAFsABysDRAA6XX0jM0sbfDsAMX80B1R5RUJlQjVNHT1DEwtrWy4ASWIjQggTAB4WAEZGVBYAJg6WAGYNVAdcgHlw5HxQ/AAvQ00sckQAN3wDNHiypMrUmrqiRuMRbwyIZAqbCBZqcKQ+1AAZK3drVUQABSMpiaXECDjSxIhCJRQwCVoAGmwXUhfU4mC4EK40K4sNyrkK7mK3iQaGMYUi0QMQkezysrw+k1S+B+fw2gPxIIM8Dp5WSVQA6qlggzCSdcTzQdh2gjUAAyUXMgGs7Z2TnIbnA3mZVB4xWCnpIsUSuAsrYpWVcoEEwx8lUConYO4o3KDSQ4s1qon8EmqF7vT5Umn/BImI2M+DGRDmIbC9rigNBoYanrhnVSvUcw3m2rIeoHB3Gi1WvqSEhHeBAA==) |
| 176 | + |
| 177 | +### reference tuple type (size > 5) |
| 178 | + |
| 179 | +* Semantics: User expects structural |
| 180 | +* Perf: User expects flattening to constituent checks |
| 181 | +* Compilation today: not flattened, compiled to `GenericEqualityIntrinsic` |
| 182 | +* Perf today: the check does type tests, does virtual calls via `IStructuralEqualityComparer`, boxes etc. ❌(Problem1) |
| 183 | +* [sharplab for size 6](https://sharplab.io/#v2:DYLgZgzgPgsAUMApgFwARlQCgB4iwSwDs0AqVI0841MimqyigSidQE9UBeLbL9p+EA==) |
| 184 | + |
| 185 | +### struct tuple type |
| 186 | + |
| 187 | +* Semantics: User expects structural |
| 188 | +* Perf: User expects flattening to constituent checks or at least the same optimizations as tuples |
| 189 | +* Compilation today: compiled to `GenericEqualityIntrinsic` |
| 190 | +* Perf today: boxes, does type tests, does virtual calls via `IStructuralEqualityComparer` etc. ❌(Problem2) |
| 191 | +* [sharplab for size 3](https://sharplab.io/#v2:DYLgZgzgPgsAUMApgFwARlQCgB4lRZAJwFcBjNTASwDs0AqVG+x2gSldQE9UBeLbXl1bwgA=) |
| 192 | + |
| 193 | +### C# or F# enum type |
| 194 | + |
| 195 | +* Semantics: User expects identical to equality on the underlying type |
| 196 | +* Perf: User expects same perf as flattening to underlying type |
| 197 | +* Compilation today: flattens to underlying type |
| 198 | +* Perf today: good ✅ |
| 199 | +* [sharplab for C# enum int](https://sharplab.io/#v2:DYLgZgzgNALiCWwA+BYAUMApjABGHAFAB4g4DKAnhDJgLYB0AIgIYUDyYA6ppgNYCUOCjgC8hIqKH90QA===) |
| 200 | +* [sharplab for F# enum int](https://sharplab.io/#v2:DYLgZgzgNALiCWwA+BYAUDAngBwKYAIBRfAXn3X0qXwEFT8BGCq/AIXoCZ11hcZ8w+ABQAPEEQCU+TPVH1ME9EA=) |
| 201 | + |
| 202 | +### C# struct type |
| 203 | + |
| 204 | +* Semantics: User expects call to `IEquatable<T>` if present, but F# spec says call `this.Equals(box that)`, in practice these are the same |
| 205 | +* Perf expected: no boxing |
| 206 | +* Compilation today: `GenericEqualityIntrinsic<SomeStructType>` |
| 207 | +* Perf today: always boxes (Problem3 ❌) |
| 208 | +* [sharplab](https://sharplab.io/#v2:DYLgZgzgNALiCWwA+BYAUMApjABGHAFAB4g4DKAnhDJgLYB0AIgIY0Aq8tmA8mJNgEocFHAF5CRMcIHogA==) |
| 209 | +* Note: [#16615](https://github.com/dotnet/fsharp/pull/16615) will improve things here since we'll start avoiding boxing |
| 210 | + |
| 211 | +### F# struct type (records, tuples - with compiler-generated structural equality) |
| 212 | + |
| 213 | +* Semantics: User expects field-by-field structural equality with no boxing |
| 214 | +* Perf expected: no boxing |
| 215 | +* Compilation today: `GenericEqualityIntrinsic<SomeStructType>` |
| 216 | +* Perf today: always boxes (Problem3 ❌) |
| 217 | +* [sharplab](https://sharplab.io/#v2:DYLgZgzgNALiCWwA+BYAUAbQDwGUYCcBXAYxgD4BddGATwAcBTAAhwHsBbBvI0gCgDcQTeADsYUJoSGiYASiYBedExVNO7AEYN8TAPoA6AGqKm/ZavVadBgKonC6dMAYwmYJrwAeQtp24k5JhoTLxMaWXQgA) |
| 218 | +* Note: the optimization path is a bit strange here, see the reductions below |
| 219 | + |
| 220 | +<details> |
| 221 | + |
| 222 | +<summary>Details</summary> |
| 223 | + |
| 224 | +```fsharp |
| 225 | +(x = y) |
| 226 | +
|
| 227 | +--inline--> |
| 228 | +
|
| 229 | +GenericEquality x y |
| 230 | +
|
| 231 | +--inline--> |
| 232 | +
|
| 233 | +GenericEqualityFast x y |
| 234 | +
|
| 235 | +--inline--> |
| 236 | +
|
| 237 | +GenericEqualityIntrinsic x y |
| 238 | +
|
| 239 | +--devirtualize--> |
| 240 | +
|
| 241 | +x.Equals(box y, LanguagePrimitives.GenericEqualityComparer); |
| 242 | +``` |
| 243 | + |
| 244 | +The struct type has these generated methods: |
| 245 | +```csharp |
| 246 | + override bool Equals(object y) |
| 247 | + override bool Equals(SomeStruct obj) |
| 248 | + override bool Equals(object obj, IEqualityComparer comp) //with EqualsVal |
| 249 | +``` |
| 250 | + |
| 251 | +These call each other in sequence, boxing then unboxing then boxing. We do NOT generate this method, we probably should: |
| 252 | + |
| 253 | +```csharp |
| 254 | + override bool Equals(SomeStruct obj, IEqualityComparer comp) //with EqualsValUnboxed |
| 255 | +``` |
| 256 | + |
| 257 | +If we did, the devirtualizing optimization should reduce to this directly, which would result in no boxing. |
| 258 | + |
| 259 | +</details> |
| 260 | + |
| 261 | +### array type (byte[], int[], some-struct-type[], ...) |
| 262 | + |
| 263 | +* Semantics: User expects structural |
| 264 | +* Perf expected: User expects perf is sum of constituent parts |
| 265 | +* Compilation today: `GenericEqualityIntrinsic<uint8[]>` |
| 266 | +* Perf today: hand-optimized ([here](https://github.com/dotnet/fsharp/blob/611e4f350e119a4173a2b235eac65539ac2b61b6/src/FSharp.Core/prim-types.fs#L1562)) for some primitive element types ✅ but boxes each element if "other" is struct or generic, see Problem3 ❌, Problem4 ❌ |
| 267 | +* [sharplab for `byte[]`](https://sharplab.io/#v2:DYLgZgzgPgsAUMApgFwARlQCgB4lQIwE9lEBtAXQEpVDUBeLbemy+IA=) |
| 268 | +* Note: ([#16615](https://github.com/dotnet/fsharp/pull/16615)) will improve this compiling to either ``FSharpEqualityComparer_PER`1<uint8[]>::get_EqualityComparer().Equals(...)`` or ``FSharpEqualityComparer_PER`1<T[]>::get_EqualityComparer().Equals(...)`` |
| 269 | + |
| 270 | +### F# large reference record/union type |
| 271 | + |
| 272 | +Here "large" means the compiler-generated structural equality is NOT inlined. |
| 273 | + |
| 274 | +* Semantics: User expects structural by default |
| 275 | +* Perf expected: User expects perf is sum of constituent parts, type-specialized if generic |
| 276 | +* Compilation today: direct call to `Equals(T)` |
| 277 | +* Perf today: the call to `Equals(T)` has specialized code but boxes fields if struct or generic, see Problem3 ❌, Problem4 ❌ |
| 278 | + |
| 279 | +### F# tiny reference (anonymous) record or union type |
| 280 | + |
| 281 | +Here "tiny" means the compiler-generated structural equality IS inlined. |
| 282 | + |
| 283 | +* Semantics: User expects structural by default |
| 284 | +* Perf expected: User expects perf is sum of constituent parts, type-specialized if generic |
| 285 | +* Compilation today: flattened, calling `GenericEqualityERIntrinsic` on struct and generic fields |
| 286 | +* Perf today: boxes on struct and generic fields, see Problem3 ❌, Problem4 ❌ |
| 287 | +* Note: [#16615](https://github.com/dotnet/fsharp/pull/16615) will help, compiling to ``FSharpEqualityComparer_ER`1<!a>::get_EqualityComparer().Equals(...)`` on struct and generic fields |
| 288 | + |
| 289 | +### Generic `'T` in non-inlined generic code |
| 290 | + |
| 291 | +* Semantics: User expects the PER equality semantics of whatever `'T` actually is |
| 292 | +* Perf expected: User expects no boxing |
| 293 | +* Compilation today: `GenericEqualityERIntrinsic` |
| 294 | +* Perf today: boxes if `'T` is any non-reference type (Problem4 ❌) |
| 295 | +* Note: [#16615](https://github.com/dotnet/fsharp/pull/16615) will improve this compiling to ``FSharpEqualityComparer_ER`1<!a>::get_EqualityComparer().Equals(...)`` |
| 296 | + |
| 297 | +### Generic `'T` in recursive position in structural comparison |
| 298 | + |
| 299 | +This case happens in structural equality for tuple types and other structural types |
| 300 | + |
| 301 | +* Semantics: User expects the PER equality semantics of whatever `'T` actually is |
| 302 | +* Perf: User expects no boxing |
| 303 | +* Compilation today: `GenericEqualityWithComparerIntrinsic LanguagePrimitives.GenericComparer` |
| 304 | +* Perf today: boxes for if `'T` is any non-reference type (Problem4 ❌) |
| 305 | +* [Sharplab](https://sharplab.io/#v2:DYLgZgzgPgsAUMApgFwARlQCgB4iwSwDs0AqVAEwHsBXAIyVTIHIAVASjdQE9UBeLbH25t48TCVFxB/LpIC0cosCJEA5goB8kgOKJCiAE74AxgFEAjtQCGy5D0Gy48BUpWF1crU7gAJKxAALAGFKAFsABysDRAA6XX0jM0sbfDsAMX80B1R5RUJlQjVNHT1DEwtrWy4ASWIjQggTAB4WAEZGVBYAJg6WAGYNVAdcgHlw5HxQ/AAvQ00sckQAN3wDNHiypMrUmrqiRuMRbwyIZAqbCBZqcKQ+1AAZK3drVUQABSMpiaXECDjSxIhCJRQwCVoAGmwXUhfU4mC4EK40K4sNyrkK7mK3iQaGMYUi0QMQkezysrw+k1S+B+fw2gPxIIM8Dp5WSVQA6qlggzCSdcTzQdh2gjUAAyUXMgGs7Z2TnIbnA3mZVB4xWCnpIsUSuAsrYpWVcoEEwx8lUConYO4o3KDSQ4s1qon8EmqF7vT5Umn/BImI2M+DGRDmIbC9rigNBoYanrhnVSvUcw3m2rIeoHB3Gi1WvqSEhHeBAA==) |
| 306 | +* Note: [#16615](https://github.com/dotnet/fsharp/pull/16615) will compile to ``FSharpEqualityComparer_ER`1<!a>::get_EqualityComparer().Equals(...)`` and avoid boxing in many cases |
| 307 | + |
| 308 | +## Techniques available to us |
| 309 | + |
| 310 | +1. Flatten and inline |
| 311 | +2. RCG: Use reflective code generation internally in FSharp.Core |
| 312 | +3. KFS: Rely on known semantics of F# structural types and treat those as special |
| 313 | +4. TS: Hand-code type-specializations using static optimization conditions in FSharp.Core |
| 314 | +5. TT: Type-indexed tables of baked (poss by reflection) equality comparers and functions, where some pre-computation is done |
| 315 | +6. DV: De-virtualization |
| 316 | +7. DEQ: Use `EqualityComparer<'T>.Default` where possible |
| 317 | + |
| 318 | +## Notes on previous attempts to improve things |
| 319 | + |
| 320 | +### [#5112](https://github.com/dotnet/fsharp/pull/5112) |
| 321 | + |
| 322 | +* Uses TT, DEQ, KFS, DV |
| 323 | +* Focuses on solving Problem4 |
| 324 | +* 99% not breaking, apart from the case of value types with custom equality implemented differently than the `EqualityComparer.Default` - the change would lead to the usage of the custom implementation which is reasonable |
| 325 | + |
| 326 | +Note: this included [changes to the optimizer to reduce GenericEqualityIntrinsic](https://github.com/dotnet/fsharp/pull/5112/files#diff-be48dbef2f0baca27a783ac4a31ec0aedb2704c7f42ea3a2b8228513f9904cfbR2360-R2363) down to a type-indexed table lookup fetching an `IEqualityComparer` and calling it. These hand-coded reductions appear unnecessary as the reduction doesn't open up any further optimizations. |
0 commit comments