Curious case of List.contains performance

Curious case of List.contains performance

Featured on Hashnode

Some time ago, colleagues stumbled upon a case where List.contains was significantly slower than identical use of List.exists.

List.contains and List.exists are functions from F# standard library, where

  • List.contains x xs check if the list xs contains element x

  • List.exists predicate xs check if there is an element in the list xs that satisfy predicate condition.

List.contains being slower than List.exists is weird because it is a specific case of List.exists, and can be rephrased as List.contains x xs = List.exists (fun y -> x = y) xs.

Let's look at this problem in more detail.

Benchmarking

Comparing exists and contains function on lists of int, string and record with Benchmark.NET library we get:

MethodMeanErrorStdDevGen0Allocated
'int - List.exists'1.482 ms0.0490 ms0.1413 ms1.953148042 B
'int - List.contains'20.230 ms0.4809 ms1.4105 ms1906.250024024059 B
'string - List.exists'2.230 ms0.0290 ms0.0271 ms-48042 B
'string - List.contains'4.269 ms0.1183 ms0.3413 ms-45 B
'record - List.exists'1.208 ms0.0237 ms0.0308 ms1.953148041 B
'record - List.contains'6.388 ms0.1276 ms0.1949 ms-45 B
💡
Benchmark run function for each item of list of size 1000. The link for code for all benchmarks is at the bottom of the article.

We see that exists is faster than contains for all types. For string it is around 2 times faster, for record it is around 5 times faster, and for int it is around 14 times faster.

Decompiled code

One way to understand why exists is faster than contains is to look at the compiled code. To do that we can use a great tool called SharpLab. This tool compiles given code in F# or C# to CIL, and allows us to look at the decompiled version of CIL in C#. Let's look at a simple example for exists and contains for int:

List.contains function:

[1 .. 1000] |> List.contains 1

decompiled CIL code is as follows

using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using <StartupCode$_>;
using Microsoft.FSharp.Collections;
using Microsoft.FSharp.Core;

[assembly: FSharpInterfaceDataVersion(2, 0, 0)]
[assembly: AssemblyVersion("0.0.0.0")]

[CompilationMapping(SourceConstructFlags.Module)]
public static class @_
{
    [CompilationMapping(SourceConstructFlags.Value)]
    internal static FSharpList<int> arg@1
    {
        get
        {
            return $_.arg@1;
        }
    }

    [CompilationMapping(SourceConstructFlags.Value)]
    internal static FSharpList<int> source@1
    {
        get
        {
            return $_.source@1;
        }
    }

    [CompilerGenerated]
    internal static bool contains@1<a>(a e, FSharpList<a> xs1)
    {
        while (true)
        {
            if (xs1.TailOrNull == null)
            {
                return false;
            }
            FSharpList<a> fSharpList = xs1;
            FSharpList<a> tailOrNull = fSharpList.TailOrNull;
            a headOrDefault = fSharpList.HeadOrDefault;
            if (LanguagePrimitives.HashCompare.GenericEqualityIntrinsic(e, headOrDefault))
            {
                break;
            }
            a val = e;
            xs1 = tailOrNull;
            e = val;
        }
        return true;
    }
}

namespace <StartupCode$_>
{
    internal static class $_
    {
        [DebuggerBrowsable(DebuggerBrowsableState.Never)]
        internal static readonly FSharpList<int> arg@1;

        [DebuggerBrowsable(DebuggerBrowsableState.Never)]
        internal static readonly FSharpList<int> source@1;

        [DebuggerBrowsable(DebuggerBrowsableState.Never)]
        [CompilerGenerated]
        [DebuggerNonUserCode]
        internal static int init@;

        static $_()
        {
            arg@1 = SeqModule.ToList(Operators.CreateSequence(Operators.OperatorIntrinsics.RangeInt32(1, 1, 1000)));
            source@1 = @_.arg@1;
            @_.contains@1(1, @_.source@1);
        }
    }
}

The important thing here is the call of LanguagePrimitives.HashCompare.GenericEqualityIntrinsic function. That's a function for comparing two values, regardless of their type. Because that function is generic, it contains runtime type checks and it is slower than = operator applied on two ints.

