Any reason to not make records value types

Viewed 109

So I have been experiment with code such as this:

[<Struct>]
type Component = {
    Num : int
}
with 
    static member New = {
        Num = 0
    }

let init n = 
    seq {
        for _ in 0..n do 
            yield Component.New
    }
    |> Seq.toArray

let test (c : Component []) = 
    c
    |> Array.map ( fun c -> { c with Num = c.Num + 1} )

[<EntryPoint>]
let main argv =
    let sw = Stopwatch()
    let c = init 10000000
    sw.Start ()
    let c' = test c
    sw.Stop ()
    printfn "%A" sw.ElapsedMilliseconds
    0

I get these benchmarks when running this test.

Reference type and list: 1450
Reference type and array: 850
Value type and list: 700
Value type and array: 80

The fact that an array would be faster than the list is obviously within expectation, I also expected the value types to be faster but not that much faster. I am wondering however, is there a scenario when I do not want a record to be a value type? It seems like it is almost always preferrable?

1 Answers

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.

Related