Почему Func <> создан из выражения <Func <>> медленнее, чем Func <> объявлен напрямую?

Почему Func<>, созданный из Expression<Func<>> через .Compile(), значительно медленнее, чем просто объявленный Func<>?

Я просто отказался от использования Func<IInterface, object>, объявленного непосредственно на созданном из Expression<Func<IInterface, object>> в приложении, над которым я работаю, и заметил, что производительность снижается.

Я только что сделал небольшой тест, а Func<>, созданный из выражения, "почти" удваивает время Func<>, объявленного напрямую.

На моей машине Direct Func<> занимает около 7,5 секунд, а Expression<Func<>> занимает около 12,6 секунд.

Вот тестовый код, который я использовал (запуская Net 4.0)

// Direct
Func<int, Foo> test1 = x => new Foo(x * 2);

int counter1 = 0;

Stopwatch s1 = new Stopwatch();
s1.Start();
for (int i = 0; i < 300000000; i++)
{
 counter1 += test1(i).Value;
}
s1.Stop();
var result1 = s1.Elapsed;



// Expression . Compile()
Expression<Func<int, Foo>> expression = x => new Foo(x * 2);
Func<int, Foo> test2 = expression.Compile();

int counter2 = 0;

Stopwatch s2 = new Stopwatch();
s2.Start();
for (int i = 0; i < 300000000; i++)
{
 counter2 += test2(i).Value;
}
s2.Stop();
var result2 = s2.Elapsed;



public class Foo
{
 public Foo(int i)
 {
  Value = i;
 }
 public int Value { get; set; }
}

Как вернуть производительность?

Есть ли что-нибудь, что я могу сделать, чтобы получить Func<>, созданный из Expression<Func<>>, чтобы выполнить, как объявлено напрямую?

Ответ 1

Как упоминалось выше, накладные расходы на вызов динамического делегата вызывают замедление. На моем компьютере накладные расходы составляют около 12 нс с моим процессором на частоте 3 ГГц. Способ обойти это - загрузить метод из скомпилированной сборки, например:

var ab = AppDomain.CurrentDomain.DefineDynamicAssembly(
             new AssemblyName("assembly"), AssemblyBuilderAccess.Run);
var mod = ab.DefineDynamicModule("module");
var tb = mod.DefineType("type", TypeAttributes.Public);
var mb = tb.DefineMethod(
             "test3", MethodAttributes.Public | MethodAttributes.Static);
expression.CompileToMethod(mb);
var t = tb.CreateType();
var test3 = (Func<int, Foo>)Delegate.CreateDelegate(
                typeof(Func<int, Foo>), t.GetMethod("test3"));

int counter3 = 0;
Stopwatch s3 = new Stopwatch();
s3.Start();
for (int i = 0; i < 300000000; i++)
{
    counter3 += test3(i).Value;
}
s3.Stop();
var result3 = s3.Elapsed;

Когда я добавляю вышеприведенный код, result3 всегда составляет лишь часть секунды выше, чем result1, для примерно 1 нс служебных данных.

Итак, зачем даже скомпилировать lambda (test2), когда у вас может быть более быстрый делегат (test3)? Поскольку создание динамической сборки в общем случае является намного более накладным и сохраняет только 10-20 нс при каждом вызове.

Ответ 2

(Это не правильный ответ, но материал предназначен для того, чтобы помочь найти ответ.)

Статистика, собранная от Mono 2.6.7 - Debian Lenny - Linux 2.6.26 i686 - 2.80GHz одноядерное:

      Func: 00:00:23.6062578
Expression: 00:00:23.9766248

Итак, на Mono, по крайней мере, оба механизма, похоже, генерируют эквивалентный IL.

Это IL, сгенерированный Mono gmcs для анонимного метода:

// method line 6
.method private static  hidebysig
       default class Foo '<Main>m__0' (int32 x)  cil managed
{
    .custom instance void class [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::'.ctor'() =  (01 00 00 00 ) // ....

    // Method begins at RVA 0x2204
    // Code size 9 (0x9)
    .maxstack 8
    IL_0000:  ldarg.0
    IL_0001:  ldc.i4.2
    IL_0002:  mul
    IL_0003:  newobj instance void class Foo::'.ctor'(int32)
    IL_0008:  ret
} // end of method Default::<Main>m__0

Я буду работать над извлечением IL, сгенерированным компилятором выражения.

Ответ 3

В конечном счете, это то, что Expression<T> не является предварительно скомпилированным делегатом. Это только дерево выражений. Вызов компиляции на LambdaExpression (что и есть на самом деле Expression<T>) генерирует код IL во время выполнения и создает для него что-то похожее на DynamicMethod.

Если вы просто используете код Func<T>, он предварительно компилирует его так же, как и любую другую ссылку делегата.

Итак, здесь есть 2 источника медлительности:

