Почему Func <> создан из Expression> медленнее, чем Func <>, объявленный напрямую? - PullRequest
24 голосов
/ 18 ноября 2010

Почему 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<>>, работал как тот, который объявлен напрямую?

Ответы [ 6 ]

19 голосов
/ 18 ноября 2010

Как уже упоминали другие, чрезмерные затраты на вызов динамического делегата приводят к замедлению работы.На моем компьютере эта нагрузка составляет около 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 нс.

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

6 голосов
/ 18 ноября 2010

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

Статистика взята из Mono 2.6.7 - Debian Lenny - Linux 2.6.26 i686 - одноядерное 2.80 ГГц:

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

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

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

// 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 голосов
/ 18 ноября 2010

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

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

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

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

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

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

1 голос
/ 18 ноября 2010

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

Следует отметить, что оба делегата создают новый экземпляр 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

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

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

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

1 голос
/ 18 ноября 2010

Скорее всего, потому что первый вызов кода не был связан.Я решил посмотреть на 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 даже функциями, которые ничего не возвращают.

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

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

0 голосов
/ 18 ноября 2010

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

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

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

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

...