imageДобрый день. Неделю назад я в третий раз применил библиотеку для создания\запуска .NET бенчмарков BenchmarkDotNet. Библиотека оказалась достаточно удобной, но практически не освещенной на хабре, что я сейчас и исправлю.

Под бенчмарком я подразумеваю измерение времени выполнения метода(ов). Для начала представим процесс написания бенчмарка руками. Создаем тестируемый метод, выбираем Release билд, создаем «замеряющий» метод, в нем собираем мусор, ставим StopWatch в начале и в конце, запускаем прогрев, запускаем тестируемый метод. Если тестируемый метод выполняется быстрее одного «тика» StopWatch, запускаем тестируемый метод много раз (пусть будет миллион), делим суммарное время на миллион, получаем результат (при этом нужно не забыть вычесть из суммарного времени время «холостого» прогона цикла на миллион операций).



Как видно, деталей уже много, и если с ними еще можно жить, то с замерами производительность для разной архитектуры (x86\x64) и разных компиляторов все становится совсем плохо (про создание бенчмарков и детали микрооптимизации подробно рассказывает один из авторов библиотеки — Андрей DreamWalker Акиньшин). Как можно догадаться, BenchmarkDotNet берет заботу об этих деталях на себя.

Установка


Nuget пакет, никаких зависимостей; на момент публикации статьи версия v0.9.1.

Простейший пример


Первым делом я проверил библиотеку «на вшивость».
    public class TheEasiestBenchmark
    {
        [Benchmark(Description = "Summ100")]
        public int Test100()
        {
            return Enumerable.Range(1, 100).Sum();
        }

        [Benchmark(Description = "Summ200")]
        public int Test200()
        {
            return Enumerable.Range(1, 200).Sum();
        }
    }

    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void TestMethod1()
        {            
            BenchmarkRunner.Run<TheEasiestBenchmark>();
        }
    }


Как видим, для простого запуска достаточно навесить на тестируемые методы атрибут [Benchmark(Description= «TestName»)], и запустить код в консоли или в модульном тесте. Требования к методу невелики: он должен быть публичным (иначе замеров не будет) и не принимать аргументов (иначе получим исключение). После завершения бенчмарка в консоли появится подробный отчет о тестах, с обобщающей таблицей в конце.

Method Median StdDev
Summ100 1.0282 us 0.1071 us
Summ200 1.9573 us 0.0648 us


По умолчанию в ней указываются имя метода, медиана, стандартное отклонение. Если не устанавливать свойство «Description» в атрибуте [Benchmark] в столбце Method высветится имя метода. Кстати, строки таблицы сортируются согласно значениям свойства Description (именам методов). Также стоит заметить, что неперехваченное в методе исключение останавливает замер (конкретно этого метода).

Для замера быстродействия методов с аргументами можно создать дополнительный «замеряющий» метод:
private double SomeBusinessLogic(int arg){ ... }

[Benchmark(Description = "Summ100")]
public void MeasurmentMethod()
{
    SomeBusinessLogic(42);    
}


Настройки бенчмарков


Конфигурирование бенчмарков осуществляется с помощью атрибута Config. Возможности немалые: настройки окружения\платформы\джиттера, количество запусков, настройки вывода, логгеров, анализаторы… Примеры настройки можно найти на страничке библиотеки на github.

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

[Config("columns=Max")]
public class TheEasiestBenchmark
{
    [Benchmark(Description = "Summ100")]
    public int Test100()
    {
         return Enumerable.Range(1, 100).Sum();
    }
} 


Method Median StdDev Max
Summ100 1.0069 us 0.0124 us 1.0441 us


Другой вариант — создать класс-наследник от ManualConfig, и передать его тип в конструктор атрибута Config.

[Config(typeof(HabrExampleConfig))]
public class TheEasiestBenchmark
{
    private class HabrExampleConfig : ManualConfig
    {
        public HabrExampleConfig()
        {                
            Add(StatisticColumn.Max); // Добавляем необходимую колонку                
        }
    }

    [Benchmark(Description = "Summ100")]
    public int Test100()
    {
        return Enumerable.Range(1, 100).Sum();
    }
}


Method Median StdDev Max
Summ100 1.0114 us 0.0041 us 1.0201 us


С одной стороны, больше кода, с другой же, при создании класса работает автодополнение: проще настраивать, сложнее ошибиться.

