Skip to content

Remove StructBox for Value Types - #549

Closed
manofstick wants to merge 10 commits into
dotnet:fsharp4from
manofstick:manofstick-structbox
Closed

Remove StructBox for Value Types#549
manofstick wants to merge 10 commits into
dotnet:fsharp4from
manofstick:manofstick-structbox

Conversation

@manofstick

Copy link
Copy Markdown
Contributor

The StructBox idiom is used for countBy/groupBy/dict so that reference type's with null values can work.

For Value Types this is just an extra layer of indirection which adds a performance cost without any benefit. This change request adds checks on the keys type, and then calls a customised value or reference type version.

This is related to #513, but can be considered a worthwhile optimization on it's own.

Paul Westcott added 7 commits July 21, 2015 15:42
The StructBox makes code that contains "hard" tail calls, which means
that performance suffers under the 64 bit JIT
There must be some other way to check if a type is a Value Type? I doubt
if it has been removed?
@msftclas

Copy link
Copy Markdown

Hi @manofstick, I'm your friendly neighborhood Microsoft Pull Request Bot (You can call me MSBOT). Thanks for your contribution!
You've already signed the contribution license agreement. Thanks!

The agreement was validated by Microsoft and real humans are currently evaluating your PR.

TTYL, MSBOT;

@manofstick

Copy link
Copy Markdown
ContributorAuthor

