Running with Code Like with scissors, only more dangerous

18Nov/150

Exploring .dbc files with Dynamic Code Generation, part 3: Dynamic codegen!

Last time, we talked about how to populate a class based on its ordered fields, using Reflection to do the heavy lifting for us. This time, we’re going to generate a DynamicMethod which runs at runtime in the same way that ConvertSlow did last time. Let’s look at that this time.

First, let’s change GetAt here:

        public T GetAt(int index)
        {
            if (_store == null)
                throw new ObjectDisposedException("DbcTable");

            _store.Seek(_perRecord * index + _headerLength, SeekOrigin.Begin);

            T target = new T();
            // This is the only change
            Convert.Value(_reader, _recordLength, this, target);
            return target;
        }

The only change here is that we’ve changed ConvertSlow to Convert.Value. The Convert field is a Lazy<DbcReaderProducer>; DbcReaderProducer is a delegate type that corresponds to the same signature as the ConvertSlow method:

internal delegate void DbcReaderProducer<T>(BinaryReader reader, int fieldsPerRecord, DbcTable table, T target)
        where T : class;

We’re going to aim for creating DynamicMethods that have this signature. The Convert field then gets defined as follows:

        private static Lazy<DbcReaderProducer<T>> Convert = new Lazy<DbcReaderProducer<T>>(() =>
        {
            if (Config.ForceSlowMode)
                return DbcTable<T>.ConvertSlow;

            try
            {
                return DbcTableCompiler.Compile<T>();
            }
            catch
            {
                if (Debugger.IsAttached && !Config.ForceSlowMode)
                    Debugger.Break();

                return DbcTable<T>.ConvertSlow;
            }
        });

So the magic is all in the DbcTableCompiler. Let’s take a look at how that will work. Before writing code, let’s outline it, using the CharTitles.dbc file we wrote in Part 1:

public class CharacterTitleRecord
{
    [DbcRecordPosition(0)]
    public int ID;
    [DbcRecordPosition(1)]
    public int RequiredAchievementID;
    [DbcRecordPosition(2)]
    public string Title;
}

Were I writing a DbcReaderProducer<CharacterTitleRecord> by hand, it might look like this:

static void FillCTR(BinaryReader reader, int fieldsPerRecord, DbcTable table, CharacterTitleRecord target)
{
    // assumption: reader is already at the beginning of the record
    target.ID = reader.ReadInt32();
    target.RequiredAchievementID = reader.ReadInt32();
    int stringTablePos = reader.ReadInt32();
    target.Title = table.GetString(stringTablePos); // Note: Table.GetString restores the current Stream position
    reader.ReadInt32(); // unused column @ index 3
    reader.ReadInt32(); // unused column @ index 4
    reader.ReadInt32(); // unused column @ index 5
}

That’s pretty straightforward, right? Let’s break down how that compiles.

  • I have to call reader.ReadInt32() (or ReadSingle if it’s a float-type parameter). That puts the return value on the evaluation stack.
  • If the field type is String, call table.GetString.
  • Store the value onto the target’s field.

Before we go on, we need to talk about the .NET execution/evaluation stack. This execution stack is a virtual stack that doesn’t necessarily have any relationship to the physical hardware; it’s just conceptually how the CLR reasons about the data that it’s processing. For example, suppose you have an assignment x = a + b;. The .NET stack would modify in the following ways:

  1. push a
  2. push b
  3. add (consumes the top two values on the stack, and pushes the result onto the stack)
  4. assign to ‘x’ (consumes the top value on the stack)

In this way, the stack remains balanced. You can read more about how the CLR runtime environment works here. The other thing to note is that the CLR follows a single calling convention. If a method being called is an instance method, then the instance reference is pushed onto the stack followed by the parameters in left-to-right order; if it is a static method, then the instance reference is skipped. (Variadic functions have their arguments converted to an array at the call site).

Knowing this, we should be able to compile our own implementation as long as we know the right IL opcodes. I do; let’s compile the above function now.

static void FillCTR(BinaryReader reader, int fieldsPerRecord, DbcTable table, CharacterTitleRecord target)
{
    ldarg.3  // push 'target' onto the stack
    ldarg.0  // push 'reader' onto the stack
    callvirt (instance method) BinaryReader.ReadInt32(void) // consumes 'reader', pushes result
    stfld (instance field) CharacterTitleRecord.ID // consumes 'target' and result of previous, assigns value

    ldarg.3 
    ldarg.0
    callvirt (instance method) BinaryReader.ReadInt32(void)
    stfld (instance field) CharacterTitleRecord.RequiredAchievementID

    ldarg.3
    ldarg.2 // push 'table' onto the stack
    ldarg.0 
    callvirt (instance method) BinaryReader.ReadInt32(void)
    callvirt (instance method) DbcTable.GetString(int32) // consumes 'table' and the result of ReadInt32
    stfld (instance field) CharacterTitleRecord.Title 

    ldarg.0
    callvirt (instance method) BinaryReader.ReadInt32()
    pop // unused column @ index 3

    ldarg.0
    callvirt (instance method) BinaryReader.ReadInt32()
    pop // unused column @ index 4

    ldarg.0
    callvirt (instance method) BinaryReader.ReadInt32()
    pop // unused column @ index 5

    ret
}