Немного о настройках

Настроек немало, и они разделены по типам.

Первый тип настроек — Job. Как следует из документации, нужен для настройки окружения: ожидаемая платформа (x64\x86), джиттер, рантайм. Кроме того, если вас не устраивает время прогона тестов (библиотека пытается подобрать оптимальное по критериям точность\время запуска), можно настроить количество прогревочных и целевых запусков, или просто указать желаемое время прогона. Кроме того, нужно быть аккуратным с настройками окружения: если класс лежит в проекте ориентированном на .NET 4.6, а конфиг настроен на .NET 4.5, в процессе запуска получим ошибку (что, в общем, логично).

Следующий тип настроек: уже знакомый нам Columns. Позволяет конфигурировать выводимую информацию. Полный список доступных колонок досутпен в разделе Columns -> default документации. В основном используются колонки вроде PropertyColumn.* (например, PropertyColumn.Runtime), StatisticColumn.* (например, StatisticColumn.Median).

Очередной пункт настроек: Exporters. Указывает какие дополнительные файлы с результатами генерировать. Возможные файлы: html, txt, csv, R plots, разметка markdown для SO, github. Так, для создания R графиков и csv документа в конструктор MyConfig добавляем Add(RPlotExporter.Default, CsvExporter.Default);

Класс со всеми этими настройками может выглядеть вот так:
internal class HabrExampleConfig : ManualConfig
{
	public HabrExampleConfig ()
	{
		Add(new Job {IterationTime = 1,WarmupCount = 1,TargetCount = 1});
		Add(StatisticColumn.Max);
		Add(RPlotExporter.Default, CsvExporter.Default);
	}
}

[Config(typeof(HabrExampleConfig ))]
public class TheEasiestBenchmark{...}


Почти также выглядит результат еще одного способа конфигурирования — создания собственного атрибута конфигурации.
    [MyConfigSource]
    public class TheEasiestBenchmark
    {
        private class MyConfigSourceAttribute : Attribute, IConfigSource
        {
            public IConfig Config { get; private set; }

            public MyConfigSourceAttribute()
            {
                Config = ManualConfig.CreateEmpty()
                    .With(StatisticColumn.Max)
                    .With(new Job {Platform = Platform.X64})
                    .With(RPlotExporter.Default);
            }
        }

        [Benchmark(Description = "Summ100")]
        public int Test100()
        {
            return Enumerable.Range(1, 100).Sum();
        }
    }


Надо заметить, что все три способа конфигурирования лишь добавляют что-то к базовой конфигурации. Так, три базовые колонки Method\Median\StdDev будут выводиться на консоль всегда.

Если есть желание ограничить вывод (и генерацию результирующих файлов), можно воспользоваться свойством UnionRule.
    [Config(typeof(HabrExampleConfig))]
    public class TheEasiestBenchmark
    {
        private class HabrExampleConfig : ManualConfig
        {
            public HabrExampleConfig()
            {
    	        Add(PropertyColumn.Method, StatisticColumn.Max); // Выводим лишь имя и максимальное время
                Add(ConsoleLogger.Default); // Добавляем вывод на консоль
                UnionRule = ConfigUnionRule.AlwaysUseLocal; // Отказываемся от стандартного конфига
            }
        }
        
        [Benchmark(Description = "Summ100")]
        public int Test100()
        {
            return Enumerable.Range(1, 100).Sum();
        }
    }


Method Max
Summ100 1.0308 us


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

Дополнительные фичи


Параметризованные тесты

Если хочется экспериментально проверить сложность алгоритма, или просто иметь представление о быстродействии метода при различных аргументах, можно использовать атрибут Params.
Так, мы можем замерить скорость подсчета включений символа ‘a’ в различные строки:

        [Params("habrahabr", "geektimes", "toster", "megamozg")]
        public string arg;

        [Benchmark(Description = "Test")]
        public int CountLetterAIncludings()
        {
            int res = 0;
            for (int i = 0; i < arg.Length; i++)
            {
                if (arg[i] == 'a'){res++;}
            }
            return res;
        }


Method Median StdDev arg
Test 112.4087 ns 1.1556 ns geektimes
Test 113.0916 ns 1.4137 ns habrahabr
Test 104.3207 ns 4.2854 ns megamozg
Test 80.3665 ns 0.4564 ns toster


Относительное время запуска

