Почему Func‹› создается из Expression‹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<>>, работал как объявленный напрямую?


person MartinF    schedule 18.11.2010    source источник
comment
Интересный вопрос; На самом деле я приближаюсь к 4-кратному увеличению производительности для прямого случая.   -  person Marc Gravell    schedule 18.11.2010
comment
(мои сроки находятся в выпуске, в командной строке, с полным сборщиком мусора перед обоими тестами)   -  person Marc Gravell    schedule 18.11.2010
comment
Кажется, нет никакой разницы, если Func — это Func‹int, int›   -  person MartinF    schedule 18.11.2010
comment
Было бы полезно отразить и прочитать IL, сгенерированный для каждого механизма.   -  person cdhowie    schedule 18.11.2010
comment
@cdhowie Я не могу заставить dnp построить разборку для этого :| dotnetpad.net/ViewPaste/_Vx1bk-DVkqxCcSU1HE8tw#   -  person jcolebrand    schedule 18.11.2010
comment
Я опубликую IL как ответ вики сообщества.   -  person cdhowie    schedule 18.11.2010
comment
Когда я запускаю сборку Debug под отладчиком, они оба работают в течение 13 секунд. Думаю, можно с уверенностью сказать, что в обоих случаях создается один и тот же IL.   -  person Gabe    schedule 18.11.2010
comment
Сгенерированный IL лишь немного отличается на 2 байта. Во-первых, из-за аргумента (ldarg0 против ldarg1), а во-вторых, из-за того, что токен отличается, поскольку они размещены в разных модулях.   -  person Michael B    schedule 18.11.2010
comment
Спасибо за все хорошие ответы. Выбрать, какой из них принять, не всегда легко :) Гейб также написал пример того, как улучшить производительность, который также был частью моего вопроса и получил наибольшее количество голосов, поэтому я принял его ответ.   -  person MartinF    schedule 24.11.2010
comment
Должно быть, что-то изменилось за 8 лет, прошедших с тех пор, как был задан этот вопрос, потому что теперь я получаю прямо противоположные результаты: скомпилированные делегаты постоянно быстрее, чем объявленные аналоги.   -  person Mike-E    schedule 02.05.2018


Ответы (6)


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

person Gabe    schedule 18.11.2010
comment
Действительно мило. Я быстро завернул это в метод расширения, и моя скорость вернулась (увеличение примерно на 30-40%) Спасибо! :) - person MartinF; 18.11.2010
comment
К вашему сведению, в .NET 4.5 я не вижу разницы между скомпилированным выражением и описанным выше подходом компиляции в метод. - person Nick Strupat; 09.09.2015

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

Статистика собрана из 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, сгенерированный 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, сгенерированного компилятором выражений.

person Community    schedule 18.11.2010
comment
Меня беспокоит то, что среда выполнения Mono недостаточно похожа на среду выполнения .Net, чтобы сравнение было полезным. - person Gabe; 18.11.2010
comment
mono использует рефлексию.emit для компиляции c Sharp, поэтому имеет смысл, что код, сгенерированный деревьями выражений, такой же быстрый. - person Michael B; 18.11.2010
comment
@Michael: Так вы говорите, что скомпилированные деревья выражений работают медленно в Mono или что скомпилированные сборки работают быстро? - person cdhowie; 18.11.2010
comment
@Gabe: IL для этого метода должен быть достаточно простым, чтобы обе среды выполнения компилировали анонимный метод и дерево выражений в один и тот же IL. Вы не можете получить более оптимизированный, чем приведенный выше IL. Я не предлагаю извлекать скомпилированный IL из дерева выражений в Mono, так как это не является целью этого вопроса. Но приведенный выше IL должен служить хорошим ориентиром для сравнения. (Кроме того, Mono — это все, что у меня есть на данный момент.) - person cdhowie; 18.11.2010
comment
Я пересмотрел свой ответ, чтобы показать IL обоих выражений. Я думаю, что у Mono на самом деле может быть преимущество, поскольку преобразователь токенов динамического модуля C # хуже справляется с работой, когда у вас много типов в динамическом методе, поскольку он выполняет итерацию, а не компилятор mono, который выполняет некоторую форму поиска. Но не цитируйте меня. - person Michael B; 18.11.2010

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

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

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

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

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

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

