Любопытство: почему Expression‹› при компиляции работает быстрее, чем минимальный DynamicMethod?

В настоящее время я занимаюсь последней оптимизацией, в основном для развлечения и обучения, и обнаружил кое-что, что оставило у меня пару вопросов.

Во-первых, вопросы:

  1. Когда я создаю метод в памяти с помощью DynamicMethod и используйте отладчик, есть ли способ войти в сгенерированный ассемблерный код при просмотре кода в представлении дизассемблера? Отладчик, кажется, просто перешагнул через весь метод для меня.
  2. Или, если это невозможно, могу ли я каким-то образом сохранить сгенерированный IL-код на диск в виде сборки, чтобы я мог проверить его с помощью Рефлектор?
  3. Почему версия Expression<...> моего простого метода сложения (Int32+Int32 => Int32) работает быстрее, чем минимальная версия DynamicMethod?

Вот короткая и полная программа, которая демонстрирует. В моей системе вывод:

DynamicMethod: 887 ms
Lambda: 1878 ms
Method: 1969 ms
Expression: 681 ms

Я ожидал, что лямбда-вызовы и вызовы методов будут иметь более высокие значения, но версия DynamicMethod постоянно примерно на 30-50% медленнее (вариации, вероятно, из-за Windows и других программ). Кто-нибудь знает причину?

Вот программа:

using System;
using System.Linq.Expressions;
using System.Reflection.Emit;
using System.Diagnostics;

namespace Sandbox
{
    public class Program
    {
        public static void Main(String[] args)
        {
            DynamicMethod method = new DynamicMethod("TestMethod",
                typeof(Int32), new Type[] { typeof(Int32), typeof(Int32) });
            var il = method.GetILGenerator();

            il.Emit(OpCodes.Ldarg_0);
            il.Emit(OpCodes.Ldarg_1);
            il.Emit(OpCodes.Add);
            il.Emit(OpCodes.Ret);

            Func<Int32, Int32, Int32> f1 =
                (Func<Int32, Int32, Int32>)method.CreateDelegate(
                    typeof(Func<Int32, Int32, Int32>));
            Func<Int32, Int32, Int32> f2 = (Int32 a, Int32 b) => a + b;
            Func<Int32, Int32, Int32> f3 = Sum;
            Expression<Func<Int32, Int32, Int32>> f4x = (a, b) => a + b;
            Func<Int32, Int32, Int32> f4 = f4x.Compile();
            for (Int32 pass = 1; pass <= 2; pass++)
            {
                // Pass 1 just runs all the code without writing out anything
                // to avoid JIT overhead influencing the results
                Time(f1, "DynamicMethod", pass);
                Time(f2, "Lambda", pass);
                Time(f3, "Method", pass);
                Time(f4, "Expression", pass);
            }
        }

        private static void Time(Func<Int32, Int32, Int32> fn,
            String name, Int32 pass)
        {
            Stopwatch sw = new Stopwatch();
            sw.Start();
            for (Int32 index = 0; index <= 100000000; index++)
            {
                Int32 result = fn(index, 1);
            }
            sw.Stop();
            if (pass == 2)
                Debug.WriteLine(name + ": " + sw.ElapsedMilliseconds + " ms");
        }

        private static Int32 Sum(Int32 a, Int32 b)
        {
            return a + b;
        }
    }
}

person Lasse V. Karlsen    schedule 18.08.2009    source источник
comment
Действительно хороший вопрос. Во-первых, для этого типа профилирования я бы использовал релиз/консоль, поэтому Debug.WriteLine выглядит неуместно; но даже с Console.WriteLine моя статистика похожа: DynamicMethod: 630 мс Lambda: 561 мс Method: 553 мс Expression: 360 мс Я все еще ищу...   -  person Marc Gravell    schedule 19.08.2009
comment
Интересный вопрос. Такие вещи можно решить с помощью WinDebug и SOS. Я опубликовал пошаговый аналогичный анализ, который проделал много месяцев назад, в своем блоге blog.barrkel.com/2006/05/clr-tailcall-optimization-or-lack.html   -  person Barry Kelly    schedule 19.08.2009
comment
Я подумал, что должен пропинговать вас - я узнал, как заставить JIT без необходимости вызывать метод один раз. Используйте аргумент конструктора restrictedSkipVisibility DynamicMethod. Однако в зависимости от контекста (безопасности кода) он может быть недоступен.   -  person Barry Kelly    schedule 19.08.2009
comment
Хорошие вопросы! Что касается № 1, я не думаю, что DynamicMethods поддаются отладке (в отличие от создания сборки). Однако вы можете тупить и анализировать тело DynamicMethod. Я использую ILVisualizer - модно и достаточно удобно. С уважением, Вадим   -  person Distagon    schedule 16.08.2011
comment
stackoverflow .com/questions/11023993/ Тони ТОНГ   -  person Teter28    schedule 07.07.2014
comment
Также не повлияет ли неиспользование result в Int32 result = fn(index, 1); на результат? Оптимизация мертвого кода должна сработать, верно? Я также попытался распечатать результат и получил DynamicMethod: ~1000 ms Lambda: ~400 ms Method: ~1000 ms Expression: ~400 ms. Для меня результаты (DynamicMethod и Method) и (Lambda и Expression) всегда выравниваются, в отличие от результатов, которые здесь выравниваются (DynamicMethod и Expression) и (Lambda и Method). Странно... с использованием С# 6.0, .NET 4.6, любого процессора на 64-битной машине.   -  person nawfal    schedule 01.07.2016