Woohoo! Now THAT is the kind of thing that seems highly automatable. Let’s start thinking about how we can implement this. Starting with the actual Compile method:

        internal static DbcReaderProducer<T> Compile<T>()
            where T : class
        {
            // Supposing <T> is "DbcReader.CharacterTitleRecord", this method 
            // creates a DynamicMethod named $DbcTable$DbcReader$CharacterTitleRecord, with the same access
            // to types as the DbcTableCompiler type (i.e., internal to this assembly.
            // It returns void, and accepts BinaryReader, int, DbcTable, and T as its arguments.  In other words, 
            // it is compatible with DbcReaderProducer<T>.
            DynamicMethod method = new DynamicMethod("$DbcTable$" + Regex.Replace(typeof(T).FullName, "\\W+", "$"), typeof(void), new Type[] { typeof(BinaryReader), typeof(int), typeof(DbcTable), typeof(T) }, typeof(DbcTableCompiler).Assembly.ManifestModule);
            ILGenerator gen = method.GetILGenerator();

            var properties = GetTargetInfoForType(typeof(T)); // Same method as before
            var propertyMap = properties.ToDictionary(ti => ti.Position);
            var maxPropertyIndex = propertyMap.Keys.Max(); // We go in order, unlike the naïve implementation
            for (int i = 0; i <= maxPropertyIndex; i++) 
            {
                TargetInfo info;
                if (propertyMap.TryGetValue(i, out info))
                {
                    EmitForField(info, gen); // We have to do our magic here.  Allow us to grow to support properties later.
                }
                else // unused column below
                {
                    gen.Emit(OpCodes.Ldarg_0);
                    gen.EmitCall(OpCodes.Callvirt, BinaryReader_ReadInt32, null);
                    gen.Emit(OpCodes.Pop);
                }
            }

            gen.Emit(OpCodes.Ret);

            return method.CreateDelegate(typeof(DbcReaderProducer<T>)) as DbcReaderProducer<T>;
        }

All told, that is absolutely not complex nor scary. EmitForField is also not terribly complex:

        private static void EmitForField(TargetInfo info, ILGenerator generator) 
        {
            Debug.Assert(info != null);
            Debug.Assert(generator != null);
            Debug.Assert(info.Field != null);

            generator.Emit(OpCodes.Ldarg_3);

            EmitTypeData(info, generator);

            generator.Emit(OpCodes.Stfld, info.Field);
        }

The EmitForField method is designed as it is to allow us to grow to support properties later. (That will require pushing a reference to the ‘target’ object before doing the type-specific mutations and then calling the property setter, which is a slightly different activity than this one). So let’s look at EmitTypeData, which does the real heavy lifting:

        private static MethodInfo BinaryReader_ReadInt32 = typeof(BinaryReader).GetMethod("ReadInt32");
        private static MethodInfo BinaryReader_ReadSingle = typeof(BinaryReader).GetMethod("ReadSingle");
        private static MethodInfo DbcTable_GetString = typeof(DbcTable).GetMethod("GetString");

        private static void EmitTypeData(TargetInfo info, ILGenerator generator)
        {
            switch (info.Type)
            {
                case TargetType.Float32:
                    generator.Emit(OpCodes.Ldarg_0);
                    generator.EmitCall(OpCodes.Callvirt, BinaryReader_ReadSingle, null);
                    break;
                case TargetType.Int32:
                    generator.Emit(OpCodes.Ldarg_0);
                    generator.EmitCall(OpCodes.Callvirt, BinaryReader_ReadInt32, null);
                    break;
                case TargetType.String:
                    generator.Emit(OpCodes.Ldarg_2);
                    generator.Emit(OpCodes.Ldarg_0);
                    generator.EmitCall(OpCodes.Callvirt, BinaryReader_ReadInt32, null);
                    generator.EmitCall(OpCodes.Callvirt, DbcTable_GetString, null);
                    break;
                default:
                    throw new NotSupportedException("Invalid type for target property.");
            }
        }

Looks suspiciously similar to what we did before! In fact, we can now see each of the code paths, plus an extra code path for 32-bit floats. So that’s pretty set to go.

What does this do for us? Iterating through the CharTitles table 5 times goes from 37ms to 17ms on my laptop when not plugged in; iterating through the ItemDisplayInfo table 5 times goes from 880ms to 240ms. That’s not a bad gain, especially with no optimizations like seeking!

Next time: We’re going to optimize string reads.

Comments (0) Trackbacks (0)

No comments yet.


Leave a comment

ERROR: si-captcha.php plugin says GD image support not detected in PHP!

Contact your web host and ask them why GD image support is not enabled for PHP.

ERROR: si-captcha.php plugin says imagepng function not detected in PHP!

Contact your web host and ask them why imagepng function is not enabled for PHP.

No trackbacks yet.