Подтвердить что ты не робот

Производительность деревьев выражений

Мое настоящее понимание заключается в том, что код "жесткого кодирования" выглядит следующим образом:

public int Add(int x, int y) {return x + y;}

всегда будет работать лучше, чем код дерева выражений, например:

Expression<Func<int, int, int>> add = (x, y) => x + y; 
var result = add.Compile()(2, 3);

var x = Expression.Parameter(typeof(int)); 
var y = Expression.Parameter(typeof(int)); 
return (Expression.Lambda(Expression.Add(x, y), x, y).
    Compile() as Func<int, int, int>)(2, 3);

поскольку компилятор имеет больше информации и может потратить больше усилий на оптимизацию кода, если вы его компилируете во время компиляции. Это вообще правда?

4b9b3361

Ответ 1

Компиляция

Вызов Expression.Compile выполняется точно так же, как и любой другой код .NET, который ваш приложение содержит в том смысле, что:

  • Создается код IL
  • IL-код JIT-ted для машинного кода

(шаг синтаксического анализа пропускается, поскольку дерево выражений уже создано и не должно генерироваться из кода ввода)

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

Оптимизация

Помните, что почти вся оптимизация, выполняемая CLR, выполняется на шаге JIT, а не в компиляции исходного кода С#. Эта оптимизация будет также выполняться при компиляции кода IL из вашего делегата лямбда в машинный код.

Ваш пример

В вашем примере вы сравниваете яблоки и апельсины. Первый пример - определение метода, второй пример - код времени выполнения, который создает метод, компилирует и выполняет его. Время, необходимое для создания/компиляции метода, намного дольше, чем его выполнение. Однако после создания можно сохранить экземпляр скомпилированного метода. Когда вы это сделали, производительность вашего сгенерированного метода должна быть идентична производительности исходного метода С#.

Рассмотрим этот случай:

private static int AddMethod(int a, int b)
{
    return a + b;
}

Func<int, int, int> add1 = (a, b) => a + b;
Func<int, int, int> add2 = AddMethod;

var x = Expression.Parameter(typeof (int));
var y = Expression.Parameter(typeof (int));
var additionExpr = Expression.Add(x, y);
Func<int, int, int> add3 = 
              Expression.Lambda<Func<int, int, int>>(
                  additionExpr, x, y).Compile();
//the above steps cost a lot of time, relatively.

//performance of these three should be identical
add1(1, 2);
add2(1, 2);
add3(1, 2);

Таким образом, можно сделать вывод: IL-код - это код IL, независимо от того, как он сгенерирован, а выражения Linq генерируют IL-код.

Ответ 2

Ваша функция Add, вероятно, компилируется в некоторые служебные служебные данные (если не вложенные) и одну команду добавления. Не получается быстрее.

Даже построение этого дерева выражений будет на несколько порядков медленнее. Компиляция новой функции для каждого вызова будет невероятно дорогой по сравнению с прямой реализацией С#.

Попробуйте выполнить компиляцию функции только один раз и сохраните ее где-нибудь.

Ответ 3

Хорошо, я написал небольшой тест (возможно, вам нужны экспертные консультации), но кажется, что деревья выражений самые быстрые (add3), а затем add2, а затем add1!

using System;
using System.Diagnostics;
using System.Linq.Expressions;

namespace ExpressionTreeTest
{
    class Program
    {
        static void Main()
        {
            Func<int, int, int> add1 = (a, b) => a + b;
            Func<int, int, int> add2 = AddMethod;

            var x = Expression.Parameter(typeof(int));
            var y = Expression.Parameter(typeof(int));
            var additionExpr = Expression.Add(x, y);
            var add3 = Expression.Lambda<Func<int, int, int>>(
                              additionExpr, x, y).Compile();


            TimingTest(add1, "add1", 1000000);
            TimingTest(add2, "add2", 1000000);
            TimingTest(add3, "add3", 1000000);
        }

        private static void TimingTest(Func<int, int, int> addMethod, string testMethod, int numberOfAdditions)
        {
            var sw = new Stopwatch();
            sw.Start();
            for (var c = 0; c < numberOfAdditions; c++)
            {
               addMethod(1, 2);              
            }
            sw.Stop();
           Console.WriteLine("Total calculation time {1}: {0}", sw.Elapsed, testMethod);
        }

