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.
Pingback: Switch expression performance: part 2 – WriteAsync .NET