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

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

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

public IEnumerable<Item> GetItems(int point)
{
    return this.items.Where(i => i.IsApplicableFor(point));
}

то здесь существует некоторый переменный подъем, связанный с параметром point, поскольку он является свободной переменной с точки зрения лямбда. Если бы я назвал этот метод миллион раз, было бы лучше сохранить его как есть или изменить его каким-либо образом, чтобы улучшить его производительность?

Какие параметры у меня есть и какие из них действительно возможны?. Насколько я понимаю, мне нужно избавиться от свободных переменных, поэтому компилятору не придется создавать класс закрытия и создавать его на каждом вызовите этот метод. Этот экземпляр обычно занимает значительное количество времени по сравнению с версиями без закрытия.

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

Альтернативный метод

& пример кода приложения консоли

Я также написал другую версию того же метода, который не требует какой-либо подстановки (по крайней мере, я думаю, что это не так, но вы, ребята, понимаете это, дайте мне знать, если это так):

public IEnumerable<Item> GetItems(int point)
{
    Func<int, Func<Item, bool>> buildPredicate = p => i => i.IsApplicableFor(p);
    return this.items.Where(buildPredicate(point));
}

Посмотрите Gist здесь. Просто создайте консольное приложение и скопируйте весь код в Program.cs файл внутри блока namespace. Вы увидите, что второй пример намного медленнее, хотя он не использует свободные переменные.

Противоречивый пример

Причина, по которой я хотел бы составить несколько лучших рекомендаций по использованию лямбда, заключается в том, что я встретил эту проблему до и к моему удивлению, что один оказался работающим быстрее, когда использовалось выражение lambda builder-предиката.

Теперь объясните это. Я полностью потерялся здесь, потому что может получиться, что я вообще не буду использовать лямбда, когда я знаю, что у меня есть какой-то метод тяжелого использования в моем коде. Но я бы хотел избежать такой ситуации и довести ее до конца.

Изменить

Ваши предложения не работают

Я пробовал реализовать пользовательский класс поиска, который внутренне работает аналогично тому, что делает компилятор со свободной переменной lambda. Но вместо того, чтобы иметь класс закрытия, я внедрил экземпляры, которые имитируют аналогичный сценарий. Это код:

private int Point { get; set; }
private bool IsItemValid(Item item)
{
    return item.IsApplicableFor(this.Point);
}

public IEnumerable<TItem> GetItems(int point)
{
    this.Point = point;
    return this.items.Where(this.IsItemValid);
}

Интересно, что это работает так же медленно, как и медленная версия. Я не знаю почему, но, похоже, это не что иное, как быстрый. Он повторяет ту же функциональность, потому что эти дополнительные члены являются частью одного и того же экземпляра объекта. Так или иначе. Я сейчас очень смущен!

Я обновил источник Gist с этим последним дополнением, чтобы вы могли проверить себя.

4b9b3361

Ответ 1

Что заставляет вас думать, что вторая версия не требует какой-либо подстановки? Вы определяете Func с помощью выражения Lambda, и для этого требуются те же фрагменты обмана компилятора, которые требуются для первой версии.

Кроме того, вы создаете Func, который возвращает Func, который немного изгибает мой мозг и почти наверняка потребует повторной оценки с каждым вызовом.

Я бы предложил вам скомпилировать это в режиме выпуска, а затем использовать ILDASM для изучения сгенерированного ИЛ. Это должно дать вам некоторое представление о том, какой код сгенерирован.

Еще один тест, который вы должны выполнить, что даст вам больше понимания, - сделать предикатный вызов отдельной функцией, использующей переменную в классе. Что-то вроде:

private DateTime dayToCompare;
private bool LocalIsDayWithinRange(TItem i)
{
    return i.IsDayWithinRange(dayToCompare);
}

public override IEnumerable<TItem> GetDayData(DateTime day)
{
    dayToCompare = day;
    return this.items.Where(i => LocalIsDayWithinRange(i));
}

Это скажет вам, стоит ли вам перетаскивать переменную day.

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

Моя точка зрения заключается в том, что "создание замыкания" в моем случае представляет собой простое назначение переменной. Если это значительно быстрее, чем ваша версия с выражением Lambda, то вы знаете, что есть некоторая неэффективность кода, который компилятор создает для закрытия.