        private static int AddMethod(int a, int b)
        {
            return a + b;
        }
    }
}

Режим отладки моих результатов:

Total calculation time add1: 00:00:00.0134612
Total calculation time add2: 00:00:00.0133916
Total calculation time add3: 00:00:00.0053629

Мой режим выпуска результатов:

Total calculation time add1: 00:00:00.0026172
Total calculation time add2: 00:00:00.0027046
Total calculation time add3: 00:00:00.0014334

Ответ 4

Попытка понять, почему моя сборка и скомпилированная лямбда работает немного медленнее, чем "просто делегировать" (я думаю, мне нужно будет создать для нее новый SO-вопрос). Я нашел этот поток и решил проверить производительность с помощью BenchmarkDotNet. Удивите меня: там строят вручную и скомпилированные лямбда быстрее. И да - существует устойчивая разница между методами.

Результаты:

BenchmarkDotNet=v0.10.5, OS=Windows 10.0.14393
Processor=Intel Core i5-2500K CPU 3.30GHz (Sandy Bridge), ProcessorCount=4
Frequency=3233539 Hz, Resolution=309.2587 ns, Timer=TSC
  [Host] : Clr 4.0.30319.42000, 64bit RyuJIT-v4.6.1648.0
  Clr    : Clr 4.0.30319.42000, 64bit RyuJIT-v4.6.1648.0
  Core   : .NET Core 4.6.25009.03, 64bit RyuJIT


         Method |  Job | Runtime |      Mean |     Error |    StdDev |    Median |       Min |        Max | Rank | Allocated |
--------------- |----- |-------- |----------:|----------:|----------:|----------:|----------:|-----------:|-----:|----------:|
     AddBuilded |  Clr |     Clr | 0.8826 ns | 0.0278 ns | 0.0232 ns | 0.8913 ns | 0.8429 ns |  0.9195 ns |    1 |       0 B |
      AddLambda |  Clr |     Clr | 1.5077 ns | 0.0226 ns | 0.0212 ns | 1.4986 ns | 1.4769 ns |  1.5395 ns |    2 |       0 B |
 AddLambdaConst |  Clr |     Clr | 6.4535 ns | 0.0454 ns | 0.0425 ns | 6.4439 ns | 6.4030 ns |  6.5323 ns |    3 |       0 B |
     AddBuilded | Core |    Core | 0.8993 ns | 0.0249 ns | 0.0233 ns | 0.8908 ns | 0.8777 ns |  0.9506 ns |    1 |       0 B |
      AddLambda | Core |    Core | 1.5105 ns | 0.0241 ns | 0.0201 ns | 1.5094 ns | 1.4731 ns |  1.5396 ns |    2 |       0 B |
 AddLambdaConst | Core |    Core | 9.3849 ns | 0.2237 ns | 0.5693 ns | 9.6577 ns | 8.3455 ns | 10.0590 ns |    4 |       0 B |

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

Код:

    static BenchmarkLambdaSimple()
    {
        addLambda = (a, b) => a + b;
        addLambdaConst = AddMethod;

        var x = Expression.Parameter(typeof(int));
        var y = Expression.Parameter(typeof(int));
        var additionExpr = Expression.Add(x, y);
        addBuilded =
                      Expression.Lambda<Func<int, int, int>>(
                          additionExpr, x, y).Compile();
    }
    static Func<int, int, int> addLambda;
    static Func<int, int, int> addLambdaConst;
    static Func<int, int, int> addBuilded;
    private static int AddMethod(int a, int b)
    {
        return a + b;
    }

    [Benchmark]
    public int AddBuilded()
    {
        return addBuilded(1, 2);
    }

    [Benchmark]
    public int AddLambda()
    {
        return addLambda(1, 2);
    }

    [Benchmark]
    public int AddLambdaConst()
    {
        return addLambdaConst(1, 2);
    }

Ответ 5

Теперь С# 6.0 позволяет это сделать:

public int Add(int x, int y) => x + y;

вместо:

public int Add(int x, int y) {return x + y;}

См. выражения метода и выражения свойств