For a small struct like that, and especially when held in an array, there is no reason not to make it a struct for performance reasons. Even if the struct is larger than 16 bytes, which is the general guideline, it will typically yield faster performance times for a situation like this:
- Allocated into an array
- Straightforward processing of that array
- No further processing of that array
I wouldn't be surprised if several subsequent array processing routines also yielded better performance, because allocating a bunch of value types into an array means it will usually all get loaded into a CPU cache line and get processed ridiculously fast.
However, things get a little more interesting when you're not using value types (arrays, spans) all the way down, or if you change up how you do things:
- Array-based operations create new arrays and eventually the consecutive re-creation of data overwhelms the actual processing of that data
- Sometimes you're working with an F# list or sequence or some other reference type where the data won't all get loaded into a CPU cache line
- Sometimes you're mixing and matching value types and reference types and the extra copying from storing things in value types negatively affects performance
In short, this particular scenario is extremely well-suited towards using a value type - the data contained is all just a single primitive value type and instances of it are stored in an array, and the same structure is getting re-created each time. So you should use value types in this kind of scenario if performance matters at all.
Change the code to use lists, pre-allocate the data rather, and instead of re-create a struct, just yield back a list of the single value under a benchmark like so:
open BenchmarkDotNet.Attributes
open BenchmarkDotNet.Running
module ReferenceType =
type Component = {
Num0 : int
}
with
static member New = {
Num0 = 0
}
let init n =
seq {
for _ in 0..n do
yield Component.New
}
|> Seq.toList
let test cs =
cs
|> List.map (fun c -> c.Num0 + 1)
module ValueType =
[<Struct>]
type Component = {
Num0 : int
}
with
static member New = {
Num0 = 0
}
let init n =
seq {
for _ in 0..n do
yield Component.New
}
|> Seq.toList
let test cs =
cs
|> List.map (fun c -> c.Num0 + 1)
[<MemoryDiagnoser>]
type ReferenceVsValueType() =
let refs = ReferenceType.init 10_000
let vals = ValueType.init 10_000
[<Benchmark(Baseline=true)>]
member _.BuiltIn() = ReferenceType.test refs
[<Benchmark>]
member _.ValueType() = ValueType.test vals
[<EntryPoint>]
let main argv =
BenchmarkRunner.Run<ReferenceVsValueType>() |> ignore
0 // return an integer exit code
And you get very, very close results:
| Method |
Mean |
Error |
StdDev |
Ratio |
RatioSD |
Gen 0 |
Gen 1 |
Gen 2 |
Allocated |
| BuiltIn |
90.99 μs |
1.979 μs |
5.709 μs |
1.00 |
0.00 |
51.5137 |
25.7568 |
- |
312.53 KB |
| ValueType |
87.13 μs |
1.712 μs |
4.600 μs |
0.95 |
0.08 |
51.3916 |
25.6348 |
- |
312.53 KB |
Add one more field and sum up the values and a reference type wins:
| Method |
Mean |
Error |
StdDev |
Median |
Ratio |
RatioSD |
Gen 0 |
Gen 1 |
Gen 2 |
Allocated |
| BuiltIn |
86.14 μs |
1.543 μs |
4.145 μs |
84.61 μs |
1.00 |
0.00 |
51.5137 |
25.7568 |
- |
312.53 KB |
| ValueType |
126.18 μs |
2.516 μs |
3.917 μs |
124.75 μs |
1.46 |
0.08 |
51.2695 |
25.6348 |
- |
312.53 KB |
So what's the lesson?
Always measure carefully. Under very specific circumstances you can arrive at a place where a value type is always faster, and when that happens, it's cool! But just a few subtle changes and you'll get very different behavior.