Ответы (1)


Метод, созданный с помощью DynamicMethod, проходит через два преобразователя, а метод, созданный с помощью Expression<>, не проходит ни одного.

Вот как это работает. Вот последовательность вызова для вызова fn(0, 1) в методе Time (я жестко запрограммировал аргументы на 0 и 1 для простоты отладки):

00cc032c 6a01            push    1           // 1 argument
00cc032e 8bcf            mov     ecx,edi
00cc0330 33d2            xor     edx,edx     // 0 argument
00cc0332 8b410c          mov     eax,dword ptr [ecx+0Ch]
00cc0335 8b4904          mov     ecx,dword ptr [ecx+4]
00cc0338 ffd0            call    eax // 1 arg on stack, two in edx, ecx

Для первого вызова, который я исследовал, DynamicMethod, строка call eax выглядит так:

00cc0338 ffd0            call    eax {003c2084}
0:000> !u 003c2084
Unmanaged code
003c2084 51              push    ecx
003c2085 8bca            mov     ecx,edx
003c2087 8b542408        mov     edx,dword ptr [esp+8]
003c208b 8b442404        mov     eax,dword ptr [esp+4]
003c208f 89442408        mov     dword ptr [esp+8],eax
003c2093 58              pop     eax
003c2094 83c404          add     esp,4
003c2097 83c010          add     eax,10h
003c209a ff20            jmp     dword ptr [eax]

Это, кажется, делает некоторую перестановку стека, чтобы переставить аргументы. Я предполагаю, что это связано с разницей между делегатами, которые используют неявный аргумент this, и теми, которые этого не делают.

Этот прыжок в конце разрешается так:

003c209a ff20            jmp     dword ptr [eax]      ds:0023:012f7edc=0098c098
0098c098 e963403500      jmp     00ce0100

Остальная часть кода по адресу 0098c098 выглядит как преобразователь JIT, начало которого было переписано с помощью jmp после JIT. Только после этого прыжка мы попадаем в настоящий код:

0:000> !u eip
Normal JIT generated code
DynamicClass.TestMethod(Int32, Int32)
Begin 00ce0100, size 5
>>> 00ce0100 03ca            add     ecx,edx
00ce0102 8bc1            mov     eax,ecx
00ce0104 c3              ret

Последовательность вызова для метода, созданного с помощью Expression<>, отличается — в ней отсутствует код перелистывания стека. Вот он, с первого прыжка через eax:

00cc0338 ffd0            call    eax {00ce00a8}

0:000> !u eip
Normal JIT generated code
DynamicClass.lambda_method(System.Runtime.CompilerServices.ExecutionScope, Int32, Int32)
Begin 00ce00a8, size b
>>> 00ce00a8 8b442404        mov     eax,dword ptr [esp+4]
00ce00ac 03d0            add     edx,eax
00ce00ae 8bc2            mov     eax,edx
00ce00b0 c20400          ret     4

Теперь, как все стало так?

  1. Прокручивание стека не требовалось (фактически используется неявный первый аргумент от делегата, т.е. не так, как делегат, привязанный к статическому методу)
  2. JIT должен был быть вызван логикой компиляции LINQ, чтобы делегат содержал реальный адрес назначения, а не поддельный.

