Writing F# is usually good fun, with an emphasis on writing simple, correct, readable and maintainable code. And since F# runs on top of .NET, it's usually pretty efficient code as well! However, there are times when you'll also need to consider compromising on one or two of those qualities in order to eke out every last CPU cycle and byte of memory. So here are some tips for doing just that.
Note: Many of the tips mentioned here should generally only be considered if you have quantifiable and repeatable evidence of the cause of a performance bottleneck.
1. Use tools
If you're trying to find a bottleneck and find yourself putting in timing statements at random parts of your code - stop for a second and consider whether a performance testing tool will help. There are some great, freely available and commercial tools for benchmarking and identifying bottlenecks, all of which can be used to prove with greater certainty where and why a bottleneck is occurring.
- Visual Studio's Performance Profiler is a good starting point if you're on Windows and already using VS. The majority of the features in it are free, and it includes enough tools to allow you to rapidly highlight bottlenecks and potential issues in your code.
Ants Profiler is a professional, mature and commercial profiling tool. There's a free trial, and if VS's out of the box profiler doesn't quite fit the bill for you, you should consider this.
JetBrains dotTrace Profiler helps you detect performance bottlenecks in a variety of .NET and .NET Core applications. It's a commercial package and included in many JetBrains subscriptions.
Benchmark .NET is a free .NET library that provides benchmarking features and is especially useful for comparing one implementation of a feature against another. It's very thorough, and can provide a whole set of metrics and diagnostics in a variety of formats for you.
The Expecto F# test runner has a little-known feature referred to as performance testing. It allows you to provide two implementations of the same function and will provide a statistical test to prove if one is faster than another.
Indeed, for any of the tips you follow below, use a profiler or benchmarking tool to prove that they have improved performance for your specific use case.
2. Look for low-hanging fruits
Before you consider some of the more invasive and costly changes below, check if there are some obvious "quick wins" for your codebase. For example, are you rapidly creating an object or making an IO call within a tight loop that could be moved outside? Or needlessly creating tuples that you already have in a different order? Oftentimes, you can make large savings in performance with relatively simple changes to your code. Sometimes getting a fresh pair of eyes to review your code can help in this regard.
3. Use the right collection structure
F# has a variety of collection structures. For sequence collections, make sure that you understand the pros and cons of the three main structures: Lists, Arrays and Sequences. There is no one-size-fits all - there are specific use cases where one will out-perform the others. Nonetheless, if you're insisting on getting a recommendation for a detail list that will normally perform well, then Array is probably your best bet.
However, make sure you're using lists in the right situation. Dictionaries and Maps will out-peform lists for lookups based on a specific key, so if you see lots of
tryFind calls in your code - consider a Dictionary of some sort. Don't overlook the
readOnlyDict functions that are built into F# - they allow you to rapidly create a high-performing read-only dictionary from a sequence of tuples.
4. Consider the use of mutable data structures
Immutable data structures provide thread safety and are much easier to reason about than mutable data. Nonetheless, if you're trying to eke out the best performance, and your code is e.g. in a tight loop and has a small scope (perhaps a single function, or just a few lines), then consider something like a
Dictionary. Both allow adding and removing items at a faster rate than immutable collections - and require less code to modify than their immutable cousins. Just beware not to return them out to callers - once you've done your modifications, return back an immutable version.
5. Consider using structs
F# now supports struct types - Tuples, Records and Discriminated Unions can all now be declared as structs rather than reference types either with the
struct keyword or
[<Struct>] attribute. There are a few restrictions, but in general simply adding the appropriate keyword will be sufficient. As structs, your values will be passed by value, and not by reference; this is not a huge difference when working with immutable data structures, but from a performance point of view, struct values do not require garbage collection. There's a flip-side to this though - since your data is passed by value, the entire object graph is copied as the value is passed from function to function - this in itself can cause performance and memory pressure!
In our experience, "small" types, such as single case discriminated unions and records with just a few simple fields are ideal candidates for structification.
6. Consider the use of parallel processing
Immutable data structures lend themselves especially well to parallelisation, and F# has a decent array of options here. Out of the box, the
Array module contains a
Parallel submodule which offers functions such as
collect, whilst Async and Tasks both have fork / join methods for parallel asynchronous processing. There are also alternative collection libraries, such as Nessos' Streams library, which contains the same basic functionality as
Seq, and more, with much improved performance characteristics.
7. Consider custom equality
If you have large records that you are frequently comparing against (whether directly or within hash-based collections such as
Map), consider overriding the default equality and comparison checks (which compare every single field) with your own implementation using the
CustomComparison attributes. It's beyond the scope of this post to demonstrate how to do this, and there's not a great deal of documentation available, so I'll go through it in more detail in a future post. However, assuming your data semantically operates on this basis, implementing custom equality and comparison can provide an elegant solution to provide performance improvements whilst only changing code in one place.
F# code generally will perform just fine without the need to spend time optimising. However, for the cases that you do need to improve performance of your application, we've looked at several ways to do this. Hope it was useful!
Have (fun _ -> ()).