Скомпилированная производительность выражения лямбда С# с импликацией

Учитывая этот класс:

/// <summary>
/// Dummy implementation of a parser for the purpose of the test
/// </summary>
class Parser
{
    public List<T> ReadList<T>(Func<T> readFunctor)
    {
        return Enumerable.Range(0, 10).Select(i => readFunctor()).ToList();
    }

    public int ReadInt32()
    {
        return 12;
    }

    public string ReadString()
    {
        return "string";
    }
}

Я пытаюсь сгенерировать следующий вызов с скомпилированным деревом выражений лямбда:

Parser parser = new Parser();
List<int> list = parser.ReadList(parser.ReadInt32);

Однако, формальность не совсем то же самое...

class Program
{
    private const int MAX = 1000000;

    static void Main(string[] args)
    {
        DirectCall();
        LambdaCall();
        CompiledLambdaCall();
    }

    static void DirectCall()
    {
        Parser parser = new Parser();
        var sw = new Stopwatch();
        sw.Start();
        for (int i = 0; i < MAX; i++)
        {
            List<int> list = parser.ReadList(parser.ReadInt32);
        }
        sw.Stop();
        Console.WriteLine("Direct call: {0} ms", sw.ElapsedMilliseconds);
    }

    static void LambdaCall()
    {
        Parser parser = new Parser();
        var sw = new Stopwatch();
        sw.Start();
        for (int i = 0; i < MAX; i++)
        {
            List<int> list = parser.ReadList(() => parser.ReadInt32());
        }
        sw.Stop();
        Console.WriteLine("Lambda call: {0} ms", sw.ElapsedMilliseconds);
    }

    static void CompiledLambdaCall()
    {
        var parserParameter = Expression.Parameter(typeof(Parser), "parser");

        var lambda = Expression.Lambda<Func<Parser, List<int>>>(
            Expression.Call(
                parserParameter,
                typeof(Parser).GetMethod("ReadList").MakeGenericMethod(typeof(int)),
                Expression.Lambda(
                    typeof(Func<int>),
                    Expression.Call(
                        parserParameter,
                        typeof(Parser).GetMethod("ReadInt32")))),
            parserParameter);
        Func<Parser, List<int>> func = lambda.Compile();

        Parser parser = new Parser();
        var sw = new Stopwatch();
        sw.Start();
        for (int i = 0; i < MAX; i++)
        {
            List<int> list = func(parser);
        }
        sw.Stop();
        Console.WriteLine("Compiled lambda call: {0} ms", sw.ElapsedMilliseconds);
    }
}

Это результаты в миллисекундах на моем компьютере:

Direct call:          647 ms
Lambda call:          641 ms
Compiled lambda call: 5861 ms

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

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

Обновление: изменен бенчмаркинг на основе DateTime.Now до Stopwatch.

Кто-нибудь знает, как настроить лямбда-выражение, чтобы получить лучшую производительность в скомпилированном лямбда-вызове?

Ответы

Ответ 1

Тест недействителен по двум причинам:

DateTime.Now недостаточно точна для краткосрочных тестов с микро-бенчмаркингом.

Вместо этого используйте класс Stopwatch. Когда я это сделаю, я получаю следующие результаты (используя MAX = 100000), в миллисекундах:

Lambda call: 86.3196
Direct call: 74.057
Compiled lambda call: 814.2178

Действительно, "прямой вызов" выполняется быстрее, чем "лямбда-вызов", что имеет смысл - "прямой вызов" включает вызовы делегату, которые ссылаются непосредственно на метод объекта Parser. "Лямбда-вызов" требует вызова делегата, который ссылается на метод на объект-сгенерированный компилятором, который, в свою очередь, вызывает метод в объекте Parser. Это дополнительное косвенное введение приводит к незначительной скорости.


"Скомпилированный лямбда-вызов" - это не то же самое, что "Лямбда-вызов"

"Лямбда" выглядит так:

() => parser.ReadInt32()

тогда как "Скомпилированная лямбда" выглядит так:

parser => parser.ReadList(() => parser.ReadInt32())

Там есть дополнительный шаг: создать встроенный делегат для внутренней лямбды. В плотной петле это дорого.

ИЗМЕНИТЬ

Я пошел вперед и осмотрел IL "лямбда" по сравнению с "скомпилированной лямбдой" и декомпилировал их обратно на "более простой" С# (см.: Просмотр кода IL, сгенерированного с помощью скомпилированное выражение).

Для "non compiled" лямбда это выглядит так:

for (int i = 0; i < 100000; i++)
{
    if (CS$<>9__CachedAnonymousMethodDelegate1 == null)
    {
        CS$<>9__CachedAnonymousMethodDelegate1 = new Func<int>(CS$<>8__locals3.<LambdaCall>b__0);
    }

    CS$<>8__locals3.parser.ReadList<int>(CS$<>9__CachedAnonymousMethodDelegate1);
}

Обратите внимание, что один делегат создается один раз и кэшируется.

В то время как для "скомпилированной лямбды" это выглядит так:

Func<Parser, List<int>> func = lambda.Compile();
Parser parser = new Parser();
for (int i = 0; i < 100000; i++)
{
    func(parser);
}

Где цель делегата:

public static List<int> Foo(Parser parser)
{
    object[] objArray = new object[] { new StrongBox<Parser>(parser) };
    return ((StrongBox<Parser>) objArray[0]).Value.ReadList<int>
      (new Func<int>(dyn_type.<ExpressionCompilerImplementationDetails>{1}lambda_method));
}

Обратите внимание, что хотя "внешний" делегат создается только один раз и кэшируется, на каждой итерации цикла создается новый "внутренний" делегат. Не говоря уже о других распределениях для массива object и экземпляра StrongBox<T>.

Ответ 2

  • Основная причина, по которой скомпилированная лямбда медленнее, состоит в том, что делегат создается снова и снова. Анонимные делегаты - особая порода: они используются только в одном месте. Таким образом, компилятор может выполнять некоторые специальные оптимизации, такие как кеширование значения при первом вызове делегата. Вот что здесь происходит.

  • Я не смог воспроизвести большую разницу между прямым вызовом и лямбда-вызовом. Фактически, в моих измерениях прямой вызов немного быстрее.

При выполнении таких тестов вы можете использовать более точный таймер. Класс Stopwatch в System.Diagnostics идеален. Вы также можете увеличить количество итераций. Код работает только в течение нескольких миллисекунд.

Кроме того, первый из трех случаев будет иметь небольшие накладные расходы от JIT'-класса Parser. Попробуйте запустить первый случай дважды и посмотрите, что произойдет. Или еще лучше: используйте количество итераций в качестве параметра в каждом методе и сначала вызовите каждый метод для 1-й итерации, чтобы все они начинались на ровном игровом поле.