person Jeff    schedule 18.11.2010
comment
Хотя вы правы насчет секундомера, в данном случае он не имеет значения, потому что JIT-компиляция лямбды занимает всего микросекунды (возможно, 3 на моем компьютере). - person Gabe; 18.11.2010
comment
Истинный. До сих пор хорошо известен тот факт, что динамически создаваемые методы вызываются медленнее, чем предварительно скомпилированные. - person Jeff; 18.11.2010
comment
Кстати, это не всегда так! Я написал делегат и фактически переписал их как выражения, поскольку скомпилированные выражения работали почти в два раза быстрее. - person Michael B; 18.11.2010
comment
Майкл Б.: JeffN825 говорил, что динамические методы вызываются медленнее (примерно на 12 нс на моей машине), а не выполняются медленнее. Другими словами, накладные расходы на вызов функции выше. - person Gabe; 18.11.2010
comment
@Jeff: Фактический Func (не лямбда) также не будет компилироваться JIT до его первого вызова. CLR JIT-компилирует методы при первом вводе. - person cdhowie; 18.11.2010
comment
Да, более медленный вызов имеет смысл, поскольку он вызывает функцию с двумя аргументами, а не функцию с одним аргументом. Это означает, что он должен добавить немного больше в стек. - person Michael B; 18.11.2010
comment
Майкл Б: Я изменил лямбду на (x,y) => new Foo(x*y), чтобы это была функция с двумя аргументами, и не заметил ожидаемое увеличение времени выполнения, поэтому я подозреваю, что ваша гипотеза неверна. - person Gabe; 18.11.2010
comment
@Jeff, не могли бы вы объяснить, как начальное время компиляции Expression‹T› в делегат замедляет выполнение, когда оно находится вне цикла и не включено в меры? - person Ark-kun; 11.01.2014

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

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

ОБНОВЛЕНИЕ

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

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

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

person Brian Rasmussen    schedule 18.11.2010
comment
Спасибо за ответ. Я также заметил это поведение и написал его как комментарий к моему собственному вопросу. Фин блог iøvrigt :) - person MartinF; 18.11.2010
comment
Я изменил Foo с класса на структуру и заметил уменьшение времени на 1 секунду для обоих вариантов, но в остальном относительная разница не уменьшилась. Я подозреваю, что вы, возможно, не измеряете то, что вы думаете. - person Gabe; 18.11.2010
comment
@Gabe: я взял код из вопроса и изменил объявления, как показано в моем ответе. Я также убедился, что каждый делегат был вызван один раз перед измерением времени. Я попробую структуры и обновлю свой ответ. - person Brian Rasmussen; 18.11.2010
comment
Я думаю, что ваши цифры для Func<int,int> ниже, потому что компилятор JIT может выполнять некоторые оптимизации (встраивание, регистрация), которые не применимы ко всем случаям. - person Gabe; 18.11.2010
comment
@Gabe: Это вполне может быть так. Следующим шагом является сравнение JIT-скомпилированного кода между ними. - person Brian Rasmussen; 18.11.2010

Скорее всего, это потому, что первый вызов кода не был сброшен. Решил посмотреть на ИЖ и они практически идентичны.

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 четными функциями, которые ничего не возвращают.

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

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

person Michael B    schedule 18.11.2010
comment
Вы предполагаете, что для JIT-компиляции функции, содержащей 5 инструкций, требуется несколько секунд? - person Gabe; 18.11.2010
comment
Я с вами в том, что меня не должна волновать небольшая разница. Но когда вы пишете часть программного обеспечения, которое будет оцениваться по его производительности, где тестовые случаи, подобные этому, будут использоваться для сравнения его с другими конкурентами, это имеет значение, когда вы сильно полагаетесь на делегатов, и вы внезапно видите снижение производительности на 30-40 % по сравнению с прямым подходом. К счастью, получение выражения дает мне возможность оптимизировать то, что происходит в лямбде, и сделать это даже быстрее, чем прямой подход. - person MartinF; 18.11.2010
comment
Более важная вещь, которую можно продать вышестоящим, заключается в том, что подход Expression позволяет вам реализовывать шаблоны и делегаты проще, чем, скажем, делать это вручную. Таким образом, хотя мы все можем согласиться с тем, что C#, настроенный вручную, может быть лучше, если вам нужен конкретный код для каждого типа или худшего экземпляра, написание бесчисленных случаев вручную будет огромным усилием разработки, в то время как вы можете написать довольно хорошо оптимизированное дерево выражений, которое может динамически генерировать код для настройки вашего поведения во время выполнения. - person Michael B; 19.11.2010

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

Что меня поразило, так это то, что, исключив JIT из уравнения, я получил противоположные результаты, чем Мартин.

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

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

person greenoldman    schedule 18.11.2010