Я не уверен, где вы получаете информацию об устранении свободных переменных и стоимости закрытия. Можете ли вы дать мне несколько ссылок?

Ответ 2

Ваш второй метод работает в 8 раз медленнее первого для меня. Как замечает @DanBryant в комментариях, это связано с построением и вызовом делегата внутри метода - не нужно делать с переменной лифтингом.

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

Как уже подтвердил код, первый пример выше (с использованием простого встроенного предиката) выполняет jsut на 10% медленнее, чем простой цикл for - из вашего кода:

foreach (TItem item in this.items)
{
    if (item.IsDayWithinRange(day))
    {
        yield return item;
    }
}

Итак, вкратце:

  • Цикл for - это самый простой подход и является "наилучшим вариантом".
  • Встроенный предикат немного медленнее из-за некоторых дополнительных накладных расходов.
  • Построение и вызов Func, который возвращает Func внутри каждой итерации, значительно медленнее, чем либо.

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

Ответ 3

Я профилировал ваш ориентир для вас и определил много вещей:

Прежде всего, он проводит половину своего времени на линии return this.GetDayData(day).ToList();, вызывающей ToList. Если вы удалите это и вместо этого вручную выполните итерацию по результатам, вы можете измерить относительные различия в методах.

Во-вторых, поскольку IterationCount = 1000000 и RangeCount = 1, вы выбираете инициализацию разных методов, а не количество времени, которое требуется для их выполнения. Это означает, что в вашем профиле выполнения доминируют создание итераторов, экранирование записей переменных и делегатов, а также сотни последующих сборок мусора gen0, которые возникают в результате создания всего этого мусора.

В-третьих, медленный метод очень медленный на x86, но примерно так же быстро, как "быстрый" метод на x64. Я считаю, что это связано с тем, как различные JITTER создают делегатов. Если вы снижаете создание делегата из результатов, "быстрые" и "медленные" методы идентичны по скорости.

В-четвертых, если вы действительно вызываете итераторы значительное количество раз (на моем компьютере, нацеливая x64, с RangeCount = 8), "медленный" на самом деле быстрее, чем "foreach" и "fast" быстрее, чем все из них.

В заключение, "лифтинговый" аспект пренебрежимо мал. Тестирование на моем ноутбуке показывает, что захват переменной, как и вам, требует дополнительного 10ns каждый раз, когда создается лямбда (не каждый раз при вызове), и это включает дополнительные дополнительные расходы GC. Кроме того, при создании итератора в вашем методе "foreach" выполняется несколько быстрее, чем создание лямбда, на самом деле вызывая, что итератор медленнее, чем вызов лямбда.

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

public class SuperFastLinqRangeLookup<TItem> : RangeLookupBase<TItem>
    where TItem : RangeItem
{

    public SuperFastLinqRangeLookup(DateTime start, DateTime end, IEnumerable<TItem> items)
        : base(start, end, items)
    {
        // create delegate only once
        predicate = i => i.IsDayWithinRange(day);
    }

    DateTime day;
    Func<TItem, bool> predicate;

    public override IEnumerable<TItem> GetDayData(DateTime day)
    {
        this.day = day; // set captured day to correct value
        return this.items.Where(predicate);
    }
}

Ответ 4

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

Способ проверить, будет ли это путем его тестирования, используя что-то вроде этого:

public class Test
{
   public static void ExecuteLambdaInScope()
   {
      // here, the lambda executes only within the scope
      // of the referenced variable 'add'

      var items = Enumerable.Range(0, 100000).ToArray();

      int add = 10;  // free variable referenced from lambda

      Func<int,int> f = x => x + add;

      // measure how long this takes:
      var array = items.Select( f ).ToArray();  
   }

   static Func<int,int> GetExpression()
   {
      int add = 10;
      return x => x + add;  // this needs a closure
   }

   static void ExecuteLambdaOutOfScope()
   {
      // here, the lambda executes outside the scope
      // of the referenced variable 'add'

      Func<int,int> f = GetExpression();

      var items = Enumerable.Range(0, 100000).ToArray();

      // measure how long this takes:
      var array = items.Select( f ).ToArray();  
   }

}