Performance for composition in F#

Jakob Christensen - Nov 7 '21 - - Dev Community

In a previous post I talked about how we at work simplified composition in an F# project because we found that we took a performance hit when we used monadic binds in our code.

I was a bit surprised how expensive the construct was so I wanted to dig deeper into it with this article.

The setup

Composition in F# can take a number of forms. Below we will take a closer look at the simple straight forward pipe (|>) operator and compare it to the Kleisli composition and monadic bind composition operating on the Result type.

In other words, we will compare the following operators:

// Monadic bind
let (>>=) m f =
    Result.bind f m 

// Monadic bind inline
let inline (>>==) m f =
    Result.bind f m 

// Kleisli
let inline (>=>) a b x =
    match a x with
    | Ok v -> b v
    | Error e -> Error e
Enter fullscreen mode Exit fullscreen mode

To test we compose five different functions. All the functions do is copy some data structure:

type Data =
    { Property1: string
      Property2: int
      Property3: DateTime 
      Property4: float
      Property5: decimal }

let processA data = { data with Property1 = "some new string" }
let processB data = { data with Property2 = 100 }
let processC data = ...

let processResultA data = Ok { data with Property1 = "some new string" }
let processResultB data = Ok { data with Property2 = 100 }
let processResultC data = ...

Enter fullscreen mode Exit fullscreen mode

We use function processData for testing |> and processDataResult for testing the compositions that operate on Result.

Now we can create the functions we wish to time:

let withPiping data =
    data
    |> processA
    |> processB
    |> processC
    |> processD
    |> processE

let withBinding data =
    data
    |> processResultA
    |> Result.bind processResultB
    |> Result.bind processResultC
    |> Result.bind processResultD
    |> Result.bind processResulte

let withOperatorWithoutInlining data =
    data
    |> processResultA
    >>= processResultB
    >>= processResultC
    >>= processResultD
    >>= processResultE

let withOperatorWithInlining data =
    data
    |> processResultA
    >>== processResultB
    >>== processResultC
    >>== processResultD
    >>== processResultE

let withKleisli data =
    data
    |> (processResultA
    >=> processResultB
    >=> processResultC
    >=> processResultD
    >=> processResultE)
Enter fullscreen mode Exit fullscreen mode

Note that withBinding, withOperatorWithoutInlining and withOperatorWithInlining are essentially the same but as we will see later there is a difference in performance between them.

Let's also define a function for timing a number of calls to a function f:

let timeFunction f data =
    let sw = Stopwatch()

    sw.Start()

    [1 .. 10_000_000]
    |> List.iter (fun _ -> f data |> ignore)

    sw.Stop()
    sw.Elapsed.TotalMilliseconds
Enter fullscreen mode Exit fullscreen mode

That way we can time 10 million calls to a function like so:

data |> timeFunction withKleisli
Enter fullscreen mode Exit fullscreen mode

To get a smaller variance on those 10 millions calls, let us define a run function that runs timeFunction a number of times and prints the average:

let run f fName =
    let data =
        { Property1 = "some string"
          Property2 = 42
          Property3 = DateTime.Today
          Property4 = 23.2
          Property5 = 23m }

    [1 .. 100]
    |> List.averageBy (fun i -> timeFunction f data)
    |> print fName
Enter fullscreen mode Exit fullscreen mode

Now we can write the final setup:

run withPiping (nameof withPiping)
run withBinding (nameof withBinding)
run withOperatorWithInlining (nameof withOperatorWithInlining)
run withOperatorWithoutInlining (nameof withOperatorWithoutInlining)
run withKleisli (nameof withKleisli)
Enter fullscreen mode Exit fullscreen mode

Granted, the setup is a bit lame but it does give some indication of differences in performance between the different types of composition.

The results are in

They say that an image is worth a thousand words, so here is a graph for you. I set withPiping as index 100, so the graph shows how the other methods compare to withPiping.

Graph showing relative performance for the different methods

It shouldn't come as a surprise that withPiping is the fastest.

I am a bit surprised that withBinding is that much faster than withOperatorWithoutInlining, i.e. the >>= operator.

Kleisli composition is surprisingly slow, but monadic bind is more useful in F# anyways so you would probably not use Kleisli that often.

Code

I put the code on Github if you want to have a look. I created it on .NET 6 RC using the preview of Visual Studio 2022.

. . . . . . . . . . . . . . . . . . . . . . . . . . . .