Я не знаю, как LINQ заставил JIT, но я знаю, как заставить JIT самому - вызвав функцию хотя бы один раз. ОБНОВЛЕНИЕ: я нашел другой способ заставить JIT: использовать аргумент restrictedSkipVisibility для конструктора и передать true. Итак, вот модифицированный код, который устраняет прокручивание стека с помощью неявного параметра this и использует альтернативный конструктор для предварительной компиляции, чтобы связанный адрес был реальным адресом, а не преобразователем:

using System;
using System.Linq.Expressions;
using System.Reflection.Emit;
using System.Diagnostics;

namespace Sandbox
{
    public class Program
    {
        public static void Main(String[] args)
        {
            DynamicMethod method = new DynamicMethod("TestMethod",
                typeof(Int32), new Type[] { typeof(object), typeof(Int32),
                typeof(Int32) }, true);
            var il = method.GetILGenerator();

            il.Emit(OpCodes.Ldarg_1);
            il.Emit(OpCodes.Ldarg_2);
            il.Emit(OpCodes.Add);
            il.Emit(OpCodes.Ret);

            Func<Int32, Int32, Int32> f1 =
                (Func<Int32, Int32, Int32>)method.CreateDelegate(
                    typeof(Func<Int32, Int32, Int32>), null);
            Func<Int32, Int32, Int32> f2 = (Int32 a, Int32 b) => a + b;
            Func<Int32, Int32, Int32> f3 = Sum;
            Expression<Func<Int32, Int32, Int32>> f4x = (a, b) => a + b;
            Func<Int32, Int32, Int32> f4 = f4x.Compile();
            for (Int32 pass = 1; pass <= 2; pass++)
            {
                // Pass 1 just runs all the code without writing out anything
                // to avoid JIT overhead influencing the results
                Time(f1, "DynamicMethod", pass);
                Time(f2, "Lambda", pass);
                Time(f3, "Method", pass);
                Time(f4, "Expression", pass);
            }
        }

        private static void Time(Func<Int32, Int32, Int32> fn,
            String name, Int32 pass)
        {
            Stopwatch sw = new Stopwatch();
            sw.Start();
            for (Int32 index = 0; index <= 100000000; index++)
            {
                Int32 result = fn(index, 1);
            }
            sw.Stop();
            if (pass == 2)
                Console.WriteLine(name + ": " + sw.ElapsedMilliseconds + " ms");
        }

        private static Int32 Sum(Int32 a, Int32 b)
        {
            return a + b;
        }
    }
}

Вот время выполнения в моей системе:

DynamicMethod: 312 ms
Lambda: 417 ms
Method: 417 ms
Expression: 312 ms

ОБНОВЛЕНО ДЛЯ ДОБАВЛЕНИЯ:

Я попытался запустить этот код на своей новой системе, которая представляет собой Core i7 920 под управлением Windows 7 x64 с установленной бета-версией 2 .NET 4 (mscoree.dll версии 4.0.30902), и результаты, ну, в общем, разные.

csc 3.5, /platform:x86, runtime v2.0.50727 (via .config)

Run #1
DynamicMethod: 214 ms
Lambda: 571 ms
Method: 570 ms
Expression: 249 ms

Run #2
DynamicMethod: 463 ms
Lambda: 392 ms
Method: 392 ms
Expression: 463 ms

Run #3
DynamicMethod: 463 ms
Lambda: 570 ms
Method: 570 ms
Expression: 463 ms

Возможно, это влияние Intel SpeedStep на результаты или, возможно, Turbo Boost. В любом случае это очень раздражает.

csc 3.5, /platform:x64, runtime v2.0.50727 (via .config)
DynamicMethod: 428 ms
Lambda: 392 ms
Method: 392 ms
Expression: 428 ms

csc 3.5, /platform:x64, runtime v4
DynamicMethod: 428 ms
Lambda: 356 ms
Method: 356 ms
Expression: 428 ms

csc 4, /platform:x64, runtime v4
DynamicMethod: 428 ms
Lambda: 356 ms
Method: 356 ms
Expression: 428 ms

csc 4, /platform:x86, runtime v4
DynamicMethod: 463 ms
Lambda: 570 ms
Method: 570 ms
Expression: 463 ms

csc 3.5, /platform:x86, runtime v4
DynamicMethod: 214 ms
Lambda: 570 ms
Method: 571 ms
Expression: 249 ms

Многие из этих результатов будут случайными совпадениями во времени, что бы ни вызывало случайное ускорение в сценарии C# 3.5/runtime v2.0. Мне придется перезагрузиться, чтобы увидеть, отвечает ли SpeedStep или Turbo Boost за эти эффекты.

