Skip to content
AgileLogics
7 min read

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.map and Array.Parallel.sum-style functions use all CPU cores for CPU-bound work.
  • Dictionary/HashSet instead of Map/Set in 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> and ReadOnlySpan<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 of Option.

Other tips

  • Use task { } over async { } in high-throughput servers — it has lower overhead.
  • inline small higher-order functions so their lambdas get inlined.
  • Use StringBuilder or String.concat rather 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.