Performance
Prerequisites: Mutable .NET collections
Idiomatic F# is already fast — it compiles to the same machine code path as C#. When a hot spot needs more, a few techniques go a long way. Always measure first.
Measuring
For quick checks, Stopwatch (or #time "on" in F# Interactive). For
trustworthy numbers, BenchmarkDotNet, which handles warm-up,
repetitions, and statistics:
// illustrative: a Release-mode console project with BenchmarkDotNet
open BenchmarkDotNet.Attributes
open BenchmarkDotNet.Running
[<MemoryDiagnoser>]
type SumBenchmarks() =
let data = [| 1 .. 1_000_000 |]
let list = List.ofArray data
[<Benchmark(Baseline = true)>]
member _.ArraySum() = Array.sum data
[<Benchmark>]
member _.ListSum() = List.sum list
[<Benchmark>]
member _.SeqSum() = Seq.sum data
BenchmarkRunner.Run<SumBenchmarks>() |> ignore
Run benchmarks with dotnet run -c Release; Debug builds and F#
Interactive aren't representative.
Choose the right collection
let numbers = [| 1 .. 5_000_000 |]
let viaSeq =
numbers |> Seq.filter (fun n -> n % 3 = 0) |> Seq.map (fun n -> int64 n * 2L) |> Seq.sum
let viaArray =
numbers |> Array.filter (fun n -> n % 3 = 0) |> Array.map (fun n -> int64 n * 2L) |> Array.sum
let mutable viaLoop = 0L
for n in numbers do
if n % 3 = 0 then viaLoop <- viaLoop + int64 n * 2L
printfn "%d %d %d" viaSeq viaArray viaLoop
printfn "all equal: %b" (viaSeq = viaArray && viaArray = viaLoop)
8333331666666 8333331666666 8333331666666
all equal: true
All three compute the same result. Typically, Seq pipelines are the
slowest (each item goes through several enumerator calls), Array
functions are much faster but allocate intermediate arrays, and a plain
loop over an array is fastest with no allocation. Rules of thumb:
- Arrays for large numeric data; lists for small, structural data.
Array.Parallel.mapandArray.Parallel.sum-style functions use all CPU cores for CPU-bound work.Dictionary/HashSetinstead ofMap/Setin hot loops (lookups are O(1) instead of O(log n)).
Avoid allocation in hot paths
open System
[<Struct>]
type Point = { X: float; Y: float }
let points = Array.init 1000 (fun i -> { X = float i; Y = float (i * 2) })
let total = points |> Array.sumBy (fun p -> p.X + p.Y)
printfn "%.0f" total
let keyOf (text: string) =
let span = text.AsSpan()
let eq = span.IndexOf '='
span.Slice(0, eq).ToString()
printfn "%s" (keyOf "key=value;other=thing")
1498500
key
[<Struct>]on small records, unions, and tuples (struct (1, 2)) stores them inline without a heap allocation per value.Span<T>andReadOnlySpan<T>slice strings and arrays without copying. They can only live on the stack, so use them inside functions, not as top-level values.ValueOption(ValueSome/ValueNone) is a struct version ofOption.
Other tips
- Use
task { }overasync { }in high-throughput servers — it has lower overhead. inlinesmall higher-order functions so their lambdas get inlined.- Use
StringBuilderorString.concatrather than repeated+. - Profile with dotnet-trace and dotnet-counters, or your IDE's profiler, before rewriting anything.
- Keep the clean functional version as the reference implementation and test the optimized one against it.