Implementation of GenericEqualityIntrinsic can be found here: https://github.com/dotnet/fsharp/blob/b4c26d84bc58a8d3abb2e7648ed7cc1e1cd7e25c/src/FSharp.Core/prim-types.fs#L1558

which just call GenericEqualityObj https://github.com/dotnet/fsharp/blob/b4c26d84bc58a8d3abb2e7648ed7cc1e1cd7e25c/src/FSharp.Core/prim-types.fs#L1436

List.exists function:

[1 .. 1000] |> List.exists (fun x -> x = 1)

decompiled CIL code is as follows

using System;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using Microsoft.FSharp.Collections;
using Microsoft.FSharp.Core;

[assembly: FSharpInterfaceDataVersion(2, 0, 0)]
[assembly: AssemblyVersion("0.0.0.0")]

[CompilationMapping(SourceConstructFlags.Module)]
public static class @_
{
    [Serializable]
    internal sealed class clo@1 : FSharpFunc<int, bool>
    {
        internal static readonly clo@1 @_instance = new clo@1();

        [CompilerGenerated]
        [DebuggerNonUserCode]
        internal clo@1()
        {
        }

        public override bool Invoke(int x)
        {
            return x == 1;
        }
    }
}

namespace <StartupCode$_>
{
    internal static class $_
    {
        [DebuggerBrowsable(DebuggerBrowsableState.Never)]
        [CompilerGenerated]
        [DebuggerNonUserCode]
        internal static int init@;

        static $_()
        {
            ListModule.Exists(@_.clo@1.@_instance, SeqModule.ToList(Operators.CreateSequence(Operators.OperatorIntrinsics.RangeInt32(1, 1, 1000))));
        }
    }
}

We see that ListModule.Exists function uses == to compare two ints. That's because = operator is inside lambda function (here compiled as @_.clo@1.@_instance), and the compiler knows it's used on ints.

Better List.contains implementation

We saw that List.exists works better than List.contains. How about we just redefine List.contains to use List.exists?

module List =
    let containsByExists value source =
        List.exists (fun x -> x = value) source

When we run benchmarks with this implementation we get:

MethodMeanErrorStdDevGen0Allocated
'int - List.exists'1.482 ms0.0490 ms0.1413 ms1.953148042 B
'int - List.contains'20.230 ms0.4809 ms1.4105 ms1906.250024024059 B
'int - List.containsByExists'19.888 ms0.4198 ms1.1977 ms1906.250024048059 B
'string - List.exists'2.230 ms0.0290 ms0.0271 ms-48042 B
'string - List.contains'4.269 ms0.1183 ms0.3413 ms-45 B
'string - List.containsByExists'5.130 ms0.1024 ms0.2837 ms-24045 B
'record - List.exists'1.208 ms0.0237 ms0.0308 ms1.953148041 B
'record - List.contains'6.388 ms0.1276 ms0.1949 ms-45 B
'record - List.containsByExists'6.400 ms0.1240 ms0.1099 ms-48045 B

Not much better.

If we return to SharpLab, we can see that LanguagePrimitives.HashCompare.GenericEqualityIntrinsic is still used:

using System;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using Microsoft.FSharp.Collections;
using Microsoft.FSharp.Core;

[assembly: FSharpInterfaceDataVersion(2, 0, 0)]
[assembly: AssemblyVersion("0.0.0.0")]

[CompilationMapping(SourceConstructFlags.Module)]
public static class @_
{
    [CompilationMapping(SourceConstructFlags.Module)]
    public static class List
    {
        [Serializable]
        internal sealed class contains@3<a> : FSharpFunc<a, bool>
        {
            public a value;

            [CompilerGenerated]
            [DebuggerNonUserCode]
            internal contains@3(a value)
            {
                this.value = value;
            }

            public override bool Invoke(a x)
            {
                return LanguagePrimitives.HashCompare.GenericEqualityIntrinsic(x, value);
            }
        }

        [CompilationArgumentCounts(new int[] { 1, 1 })]
        public static bool contains<a>(a value, FSharpList<a> source)
        {
            return ListModule.Exists(new contains@3<a>(value), source);
        }
    }
}

namespace <StartupCode$_>
{
    internal static class $_
    {
        [DebuggerBrowsable(DebuggerBrowsableState.Never)]
        [CompilerGenerated]
        [DebuggerNonUserCode]
        internal static int init@;

