4

Performance for composition in F#

 2 years ago
source link: https://dev.to/t4rzsan/performance-for-composition-in-f-3fl
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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 modeExit 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 modeExit 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 modeExit 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 modeExit fullscreen mode

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

data |> timeFunction withKleisli
Enter fullscreen modeExit 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 modeExit 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 modeExit 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.

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.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK