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