C# Pattern Matching Ranges
Pattern matching ranges
C# 9.0 introduced new features in pattern matching that allow range expressions (<
, >
, <=
, >=
) in switch
es.
I examined the impact of using these new range expressions in an existing project.
Original Method
My original method was implemented with pattern matching but without range expressions:
private static (int month, int day) MonthDayFromJulian(int daysSinceJan1)
=> daysSinceJan1 switch
{
int x when x >= 0 && x <= 30 => (1, daysSinceJan1 + 1),
int x when x >= 31 && x <= 58 => (2, daysSinceJan1 - 30),
int x when x >= 59 && x <= 89 => (3, daysSinceJan1 - 58),
int x when x >= 90 && x <= 119 => (4, daysSinceJan1 - 89),
int x when x >= 120 && x <= 150 => (5, daysSinceJan1 - 119),
int x when x >= 151 && x <= 180 => (6, daysSinceJan1 - 150),
int x when x >= 181 && x <= 211 => (7, daysSinceJan1 - 180),
int x when x >= 212 && x <= 242 => (8, daysSinceJan1 - 211),
int x when x >= 243 && x <= 272 => (9, daysSinceJan1 - 242),
int x when x >= 273 && x <= 303 => (10, daysSinceJan1 - 272),
int x when x >= 304 && x <= 333 => (11, daysSinceJan1 - 303),
int x when x >= 334 && x <= 364 => (12, daysSinceJan1 - 333),
_ => throw new ArgumentOutOfRangeException(nameof(daysSinceJan1))
};
It includes some ugly boilerplate to create the temporary int x
variable for each case, in order to compare the pattern expression. The IL produced by this implementation also sets up a local variable for each int x
declared:
.method private hidebysig static
valuetype [System.Private.CoreLib]System.ValueTuple`2<int32, int32> MonthDayFromJulian (
int32 daysSinceJan1
) cil managed
{
.param [0]
.custom instance void [System.Private.CoreLib]System.Runtime.CompilerServices.TupleElementNamesAttribute::.ctor(string[]) = (
01 00 02 00 00 00 05 6d 6f 6e 74 68 03 64 61 79
00 00
)
// Method begins at RVA 0x2080
// Code size 445 (0x1bd)
.maxstack 3
.locals init (
👉 [0] int32 x,
👉 [1] int32 x,
👉 [2] int32 x,
👉 [3] int32 x,
👉 [4] int32 x,
👉 [5] int32 x,
👉 [6] int32 x,
👉 [7] int32 x,
👉 [8] int32 x,
👉 [9] int32 x,
👉 [10] int32 x,
👉 [11] int32 x,
[12] valuetype [System.Private.CoreLib]System.ValueTuple`2<int32, int32>,
[13] valuetype [System.Private.CoreLib]System.ValueTuple`2<int32, int32>
)
Fortunately, when this expression is compiled to procedural code, the compiler inlines these temporary variables and generates a gateway-style method with several flat branches:
[return: TupleElementNames(new string[] { "month", "day" })]
private static ValueTuple<int, int> MonthDayFromJulian(int daysSinceJan1)
{
if (daysSinceJan1 >= 0 && daysSinceJan1 <= 30)
{
return new ValueTuple<int, int>(1, daysSinceJan1 + 1);
}
if (daysSinceJan1 >= 31 && daysSinceJan1 <= 58)
{
return new ValueTuple<int, int>(2, daysSinceJan1 - 30);
}
if (daysSinceJan1 >= 59 && daysSinceJan1 <= 89)
{
return new ValueTuple<int, int>(3, daysSinceJan1 - 58);
}
if (daysSinceJan1 >= 90 && daysSinceJan1 <= 119)
{
return new ValueTuple<int, int>(4, daysSinceJan1 - 89);
}
if (daysSinceJan1 >= 120 && daysSinceJan1 <= 150)
{
return new ValueTuple<int, int>(5, daysSinceJan1 - 119);
}
if (daysSinceJan1 >= 151 && daysSinceJan1 <= 180)
{
return new ValueTuple<int, int>(6, daysSinceJan1 - 150);
}
if (daysSinceJan1 >= 181 && daysSinceJan1 <= 211)
{
return new ValueTuple<int, int>(7, daysSinceJan1 - 180);
}
if (daysSinceJan1 >= 212 && daysSinceJan1 <= 242)
{
return new ValueTuple<int, int>(8, daysSinceJan1 - 211);
}
if (daysSinceJan1 >= 243 && daysSinceJan1 <= 272)
{
return new ValueTuple<int, int>(9, daysSinceJan1 - 242);
}
if (daysSinceJan1 >= 273 && daysSinceJan1 <= 303)
{
return new ValueTuple<int, int>(10, daysSinceJan1 - 272);
}
if (daysSinceJan1 >= 304 && daysSinceJan1 <= 333)
{
return new ValueTuple<int, int>(11, daysSinceJan1 - 303);
}
if (daysSinceJan1 >= 334 && daysSinceJan1 <= 364)
{
return new ValueTuple<int, int>(12, daysSinceJan1 - 333);
}
throw new ArgumentOutOfRangeException("daysSinceJan1");
}
On x64, this produces jitted output closely matching the procedural output, with several branching comparisons against our daysSinceJan1
parameter.
Range-based method
I hoped that replacing the temporary variable usage with range-based pattern matching would avoid the unnecessary temporary variables and speed up the lookup:
private static (int month, int day) MonthDayFromJulian(int daysSinceJan1)
=> daysSinceJan1 switch
{
(>= 0 and <= 30) => (1, daysSinceJan1 + 1),
(>= 31 and <= 58) => (2, daysSinceJan1 - 30),
(>= 59 and <= 89) => (3, daysSinceJan1 - 58),
(>= 90 and <= 119) => (4, daysSinceJan1 - 89),
(>= 120 and <= 150) => (5, daysSinceJan1 - 119),
(>= 151 and <= 180) => (6, daysSinceJan1 - 150),
(>= 181 and <= 211) => (7, daysSinceJan1 - 180),
(>= 212 and <= 242) => (8, daysSinceJan1 - 211),
(>= 243 and <= 272) => (9, daysSinceJan1 - 242),
(>= 273 and <= 303) => (10, daysSinceJan1 - 272),
(>= 304 and <= 333) => (11, daysSinceJan1 - 303),
(>= 334 and <= 364) => (12, daysSinceJan1 - 333),
_ => throw new ArgumentOutOfRangeException(nameof(daysSinceJan1))
};
The generated IL confirms that the additional locals are avoided:
.method private hidebysig static
valuetype [System.Private.CoreLib]System.ValueTuple`2<int32, int32> MonthDayFromJulian (
int32 daysSinceJan1
) cil managed
{
.param [0]
.custom instance void [System.Private.CoreLib]System.Runtime.CompilerServices.TupleElementNamesAttribute::.ctor(string[]) = (
01 00 02 00 00 00 05 6d 6f 6e 74 68 03 64 61 79
00 00
)
// Method begins at RVA 0x2080
// Code size 343 (0x157)
.maxstack 3
👉 .locals init (
[0] valuetype [System.Private.CoreLib]System.ValueTuple`2<int32, int32>,
[1] valuetype [System.Private.CoreLib]System.ValueTuple`2<int32, int32>
)
However, the procedural output is now a bubble-style method with nested branches:
[return: TupleElementNames(new string[] { "month", "day" })]
private static ValueTuple<int, int> MonthDayFromJulian(int daysSinceJan1)
{
if (daysSinceJan1 <= 150)
{
if (daysSinceJan1 > 89)
{
if (daysSinceJan1 <= 119)
{
return new ValueTuple<int, int>(4, daysSinceJan1 - 89);
}
return new ValueTuple<int, int>(5, daysSinceJan1 - 119);
}
if (daysSinceJan1 > 30)
{
if (daysSinceJan1 <= 58)
{
return new ValueTuple<int, int>(2, daysSinceJan1 - 30);
}
return new ValueTuple<int, int>(3, daysSinceJan1 - 58);
}
if (daysSinceJan1 >= 0)
{
return new ValueTuple<int, int>(1, daysSinceJan1 + 1);
}
}
else
{
if (daysSinceJan1 <= 272)
{
if (daysSinceJan1 <= 211)
{
if (daysSinceJan1 <= 180)
{
return new ValueTuple<int, int>(6, daysSinceJan1 - 150);
}
return new ValueTuple<int, int>(7, daysSinceJan1 - 180);
}
if (daysSinceJan1 <= 242)
{
return new ValueTuple<int, int>(8, daysSinceJan1 - 211);
}
return new ValueTuple<int, int>(9, daysSinceJan1 - 242);
}
if (daysSinceJan1 <= 333)
{
if (daysSinceJan1 <= 303)
{
return new ValueTuple<int, int>(10, daysSinceJan1 - 272);
}
return new ValueTuple<int, int>(11, daysSinceJan1 - 303);
}
if (daysSinceJan1 <= 364)
{
return new ValueTuple<int, int>(12, daysSinceJan1 - 333);
}
}
throw new ArgumentOutOfRangeException("daysSinceJan1");
}
On x64, this produces jitted output of a jump table, as if our pattern matching expression was a large switch
statement with fall-through cases in between the comparison values.
Performance differences
These differences in code generation do have an effect on real-world performance, primarily through reducing branch mispredictions.
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.22000
AMD Ryzen 9 3900X, 1 CPU, 24 logical and 12 physical cores
.NET SDK=6.0.100-rc.2.21505.57
[Host] : .NET 6.0.0 (6.0.21.48005), X64 RyuJIT
Job-PKIECE : .NET 6.0.0 (6.0.21.48005), X64 RyuJIT
RunStrategy=Throughput
Method | Mean | Error | StdDev | BranchInstructions/Op | BranchMispredictions/Op |
---|---|---|---|---|---|
CountMonths | 2.594 us | 0.0160 us | 0.0134 us | 6,443 | 22 |
CountMonthsNew | 2.327 us | 0.0079 us | 0.0070 us | 3,333 | 14 |