Первое выполнение двух эквивалентных запросов LINQ всегда выполняется медленнее

#c# #performance #linq #benchmarking

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

Вопрос:

Рассмотрим следующие два способа написания этого запроса LINQ:

Вариант 1:

 public void MyMethod(List<MyObject> myList)
{
   ...
   var isValid = myList.Where(l => l.IsActive)
                       .GroupBy(l => l.Category)
                       .Select(g => g.Count() > 300) //arbitrary number for the sake of argument
                       .Any();
}
  

Вариант 2:

 public void MyMethod(List<MyObject> myList)
{
   ...
   var isValid = myList.Where(l => l.IsActive)
                       .GroupBy(l => l.Category)
                       .Select(g => g.Count()) 
                       .Any(total => total > 300); //arbitrary number for the sake of argument
}
  

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

Происходит то, что запрос, который выполняется первым, всегда выполняется медленнее, а затем при последующих запусках они оба отображаются как выполняемые за 0 миллисекунд. Затем я изменил значение сравнения на Ticks и получил аналогичные результаты. Если я поменяю порядок, в котором выполняются запросы, новый первый теперь выполняется медленнее.

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

Вот тестовый код:

 static void Main(string[] args)
{
    Console.WriteLine("Running test");

    var rnd = new Random();

    for (var i = 0;i < 5; i  )
    {
        RunTest(i, rnd);
        Console.WriteLine();
        Console.WriteLine();
    }

    Console.ReadKey();
}

private static void RunTest(int runId, Random rnd)
{
    var list = GetData(rnd);

    var startOne = DateTime.Now.TimeOfDay;

    var one = list.Where(l => l.IsActive)
        .GroupBy(l => l.Category)
        .Select(g => g.Count() > 300)
        .Any();

    var endOne = DateTime.Now.TimeOfDay;

    var startTwo = DateTime.Now.TimeOfDay;

    var two = list.Where(l => l.IsActive)
        .GroupBy(l => l.Category)
        .Select(g => g.Count())
        .Any(c => c > 300);

    var endTwo = DateTime.Now.TimeOfDay;

    var resultOne = (endOne - startOne).Milliseconds;
    var resultTwo = (endTwo - startTwo).Milliseconds;

    Console.WriteLine($"Results for test run #{  runId}");
    Console.WriteLine();

    Console.WriteLine($"Category 1 total: {list.Where(l => l.Category == 1 amp;amp; l.IsActive).Count()}");
    Console.WriteLine($"Category 2 total: {list.Where(l => l.Category == 2 amp;amp; l.IsActive).Count()}");
    Console.WriteLine($"Category 3 total: {list.Where(l => l.Category == 3 amp;amp; l.IsActive).Count()}");
    Console.WriteLine();

    Console.WriteLine($"First option runs in: {resultOne} ");
    Console.WriteLine();
    Console.WriteLine($"Second option runs in: {resultTwo} ");
}

    private static List<MyObject> GetData(Random rnd)
    {
        var result = new List<MyObject>();

        for (var i = 0; i < 1000; i  )
        {                
            result.Add(new MyObject { Category = rnd.Next(1, 4), IsActive = rnd.Next(0, 2) != 0 });
        }

        return resu<
    }
}

    public class MyObject
    {
        public bool IsActive { get; set; }
        public int Category { get; set; }
    }
  

Комментарии:

1. Ваши два запроса не проверяют одно и то же. В первом запросе говорится, что подсчитайте каждую группу, преобразуйте в список логических значений, которые являются истинными, когда количество превышает 300, затем посмотрите, существуют ли какие-либо группы, что аналогично myList.Where(l => l.IsActive).Any() с большим количеством дополнительной работы. Ваш второй запрос запрашивает, существует ли группа с числом более 300.

2. Какой тип list возвращается GetData ? Возможно, вы выполняете что-то при первой list обработке, а не во второй раз.

Ответ №1:

Да, вы можете точно сравнить производительность ваших двух вариантов, используя BenchmarkDotNet. Это становится простым тестовым скриптом для настройки.

 void Main()
{
    var summary = BenchmarkRunner.Run<CollectionBenchmark>();
}

[MemoryDiagnoser]
public class CollectionBenchmark
{
    private static Random random = new Random();
    private List<MyObject> _list = new List<MyObject>();

    [GlobalSetup]
    public void GlobalSetup()
    {
        var rnd = new Random();

        for (var i = 0; i < 1000; i  )
        {
            _list.Add(new MyObject { Category = rnd.Next(1, 4), IsActive = rnd.Next(0, 2) != 0 });
        }
    }

    [Benchmark]
    public void OptionOne()
    {
        var one = _list.Where(l => l.IsActive)
            .GroupBy(l => l.Category)
            .Select(g => g.Count() > 300)
            .Any();
    }

    [Benchmark]
    public void OptionTwo()
    {
        var two = _list.Where(l => l.IsActive)
            .GroupBy(l => l.Category)
            .Select(g => g.Count())
            .Any(c => c > 300);
    }
}

public class MyObject
{
    public bool IsActive { get; set; }
    public int Category { get; set; }
}
  

Это дало следующие результаты на моей машине:

 BenchmarkDotNet=v0.10.14, OS=Windows 10.0.17134
Intel Core i5-6300U CPU 2.40GHz (Skylake), 1 CPU, 4 logical and 2 physical cores
Frequency=2437498 Hz, Resolution=410.2567 ns, Timer=TSC
  [Host]     : .NET Framework 4.6.2 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.3324.0
  DefaultJob : .NET Framework 4.6.2 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.3324.0


