Скомпилированная производительность лямбда-выражения C # с имбрикацией - PullRequest
15 голосов
/ 08 ноября 2011

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

/// <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.

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

Ответы [ 2 ]

13 голосов
/ 08 ноября 2011

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

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())

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

EDIT

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

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

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>.

7 голосов
/ 08 ноября 2011
  1. Основная причина, по которой скомпилированная лямбда медленнее, заключается в том, что делегат создается снова и снова.Анонимные делегаты - особая порода: они используются только в одном месте.Таким образом, компилятор может выполнять некоторые специальные оптимизации, такие как кэширование значения при первом вызове делегата.Это то, что здесь происходит.

  2. Мне не удалось воспроизвести большую разницу между прямым вызовом и лямбда-вызовом.На самом деле, в моих измерениях прямой вызов немного быстрее.

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

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

...