person Barry Kelly    schedule 18.08.2009
comment
Значит, мне нужно добавить способ безопасного вызова моего метода, просто чтобы повысить производительность? Я, конечно, могу это сделать. - person Lasse V. Karlsen; 19.08.2009
comment
Я имею в виду... методы, которые я создаю, на самом деле не будут суммировать два числа, а будут отвечать за создание и разрешение сервисов в реализации IoC. В этом случае я действительно не хочу, чтобы полный метод выполнялся и создавал службу, просто чтобы получить небольшое повышение производительности. Поскольку некоторые сервисы будут использоваться много, а сам сервис небольшой и легковесный, я также прилагаю некоторые усилия к фактическому коду разрешения. Кроме того, это интересный обучающий проект для Reflection.emit. Очень ценю работу, которую вы вложили в свой ответ! - person Lasse V. Karlsen; 19.08.2009
comment
Хорошая статья! Это известный факт, что использование DynamicMethod для статического вызова генерирует это дополнительное преобразование. - person Lucero; 02.06.2010
comment
Между прочим, я подумал об этом и считаю, что дополнительный преобразователь для DynamicMethod заключается в следующем: результатом преобразования является уменьшение размера записи активации выше динамического вызова на 1 слово. Преобразователь также приводит к удалению неявного указателя this в вызове функции. Вы заметите, что после thunk нет call, только jmp инструкций. Таким образом, любые обходы стека, которые происходят в динамическом методе, будут видеть текущую запись активации, как если бы это был статический метод, даже если он был вызван с указателем this. - person Michael Graczyk; 11.07.2012
comment
Почему CLR всегда обрабатывает динамические методы как нестатические во время JIT, но затем вставляет преобразователь в зависимости от типа делегата, созданного из вызова CreateDelegate? Я почти уверен, что это для управления памятью. Память, в которой хранится сам объект делегата, привязка между динамическим методом и объектом (и его типом делегата), а также сгенерированный IL (я думаю) хранятся в куче собранного мусора. Это управляемая память. JIT-код хранится в рабочем наборе процессов, но не управляется сборщиком мусора. - person Michael Graczyk; 11.07.2012
comment
Таким образом, вместо того, чтобы выполнять JIT-компиляцию метода каждый раз, когда вызывается CreateDelegate, CLR просто выполняет JIT-компиляцию DynamicMethod один раз. Вызовы CreateDelegate иногда создают статический делегат, а иногда создают делегат экземпляра. Однако JIT не может знать, какой из них будет использоваться во время компиляции DynamicMethod, если только он не компилирует его один раз для каждого вызова CreateDelegate!! Таким образом, есть противоречивые цели: 1. JIT как можно меньше кода и 2. Разрешить одному DynamicMethod быть как статическим, так и экземпляром. - person Michael Graczyk; 11.07.2012
comment
Единственный способ, который я могу представить для достижения обеих этих целей, — это вставить преобразователь, который вставляет CLR. Я пытался придумать творческие способы размещения DynamicMethod... Может быть, иметь две точки входа в JIT-код и просто представить переход к началу DynamicMethod. Таким образом, вызов CreateDelegate для данного DynamicMethod всегда будет использовать одну и ту же JIT-функцию, но при статике будет добавлено смещение, равное размеру Thunk, к указателю функции в созданном делегате. Но тогда сигналы могут повторяться во многих местах (пустая трата памяти...) - person Michael Graczyk; 11.07.2012
comment
В качестве альтернативы JIT может вставлять преобразователи в том виде, в котором они есть сейчас, но делать это только для делегатов типа экземпляра. По-видимому, CLR не может этого сделать. Я предполагаю, что существует специальный код для реализации DynamicMethod, создающий собственный код в восстанавливаемом адресном пространстве, и по какой-то причине делегат не может переносить указатель функции на код в этом адресном пространстве (CLR не будет разрешить для безопасности), или JIT не может выполнить вызов адресного пространства, используемого DynamicMethod (возможно, не знает или специальный код является функцией). - person Michael Graczyk; 11.07.2012
comment
Черт, может быть, нативный код из DynamicMethod все-таки каким-то образом хранится в куче мусора. - person Michael Graczyk; 11.07.2012
comment
Тупик в SSCLI. Внутренний вызов, используемый CreateDelegate, не существует в Rotor. - person Michael Graczyk; 11.07.2012