Perfomance of Mapping C# Poco objects 3

In the last part of this series I will come up with a more generalized way of mapping objects one to another.

In the previous episodes

In the previous posts we have used AutoMapper to map People1, People2 and God1, God2 types respectively to each other. I have seen it is very easy to use AutoMapper, but when doing a performance measurement, it was shown that it takes 237ms to run our test sample.

In the second post, we repeated the same measurement, but instead of using AutoMapper, we used poor man's type, and manually created an extension method to map each type to the other. This approach was significantly faster, the same test took only 40ms to run. It has a huge disadvantage on the other hand, each mapping method has to be written by hand, which is significant amount of work.

Code generation

In this post I will show a third way, which is general enough, we do not need to write each mapping function by hand, but also fast enough, so it can compete with the poor man's solution.

To achieve all this, we will use IL code generation. The approach to this will be similar to the extension method, where the target object must exists before the mapping happens, but instantiation will be included to the measurement.

I created a Generator class.

Note, this is not production ready code. I briefly tested myself on only a couple of use-cases. But is should give you an idea, for a more complex solution.

public class Generator
{
  private readonly Dictionary<string, DynamicMethod> _cache = new Dictionary<string, DynamicMethod>();

  public Func<TResult, TSource, TResult> Create<TSource, TResult>()
  {
    var cacheKey = CreateKey<TSource, TResult>();
    if(_cache.TryGetValue(cacheKey, out DynamicMethod result))
    {
      return (Func<TResult, TSource, TResult>)result.CreateDelegate(typeof(Func<TResult, TSource, TResult>));
    }
    var sourceType = typeof(TSource);
    var resultType = typeof(TResult);
    var dynamicMethod = new DynamicMethod("Convert", resultType, new[] { resultType, sourceType }, this.GetType().Module);
    var ilGenerator = dynamicMethod.GetILGenerator();
    var methodEnd1 = ilGenerator.DefineLabel();
    foreach(var sourceProp in sourceType.GetProperties().Where(x => x.CanRead))
    {
      var targetProp = resultType.GetProperties().FirstOrDefault(x => x.CanWrite && x.Name == sourceProp.Name && x.PropertyType == sourceProp.PropertyType);
      if(targetProp != null)
      {
        ilGenerator.Emit(OpCodes.Ldarg_0);
        ilGenerator.Emit(OpCodes.Ldarg_1);
        ilGenerator.Emit(OpCodes.Call, sourceProp.GetMethod);
        ilGenerator.Emit(OpCodes.Call, targetProp.SetMethod);
      }
      else
      {
        targetProp = resultType.GetProperties().FirstOrDefault(x => x.CanWrite && x.Name ==       sourceProp.Name && x.PropertyType != sourceProp.PropertyType);
        if(_cache.TryGetValue(CreateKey(sourceProp.PropertyType, targetProp.PropertyType), out DynamicMethod mappedInner))
        {
          ilGenerator.Emit(OpCodes.Ldarg_0);
          ilGenerator.Emit(OpCodes.Call, targetProp.GetMethod);
          ilGenerator.Emit(OpCodes.Ldarg_1);
          ilGenerator.Emit(OpCodes.Call, sourceProp.GetMethod);
          ilGenerator.Emit(OpCodes.Brfalse_S, methodEnd1);
          ilGenerator.Emit(OpCodes.Ldarg_1);
          ilGenerator.Emit(OpCodes.Call, sourceProp.GetMethod);
          ilGenerator.Emit(OpCodes.Call, mappedInner);
          ilGenerator.Emit(OpCodes.Br_S, methodEnd1);

          ilGenerator.MarkLabel(methodEnd1);
          ilGenerator.Emit(OpCodes.Pop);
        }
      }
    }
    ilGenerator.Emit(OpCodes.Ldarg_0);
    ilGenerator.Emit(OpCodes.Ret);
    var generatedDelegate = dynamicMethod.CreateDelegate(typeof(Func<TResult, TSource, TResult>));
    _cache.TryAdd(cacheKey, dynamicMethod);
    return (Func<TResult, TSource, TResult>)generatedDelegate;
  }

  private string CreateKey<TSource, TResult>()
  {
    var sourceType = typeof(TSource);
    var resultType = typeof(TResult);
    return CreateKey(sourceType, resultType);
  }
  
  private string CreateKey(Type source, Type target)
  {
    return source.FullName + "_" + target.FullName;
  }
}

Let me briefly go over the code here. We create a Dictionary of string - DynamicMethod. This simply to store multiple mapper registrations, which can be re-used later on. Say we register a mapper for God1 to God2, we can map objects these types. But if People1 has a reference to God1, and People2 on God2, we might want to reuse an existing mapping here. Going with the cache, there is also two CreateKey() methods defined to generate a key for the key value pairs stored by the dictionary.

We create a dynamic method, which will be our method doing the actual copy, its signature is Func, so it expects a source and a target object, and returns the target with the values mapped. It does not create a deep copy, value types are copied by value, reference types by reference.

We use reflection to iterate the source and target properties. We try to match each property of the source type by name and property type. If there is a match, we simply load the source and target objects from the argument list and call the get and set methods respectively, to do the copy. If there is a match ony by name, and the property types do not match, we check if there is an existing registration in the cache for the given types. If so we load the source and target objects on the stack (we check the source not to be null) and we invoke the cached method on the loaded objects.

We generate our delegate from the dynamic type and return. When using this code, we will need to mappings registered:

var gen = new Generator();
gen.Create<God1, God2>();
var mappingFunc = gen.Create<People1, People2>();

Finally, to do performance test, we can invoke:

target = mappingFunc(new People2() { God = new God2() }, source);

Performance

Testing the performance of this method the same way as the pervious tests reveals that the generated code runs as fast as it was written by hand. It executes in 39ms. This step though goes with a registration and code generation phase, though which has a slight (one time) impact only. Reading and debugging through the generated code is slightly more complex though.