|    Method |     Mean |     Error |    StdDev |  Gen 0 | Allocated |
|---------- |---------:|----------:|----------:|-------:|----------:|
| OptionOne | 36.73 us | 0.7491 us | 1.9202 us | 8.4839 |  13.13 KB |
| OptionTwo | 36.37 us | 0.6993 us | 0.8053 us | 8.4839 |  13.13 KB |
  

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

Ответ №2:

Существует несколько проблем с вашей методологией сравнительного анализа.

Во-первых, когда у вас есть два DateTime значения и вы сравниваете их по их TimeOfDay свойствам…

 var startOne = DateTime.Now.TimeOfDay;
// Do some work
var endOne = DateTime.Now.TimeOfDay;
var resultOne = (endOne - startOne).Milliseconds;
  

…тогда вы рискуете получить отрицательную продолжительность, если тест должен был охватывать дневной переход (полночь). Рассмотрим это…

 DateTime midnight = DateTime.Today;
DateTime fiveSecondsBeforeMidnight = midnight - TimeSpan.FromSeconds(5);
DateTime fiveSecondsAfterMidnight  = midnight   TimeSpan.FromSeconds(5);

Console.WriteLine($"Difference between DateTime  values: {fiveSecondsAfterMidnight - fiveSecondsBeforeMidnight}");
Console.WriteLine($"Difference between TimeOfDay values: {fiveSecondsAfterMidnight.TimeOfDay - fiveSecondsBeforeMidnight.TimeOfDay}");
  

…который печатает…

 Difference between DateTime  values: 00:00:10
Difference between TimeOfDay values: -23:59:50
  

Вместо этого вы можете исправить эту ошибку и упростить свой код, напрямую сравнивая DateTime значения…

 var startOne = DateTime.Now;
// Do some work
var endOne = DateTime.Now;
var resultOne = (endOne - startOne).Milliseconds;
  

Однако это может быть дополнительно улучшено с помощью Stopwatch класса, который является более точным, чем сравнение DateTime значений, и специально разработан для этой цели…

 Stopwatch stopwatch = Stopwatch.StartNew();
// Do some work
TimeSpan resultOne = stopwatch.Elapsed;

stopwatch.Restart();
// Do some work
TimeSpan resultTwo = stopwatch.Elapsed;
  

Во-вторых, TimeSpan.Milliseconds свойство возвращает только составляющую TimeSpan миллисекунд значения. Чтобы получить TimeSpan значение в миллисекундах, вам нужно TotalMilliseconds свойство. Рассмотрим разницу здесь…

 TimeSpan value1 = TimeSpan.FromSeconds(1)   TimeSpan.FromMilliseconds(500);
TimeSpan value2 = TimeSpan.FromMilliseconds(900);

Console.WriteLine($"     value1.Milliseconds: {value1.Milliseconds}");
Console.WriteLine($"value1.TotalMilliseconds: {value1.TotalMilliseconds}");
Console.WriteLine($"     value2.Milliseconds: {value2.Milliseconds}");
Console.WriteLine($"value2.TotalMilliseconds: {value2.TotalMilliseconds}");

Console.WriteLine($"value1 is {(value1.Milliseconds      < value2.Milliseconds      ? "less" : "greater")} than value2 (by Milliseconds)");
Console.WriteLine($"value1 is {(value1.TotalMilliseconds < value2.TotalMilliseconds ? "less" : "greater")} than value2 (by TotalMilliseconds)");
  

…который печатает…

      value1.Milliseconds: 500
value1.TotalMilliseconds: 1500
     value2.Milliseconds: 900
value2.TotalMilliseconds: 900
value1 is less than value2 (by Milliseconds)
value1 is greater than value2 (by TotalMilliseconds)
  

Сравнение Ticks свойства, как вы это сделали, было бы другим способом обойти это, или вы могли бы просто сохранить разницу во времени как TimeSpan , не выбирая одно из его свойств, и позволить строковому форматированию обрабатывать меньшие компоненты…

 TimeSpan resultOne = endOne - startOne;
TimeSpan resultTwo = endTwo - startTwo;

// ...

Console.WriteLine($"First option runs in: {resultOne:s\.ffffff} seconds");
Console.WriteLine();
Console.WriteLine($"Second option runs in: {resultTwo:s\.ffffff} seconds");
  

Наконец, я запустил ваш код и вижу те же результаты, что и вы: первые запуски отличны от нуля, а последующие — равны нулю. Я предполагаю, что первые запуски занимают больше времени, потому что ваш код еще не был оптимизирован для JIT. Даже эти «медленные» первые запуски занимают всего несколько миллисекунд, потому что ваш список состоит всего из тысячи элементов. Такие короткие прогоны бенчмарка не обеспечивают значимых сравнений.

После внесения описанных выше изменений и увеличения размера List<> возвращаемых на GetData() элементов до 10 миллионов каждый запуск занимает несколько секунд, при этом первый вариант выполняется на несколько миллисекунд быстрее при первом запуске и на 25-125 миллисекунд медленнее при последующих запусках.

Вместо того, чтобы запускать собственный тестовый код, вы могли бы рассмотреть возможность использования библиотеки, подобной BenchmarkDotNet. Он обрабатывает такие детали, как определение количества запусков, которые необходимо выполнить, «прогрев» вашего кода, чтобы убедиться, что он уже оптимизирован, и вычисляет статистику для вас.

Комментарии:

1. Увеличение частоты процессора до максимальной turbo — еще один фактор, который может повлиять на первый, но не на второй. Подумайте, имеет ли значение, что в первом тесте произойдет сбой страницы в некоторой памяти кучи, а затем ее возврат в свободный список, поэтому выделение во втором тесте может быть намного быстрее. Вероятно, этого недостаточно, чтобы объяснить целые миллисекунды (миллионы тактов).