Switch expression performance: part 1

Spread the love

Being the good multi-paradigm language that it is, C# has adopted features from across the programming spectrum. One recent addition of the functional programming variety is pattern matching, first arriving in C# 7.0 with enhancements in C# 8.0. The latter defines a new syntax called the switch expression which can make your code a lot less cluttered and warn you when your cases are not exhaustive. But of course, I need to ask — does this syntactic sugar come at a price?

To answer this, we will run a head-to-head benchmark. We’ll compare these three implementations of an enum translation method:

    public static class MyLevel
    {
        public static char Translate0(TraceLevel level)
        {
            if (level == TraceLevel.Verbose)
            {
                return 'V';
            }

            if (level == TraceLevel.Info)
            {
                return 'I';
            }

            if (level == TraceLevel.Warning)
            {
                return 'W';
            }

            if (level == TraceLevel.Error)
            {
                return 'E';
            }

            if (level == TraceLevel.Off)
            {
                return ' ';
            }

            throw new ArgumentOutOfRangeException(nameof(level));
        }

        public static char Translate1(TraceLevel level)
        {
            switch (level)
            {
                case TraceLevel.Verbose: return 'V';
                case TraceLevel.Info: return 'I';
                case TraceLevel.Warning: return 'W';
                case TraceLevel.Error: return 'E';
                case TraceLevel.Off: return ' ';
                default: throw new ArgumentOutOfRangeException(nameof(level));
            };
        }

        public static char Translate2(TraceLevel level)
        {
            return level switch
            {
                TraceLevel.Verbose => 'V',
                TraceLevel.Info => 'I',
                TraceLevel.Warning => 'W',
                TraceLevel.Error => 'E',
                TraceLevel.Off => ' ',
                _ => throw new ArgumentOutOfRangeException(nameof(level)),
            };
        }
    }

The first method uses a boring if/else statement; we can think of it as the control group. The next method uses the classic switch statement. The final method is the same switch statement transformed into a switch expression — exactly what Visual Studio would do to apply the “Convert switch statement to switch expression” refactoring. Finally, we need a benchmark to compare the raw runtime performance:

    [SimpleJob(RuntimeMoniker.NetCoreApp31)]
    [MemoryDiagnoser]
    public class SwitchBenchmark
    {
        [Benchmark]
        public int IfElse()
        {
            int sum = 0;
            sum += MyLevel.Translate0(TraceLevel.Error);
            sum += MyLevel.Translate0(TraceLevel.Warning);
            sum += MyLevel.Translate0(TraceLevel.Info);
            sum += MyLevel.Translate0(TraceLevel.Verbose);
            sum += MyLevel.Translate0(TraceLevel.Off);
            return sum;
        }

        [Benchmark]
        public int Switch1()
        {
            int sum = 0;
            sum += MyLevel.Translate1(TraceLevel.Error);
            sum += MyLevel.Translate1(TraceLevel.Warning);
            sum += MyLevel.Translate1(TraceLevel.Info);
            sum += MyLevel.Translate1(TraceLevel.Verbose);
            sum += MyLevel.Translate1(TraceLevel.Off);
            return sum;
        }

        [Benchmark]
        public int Switch2()
        {
            int sum = 0;
            sum += MyLevel.Translate2(TraceLevel.Error);
            sum += MyLevel.Translate2(TraceLevel.Warning);
            sum += MyLevel.Translate2(TraceLevel.Info);
            sum += MyLevel.Translate2(TraceLevel.Verbose);
            sum += MyLevel.Translate2(TraceLevel.Off);
            return sum;
        }
    }

The results:

|  Method |     Mean |    Error |   StdDev |   Median | Gen 0 | Gen 1 | Gen 2 | Allocated |
|-------- |---------:|---------:|---------:|---------:|------:|------:|------:|----------:|
|  IfElse | 16.11 ns | 0.038 ns | 0.030 ns | 16.10 ns |     - |     - |     - |         - |
| Switch1 | 14.76 ns | 0.329 ns | 0.756 ns | 14.31 ns |     - |     - |     - |         - |
| Switch2 | 16.49 ns | 0.364 ns | 0.933 ns | 15.93 ns |     - |     - |     - |         - |

Now this is interesting. It appears that the classic switch statement is consistently faster! I completely expected it to do better than if/else because of the possibility of a jump table implementation for switch, but I am a bit surprised that the switch expression is measurably slower. To see why this might be the case, we have to go spelunking in the IL code again:

.method public hidebysig static 
    char Translate0 (
        valuetype [System.Diagnostics.TraceSource]System.Diagnostics.TraceLevel level
    ) cil managed 
{
    // Method begins at RVA 0x2050
    // Code size 45 (0x2d)
    .maxstack 8

    // (no C# code)
    IL_0000: ldarg.0
    IL_0001: ldc.i4.4
    IL_0002: bne.un.s IL_0007

    // return 'V';
    IL_0004: ldc.i4.s 86
    // (no C# code)
    IL_0006: ret

    IL_0007: ldarg.0
    IL_0008: ldc.i4.3
    IL_0009: bne.un.s IL_000e

    // return 'I';
    IL_000b: ldc.i4.s 73
    // (no C# code)
    IL_000d: ret

    IL_000e: ldarg.0
    IL_000f: ldc.i4.2
    IL_0010: bne.un.s IL_0015

    // return 'W';
    IL_0012: ldc.i4.s 87
    // (no C# code)
    IL_0014: ret

    IL_0015: ldarg.0
    IL_0016: ldc.i4.1
    IL_0017: bne.un.s IL_001c

    // return 'E';
    IL_0019: ldc.i4.s 69
    // (no C# code)
    IL_001b: ret

    IL_001c: ldarg.0
    IL_001d: brtrue.s IL_0022

    // return ' ';
    IL_001f: ldc.i4.s 32
    // (no C# code)
    IL_0021: ret

    // throw new ArgumentOutOfRangeException("level");
    IL_0022: ldstr "level"
    IL_0027: newobj instance void [System.Runtime]System.ArgumentOutOfRangeException::.ctor(string)
    // (no C# code)
    IL_002c: throw
} // end of method MyLevel::Translate0

.method public hidebysig static 
    char Translate1 (
        valuetype [System.Diagnostics.TraceSource]System.Diagnostics.TraceLevel level
    ) cil managed 
{
    // Method begins at RVA 0x207e
    // Code size 54 (0x36)
    .maxstack 8

    // switch (level)
    IL_0000: ldarg.0
    // (no C# code)
    IL_0001: switch (IL_0028, IL_0025, IL_0022, IL_001f, IL_001c)

    IL_001a: br.s IL_002b

    // return 'V';
    IL_001c: ldc.i4.s 86
    // (no C# code)
    IL_001e: ret

    // return 'I';
    IL_001f: ldc.i4.s 73
    // (no C# code)
    IL_0021: ret

    // return 'W';
    IL_0022: ldc.i4.s 87
    // (no C# code)
    IL_0024: ret

    // return 'E';
    IL_0025: ldc.i4.s 69
    // (no C# code)
    IL_0027: ret

    // return ' ';
    IL_0028: ldc.i4.s 32
    // (no C# code)
    IL_002a: ret

    // throw new ArgumentOutOfRangeException("level");
    IL_002b: ldstr "level"
    IL_0030: newobj instance void [System.Runtime]System.ArgumentOutOfRangeException::.ctor(string)
    // (no C# code)
    IL_0035: throw
} // end of method MyLevel::Translate1

.method public hidebysig static 
    char Translate2 (
        valuetype [System.Diagnostics.TraceSource]System.Diagnostics.TraceLevel level
    ) cil managed 
{
    // Method begins at RVA 0x20b8
    // Code size 66 (0x42)
    .maxstack 1
    .locals init (
        [0] char
    )

    // switch (level)
    IL_0000: ldarg.0
    // (no C# code)
    IL_0001: switch (IL_0030, IL_002b, IL_0026, IL_0021, IL_001c)

    IL_001a: br.s IL_0035

    // return 'V';
    IL_001c: ldc.i4.s 86
    IL_001e: stloc.0
    // (no C# code)
    IL_001f: br.s IL_0040

    // return 'I';
    IL_0021: ldc.i4.s 73
    IL_0023: stloc.0
    // (no C# code)
    IL_0024: br.s IL_0040

    // return 'W';
    IL_0026: ldc.i4.s 87
    IL_0028: stloc.0
    // (no C# code)
    IL_0029: br.s IL_0040

    // return 'E';
    IL_002b: ldc.i4.s 69
    IL_002d: stloc.0
    // (no C# code)
    IL_002e: br.s IL_0040

    // return ' ';
    IL_0030: ldc.i4.s 32
    IL_0032: stloc.0
    // (no C# code)
    IL_0033: br.s IL_0040

    // throw new ArgumentOutOfRangeException("level");
    IL_0035: ldstr "level"
    IL_003a: newobj instance void [System.Runtime]System.ArgumentOutOfRangeException::.ctor(string)
    // (no C# code)
    IL_003f: throw

    IL_0040: ldloc.0
    IL_0041: ret
} // end of method MyLevel::Translate2

As we might expect, the if/else implementation wins in terms of code size. However, it must sequentially evaluate all the conditions so it will on average need more cycles to produce a result. The classic switch statement as predicted uses a jump table in IL at the expense of a slightly larger code size. The switch expression has the largest code size, which partially explains its worse performance. Looking more closely, we can see that this implementation requires storing to a local value and branching in every case. By contrast, the classic switch statement does early returns and elides the local.

Maybe a slightly smarter compiler/optimizer could close this gap. However, for now, it seems that switch expressions have a slight overhead which could give classic switch statements an edge in the hottest of hot paths.

One thought on “Switch expression performance: part 1

  1. Pingback: Switch expression performance: part 2 – WriteAsync .NET

Leave a Reply

Your email address will not be published. Required fields are marked *