Switch expression performance: part 2

Spread the love

Previously we looked at the performance of simple switch statements and expressions. Today we will consider switch expressions with when clauses.

This example shows a simple letter grade calculator as might be defined for a US high school:

    public static class MyGrade
    {
        public static char Calc0(double pct)
        {
            if (pct < 0.6d)
            {
                return 'F';
            }
            else if (pct < 0.7d)
            {
                return 'D';
            }
            else if (pct < 0.8d)
            {
                return 'C';
            }
            else if (pct < 0.9d)
            {
                return 'B';
            }
            else
            {
                return 'A';
            }
        }

        public static char Calc1(double pct)
        {
            return pct switch
            {
                _ when pct < 0.6d => 'F',
                _ when pct < 0.7d => 'D',
                _ when pct < 0.8d => 'C',
                _ when pct < 0.9d => 'B',
                _ => 'A',
            };
        }
    }

The first method shows the legacy approach with successive if/else statements. Yawn. The second method replaces all this boilerplate with a simple switch expression. It’s much nicer to read, for sure. But will it result in any unexpected overhead? To the benchmark!

    [SimpleJob(RuntimeMoniker.NetCoreApp31)]
    [MemoryDiagnoser]
    public class SwitchBenchmark2
    {
        [Benchmark]
        public int IfElse()
        {
            int sum = 0;
            sum += MyGrade.Calc0(1.2d);
            sum += MyGrade.Calc0(0.94d);
            sum += MyGrade.Calc0(0.81d);
            sum += MyGrade.Calc0(0.7d);
            sum += MyGrade.Calc0(-0.1d);
            return sum;
        }

        [Benchmark]
        public int Switch()
        {
            int sum = 0;
            sum += MyGrade.Calc1(1.2d);
            sum += MyGrade.Calc1(0.94d);
            sum += MyGrade.Calc1(0.81d);
            sum += MyGrade.Calc1(0.7d);
            sum += MyGrade.Calc1(-0.1d);
            return sum;
        }
    }

The result:

| Method |     Mean |    Error |   StdDev | Gen 0 | Gen 1 | Gen 2 | Allocated |
|------- |---------:|---------:|---------:|------:|------:|------:|----------:|
| IfElse | 19.84 ns | 0.123 ns | 0.109 ns |     - |     - |     - |         - |
| Switch | 19.48 ns | 0.104 ns | 0.087 ns |     - |     - |     - |         - |

Luckily the performance is essentially the same. For fun, let’s peek at the IL anyway:

.method public hidebysig static 
    char Calc0 (
        float64 pct
    ) cil managed 
{
    // Method begins at RVA 0x2050
    // Code size 63 (0x3f)
    .maxstack 8

    // if (pct < 0.6)
    IL_0000: ldarg.0
    IL_0001: ldc.r8 0.6
    IL_000a: bge.un.s IL_000f

    // return 'F';
    IL_000c: ldc.i4.s 70
    // (no C# code)
    IL_000e: ret

    // if (pct < 0.7)
    IL_000f: ldarg.0
    IL_0010: ldc.r8 0.7
    IL_0019: bge.un.s IL_001e

    // return 'D';
    IL_001b: ldc.i4.s 68
    // (no C# code)
    IL_001d: ret

    // if (pct < 0.8)
    IL_001e: ldarg.0
    IL_001f: ldc.r8 0.8
    IL_0028: bge.un.s IL_002d

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

    // if (pct < 0.9)
    IL_002d: ldarg.0
    IL_002e: ldc.r8 0.9
    IL_0037: bge.un.s IL_003c

    // return 'B';
    IL_0039: ldc.i4.s 66
    // (no C# code)
    IL_003b: ret

    // return 'A';
    IL_003c: ldc.i4.s 65
    // (no C# code)
    IL_003e: ret
} // end of method MyGrade::Calc0

.method public hidebysig static 
    char Calc1 (
        float64 pct
    ) cil managed 
{
    // Method begins at RVA 0x2090
    // Code size 73 (0x49)
    .maxstack 2
    .locals init (
        [0] char
    )

    // if (pct < 0.6)
    IL_0000: ldarg.0
    IL_0001: ldc.r8 0.6
    IL_000a: bge.un.s IL_0011

    // return 'F';
    IL_000c: ldc.i4.s 70
    IL_000e: stloc.0
    // (no C# code)
    IL_000f: br.s IL_0047

    // if (pct < 0.7)
    IL_0011: ldarg.0
    IL_0012: ldc.r8 0.7
    IL_001b: bge.un.s IL_0022

    // return 'D';
    IL_001d: ldc.i4.s 68
    IL_001f: stloc.0
    // (no C# code)
    IL_0020: br.s IL_0047

    // if (pct < 0.8)
    IL_0022: ldarg.0
    IL_0023: ldc.r8 0.8
    IL_002c: bge.un.s IL_0033

    // return 'C';
    IL_002e: ldc.i4.s 67
    IL_0030: stloc.0
    // (no C# code)
    IL_0031: br.s IL_0047

    // if (pct < 0.9)
    IL_0033: ldarg.0
    IL_0034: ldc.r8 0.9
    IL_003d: bge.un.s IL_0044

    // return 'B';
    IL_003f: ldc.i4.s 66
    IL_0041: stloc.0
    // (no C# code)
    IL_0042: br.s IL_0047

    // return 'A';
    IL_0044: ldc.i4.s 65
    IL_0046: stloc.0

    // (no C# code)
    IL_0047: ldloc.0
    IL_0048: ret
} // end of method MyGrade::Calc1

Funny enough, the switch expression code is again larger with an extra local, while the if/else formulation takes advantage of early returns. In the case of this microbenchmark, however, it doesn’t seem to make a difference in speed.

Summing up, switch expressions are great for conciseness and readability. Their runtime cost is not bad either, though they could perhaps benefit from better compiler optimization.

Leave a Reply

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