        static $_()
        {
            @_.List.contains(1, SeqModule.ToList(Operators.CreateSequence(Operators.OperatorIntrinsics.RangeInt32(1, 1, 1000))));
        }
    }
}

Why? The reason is that the type of = operator in containsByExists is not known at the time of compilation. That's different from exists where the type of = operator is used outside of a function, in lambda parameter.

But there is a solution, we can use inlined function:

module List =
    let inline containsByExistsInline value source =
        List.exists (fun x -> x = value) source

When we run benchmarks with this implementation we get:

MethodMeanErrorStdDevGen0Allocated
'int - List.exists'1.482 ms0.0490 ms0.1413 ms1.953148042 B
'int - List.contains'20.230 ms0.4809 ms1.4105 ms1906.250024024059 B
'int - List.containsByExists'19.888 ms0.4198 ms1.1977 ms1906.250024048059 B
'int - List.containsByExistsInline'1.164 ms0.0230 ms0.0519 ms-24041 B
'string - List.exists'2.230 ms0.0290 ms0.0271 ms-48042 B
'string - List.contains'4.269 ms0.1183 ms0.3413 ms-45 B
'string - List.containsByExists'5.130 ms0.1024 ms0.2837 ms-24045 B
'string - List.containsByExistsInline'2.089 ms0.0373 ms0.0602 ms-24042 B
'record - List.exists'1.208 ms0.0237 ms0.0308 ms1.953148041 B
'record - List.contains'6.388 ms0.1276 ms0.1949 ms-45 B
'record - List.containsByExists'6.400 ms0.1240 ms0.1099 ms-48045 B
'record - List.containsByExistsInline'1.573 ms0.0312 ms0.0630 ms-24041 B

Yay! Much better. Now List.containsByExistsInline have comparable performance to List.exists.

Other collection types

Interestingly, this problem is not present in other collection types. It's probably because the implementation of exists and contains functions is different.

Array

MethodMeanErrorStdDevGen0Allocated
'int - Array.exists'680.7 us9.28 us8.22 us-33 B
'int - Array.contains'678.8 us4.71 us4.18 us-33 B
'int - Array.containsByExists'25,871.9 us516.35 us671.40 us1906.250024024051 B
'int - Array.containsByExistsInline'674.6 us9.77 us9.14 us-33 B
'string - Array.exists'2,601.8 us48.39 us69.41 us-34 B
'string - Array.contains'2,531.9 us50.01 us53.51 us-34 B
'string - Array.containsByExists'6,809.1 us96.07 us85.16 us-37 B
'string - Array.containsByExistsInline'2,508.0 us47.43 us44.37 us-34 B
'record - Array.exists'788.1 us12.45 us11.03 us-33 B
'record - Array.contains'806.3 us15.28 us16.34 us-33 B
'record - Array.containsByExists'10,248.9 us196.80 us210.57 us-24041 B
'record - Array.containsByExistsInline'802.7 us15.17 us14.19 us-33 B

Seq

MethodMeanErrorStdDevGen0Allocated
'int - Seq.exists'3.937 ms0.0760 ms0.0961 ms7.8125101.62 KB
'int - Seq.contains'2.885 ms0.0559 ms0.0574 ms3.906354.74 KB
'int - Seq.containsByExists'29.364 ms0.4812 ms0.4501 ms1906.250023539.14 KB
'int - Seq.containsByExistsInline'3.682 ms0.0726 ms0.0864 ms-78.18 KB
'string - Seq.exists'20.745 ms0.2531 ms0.2114 ms1250.000015540.03 KB
'string - Seq.contains'21.457 ms0.3560 ms0.3330 ms1250.000015493.15 KB
'string - Seq.containsByExists'25.581 ms0.4793 ms0.4484 ms1250.000015516.59 KB
'string - Seq.containsByExistsInline'21.232 ms0.3437 ms0.3678 ms1250.000015516.59 KB
'record - Seq.exists'23.734 ms0.2825 ms0.2643 ms2531.250031211.9 KB
'record - Seq.contains'22.695 ms0.2858 ms0.2534 ms2531.250031165.03 KB
'record - Seq.containsByExists'33.592 ms0.6649 ms0.6530 ms2500.000031211.92 KB
'record - Seq.containsByExistsInline'23.483 ms0.4104 ms0.3638 ms2531.250031188.46 KB