The following are some results from a timing test listed below. Note that the times for the 64-bit JIT are awful, even after this change (albeit a fair bit better). This is due to the 'hard' tail call optimizations. Removing the StructBox removed a layer of function calls that emitted tails, but there are still other performance layers to tear away (i.e. #513)

(not sure if the broken build is my fault - I do need to check this [on my laptop on the tram doesn't make for good full builds], but the results from appveyor seem to not have done anything)

Testx86_oldx86_newx64_oldx64_newx86_newx64_new
seqGroupBy170716774188298098.24%71.16%
seqCountBy228020326976429789.12%61.60%
listGroupBy123010923538228388.78%64.53%
listCountBy182215546466378385.29%58.51%
arrayCountBy162413506212358983.13%57.78%
arrayGroupBy9317743230188783.14%58.42%
dict8487801995133691.98%66.97%

The code

module Program
open System.Diagnostics
type Key1 =
struct
val A : int
new (a)= { A = a }
end
type Key2 =
struct
val A : int
val B : int
new (a,b)= { A=a;B=b }
end
type Key3 =
struct
val A : int
val B : int
val C : int
new (a,b,c)= { A=a;B=b;C=c }
end
type Key4 =
struct
val A : int
val B : int
val C : int
val D : int
new (a,b,c,d)= { A=a;B=b;C=c;D=d }
end
type Key5 =
struct
val A : int
val B : int
val C : int
val D : int
val E : int
new (a,b,c,d,e)= { A=a;B=b;C=c;D=d;E=e }
end
let seqTest f n createKey =
let sw = Stopwatch.StartNew ()
let count =
Seq.init n id
|> f createKey
|> Seq.length
count, sw.ElapsedMilliseconds
let listTest f n createKey =
let sw = Stopwatch.StartNew ()
let count =
List.init n id
|> f createKey
|> List.length
count, sw.ElapsedMilliseconds
let arrayTest f n createKey =
let sw = Stopwatch.StartNew ()
let count =
Array.init n id
|> f createKey
|> Array.length
count, sw.ElapsedMilliseconds
let dictTest n createKey =
let sw = Stopwatch.StartNew ()
let count =
Array.init n (fun n -> createKey n, n)
|> dict
|> fun d -> d.Count
count, sw.ElapsedMilliseconds
let createKey2 n = Key2(n%11,n%7)
let createKey3 n = Key3(n%11,n%7,n%5)
let createKey4 n = Key4(n%11,n%7,n%5,n%3)
let createKey5 n = Key5(n%11,n%7,n%5,n%3,n%2)
let createDictKey2 n = Key2(n,n)
let createDictKey3 n = Key3(n,n,n)
let createDictKey4 n = Key4(n,n,n,n)
let createDictKey5 n = Key5(n,n,n,n,n)
let runSeqGroupByTest () =
let size = 100000
let mutable totalTime = 0L
for i = 1 to 5 do
let c2, t2 = seqTest Seq.groupBy size createKey2
let c3, t3 = seqTest Seq.groupBy size createKey3
let c4, t4 = seqTest Seq.groupBy size createKey4
let c5, t5 = seqTest Seq.groupBy size createKey5
totalTime <- totalTime + t2 + t3 + t4 + t5
printfn "seqGroupBy %d,%d,%d,%d (%d,%d,%d,%d)" t2 t3 t4 t5 c2 c3 c4 c5
printfn "seqGroupBy totalTime=%d" totalTime
let runSeqCountByTest () =
let size = 100000
let mutable totalTime = 0L
for i = 1 to 5 do
let c2, t2 = seqTest Seq.countBy size createKey2
let c3, t3 = seqTest Seq.countBy size createKey3
let c4, t4 = seqTest Seq.countBy size createKey4
let c5, t5 = seqTest Seq.countBy size createKey5
totalTime <- totalTime + t2 + t3 + t4 + t5
printfn "seqCountBy %d,%d,%d,%d (%d,%d,%d,%d)" t2 t3 t4 t5 c2 c3 c4 c5
printfn "seqCountBy totalTime=%d" totalTime
let runListGroupByTest () =
let size = 100000
let mutable totalTime = 0L
for i = 1 to 5 do
let c2, t2 = listTest List.groupBy size createKey2
let c3, t3 = listTest List.groupBy size createKey3
let c4, t4 = listTest List.groupBy size createKey4
let c5, t5 = listTest List.groupBy size createKey5
totalTime <- totalTime + t2 + t3 + t4 + t5
printfn "listGroupBy %d,%d,%d,%d (%d,%d,%d,%d)" t2 t3 t4 t5 c2 c3 c4 c5
printfn "listGroupBy totalTime=%d" totalTime
let runListCountByTest () =
let size = 100000
let mutable totalTime = 0L
for i = 1 to 5 do
let c2, t2 = listTest List.countBy size createKey2
let c3, t3 = listTest List.countBy size createKey3
let c4, t4 = listTest List.countBy size createKey4
let c5, t5 = listTest List.countBy size createKey5
totalTime <- totalTime + t2 + t3 + t4 + t5
printfn "listCountBy %d,%d,%d,%d (%d,%d,%d,%d)" t2 t3 t4 t5 c2 c3 c4 c5
printfn "listCountBy totalTime=%d" totalTime
let runArrayGroupByTest () =
let size = 100000
let mutable totalTime = 0L
for i = 1 to 5 do
let c2, t2 = arrayTest Array.groupBy size createKey2
let c3, t3 = arrayTest Array.groupBy size createKey3
let c4, t4 = arrayTest Array.groupBy size createKey4
let c5, t5 = arrayTest Array.groupBy size createKey5
totalTime <- totalTime + t2 + t3 + t4 + t5
printfn "arrayGroupBy %d,%d,%d,%d (%d,%d,%d,%d)" t2 t3 t4 t5 c2 c3 c4 c5
printfn "arrayGroupBy totalTime=%d" totalTime
let runArrayCountByTest () =
let size = 100000
let mutable totalTime = 0L
for i = 1 to 5 do
let c2, t2 = arrayTest Array.countBy size createKey2
let c3, t3 = arrayTest Array.countBy size createKey3
let c4, t4 = arrayTest Array.countBy size createKey4
let c5, t5 = arrayTest Array.countBy size createKey5
totalTime <- totalTime + t2 + t3 + t4 + t5
printfn "arrayCountBy %d,%d,%d,%d (%d,%d,%d,%d)" t2 t3 t4 t5 c2 c3 c4 c5
printfn "arrayCountBy totalTime=%d" totalTime
let runDictTest () =
let size = 100000
let mutable totalTime = 0L
for i = 1 to 5 do
let c2, t2 = dictTest size createDictKey2
let c3, t3 = dictTest size createDictKey3
let c4, t4 = dictTest size createDictKey4
let c5, t5 = dictTest size createDictKey5
totalTime <- totalTime + t2 + t3 + t4 + t5
printfn "arrayCountBy %d,%d,%d,%d (%d,%d,%d,%d)" t2 t3 t4 t5 c2 c3 c4 c5
printfn "arrayCountBy totalTime=%d" totalTime
[<EntryPoint>]
let main _ =
//printfn "%s" Id.Name
runSeqGroupByTest()
runSeqCountByTest()
runListGroupByTest()
runListCountByTest()
runArrayCountByTest()
runArrayGroupByTest()
runDictTest ()
0

I don't think this is a good way to structure exceptions, but it's to
match current functionality
@dsyme

Copy link
Copy Markdown
Contributor

Impressive. I haven't done a detailed code review yet (others - please help) but there's a lot of goodness here.

Which specific tailcalls are causing the 64-bit slowdown? (give links to specific lines?) We should likely add a general .NET perf bug about that, and also consider if there is a systematic way to eliminate these in the F# compiler itself rather than manually.

@manofstick

Copy link
Copy Markdown
ContributorAuthor

@dsyme

Here is a cut down version which replicates the issue (could probably be a bit more minimal, but it's not too long...)

open System
open System.Diagnostics
open System.Collections.Generic
[<Struct>]
type StructBox<'T when 'T : equality>(value:'T) = member x.Value = value
static member Comparer =
{ new IEqualityComparer<StructBox<'T>> with member __.GetHashCode(v) =
#if MAKE_IT_FAST
0 +
#endif
EqualityComparer<'T>.Default.GetHashCode(v.Value)
member __.Equals(v1,v2) = failwith "not implemented" }
type StructTuple<'a,'b> =
struct val A : 'a
val B : 'b
new(a,b) = { A=a; B=b }
end
let countlots<'a when 'a : equality>(a : StructBox<'a>) =
let mutable sum = 0
for i=0 to 10000000 do
sum <- sum + StructBox.Comparer.GetHashCode(a)
sum
let inline time_it<'a when 'a : equality> (a:StructBox<'a>) =
let sw = Stopwatch.StartNew ()
let count = countlots a
printfn "%d (%d)" sw.ElapsedMilliseconds count
[<EntryPoint>]
let main argv = time_it (StructBox (StructTuple(1, false)))
time_it (StructBox (StructTuple(1L, false)))
0

So build this with the MAKE_IT_FAST compilation symbol, and the f# compiler won't generate a tail instruction, and the performance will be basically equal for the two versions, otherwise the one with tail with be ~10x slower.

(I have just exchanged the HashIdentity.Structural to EqualityComparer.Default just to simplify what is going on)

I don't think this is a JIT "issue" per se - i.e. I think it is just a natural consequence of the x64 calling convention, although I haven't actually worked through the underlying assembly myself, I'm just relying on the overall gist of this and this.

@manofstick

Copy link
Copy Markdown
ContributorAuthor

(Oh, and even without the tailcall issue, the change shows a 10-15% time decrease in the test suite on the x32 version which I think is still a reason enough improvement to warrant the change.)

Comment threadsrc/fsharp/FSharp.Core/array.fs Outdated

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.

For these guys I would consider naming the value safeKey, to match the given type name. As it stands, the value key does not have type 'Key, nor could it be returned by getKey...

Similarly throughout.

@manofstick

Copy link
Copy Markdown
ContributorAuthor

@latkin,

I'm happy to use "ResizeArray ()", and that is actually what I originally did, but the "problem" I found was that that decreased the performance of the use case where single allocations were occurring when there wasn't a second use of initialBucketSize to limit the call to TrimExcess().

That was why my comment/rant re: stingy allocation due to all the other baggage that is allocated.

Personally I use groupBy a lot, and I can't recall the last time I used it to bucket things into single items. (And I assume my usage is not that uncommon.)

So... How about we keep the restriction on the call to TrimExcess, and use the default constructor for ResizeArray ()? (I'm happy not to have the restriction on TrimExcess and use the default constructor, but it does punish people performance wise if they are using the single item case... or two or three items... Really the restriction should be there...)

Does this make sense? I'm sure this would be easier to explain over a beer... (Maybe I'll just have a few and rewrite the message...)

@latkin

Copy link
Copy Markdown
Contributor

That sounds good to me - just use defaults for the resizearrays themselves, but maintain the new limitation on TrimExcess calls.

Paul Westcott added 2 commits August 13, 2015 05:45
Initially this had been set to 1, I had changed it to 4, but after
discussion it was decided that the default is probably the correct
choice. As per
#549 (comment)
@manofstick

Copy link
Copy Markdown
ContributorAuthor

@latkin,

Done, and done.

@latkin

Copy link
Copy Markdown
Contributor

Oh geez, i was just sending you a PR with those, plus an adjustment to the IsStructType check so that it works for all profiles.

I'll just add it myself then merge, unless you have an objection.

@manofstick

Copy link
Copy Markdown
ContributorAuthor

SNAP! :-)

no worries; do as you see fit, I am but your humble servant!

@latkin

Copy link
Copy Markdown
Contributor

@manofstick using your test code, I get below results on my 2 machines. On my slow box I see very minor (but consistent) improvement. On my fast box I see much more significant improvement, in line with your results.

Machine 1

x86

Testoldnewnew result
seqGroupBy1162115793.49%
seqCountBy1473147596.59%
listGroupBy77375099.58%
listCountBy1149115298.87%
arrayCountBy1003102097.30%
arrayGroupBy58857498.94%
dict60159196.44%

x64 RyuJIT

Testoldnewnew result
seqGroupBy17531780101.54%
seqCountBy2726270999.38%
listGroupBy14341448100.98%
listCountBy2481244698.59%
arrayCountBy22582261100.13%
arrayGroupBy1220121799.75%
dict97895797.85%

x64 legacy JIT

Testoldnewnew result
seqGroupBy1941180292.84%
seqCountBy2925274393.78%
listGroupBy15111572104.04%
listCountBy25132535100.88%
arrayCountBy2340233699.83%
arrayGroupBy12791282100.23%
dict967974100.72%

Machine 2

x86

Testoldnewnew result
seqGroupBy1131103091.07%
seqCountBy1551128282.66%
listGroupBy74966488.65%
listCountBy1175101486.30%
arrayCountBy105687783.05%
arrayGroupBy59449383.00%
dict59653489.60%

x64 RyuJIT

Testoldnewnew result
seqGroupBy2504157863.02%
seqCountBy4114238557.97%
listGroupBy2275130657.41%
listCountBy3968225756.88%
arrayCountBy3779224659.43%
arrayGroupBy1971117659.67%
dict141798369.37%

x64 legacy JIT

Testoldnewnew result
seqGroupBy2345167071.22%
seqCountBy3943249663.30%
listGroupBy2123138665.28%
listCountBy3766228460.65%
arrayCountBy3886209153.81%
arrayGroupBy2041114055.85%
dict141691764.76%

latkin pushed a commit that referenced this pull request Aug 13, 2015
The StructBox makes code that contains "hard" tail calls, which means
that performance suffers under the 64 bit JIT
closes#549
commit 36f10b6214d8b73140b481e391f7999b9b8be8a3
Author: latkin <latkin@microsoft.com>
Date: Wed Aug 12 12:40:46 2015 -0700
Proper ref/val type checking for all portable profiles
commit 037a5e1
Author: Paul Westcott <p...w.e.s.t.c.o.t.t@gmail.com>
Date: Thu Aug 13 05:50:29 2015 +1000
Using default constructor for ResizeArray
Initially this had been set to 1, I had changed it to 4, but after
discussion it was decided that the default is probably the correct
choice. As per
#549 (comment)
commit 3796a55
Author: Paul Westcott <p...w.e.s.t.c.o.t.t@gmail.com>
Date: Thu Aug 13 05:45:38 2015 +1000
Renamed key to safeKey where appropriate
As per
#549 (diff)
commit b7884f8
Author: Paul Westcott <p...w.e.s.t.c.o.t.t@gmail.com>
Date: Wed Jul 22 17:12:30 2015 +1000
Restored null arg exception as lazy
I don't think this is a good way to structure exceptions, but it's to
match current functionality
commit 23cc156
Author: Paul Westcott <p...w.e.s.t.c.o.t.t@gmail.com>
Date: Wed Jul 22 05:53:33 2015 +1000
Split dict by ValueType/RefType
commit d4b6861
Author: Paul Westcott <p...w.e.s.t.c.o.t.t@gmail.com>
Date: Wed Jul 22 05:10:39 2015 +1000
Split Array.countBy/groupBy by ValueType/RefType
commit 02e6d42
Author: Paul Westcott <p...w.e.s.t.c.o.t.t@gmail.com>
Date: Wed Jul 22 04:55:42 2015 +1000
Split List.groupBy for ValueType/RefType
commit d80e616
Author: Paul Westcott <p...w.e.s.t.c.o.t.t@gmail.com>
Date: Wed Jul 22 04:43:54 2015 +1000
Split List.countBy by RefType/ValueType
commit 202e12e
Author: Paul Westcott <p...w.e.s.t.c.o.t.t@gmail.com>
Date: Wed Jul 22 04:27:45 2015 +1000
"Fixing" Reflection issues with Profile builds
There must be some other way to check if a type is a Value Type? I doubt
if it has been removed?
commit c06d8e6
Author: Paul Westcott <p...w.e.s.t.c.o.t.t@gmail.com>
Date: Tue Jul 21 16:07:33 2015 +1000
Split Seq.countBy for ValueType/RefType
commit 1c5ce38
Author: Paul Westcott <p...w.e.s.t.c.o.t.t@gmail.com>
Date: Tue Jul 21 15:42:25 2015 +1000
Split Seq.groupBy for ValueType/RefType
The StructBox makes code that contains "hard" tail calls, which means
that performance suffers under the 64 bit JIT
@latkin

Copy link
Copy Markdown
Contributor

Applied to OOB branch

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.

4 participants

@manofstick@msftclas@dsyme@latkin