Part of the series: Performance Hacking .NET
Update: The code discussed in this post is now available on GitHub.
Reflection is great. I have used it many times to solve the unsolvable, as I’m sure you have. But it is slow! Isn’t it? I mean… it has to be, right? It’s doing things at runtime looking at metadata. But if you’re like me, you’ve never actually benchmarked it.
Last year when I found myself fixing some bugs and messing around in the System.Text.Json source I came across this code using a DynamicMethod to construct instances of types not known until runtime in order to populate them with data from the supplied JSON string.
If you take a look at that code your reaction will probably be a lot like mine was when I first saw it: What the @$&% is this dark magic?!
It’s a game-changer. DynamicMethod gives us a way to execute delegates built by emitting specific MSIL instructions at runtime. MSIL is what our C# compiles to, that stuff that looks like assembly language. Maybe you took a class in it along the way? I know what you’re thinking, why would anyone in their right mind write something like that? Given the code is in System.Text.Json, a JSON library built from the ground-up for performance, we can kind of infer that it must be faster than Reflection. Fast enough to justify the effort. It makes sense when you think about it. The IL is basically raw instructions for what you want to do. They have to be interpreted but once the JITter comes along, it will turn that into native code (x64, or whatever depending on where it’s running). We’re talking native performance on par with normal code, you’re not going to do better than that, friends.
So there are a couple things I want to do in this post. I want to benchmark Reflection vs DynamicMethod, so we can get an idea of the speed differences, and then I want to show you how to build the IL. This is really advanced stuff and there’s going to be a lot of code. Sorry about that. I encourage you to read on but don’t say I didn’t warn you!
A real-world problem
Writing something like this is a bit of a challenge. I want to give you guys an example that demonstrates the concept, but I don’t want it to be trivial or too contrived. At the same time, it can’t be complicated beyond what you can grasp over your morning coffee. I’m going to use basically a real-world problem I just had to solve where DynamicMethod was useful. It’s way more on the complicated side, but I couldn’t think of anything simpler that would also be interesting.
At the moment I’m helping out another team because a couple of their big projects are blocking some of our other groups. This particular application is a RESTful API called by other teams for working with one of our bigger data repositories. The API has its own kind of home-grown OData/GraphQL thing going on where you can customize the data to be retrieved, the filtering clauses that are applied, and the sorting of the records when you call it.
Imagine a signature like this:
public interface IThingRepository { public IEnumerable<Thing> SearchThings(IEnumerable<SortCriteria> sortCriteria); }
SortCriteria
is a simple class:
public class SortCriteria { public SortDirection SortDirection { get; set; } public string SortField { get; set; } }
Implementation is something like this:
public IEnumerable<Thing> SearchThings(IEnumerable<SortCriteria> sortCriteria) { return _ThingQuery.Execute().Sort(sortCriteria); }
It boils down to a very simple concept: We need to search our data store and then return the Thing
s we find using the sorting rules provided by the user. Thing
composites a bunch of tables so the sorting is not done in the DB, it is done in the code.
Here’s the Sort
extension as it existed when I got on the project (more or less):
public static class Extensions { public static IEnumerable<T> Sort<T>(this IEnumerable<T> items, IEnumerable<SortCriteria> sortCriteria) { if (items == null) throw new ArgumentNullException(nameof(items)); IOrderedEnumerable<T>? query = null; foreach (SortCriteria criteria in sortCriteria ?? Array.Empty<SortCriteria>()) { if (query == null) { query = criteria.SortDirection == SortDirection.Ascending ? items.OrderBy(i => GetSortFieldValue(i, criteria.SortField)) : items.OrderByDescending(i => GetSortFieldValue(i, criteria.SortField)); } else { query = criteria.SortDirection == SortDirection.Ascending ? query.ThenBy(i => GetSortFieldValue(i, criteria.SortField)) : query.ThenByDescending(i => GetSortFieldValue(i, criteria.SortField)); } } return query ?? items; } private static object GetSortFieldValue<T>(T item, string sortField) { return item? .GetType() .GetProperty(sortField, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.IgnoreCase)? .GetMethod? .Invoke(item, null); }
I will say this, it’s clever. Works surprisingly well without a whole lot of code. The item.GetType()
is actually bringing a lot to the table. We’re enumerating over Thing
s (or whatever) but that could be a base type for a collection of more interesting items. We may have SwampThing
s or SpaceThing
s in our list or some future Thing
we don’t even know about. Because the code is doing item.GetType()
you can actually sort on fields that aren’t on the base class. If a field doesn’t exist on the runtime type, a null
is used for it. That’s going to add a lot of complexity to our solution later on.
.NET has this behavior for comparing null
s:
Left | Right | Compare Result (int) |
---|---|---|
null | null | 0 |
null | Not null | -1 |
Not null | null | 1 |
Just glancing over that code you can probably spot some performance issues. It’s using Reflection, but it’s using it the hard way. Performing the metadata lookup each time it is called. We can probably speed it up a lot by just caching some of that stuff. It’s also using an object
for every value, which means it is paying boxing penalties for value types.
But the performance is actually not why I’m working on this. The sort today is object
based. Linq will use Comparer<object>.Default
for that. There’s a note on the doc about string comparison. Very easy to miss, but it’s saying the object comparison is not culture-aware. Guess what we’re doing? A globalization project. Different languages can sort using different rules! Interesting, right? I learned that as part of this project.
So what we need to do is somehow make this code use a proper comparer that uses a culture-aware sorting. If we can also fix the performance issues, great.
A solution to the problem using vanilla Reflection
Let’s formalize our requirements a bit. We want to maintain backward compatibility with what is currently in place while adding in culture-aware sorting (where we can). Here’s the algorithm I came up with:
- Given two items of some base type…
- If either item is
null
, use thenull
rules in the table above. - If both items are NOT
null
, look for a property matching the sort field using the actual type of the item. Any defined property or inherited property is valid. If a property match is found, get its value. Otherwise usenull
as the value. - If either property value is
null
, use thenull
rules in the table above. - If both property values are NOT
null
, determine the type to compare…- For reference types, both property values must match exactly. For example, we can only compare
string
tostring
. - For value types accept type-to-type, type-to-Nullable<type>, Nullable<type>-to-type, or Nullable<type>-to-Nullable<type> as a match. For example we can compare
int
toint
,int?
toint
,int
toint?
, orint?
toint?
.
- For reference types, both property values must match exactly. For example, we can only compare
- If a comparison type was NOT resolved, use the default object comparer.
- If a comparison type was resolved, look for a strongly-typed comparison (
int CompareTo([ComparisonType] other)
) method.- If a strongly-typed comparison method was found, use that for comparison.
- If a strongly-typed comparison method was NOT found, use the default object comparer.
- If either item is
That’s a mouthful, eh? Probably easier to just see it in code:
private static Func<T, T, int> BuildComparerFunc<T>(SortCriteria sortCriteria) { string sortField = sortCriteria.SortField; return (x, y) => { object? valueX = x!.GetType().GetProperty(sortField, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.IgnoreCase)?.GetMethod.Invoke(x, null); object? valueY = y!.GetType().GetProperty(sortField, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.IgnoreCase)?.GetMethod.Invoke(y, null); if (!ReflectionHelper.TryEnsureValidReferences(valueX, valueY, out int referenceComparisonResult)) return referenceComparisonResult; Type targetType = ReflectionHelper.ResolveTargetType(valueX.GetType()); if (targetType == ReflectionHelper.ResolveTargetType(valueY.GetType())) { MethodInfo? CompareToTyped = null; foreach (MethodInfo method in targetType.GetMethods(BindingFlags.Instance | BindingFlags.Public)) { if (method.Name != "CompareTo") continue; ParameterInfo[] methodParams = method.GetParameters(); if ((methodParams?.Length ?? 0) != 1) continue; if (methodParams![0].ParameterType == targetType) { CompareToTyped = method; break; } } if (CompareToTyped != null) return (int)CompareToTyped.Invoke(valueX, new object[] { valueY }); } return Comparer<object>.Default.Compare(valueX, valueY); }; }
Here’s the helper methods being called in that code:
public static bool TryEnsureValidReferences(object? x, object? y, out int comparisonResult) { bool xIsNull = x is null; bool yIsNull = y is null; if (xIsNull && yIsNull) { comparisonResult = 0; return false; } if (xIsNull) { comparisonResult = -1; return false; } if (yIsNull) { comparisonResult = 1; return false; } comparisonResult = 0; return true; } public static Type ResolveTargetType(Type propertyType) { if (propertyType.IsValueType) propertyType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; return propertyType; }
All of our comparison methods are going to be invoked by this class:
public class DelegateComparer<T> : IComparer<T> { private readonly Func<T, T, int> _CompareFunc; public DelegateComparer(Func<T, T, int> compareFunc) { _CompareFunc = compareFunc; } public int Compare(T x, T y) { return !ReflectionHelper.TryEnsureValidReferences(x, y, out int referenceComparisonResult) ? referenceComparisonResult : _CompareFunc.Invoke(x, y); } }
It takes care of the first check in our algorithm, making sure both items are NOT null
before invoking our Func<T, T, int>
to do the rest.
The Sort
extension has been modified to call a comparer now:
public static class Extensions { public static IEnumerable<T> Sort<T>(this IEnumerable<T> items, SortComparerFactory sortComparerFactory, IEnumerable<SortCriteria> sortCriteria) { if (items == null) throw new ArgumentNullException(nameof(items)); if (sortComparerFactory == null) throw new ArgumentNullException(nameof(sortComparerFactory)); IOrderedEnumerable<T>? query = null; foreach (SortCriteria criteria in sortCriteria ?? Array.Empty<SortCriteria>()) { IComparer<T> sortComparer = sortComparerFactory.BuildSortComparer<T>(criteria); if (query == null) { query = criteria.SortDirection == SortDirection.Ascending ? items.OrderBy(i => i, sortComparer) : items.OrderByDescending(i => i, sortComparer); } else { query = criteria.SortDirection == SortDirection.Ascending ? query.ThenBy(i => i, sortComparer) : query.ThenByDescending(i => i, sortComparer); } } return query ?? items; } }
I wrote a bunch of unit tests to make sure this all works as I expect it to, but I’m going to leave out as much as I can to keep this post somewhat manageable. I’ll make all the code available for anyone interested in those details.
Benchmarking the vanilla Reflection solution
I know the solution works because I wrote tests for it, but how do we gauge the performance? This is a series about performance, after all! Let’s use BenchmarkDotNet, which is what the framework team uses to benchmark the runtime. [BenchmarkDotNet is awesome but I’m not going to go into too much detail on how you set it up here, post in the comments if you want to see a dedicated post going into more detail.]
First, we’re going to need some data to feed into our benchmarks. Here’s the Thing
classes I wrote to be analogous to what the actual application I’m working on is doing:
public class SwampThing : Thing { public string? SwampName { get; set; } public int License { get; set; } public long Value { get; set; } } public class SpaceThing : Thing { public string? StarshipName { get; set; } public int? License { get; set; } public string? Value { get; set; } = "Hello world"; } public class Thing { public int Id { get; set; } public string? Text { get; set; } public DayOfWeek Day { get; set; } public bool? Flag { get; set; } public static List<Thing> GenerateThings(int count, bool useDerivedTyped = true) { List<Thing> Things = new List<Thing>(count); for (int i = 0; i < count; i++) { Thing Thing = CreateThing(i, useDerivedTyped); Thing.Id = i; Thing.Text = i % 2 == 0 ? "Even" : i % 3 == 0 ? "Odd" : null; Thing.Day = (DayOfWeek)(i % 7); Thing.Flag = i % 4 == 0 ? true : i % 5 == 0 ? false : (bool?)null; Things.Add(Thing); } return Things; } private static Thing CreateThing(int i, bool useDerivedTyped) { if (!useDerivedTyped) return new Thing(); switch (i % 3) { case 1: return new SpaceThing() { StarshipName = $"Ship {i}", License = i % 4 == 0 ? (i * 10000) + i : (int?)null }; case 2: return new SwampThing { SwampName = $"Swamp {i}", License = (i * 10000) + i }; default: return new Thing(); } } }
There’s a couple curveballs in there. “License” property doesn’t exist on the Thing
base class and uses the mixed nullable case (int
and int?
) on the derived types, which should work fine. “Value” property also doesn’t exist on the base class, but it uses the non-matching type case (long
and string
) which should not work. The GenerateThings
and CreateThing
methods are designed to build an interesting yet deterministic mix of the types so we can sort them.
Now we can craft a benchmark:
[MemoryDiagnoser] public class SortProviderBenchmarks { private List<Thing>? _Things; [Params(100, 5000)] public int NumberOfItems { get; set; } [GlobalSetup] public void GlobalSetup() => _Things = Thing.GenerateThings(NumberOfItems); [Benchmark(Baseline = true)] public void CodeBaseline() { IEnumerable<Thing> Results = _Things .OrderBy(i => i.Text) .ThenByDescending(i => i.Flag) .OrderBy(i => i.Day) .ThenByDescending( i => i is SwampThing swampThing ? swampThing.License : i is SpaceThing spaceThing ? spaceThing.License : null) .OrderBy(i => i.Id); if (Results.ToArray().Length != NumberOfItems) throw new InvalidOperationException(); } [Benchmark] public void ReflectionSortProvider() { IEnumerable<Thing> Results = _Things.Sort( new ReflectionSortComparerFactory(), s_SortCriteria); if (Results.ToArray().Length != NumberOfItems) throw new InvalidOperationException(); } private static readonly SortCriteria[] s_SortCriteria = new SortCriteria[] { new SortCriteria { SortDirection = SortDirection.Ascending, SortField = "Text" }, new SortCriteria { SortDirection = SortDirection.Descending, SortField = "Flag" }, new SortCriteria { SortDirection = SortDirection.Ascending, SortField = "Day" }, new SortCriteria { SortDirection = SortDirection.Descending, SortField = "License" }, new SortCriteria { SortDirection = SortDirection.Ascending, SortField = "Id" }, }; }
The CodeBaseline
method is sorting our Things
using pure code. ReflectionSortProvider
is sorting our Things
using the Reflection solution we just made. That’s all we need to do, BenchmarkDotNet is going to take care of warming everything up, running a whole bunch of iterations, tracking everything, and then giving us easy to digest results. If you’ve never used it, it’s also a game-changer.
Method | NumberOfItems | Mean | Error | StdDev | Ratio | RatioSD | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|---|---|---|
CodeBaseline | 100 | 28.33 us | 0.295 us | 0.261 us | 1.00 | 0.00 | 0.9766 | – | – | 8.11 KB |
ReflectionSortProvider | 100 | 2,407.89 us | 12.790 us | 11.338 us | 85.01 | 0.74 | 238.2813 | 7.8125 | – | 1971.34 KB |
CodeBaseline | 5000 | 2,809.60 us | 27.198 us | 24.110 us | 1.00 | 0.00 | 39.0625 | 7.8125 | – | 343.07 KB |
ReflectionSortProvider | 5000 | 281,215.52 us | 2,544.578 us | 2,255.702 us | 100.10 | 1.23 | 26000.0000 | 1000.0000 | – | 217890.73 KB |
So we’re looking at the Reflection method being 80 to 100 times slower? And using a lot more memory!
That’s scary, but the good news is there are a couple quick and easy things we can do to improve this.
A solution to the problem using Reflection with caching
You probably already know this, but when you’re using Reflection you should cache the metadata you are looking up (Type, MethodInfo, PropertyInfo, etc.) the first time you execute and then reuse whatever you can for all future invocations. That will help a lot with performance.
Here’s the Reflection solution with some caching put in:
private static readonly ConcurrentDictionary<string, Func<T, T, int>> s_ComparerCache = new ConcurrentDictionary<string, Func<T, T, int>>(); public static Func<T, T, int> BuildComparerFunc(SortCriteria sortCriteria) { string sortField = sortCriteria.SortField; if (!SortComparerFactory<T>.s_ComparerCache.TryGetValue(sortField, out Func<T, T, int> comparerFunc)) { comparerFunc = (x, y) => { object? valueX = SortComparerReflectionHelper.FindPropertyGetMethod(x!.GetType(), sortField)?.Invoke(x, null); object? valueY = SortComparerReflectionHelper.FindPropertyGetMethod(y!.GetType(), sortField)?.Invoke(y, null); if (!TryEnsureValidReferences(valueX, valueY, out int referenceComparisonResult)) return referenceComparisonResult; Type targetType = SortComparerReflectionHelper.ResolveTargetType(valueX.GetType()); if (targetType == SortComparerReflectionHelper.ResolveTargetType(valueY.GetType())) { MethodInfo? compareToMethodInfo = SortComparerReflectionHelper.FindCompareToMethodInfo(targetType); if (compareToMethodInfo != null) return (int)compareToMethodInfo.Invoke(valueX, new object[] { valueY }); } return Comparer<object>.Default.Compare(valueX, valueY); }; SortComparerFactory<T>.s_ComparerCache.TryAdd(sortField, comparerFunc); } return comparerFunc; }
We’re now reusing:
- The comparer func (
Func<T, T, int>
). MethodInfo
s for the property getters.MethodInfo
for the CompareTo method.
Seems simple, but this will help a lot with the performance:
Method | NumberOfItems | Mean | Error | StdDev | Ratio | RatioSD | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|---|---|---|
CodeBaseline | 100 | 28.33 us | 0.295 us | 0.261 us | 1.00 | 0.00 | 0.9766 | – | – | 8.11 KB |
ReflectionSortProvider | 100 | 2,407.89 us | 12.790 us | 11.338 us | 85.01 | 0.74 | 238.2813 | 7.8125 | – | 1971.34 KB |
CachedReflectionSortProvider | 100 | 743.17 us | 9.519 us | 8.438 us | 26.24 | 0.37 | 15.6250 | – | – | 130.27 KB |
CodeBaseline | 5000 | 2,809.60 us | 27.198 us | 24.110 us | 1.00 | 0.00 | 39.0625 | 7.8125 | – | 343.07 KB |
ReflectionSortProvider | 5000 | 281,215.52 us | 2,544.578 us | 2,255.702 us | 100.10 | 1.23 | 26000.0000 | 1000.0000 | – | 217890.73 KB |
CachedReflectionSortProvider | 5000 | 95,066.90 us | 708.696 us | 662.915 us | 33.86 | 0.35 | 2000.0000 | 166.6667 | – | 17435.83 KB |
Much better! I think most of us have lived with this level of performance for as long as we’ve been doing .NET.
Now let’s have some fun!
DynamicMethod solution
I’ll warn you right now, the performance is going to go way up with this, but so is the complexity. You are more than welcome to do this everywhere but you’re probably going to want to weight the maintainability against the performance.
Here’s a DynamicMethod
to handle the reference type case:
private static Func<T, T, int> BuildReferenceTypePropertyComparer( MethodInfo? propertyX, MethodInfo? propertyY, string sortField, Type targetReferenceType, MethodInfo? compareToMethod) { DynamicMethod compareMethod = new DynamicMethod($"{propertyX?.ReturnType.Name}.{propertyY?.ReturnType.Name}.{sortField}$ReferenceComparer", typeof(int), new[] { s_SourceType, s_SourceType }, true); ILGenerator generator = compareMethod.GetILGenerator(); Label executeComparisonLabel = generator.DefineLabel(); generator.DeclareLocal(targetReferenceType ?? typeof(object)); generator.DeclareLocal(targetReferenceType ?? typeof(object)); generator.DeclareLocal(typeof(int)); if (propertyX == null) { generator.Emit(OpCodes.Ldnull); generator.Emit(OpCodes.Stloc_0); } else { generator.Emit(OpCodes.Ldarg_0); if (propertyX.DeclaringType != s_SourceType) generator.Emit(OpCodes.Castclass, propertyX.DeclaringType); generator.Emit(OpCodes.Callvirt, propertyX); generator.Emit(OpCodes.Stloc_0); } if (propertyY == null) { generator.Emit(OpCodes.Ldnull); generator.Emit(OpCodes.Stloc_1); } else { generator.Emit(OpCodes.Ldarg_1); if (propertyY.DeclaringType != s_SourceType) generator.Emit(OpCodes.Castclass, propertyY.DeclaringType); generator.Emit(OpCodes.Callvirt, propertyY); generator.Emit(OpCodes.Stloc_1); } generator.Emit(OpCodes.Ldloc_0); generator.Emit(OpCodes.Ldloc_1); generator.Emit(OpCodes.Ldloca_S, 2); generator.Emit(OpCodes.Call, s_TryEnsureValidReferencesMethodInfo); generator.Emit(OpCodes.Brtrue_S, executeComparisonLabel); generator.Emit(OpCodes.Ldloc_2); generator.Emit(OpCodes.Ret); generator.MarkLabel(executeComparisonLabel); if (targetReferenceType == null) { // If we got here it means property wasn't defined on each type. We should never execute this code, TryEnsureValidReferences should handle this. generator.Emit(OpCodes.Ldc_I4_0); generator.Emit(OpCodes.Ret); } else { generator.Emit(OpCodes.Ldloc_0); generator.Emit(OpCodes.Ldloc_1); generator.Emit(OpCodes.Callvirt, compareToMethod); } generator.Emit(OpCodes.Ret); return (Func<T, T, int>)compareMethod.CreateDelegate(typeof(Func<T, T, int>)); }
Hey, I warned you! Ultra-high complexity. BUT… the performance:
Method | NumberOfItems | Mean | Error | StdDev | Ratio | RatioSD | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|---|---|---|
CodeBaseline | 100 | 28.33 us | 0.295 us | 0.261 us | 1.00 | 0.00 | 0.9766 | – | – | 8.11 KB |
ReflectionSortProvider | 100 | 2,407.89 us | 12.790 us | 11.338 us | 85.01 | 0.74 | 238.2813 | 7.8125 | – | 1971.34 KB |
CachedReflectionSortProvider | 100 | 743.17 us | 9.519 us | 8.438 us | 26.24 | 0.37 | 15.6250 | – | – | 130.27 KB |
DynamicMethod | 100 | 175.19 us | 3.463 us | 3.988 us | 6.21 | 0.18 | 0.7324 | – | – | 7.34 KB |
CodeBaseline | 5000 | 2,809.60 us | 27.198 us | 24.110 us | 1.00 | 0.00 | 39.0625 | 7.8125 | – | 343.07 KB |
ReflectionSortProvider | 5000 | 281,215.52 us | 2,544.578 us | 2,255.702 us | 100.10 | 1.23 | 26000.0000 | 1000.0000 | – | 217890.73 KB |
CachedReflectionSortProvider | 5000 | 95,066.90 us | 708.696 us | 662.915 us | 33.86 | 0.35 | 2000.0000 | 166.6667 | – | 17435.83 KB |
DynamicMethod | 5000 | 21,274.92 us | 400.929 us | 375.029 us | 7.57 | 0.13 | 31.2500 | – | – | 294.45 KB |
Look at the last column. I actually got it to use less memory than the code solution! Amazing.
That’s all for this one. Happy coding everyone! See you next time.
Just kidding. Let’s break this down a bit.
How to build DynamicMethods
I’m sure there are people who can read that MSIL and understand what’s going on. I’ve done four or five of these now, so I can sort of but I would be lying to you if I told you I was an expert in this. So how did I do it? I cheated!
What I did was write the code how I needed it to work, as if the types were all known ahead of time:
public static class MethodsToDecompile { public static int CompareValueType(Thing x, Thing y) { int? valueX = ((SwampThing)x).License; int? valueY = ((SpaceThing)y).License; if (!ReflectionHelper.TryEnsureValidValues(valueX.HasValue, valueY.HasValue, out int valueComparisonResult)) return valueComparisonResult; return valueX.Value.CompareTo(valueY.Value); } public static int CompareValueBaseType(Thing x, Thing y) { int? valueX = x.Id; int? valueY = y.Id; if (!ReflectionHelper.TryEnsureValidValues(valueX.HasValue, valueY.HasValue, out int valueComparisonResult)) return valueComparisonResult; return valueX.Value.CompareTo(valueY.Value); } public static int CompareValueTypeNoGetter() { int? valueX = null; int? valueY = null; if (!ReflectionHelper.TryEnsureValidValues(valueX.HasValue, valueY.HasValue, out int valueComparisonResult)) return valueComparisonResult; return valueX.Value.CompareTo(valueY.Value); } public static int CompareEnumType(Thing x, Thing y) { long? valueX = (long)x.Day; long? valueY = (long)y.Day; if (!ReflectionHelper.TryEnsureValidValues(valueX.HasValue, valueY.HasValue, out int valueComparisonResult)) return valueComparisonResult; return valueX.Value.CompareTo(valueY); } public static int CompareReferenceType(Thing x) { string? valueX = x.Text; string? valueY = null; if (!ReflectionHelper.TryEnsureValidReferences(valueX, valueY, out int referenceComparisonResult)) return referenceComparisonResult; return valueX.CompareTo(valueY); } public static int DefaultComparer(Thing x, Thing y) { object? valueX = ((SwampThing)x).Value; object? valueY = ((SpaceThing)y).Value; return Comparer<object>.Default.Compare(valueX, valueY); } public static int DefaultOneSidedReferenceComparer(Thing x) { object? valueX = x.Text; if (valueX is null) return 0; return -1; } public static int DefaultOneSidedValueComparer(Thing x) { int? valueX = x.Id; if (!valueX.HasValue) return 0; return 1; } }
There’s a lot of cases you need due to the complex nature of this solution. Value types & reference types. When a type is only on one side of the comparison. A method to call the default comparer. And Enums! It turns out Enum doesn’t have a typed comparer, they have int CompareTo(object value)
which is going to box the Enum and kill performance. So I made a special case for that, treating it as a long
. I think that might be why the final method uses less memory than the code version. Not sure, to be honest.
Anyway, once you have your samples built, decompile them! I used ILSpy to do this, but there are a lot of options. Basically, you want to look at the IL that was generated for your code. Make sure you decompile Release
builds for the best performance!
You will get something that looks like this:
.method public hidebysig static int32 CompareReferenceType ( class Code.Thing x ) cil managed { // Method begins at RVA 0x2420 // Code size 30 (0x1e) .maxstack 3 .locals init ( [0] string valueX, [1] string valueY, [2] int32 referenceComparisonResult ) // string text = x.Text; IL_0000: ldarg.0 IL_0001: callvirt instance string Code.Thing::get_Text() IL_0006: stloc.0 // string text2 = null; IL_0007: ldnull IL_0008: stloc.1 // if (!ReflectionHelper.TryEnsureValidReferences(text, text2, out int comparisonResult)) IL_0009: ldloc.0 IL_000a: ldloc.1 IL_000b: ldloca.s 2 IL_000d: call bool Code.ReflectionHelper::TryEnsureValidReferences(object, object, int32&) // (no C# code) IL_0012: brtrue.s IL_0016 // return comparisonResult; IL_0014: ldloc.2 // (no C# code) IL_0015: ret // return text.CompareTo(text2); IL_0016: ldloc.0 IL_0017: ldloc.1 IL_0018: callvirt instance int32 [netstandard]System.String::CompareTo(string) // (no C# code) IL_001d: ret } // end of method MethodsToDecompile::CompareReferenceType
That is the blueprint for what you need to build dynamically. The ILGenerator
API is extremely easy and will map basically 1-1 to the IL you see. Just look at it carefully. Extremely carefully. Attention to detail at maximum. Look at how your different methods ended up as different IL. Think like the compiler. There is no spoon.
Your mission is to spit that out dynamically, feeding it the types and methods at runtime. Sound fun? It is. And it is very unforgiving. If you make a mistake, don’t expect a lot of help. You’ll get errors about invalid programs and destabilized runtimes. You just have to look at every statement and make sure it matches.
Here’s the code for doing the value type case with the comments left in:
/// <remarks> /// Compare two value types. For example, int to int. Mixed nullable is allowed, for example int? to int. Comparison is always done using <see cref="Nullable{T}"/>. /// Enums are converted to long and compared that way. /// Built using MethodsToDecompile.CompareValueType, MethodsToDecompile.CompareValueBaseType, MethodsToDecompile.CompareValueTypeNoGetter, & MethodsToDecompile.CompareEnumType. /// </remarks> private static Func<T, T, int> BuildValueTypePropertyComparer( MethodInfo? propertyX, MethodInfo? propertyY, string sortField, Type targetNullableType, Type targetUnderlyingType, MethodInfo? compareToMethod, bool isEnum) { NullableTypeInfo nullableTypeInfo = NullableTypeInfo.FindNullableTypeInfo(targetNullableType, targetUnderlyingType); DynamicMethod compareMethod = new DynamicMethod($"{propertyX?.ReturnType.Name}.{propertyY?.ReturnType.Name}.{sortField}$Comparer", typeof(int), new[] { s_SourceType, s_SourceType }, true); ILGenerator generator = compareMethod.GetILGenerator(); Label executeComparisonLabel = generator.DefineLabel(); /* .locals init ( [0] valuetype [netstandard]System.Nullable`1<int32> valueX, <- Nullable<valueType> [1] valuetype [netstandard]System.Nullable`1<int32> valueY, <- Nullable<valueType> [2] int32 valueComparisonResult, [3] int32 <- valueType ) */ generator.DeclareLocal(targetNullableType); generator.DeclareLocal(targetNullableType); generator.DeclareLocal(typeof(int)); generator.DeclareLocal(targetUnderlyingType); ReadValueTypeIntoFirstLocal(propertyX, true, targetNullableType, isEnum, nullableTypeInfo.TargetNullableTypeCtor, generator); if (propertyY == null) { /* // int? valueY = null; ldloca.s 1 initobj valuetype [netstandard]System.Nullable`1<int32> */ generator.Emit(OpCodes.Ldloca_S, 1); generator.Emit(OpCodes.Initobj, targetNullableType); } else if (!isEnum && propertyY.ReturnType == targetNullableType) { /* // int? valueY = ((SpaceThing)y).License; ldarg.1 castclass Code.SpaceThing <-- cast to derived type if property doesn't exist on base callvirt instance valuetype [netstandard]System.Nullable`1<int32> Code.SpaceThing::get_License() stloc.1 */ generator.Emit(OpCodes.Ldarg_1); if (propertyY.DeclaringType != s_SourceType) generator.Emit(OpCodes.Castclass, propertyY.DeclaringType); generator.Emit(OpCodes.Callvirt, propertyY); generator.Emit(OpCodes.Stloc_1); } else { /* // int? valueY = ((SwampThing)y).License; ldloca.s 1 ldarg.1 castclass Code.SwampThing callvirt instance int32 Code.SwampThing::get_License() conv.i8 <- cast enums to long call instance void valuetype [netstandard]System.Nullable`1<int32>::.ctor(!0) */ generator.Emit(OpCodes.Ldloca_S, 1); generator.Emit(OpCodes.Ldarg_1); if (propertyY.DeclaringType != s_SourceType) generator.Emit(OpCodes.Castclass, propertyY.DeclaringType); generator.Emit(OpCodes.Callvirt, propertyY); if (isEnum) generator.Emit(OpCodes.Conv_I8); generator.Emit(OpCodes.Call, nullableTypeInfo.TargetNullableTypeCtor); } /* // if (!ReflectionHelper.TryEnsureValidValues(valueX.HasValue, valueY.HasValue, out int comparisonResult)) ldloca.s 0 call instance bool valuetype [netstandard]System.Nullable`1<int32>::get_HasValue() ldloca.s 1 call instance bool valuetype [netstandard]System.Nullable`1<int32>::get_HasValue() ldloca.s 2 call bool Code.ReflectionHelper::TryEnsureValidValues(bool, bool, int32&) brtrue.s executeComparisonLabel // return comparisonResult; ldloc.2 ret */ generator.Emit(OpCodes.Ldloca_S, 0); generator.Emit(OpCodes.Call, nullableTypeInfo.TargetNullableTypeHasValue); generator.Emit(OpCodes.Ldloca_S, 1); generator.Emit(OpCodes.Call, nullableTypeInfo.TargetNullableTypeHasValue); generator.Emit(OpCodes.Ldloca_S, 2); generator.Emit(OpCodes.Call, s_TryEnsureValidValuesMethodInfo); generator.Emit(OpCodes.Brtrue_S, executeComparisonLabel); generator.Emit(OpCodes.Ldloc_2); generator.Emit(OpCodes.Ret); generator.MarkLabel(executeComparisonLabel); /* // return num.Value.CompareTo(license.Value); ldloca.s 0 call instance !0 valuetype [netstandard]System.Nullable`1<int32>::get_Value() stloc.3 ldloca.s 3 ldloca.s 1 call instance !0 valuetype [netstandard]System.Nullable`1<int32>::get_Value() call instance int32 [netstandard]System.Int32::CompareTo(int32) ret */ generator.Emit(OpCodes.Ldloca_S, 0); generator.Emit(OpCodes.Call, nullableTypeInfo.TargetNullableTypeValue); generator.Emit(OpCodes.Stloc_3); generator.Emit(OpCodes.Ldloca_S, 3); generator.Emit(OpCodes.Ldloca_S, 1); generator.Emit(OpCodes.Call, nullableTypeInfo.TargetNullableTypeValue); generator.Emit(OpCodes.Call, compareToMethod); generator.Emit(OpCodes.Ret); return (Func<T, T, int>)compareMethod.CreateDelegate(typeof(Func<T, T, int>)); } /// <remarks> /// This will call the getter referenced by <paramref name="property"/> and store it into the first local variable in the scope. /// Normally the getter refers to PropertyX (the left) but in the case of BuildOneSidedComparer it could also be PropertyY (the right). /// </remarks> private static void ReadValueTypeIntoFirstLocal(MethodInfo? property, bool isPropertyX, Type targetNullableType, bool isEnum, ConstructorInfo targetNullableTypeCtor, ILGenerator generator) { if (property == null) { /* // int? valueX = null; ldloca.s 0 initobj valuetype [netstandard]System.Nullable`1<int32> */ generator.Emit(OpCodes.Ldloca_S, 0); generator.Emit(OpCodes.Initobj, targetNullableType); } else if (!isEnum && property.ReturnType == targetNullableType) { /* // int? valueX = ((SpaceThing)x).License; ldarg.0 castclass Code.SpaceThing <-- cast to derived type if property doesn't exist on base callvirt instance valuetype [netstandard]System.Nullable`1<int32> Code.SpaceThing::get_License() stloc.0 */ if (isPropertyX) generator.Emit(OpCodes.Ldarg_0); else generator.Emit(OpCodes.Ldarg_1); if (property.DeclaringType != s_SourceType) generator.Emit(OpCodes.Castclass, property.DeclaringType); generator.Emit(OpCodes.Callvirt, property); generator.Emit(OpCodes.Stloc_0); } else { /* int? valueX = ((SwampThing)x).License; ldloca.s 0 ldarg.0 castclass Code.SwampThing <-- cast to derived type if property doesn't exist on base callvirt instance int32 Code.SwampThing::get_License() conv.i8 <- cast enums to long call instance void valuetype [netstandard]System.Nullable`1<int32>::.ctor(!0) <-- if type isn't nullable, make one */ generator.Emit(OpCodes.Ldloca_S, 0); if (isPropertyX) generator.Emit(OpCodes.Ldarg_0); else generator.Emit(OpCodes.Ldarg_1); if (property.DeclaringType != s_SourceType) generator.Emit(OpCodes.Castclass, property.DeclaringType); generator.Emit(OpCodes.Callvirt, property); if (isEnum) generator.Emit(OpCodes.Conv_I8); generator.Emit(OpCodes.Call, targetNullableTypeCtor); } }
I find it useful to have the decompiled version mixed in with the dynamic version so that’s what you see going on there. With some hints left in for future maintainers.
Could the performance be better?
I really wanted to show the DynamicMethod
performance being closer to the raw code. But if you think about it, that’s a really unfair comparison. The DynamicMethod
here is doing something that is impossible to do with just code. That’s why we need it in the first place!
The main hit we’re taking is this code:
public static Func<T, T, int> BuildComparerFunc(SortCriteria sortCriteria) { string sortField = sortCriteria.SortField; return (x, y) => { Type typeX = x!.GetType(); if (!s_PropertyComparerCache.TryGetValue(typeX, out ConcurrentDictionary<Type, ConcurrentDictionary<string, Func<T, T, int>>> subCache)) { subCache = new ConcurrentDictionary<Type, ConcurrentDictionary<string, Func<T, T, int>>>(); s_PropertyComparerCache.TryAdd(typeX, subCache); } Type typeY = y!.GetType(); if (!subCache.TryGetValue(typeY, out ConcurrentDictionary<string, Func<T, T, int>> functionCache)) { functionCache = new ConcurrentDictionary<string, Func<T, T, int>>(); subCache.TryAdd(typeY, functionCache); } if (!functionCache.TryGetValue(sortField, out Func<T, T, int> propertyComparer)) { propertyComparer = BuildPropertyComparer(typeX, typeY, sortField); functionCache.TryAdd(sortField, propertyComparer); } return propertyComparer(x, y); }; }
What that is doing is checking to see if we can re-use a DynamicMethod
we’ve already put together. The problem is, the comparison depends on the final types used on each side of the comparison, which isn’t known until runtime. If you are comparing Thing
to Thing
or SwapThing
to SpaceThing
you need a specific path through the code.
Let’s for fun remove that requirement and say you can only search on fields that exist on the base class. Here’s how the performance looks in that case:
Method | NumberOfItems | Mean | Error | StdDev | Ratio | RatioSD | Gen 0 | Gen 1 | Gen 2 | Allocated |
---|---|---|---|---|---|---|---|---|---|---|
CodeBaseline | 100 | 24.78 us | 0.172 us | 0.134 us | 1.00 | 0.00 | 0.8545 | – | – | 7.2 KB |
BaseTypeDynamicMethod | 100 | 30.87 us | 0.206 us | 0.161 us | 1.25 | 0.01 | 0.7019 | – | – | 5.98 KB |
CodeBaseline | 5000 | 2,578.72 us | 49.600 us | 62.728 us | 1.00 | 0.00 | 35.1563 | 7.8125 | – | 303.88 KB |
BaseTypeDynamicMethod | 5000 | 3,380.91 us | 21.678 us | 19.217 us | 1.32 | 0.04 | 27.3438 | 7.8125 | – | 254.82 KB |
Almost zero impact to the performance! This better demonstrates the raw power that DynamicMethod
unlocks for our solutions.
Happy coding everyone!