Impementation

Implementation of Array.contains in F# compiler source code:

let inline contains value (array: 'T[]) =
    checkNonNull "array" array
    let mutable state = false
    let mutable i = 0

    while not state && i < array.Length do
        state <- value = array.[i]
        i <- i + 1

    state

https://github.com/dotnet/fsharp/blob/df3919d6940ca3ab63fa8f8fe7b1a511cf4cc96d/src/FSharp.Core/array.fs#L485

Implementation of Seq.contains in F# compiler source code:

let inline contains value (source: seq<'T>) =
    checkNonNull "source" source
    use e = source.GetEnumerator()
    let mutable state = false

    while (not state && e.MoveNext()) do
        state <- value = e.Current

    state

https://github.com/dotnet/fsharp/blob/97c2d3784e907862bf4266cb0ffd8d2788e0a54b/src/FSharp.Core/seq.fs#L681

Both implementations use explicit mutation and while cycle. Inlining of = operator works well here. Problem with List.contains is probably that it uses recursion, and = operator is not inlined.

Better solution?

Can we use a similar implementation for List.contains? Let's try:

let inline containsMutable value (source: list<_>) =
    let mutable xs = source
    let mutable state = false

    while (not state && not xs.IsEmpty) do
        state <- value = xs.Head
        xs <- xs.Tail

    state

Benchmark results:

MethodMeanErrorStdDevGen0Allocated
'int - List.exists'1.865 ms0.0275 ms0.0257 ms-48042 B
'int - List.contains'25.762 ms0.1914 ms0.1696 ms1906.250024024059 B
'int - List.containsByExists'25.623 ms0.5022 ms0.5157 ms1906.250024048059 B
'int - List.containsByExistsInline'1.562 ms0.0229 ms0.0214 ms-24041 B
'int - List.containsMutable'2.461 ms0.0371 ms0.0310 ms-42 B
'string - List.exists'3.330 ms0.0293 ms0.0274 ms-48042 B
'string - List.contains'6.705 ms0.0284 ms0.0266 ms-45 B
'string - List.containsByExists'8.022 ms0.0956 ms0.0894 ms-24049 B
'string - List.containsByExistsInline'3.360 ms0.0516 ms0.0482 ms-24042 B
'string - List.containsMutable'4.337 ms0.0760 ms0.0711 ms-45 B
'record - List.exists'1.856 ms0.0191 ms0.0179 ms1.953148041 B
'record - List.contains'9.894 ms0.1830 ms0.1712 ms-49 B
'record - List.containsByExists'11.263 ms0.1205 ms0.1127 ms-48049 B
'record - List.containsByExistsInline'2.369 ms0.0341 ms0.0319 ms-24042 B
'record - List.containsMutable'2.878 ms0.0249 ms0.0233 ms-42 B

It's better, but still not as good as List.containsByExistsInline.

Let's try something little different - use of List.tryFind function:

    let inline containsTryFind value (source: list<_>) =
        source |> List.tryFind (fun x -> value = x) |> Option.isSome
MethodMeanErrorStdDevGen0Allocated
'int - List.exists'1.844 ms0.0235 ms0.0220 ms1.953148041 B
'int - List.contains'25.150 ms0.4391 ms0.4108 ms1906.250024024059 B
'int - List.containsByExists'27.099 ms0.3463 ms0.3239 ms1906.250024048059 B
'int - List.containsByExistsInline'1.553 ms0.0171 ms0.0160 ms-24041 B
'int - List.containsMutable'2.494 ms0.0471 ms0.0595 ms-42 B
'int - List.containsTryFind'1.247 ms0.0245 ms0.0240 ms1.953148041 B
'string - List.exists'3.396 ms0.0351 ms0.0311 ms-48042 B
'string - List.contains'6.803 ms0.1287 ms0.1204 ms-45 B
'string - List.containsByExists'8.119 ms0.1230 ms0.1151 ms-24049 B
'string - List.containsByExistsInline'3.393 ms0.0451 ms0.0422 ms-24042 B
'string - List.containsMutable'4.448 ms0.0770 ms0.0721 ms-45 B
'string - List.containsTryFind'3.046 ms0.0602 ms0.0670 ms-48042 B
'record - List.exists'1.928 ms0.0372 ms0.0382 ms-48042 B
'record - List.contains'10.270 ms0.1622 ms0.1593 ms-49 B
'record - List.containsByExists'11.283 ms0.1212 ms0.1133 ms-48049 B
'record - List.containsByExistsInline'2.344 ms0.0235 ms0.0208 ms-24042 B
'record - List.containsMutable'2.888 ms0.0455 ms0.0487 ms-42 B
'record - List.containsTryFind'1.837 ms0.0360 ms0.0337 ms1.953148041 B

This looks good!

List.containsTryFind is even slightly better than List.containsByExistsInline.

Conclusion

We show that List.contains is much slower than List.exists. We analyzed the reason behind this, the usage of GenericEqualityIntrinsic, which is slower than inlined = operator for specific types. We present 2 possible faster implementations of List.contains, List.containsByExistsInline and List.containsTryFind, which is as fast as List.exists or better.

Resources

Full benchmark code can be found in the Benchmarks-list-contains repo.

Update 2023-08-02

I raised an issue about this in F# compiler repo, and Tomáš Grošup from F# compiler team find out alteration of original List.contains function that solves the issue:

let inline outerContainsWhatIf value source =
    let inline isMatch elem = elem = value
    let rec innerContainsWhatIf e xs1 =
        match xs1 with
        | [] -> false
        | h1 :: t1 -> isMatch h1 || innerContainsWhatIf e t1

    innerContainsWhatIf value source

The important thing here is isMatch function outside recursive inner function, that allow inlining of =. I added this function to benchmarks, and it turns out its the fastest variant:

MethodMeanErrorStdDevMedianGen0Allocated
'int - List.exists'785.5 μs9.62 μs9.00 μs784.0 μs2.929748041 B
'int - List.contains'9,284.4 μs158.63 μs148.38 μs9,266.7 μs1906.250024024049 B
'int - List.containsByExists'9,068.0 μs108.59 μs90.67 μs9,034.4 μs1906.250024048049 B
'int - List.containsByExistsInline'695.4 μs13.82 μs24.92 μs691.4 μs0.976624041 B
'int - List.containsMutable'840.1 μs10.33 μs9.16 μs843.2 μs-41 B
'int - List.containsTryFind'566.2 μs11.21 μs21.06 μs558.9 μs2.929748041 B
'int - List.outerContainsWhatIf'548.4 μs10.35 μs15.17 μs541.9 μs-41 B
'string - List.exists'1,376.0 μs26.54 μs34.51 μs1,373.1 μs1.953148041 B
'string - List.contains'2,393.9 μs43.59 μs83.98 μs2,359.5 μs-42 B
'string - List.containsByExists'3,699.8 μs197.62 μs579.57 μs3,653.1 μs-24042 B
'string - List.containsByExistsInline'1,332.1 μs26.59 μs75.86 μs1,314.9 μs-24041 B
'string - List.containsMutable'1,637.5 μs32.71 μs63.03 μs1,610.3 μs-41 B
'string - List.containsTryFind'1,113.8 μs22.80 μs66.88 μs1,101.8 μs1.953148041 B
'string - List.outerContainsWhatIf'838.6 μs21.62 μs62.38 μs824.1 μs-41 B
'record - List.exists'789.1 μs21.43 μs62.85 μs770.7 μs2.929748041 B
'record - List.contains'3,796.8 μs66.80 μs113.44 μs3,753.0 μs-45 B
'record - List.containsByExists'3,927.7 μs34.48 μs30.57 μs3,924.5 μs-48045 B
'record - List.containsByExistsInline'987.5 μs19.62 μs55.00 μs969.5 μs-24041 B
'record - List.containsMutable'1,147.1 μs18.58 μs15.52 μs1,151.4 μs-41 B
'record - List.containsTryFind'1,002.2 μs19.40 μs22.34 μs994.7 μs1.953148041 B
'record - List.outerContainsWhatIf'691.4 μs13.61 μs16.20 μs688.9 μs-41 B

And there is already PR with this change! 🎉