-
Notifications
You must be signed in to change notification settings - Fork 805
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Avoid boxing on equality and comparisons #9404
Avoid boxing on equality and comparisons #9404
Conversation
b6904de
to
f3ac895
Compare
/// <summary>The F# compiler emits calls to some of the functions in this module as part of the compiled form of some language constructs</summary> | ||
module HashCompare = | ||
[<AbstractClass; Sealed>] | ||
type FSharpEqualityComparer_ER<'T> = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are now public APIs. We need to be sure the names are what we would want.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Way past my pay-grade! @cartermp ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm.. Some thoughts here though; maybe, to mimic EqualityComparer.Default
, FSharpEqualityComparer_PER.EqualityComparer
should become FSharpEqualityCompare.Default
(and just leave FSharpEqualityCompare_ER
... As I think that one is the lesser used...) But then is it right to leave it in it's current namespace? EqualityComparer
lives in System.Collections.Generic
so should it be in FSharp.Collections
? Or should it remain 'public'-lite, i.e. not really for use by anyone except the compiler...
Didn't know we had this bug. Is it possible to make a separate PR that just fixes this bug? |
One thing to clarify in this PR, is how this avoids boxing as it's not entirely clear. Also, are there any behavior changes that change the choice of what equality/comparison interface methods on a type get called? For example: F# struct equality will always choose |
Due to covariance weirdness. Of course it could be a separate PR... Just would obviously cause merge issues with this PR, because the code changed is deleted with this... |
@TIHan - as I thought I'd put here, but it appears I wrote in a comment in the issue, this is only a partial solution to the problem, and your optimizer changes (which were never finished/merged/whatever) really need to be part of this as well (once they are complete? accepted? etc...). So this change is really for when equality/comparison is used in a generic context rather than attempting to solve the issue for types known at compile time (which requires more fiddling with the optimizer). Now your concern about when IStructuralEquality is used is decided upon in the code snippet at the top of this PR - probably hard to spot given that the logic is more complex than I would like, but: && ((not (hasStructuralInterface ty))
|| isSuitableTupleType ty
|| isSuitableStructType ty
|| isSuitableRecordType ty
|| isSuitableUnionType ty) So it's not only checking for hasStructuralInterface, but that it's only used under very specific conditions, which is a known FSharp type and that the type arguments for FSharp type comply with the set of rules. I think this whole functions ( Does that make things clearer? Or need more details? |
We should definitely put that in your main description of the PR and/or title.
Ok, so this is checking if it has that interface in general. I need to look more carefully at this.
I should have finished this last year but did not get around to it. I still have some worries with it, such a new
That clears it up :) - thank you. |
@TIHan just to make it clear that my previous comment doesn't preclude any improvements in non-generic code! I.e. it would have sped up the So using a non-IStructural* type, like most non-fsharp defined types, you will be a performance boost. As an example... // needs to reference NodaTime - https://nodatime.org/
let data =
let now = SystemClock.Instance.GetCurrentInstant ()
let rng = Random ()
Array.init 2500 (fun _ -> now.Plus (Duration.FromSeconds (int64 (rng.Next (System.Int32.MinValue, System.Int32.MaxValue)))))
for i = 1 to 5 do
let sw = Stopwatch.StartNew ()
let mutable count = 0
for i = 0 to data.Length-1 do
for j = 0 to data.Length-1 do
if data.[i] < data.[j] then
count <- count + 1
printfn "%d (%d)" sw.ElapsedMilliseconds count So here, the (On my machine, this loop takes ~400ms on current build, and ~65ms on build with this PR) |
I am beginning to gasp this and need bake time in my head. |
Oh, and another terrible, terrible performance thing that is fixed by this is when used in a generic context, comparisons of floats where NaNs is (currently) a disaster... As it relies on throwing exceptions. This caught me once (in testing luckily!)... So if I slighty modify the example previously to... let runStuff<'a when 'a : comparison> (data:array<'a>) =
for i = 1 to 5 do
let sw = Stopwatch.StartNew ()
let mutable count = 0
for i = 0 to data.Length-1 do
for j = 0 to data.Length-1 do
if data.[i] <= data.[j] then
count <- count + 1
printfn "%d (%d)" sw.ElapsedMilliseconds count
let data =
Array.init 1000 (fun i -> if i = 0 then System.Double.NaN else float i)
runStuff data then this takes ~140ms currently, and ~5ms after this PR. if you bump up the number of NaNs from 1 in 1000 to the whole array being NaNs... then that takes over 30 second! (It still takes ~5ms after this PR)... |
@TIHan - How's the head baking going? Do you need any further details? |
I really like a lot of what's in this PR, your latest comment on NaN is an eye opener, can't believe it was based on exceptions! I wonder if a recent proposed optimization, where a boxed known primitive type next calls an interface member would be optimized to a constrained callvirt, would help here, or with @TIHan's optimizations (discussed in my string pr). I might have some other insights to share, I was planning on playing around a bit with your pr. Again, I really like it! |
@manofstick I have a couple of more details. There is really some complicated logic that is added to Your idea by making the intrinsic generic functions not do boxing is good; everything can benefit from it without the compiler having to re-write/re-do everything. However, the reason your approach is working is because of the use of the .NET comparer, where it internally looks for Not saying this puts it off the table, but we have always chosen In my personal opinion, I think we should consider a breaking change. The fact that will never choose
So, in theory, if everyone followed this guidance, then we could make the change and it wouldn't break anyone. But, users can technically make |
@manofstick Looking a bit more closely, I see you are checking for the and isSuitableStructType (ty:Type) =
ty.IsValueType &&
Reflection.isObjectType (ty, bindingPublicOrNonPublic) &&
(not (isCustom ty)) &&
checkType 0 (Reflection.getAllInstanceFields ty) While this can cover F# types to ensure its safe to choose |
I am incorrect: This is some really important nuance here and it should be documented. |
true) | ||
|
||
let isRecordType (typ:Type, bindingFlags:BindingFlags) = isKnownType (typ, bindingFlags, SourceConstructFlags.RecordType) | ||
let isObjectType (typ:Type, bindingFlags:BindingFlags) = isKnownType (typ, bindingFlags, SourceConstructFlags.ObjectType) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To make this clearer, this should be isFSharpObjectType
. Same with the others: isFSharpRecordType
, isFSharpUnionType
.
else | ||
false | ||
|
||
let isKnownType (typ:Type, bindingFlags:BindingFlags, knownType:SourceConstructFlags) = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isFSharpType
would be clearer
Yes this is true; I tend to think of them as the same, but obviously that is not true. Hmmm. The whole this is crippled without this (well could still be used for fsharp types but...) So how would we proceed with either getting that ratified (or not)? |
Buuuuttt... Normal .net struct types don't implement IStructuralXXX so they are not checking that logic. As said it could be harsh only only work for fsharp types, but I want it to work with things like NodaTime... (and Guid and DateTime and ...) So yes, the code is a breaking change unless I really cripple it... (Would have to put back the special handling for all the intrinsic types as well) |
@manofstick , Please feel free to reactivate it when you are in a position to work on it some more. Thanks |
As per #9348, Updated the old pull request to be (effectively) rebased off current master.
Why does this code change the test results for tests/FSharp.Core.UnitTests/FSharp.Core/ComparersRegression.fs?
This code includes a "breaking change" which fixes a long outstanding bug...
Why is the test for IL output changed?
Changes to
Optimizer.fs
are done in order to bubble up access to the comparers. If this isn't done we get particularly nasty performance in some cases due to > 64 bit value types due to the tail call "optimization" (i.e. you have basically a forwarding call which takes 2 Ts and passes them on to lower layer, which should be able to be inlined by the JIT, but the JIT doesn't do it because of tail, and then if they are > 64 bit they JIT spends time setting up stackframe for recursive calls which is expensive)This can still be a problem in client code. Using
HashIdentity.Structural
creates anIEqualityComparer
which is just a stub code which calls the underlyingLanguagePrimitives.HashCompare.FSharpEqualityComparer_PER<'a>.EqualityComparer
. This is not ideal, and an additional change to the optimizer should just return that object (a current work around is just to compile the client with no tail call optimization, which if you're working with > 64bit value types you should probably be doing anyway because of all the places where this same issue raises its ugly head).Why is
reflect.fs
changed?The code was just moved up to prim_types so it could be used from there. As prim_types doesn't know about the option type, the functions were slightly modified to by
byref
s withbool
s instead.What are the full rules of where this is used.
Here's the code which determines if we can use the default .net comparer (which checks to see if
I(Equatable|Comparable)
are available, otherwise falling back toobj.(GetHashCode|Equals)
.