Skip to content

Commit b9519e7

Browse files
authored
Docs on equality in F# (#16537)
1 parent b33ad19 commit b9519e7

File tree

1 file changed

+326
-0
lines changed

1 file changed

+326
-0
lines changed

docs/optimizations-equality.md

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
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

Comments
 (0)