Предположим, мы желаем узнать не только абсолютные времена тестовых методов, но и относительные. Для этого выберем метод, время которого считаем “нормой”, и изменяем его Benchmark атрибут, установив BaseLine в true.

        [Benchmark(Description = "Summ100")]
        public int Test100()
        {
            return Enumerable.Range(1, 100).Sum();
        }

        [Benchmark(Description = "Summ200", Baseline = true)]
        public int Test200()
        {
            return Enumerable.Range(1, 200).Sum();
        }


Method Median StdDev Scaled
Summ100 1.0113 us 0.0055 us 0.52
Summ200 1.9516 us 0.0120 us 1.00


Обработка результатов


Если есть желание\потребность каким-либо образом поизвращаться со статистикой, или хочется написать свой Exporter, к Вашим услугам класс Summary. Запустите тест в модульном тесте
Summary result = BenchmarkRunner.Run<TheEasiestBenchmark>();

и пользуйтесь всей информацией о каждом бенчмарке совершенно бесплатно и без СМС.
result.Benchmarks[index] содержит информацию о Job'e и параметрах, result.Reports[index] хранит данные о времени тестового прогона и его типе (прогревочный\боевой).

Кроме того, как я уже писал выше, библиотека позволяет сохранять результаты тестов в html, csv, txt форматах, а также поддерживает сохранение в markdown разметке и сформированных в R png-рисунках. Так, все результаты тестов в этой статье скопированы из сгенерированных html файлов.

Примеры рисунков
image
image


Подытоживая вышесказанное, BenchmarkDotNet берет на себя рутинные действия при составлении бенчмарков и обеспечивает приличные возможности форматирования результатов ценой минимальных усилий. Так что, если хотите быстро замерить быстродействие метода, получить точные результаты для методов с малым временем исполнения, или получить красивый график для менеджмента — вы уже знаете что делать. :)