  • Исходное время компиляции для компиляции Expression<T> в делегат. Это огромно. Если вы делаете это для каждого вызова - определенно нет (но это не так, поскольку вы используете свой секундомер после вызова компиляции.

  • Это a DynamicMethod в основном после вызова компиляции. DynamicMethod (даже сильно типизированные делегаты для них) Фактически медленнее выполнять, чем прямые вызовы. Func<T>, разрешенные во время компиляции, являются прямыми вызовами. Там были сопоставления производительности между динамически испускаемым IL и временем компиляции IL. Случайный URL: http://www.codeproject.com/KB/cs/dynamicmethoddelegates.aspx?msg=1160046

... Кроме того, в вашем секундомерном тесте для Expression<T> вы должны запустить свой таймер, когда я = 1, а не 0... Я считаю, что ваша скомпилированная Лямбда не будет JIT, скомпилированной до первого вызова, так что будет хитом производительности для этого первого вызова.

Ответ 4

Это, скорее всего, потому, что первый вызов кода не был перекошен. Я решил посмотреть на IL, и они практически идентичны.

Func<int, Foo> func = x => new Foo(x * 2);
Expression<Func<int, Foo>> exp = x => new Foo(x * 2);
var func2 = exp.Compile();
Array.ForEach(func.Method.GetMethodBody().GetILAsByteArray(), b => Console.WriteLine(b));

var mtype = func2.Method.GetType();
var fiOwner = mtype.GetField("m_owner", BindingFlags.Instance | BindingFlags.NonPublic);
var dynMethod = fiOwner.GetValue(func2.Method) as DynamicMethod;
var ilgen = dynMethod.GetILGenerator();


byte[] il = ilgen.GetType().GetMethod("BakeByteArray", BindingFlags.NonPublic | BindingFlags.Instance).Invoke(ilgen, null) as byte[];
Console.WriteLine("Expression version");
Array.ForEach(il, b => Console.WriteLine(b));

Этот код получает байт-массивы и печатает их на консоли. Вот вывод на моем компьютере::

2
24
90
115
13
0
0
6
42
Expression version
3
24
90
115
2
0
0
6
42

И вот рефлекторная версия первой функции::

   L_0000: ldarg.0 
    L_0001: ldc.i4.2 
    L_0002: mul 
    L_0003: newobj instance void ConsoleApplication7.Foo::.ctor(int32)
    L_0008: ret 

Во всем методе всего 2 байта! Это первый код операции, который для первого метода, ldarg0 (загружает первый аргумент), но во втором методе ldarg1 (загружает второй аргумент). Разница здесь в том, что объект сгенерированный выражением фактически имеет цель объекта Closure. Это также может иметь значение.

Следующий код операции для обоих - ldc.i4.2 (24), что означает загрузку 2 в стек, следующий - код операции для mul (90), следующий код операции - код операции newobj (115), Следующие 4 байта - это токен метаданных для объекта .ctor. Они отличаются друг от друга, поскольку два метода фактически размещаются в разных сборках. Анонимный метод находится в анонимной сборке. К сожалению, я не совсем понял, как решить эти жетоны. Конечный код операции - 42, который равен ret. Каждая функция CLI должна заканчиваться ret четными функциями, которые ничего не возвращают.

Есть несколько возможностей, объект закрытия каким-то образом заставляет вещи замедляться, что может быть правдой (но маловероятно), дрожание не способствовало этому методу, и поскольку вы стреляли быстрым чередованием, у него не было до времени до этого пути, ссылаясь на более медленный путь. Компилятор С# в vs также может излучать разные соглашения о вызовах и MethodAttributes, которые могут выступать в качестве подсказок для джиттера для выполнения различных оптимизаций.

В конечном счете, я бы даже не отдаленно беспокоился об этой разнице. Если вы действительно используете свою функцию 3 миллиарда раз в ходе вашего приложения, а разница в том, что она составляет 5 целых секунд, вы, вероятно, будете в порядке.

Ответ 5

Только для записи: я могу воспроизвести числа с приведенным выше кодом.

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

Если я изменил код на

Func<int, int> test1 = x => x * 2;

и

Expression<Func<int, int>> expression = x => x * 2;
Func<int, int> test2 = expression.Compile();

Номера производительности практически идентичны (фактически результат2 немного лучше результата1). Это подтверждает теорию о том, что дорогая часть - это распределение кучи и/или коллекции, а не то, как создается делегат.

UPDATE

Следуя комментарию от Gabe, я попытался изменить Foo как структуру. К сожалению, это дает более или менее то же количество, что и исходный код, поэтому, возможно, сбор кучи/сбор мусора не является причиной в конце концов.

Однако я также проверил числа для делегатов типа Func<int, int>, и они очень похожи и намного ниже цифр исходного кода.

Я продолжу копать и с нетерпением жду новых/обновленных ответов.

Ответ 6

Меня интересовал ответ Майкла Б., поэтому я добавил в каждом случае дополнительный вызов до начала секундомера. В режиме отладки метод компиляции (случай 2) выполнялся быстрее почти в два раза (от 6 секунд до 10 секунд), а в режиме выпуска обе версии обе версии были на уровне (разница составляла около ~ 0,2 секунды).

Теперь, что поразительно для меня, что с JIT вышло из уравнения, я получил противоположные результаты, чем Мартин.

Изменить: сначала я пропустил Foo, поэтому результаты выше для Foo с полем, а не с собственностью, с оригинальным Foo сравнение одинаковое, только время больше - 15 секунд для прямого func, 12 секунд для скомпилированных версия. Опять же, в режиме выпуска время похоже, теперь разница составляет около ~ 0,5.

Однако это указывает на то, что если ваше выражение будет более сложным, даже в режиме выпуска будет реальная разница.