Комментарии (15)


  1. PsyHaSTe
    24.02.2016 17:43
    +1

    Я правильно понимаю, что настроить экспорт в CSV/этц можно только создавая ManualConfig, через атрибуты этого никак не добиться? И второй вопрос, можно ли группировать методы по BaseLine? Например я тестирую методы A1 A2 и B1 B2 в одном прогоне. Я могу сравнить A1 с A2, а B1 с B2, попарно? Или Baseline выбирается один на весь прогон?


    1. DreamWalker
      24.02.2016 18:08
      +1

      1. На текущий момент экспорт в csv делается всегда, а через ManualConfig можно его выключить.
      2. Сейчас Baseline выбирается на группу параметров. Т.е. если у меня есть [Params(1,2,3)] int X, то на каждое значение X будет собственный Baseline. Если вы хотите сравнивать A1 с A2, а B1 с B2 попарно, то нужно делать два отдельных класса. Группировку методов сделать не так сложно, но сложно сделать так, чтобы для общего случая результаты отображались бы так, чтобы всем было понятно, к чему какой Baseline-относится (если у вас есть хорошие идеи, то я их с радостью послушаю).
        Но если очень хочется, то всегда можно прописать собственный IColumn по аналогии с BaselineDiffColumn и добавить в конфиг (кода нужно совсем немного, зато вы сможете заточить колонку под собственные нужды).


      1. PsyHaSTe
        24.02.2016 20:25

        Ну мой юзкейс на данный момент такой: у меня есть мой класс, который повторяет функционал другого, АПИ одинаковый. Я хочу сравнить их с точки зрения производительности, допустим 10 моих методов и 10 методов этого класса. Соответственно каждый сравнить со своим аналогом и сделать какие-то выводы. На данный момент формируется все в одну стенку, и без baseline немного неудобно.

        На данный момент я еще думаю, есть ли смысл делать делегат возможным вариантом Params'ов? Ну например, анализируем метод Max, Соответственно пишем

        [Params(Enumerable.Max, CustomClass.Max)]
        public Func<int[], int> Max {get;set;}

        Либо это блажь и через методы все прекрасно делается?.. Честно, сейчас не могу привести конкретного примера, на котором именно атрибуты лучше, но читаемость на мой взгляд не страдает. А, вспомнил. Очень удобно, когда функция показана в отдельном стобце, а не кодируется в названии метода LinqMax, CustomMax и т.п.


        1. DreamWalker
          24.02.2016 20:49
          +1

          А есть возможность сделать так, чтобы все методы были в интерфейсе, который реализуется в каждом из классов? Если да, то проще всего было бы завести параметр, который бы определял имплементацию. Ну, и в каждом бенчмарк-методе использовать этот параметр, чтобы использовался бы инстанс нужного класса. И можно было бы легко написать свой BaselineColumn, который сравнивает первую имплементацию со второй. Если это не нанобенчмарки (на метод уходит хотя бы 50ns), то накладными расходами на выбор имплементации вполне можно пренебречь.

          Очень удобно, когда функция показана в отдельном стобце, а не кодируется в названии метода LinqMax, CustomMax и т.п.

          А можете привести пример того, как вы это себе представляете?


          1. PsyHaSTe
            24.02.2016 20:57

            Ну например так:

            Max     | Linq   | 0.456347
            Max     | Custom | 1
            Average | Linq   | 0.784584
            Average | Custom | 1

            А есть возможность сделать так, чтобы все методы были в интерфейсе, который реализуется в каждом из классов?

            Ну в моем случае нет, т.к. тестируются статические extension методы. Нет, не нанобенчмарки, но ООП тут использовать, увы, нельзя.

            Я планирую написать статью на эту тему (со скриншотами вашей классной библиотечки :) ), там подробнее обо всем расскажу. Но в двух словах вот так.


            1. DreamWalker
              24.02.2016 21:10
              +1

              Ну, выдирать имя функции из тела — это достаточно частная задача, не думаю, что имеет смысл включать её в ядро библиотеки. В вашем случае я бы предложил использовать TagColumn для формирования дополнительных колонок из названий методов, см. IntroTags. Свой Baseline будет писаться очень просто: если нам дали Custom-класс, то возвращаем в новой чудо-колонке 1, а если Linq, то находим бенчмарк с такой же функцией в Custom-случае и считаем отношение.

              Если по мере подготовки статьи будут возникать какие-то дополнительные вопросы/замечания/предложения, то не стесняйтесь обращаться. В BenchmarkDotNet мне хочется сделать такой API, который можно действительно легко адаптировать под любые кастомные задачи.


              1. PsyHaSTe
                24.02.2016 21:18

                С удовольствием оставлю фидбек, 2 issue уже завел на гитхабе :) По мере возникновения вопросов конечно можно вырабатывать какую-то общую точку зрения. Статья пока в самом зачаточном состоянии, я только-только закончил писать тесты и определил некоторые необходимые константы, собственно с помощью бенчмарков я и пытаюсь это определить, например, начиная с какого размера коллекции имеет смысл распараллеливать алгоритм. Т.к. задача зависит от нескольких факторов, например от сложности делегата, то для определения оптимальной константы требуется работа с делегатами. В связи с чем на данный момент используется подход, что есть просто строковое представление функций и словарь, который в нужный момент времени подставляет нужную функцию. Но если вы считаете, что это достаточно частная задача, что ж, не буду спорить, не я разработчик :) Я не знаю всех нюансов.


                1. DreamWalker
                  24.02.2016 21:36

                  По поводу Issues. #97 уже сделан в develop ветке, #96 скоро сделаю. Постараюсь выложить новую версию в NuGet на этих выходных. Помимо прочего, у нас там идёт работа над поддержкой DNX и CoreCLR, что занимает много времени.
                  По поводу вашего случая: если у вас получится придумать удобный API который бы покрывал ваш случай и не мешал бы работать обычным бенчмарком, то я с удовольствием включу его в библиотеку. Но лично я пока плохо представляю, как такой функционал следовало бы реализовать. Было бы чудно, если бы вы накидали в виде кода то, как вы всё это видите (+ желаемый вид summary-таблички).


                  1. PsyHaSTe
                    25.02.2016 10:08

                    Ну я себе это так представляю:

                    var summary = BenchmarkRunner.Run<BaseLineClass, Comp1Class, Comp2Class>();

                    В результате ищутся все методы помеченные как [Benchmark], в остальных классах ищутся соответствующие им (по названию, например) методы, после чего замеряются, причем вызов первого класса является эталонным, а остальные замеряются в пропорции к нему, как у вас уже сделано. Пример вывода выше: первичны функции, вторичны классы. То есть нужно наглядно сравнить, что вот ага, этот метод быстрее чем аналог в 10 раз, а вот этот медленнее в полтора раза, его надо пофиксить.

                    Еще есть мысль сортировать по выигрышу. То есть например в самом верху метод, который в 100 раз выиграл у дефолтной реализации. А в самом низу метод, который в 10000 раз проиграл. Ну и дальше тиражировать с помощью Params всё вот это дело, как сейчас собственно и работает.

                    Самый простой вариант: сравнивается 2 класса. Менее типичный, но все же реальный: сравниваются 3-4 класса и выбирается наилучший. Ну и для извращенцев можно сделать params Type[], чтобы аналогично сколько угодно классов можно было сравнивать. В результате первые 3 это просто удобная обертка для последнего метода, который должен учесть, что есть N реализаций какого-то метода, причем не обязательно с одинаковой сигнатурой: например я сравниваю стандартный метод, который принимает Func<T, TResult>, а в моей реализации используется Expression<Func<T, TResult>>, соответственно нужно ориентироваться только на имя. Ну или можно еще атрибут навесить, с названием метода, которое должно быть уникальным в пределах класса и соответствовать именам тестируемых функций в соревнующихся классах.


                    1. DreamWalker
                      25.02.2016 15:04

                      Ок, я подумаю, что с этим можно сделать.


        1. Oxoron
          24.02.2016 20:59

          В комментарии выше дан разумный совет.
          У вас есть Method1, Method2… Method10 методы, которые реализуются классами OldClass, NewClass. Нужно сравнить OldClass.Method1 с NewClass.Method1, OldClass.Method2 с NewClass.Method2, etc.

          По факту, нужны 10 бенчмарков (по бенчмарку для каждого из методов). Вы же не хотите сравнивать быстродействие OldClass.Method3 с NewClass.Method4, например. Так что вам нужен код вроде

              public class Benchmark_Method1
              {
                  [Benchmark(Description="OldClass_Method1", BaseLine = true)]
                  public void OldClass_Method1()
                  {
                      new OldClass().Method1();
                  }
          
                  [Benchmark(Description="NewClass_Method1")]
                  public void NewClass_Method1()
                  {
                      new NewClass().Method1();
                  }
              }
          
              // Тут еще 9 похожих классов
              [TestClass]
              public class Benchmarks
              {
                  [TestMethod]
                  public void TestMethod1()
                  {            
                      BenchmarkRunner.Run<Benchmark_Method1>();
                  }
          
                  [TestMethod]
                  public void TestMethod2()
                  {            
                      BenchmarkRunner.Run<Benchmark_Method2>();
                  }
          
                  // Тут еще 8 похожих тестов
              }


          1. PsyHaSTe
            24.02.2016 21:21
            +2

            Это копипаста. А любая копипаста намекает на то, что программист что-то делает неправильно. DRY придумали давно. Соответственно проблема может быть только в двух вещах: либо неправильное АПИ у библиотеки, либо криворукий Я не может правильно воспользоваться данным инструментом. Собственное, я бы хотел возможность сравнить два массива методов попарно. Я не думаю, что задача написать более быстрый аналог какого-то класса и обоснование его "лучшести" по результатам бенчмарка такая редкая.


            1. Oxoron
              24.02.2016 23:24
              +1

              Ваши аргументы понятны, но есть пара моментов.

              1. Каждый модульный тест должен тестировать одну проблему (в идеале). В нашем случае — проводить сравнение одной пары методов.
              2. Классы с "замеряющими" методами выглядят схоже, но начинка у них может быть разная. Где-то тестируются методы с аргументами, где-то проводится инициация. В одной из веток комментариев Вы упоминали про желание определить, при каких параметрах распараллеленный метод работает быстрее обычного. То есть, настраивается поле с Params атрибутом. Для других методов этот Params будет лишним, и тогда вынос этих методов в отдельный класс становится необходимостью.

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

                      [TestMethod]
                      public void TestMethod1()
                      {            
                          Summary result1 = BenchmarkRunner.Run<Benchmark_Method1>();
                          Summary result2 = BenchmarkRunner.Run<Benchmark_Method2>();
                          ...
              
                          Console.WriteLine(ExtractInfoFromSummary(result1));
                          Console.WriteLine(ExtractInfoFromSummary(result2));
                          ...            
                      }

              Думаю, Вы сможете отрефакторить пример парочкой foreach, избавившись от копипасты.


  1. Oxoron
    24.02.2016 23:24

    Промахнулся.