Добрый день. Неделю назад я в третий раз применил библиотеку для создания\запуска .NET бенчмарков BenchmarkDotNet. Библиотека оказалась достаточно удобной, но практически не освещенной на хабре, что я сейчас и исправлю.
Под бенчмарком я подразумеваю измерение времени выполнения метода(ов). Для начала представим процесс написания бенчмарка руками. Создаем тестируемый метод, выбираем Release билд, создаем «замеряющий» метод, в нем собираем мусор, ставим StopWatch в начале и в конце, запускаем прогрев, запускаем тестируемый метод. Если тестируемый метод выполняется быстрее одного «тика» StopWatch, запускаем тестируемый метод много раз (пусть будет миллион), делим суммарное время на миллион, получаем результат (при этом нужно не забыть вычесть из суммарного времени время «холостого» прогона цикла на миллион операций).
Как видно, деталей уже много, и если с ними еще можно жить, то с замерами производительность для разной архитектуры (x86\x64) и разных компиляторов все становится совсем плохо (про создание бенчмарков и детали микрооптимизации подробно рассказывает один из авторов библиотеки — Андрей DreamWalker Акиньшин). Как можно догадаться, BenchmarkDotNet берет заботу об этих деталях на себя.
Nuget пакет, никаких зависимостей; на момент публикации статьи версия v0.9.1.
Первым делом я проверил библиотеку «на вшивость».
Как видим, для простого запуска достаточно навесить на тестируемые методы атрибут [Benchmark(Description= «TestName»)], и запустить код в консоли или в модульном тесте. Требования к методу невелики: он должен быть публичным (иначе замеров не будет) и не принимать аргументов (иначе получим исключение). После завершения бенчмарка в консоли появится подробный отчет о тестах, с обобщающей таблицей в конце.
По умолчанию в ней указываются имя метода, медиана, стандартное отклонение. Если не устанавливать свойство «Description» в атрибуте [Benchmark] в столбце Method высветится имя метода. Кстати, строки таблицы сортируются согласно значениям свойства Description (именам методов). Также стоит заметить, что неперехваченное в методе исключение останавливает замер (конкретно этого метода).
Для замера быстродействия методов с аргументами можно создать дополнительный «замеряющий» метод:
Конфигурирование бенчмарков осуществляется с помощью атрибута Config. Возможности немалые: настройки окружения\платформы\джиттера, количество запусков, настройки вывода, логгеров, анализаторы… Примеры настройки можно найти на страничке библиотеки на github.
Самый простой вариант настройки: вешаем атрибут Config на класс, содержащий Benchmark-методы, и в конструкторе передаем строку с настройками. Так, если хочется увидеть максимальное время запуска в итоговой таблице, используем следующий код:
Другой вариант — создать класс-наследник от ManualConfig, и передать его тип в конструктор атрибута Config.
С одной стороны, больше кода, с другой же, при создании класса работает автодополнение: проще настраивать, сложнее ошибиться.
Настроек немало, и они разделены по типам.
Первый тип настроек — 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);
Класс со всеми этими настройками может выглядеть вот так:
Почти также выглядит результат еще одного способа конфигурирования — создания собственного атрибута конфигурации.
Надо заметить, что все три способа конфигурирования лишь добавляют что-то к базовой конфигурации. Так, три базовые колонки Method\Median\StdDev будут выводиться на консоль всегда.
Если есть желание ограничить вывод (и генерацию результирующих файлов), можно воспользоваться свойством UnionRule.
Такой подход пригодится тем, кто желает настроить запуск бенчмарков в CI процессе, ибо дополнительные генерируемые файлы с результатами скорее всего будут лишними.
Если хочется экспериментально проверить сложность алгоритма, или просто иметь представление о быстродействии метода при различных аргументах, можно использовать атрибут Params.
Так, мы можем замерить скорость подсчета включений символа ‘a’ в различные строки:
Предположим, мы желаем узнать не только абсолютные времена тестовых методов, но и относительные. Для этого выберем метод, время которого считаем “нормой”, и изменяем его Benchmark атрибут, установив BaseLine в true.
Если есть желание\потребность каким-либо образом поизвращаться со статистикой, или хочется написать свой Exporter, к Вашим услугам класс Summary. Запустите тест в модульном тесте
и пользуйтесь всей информацией о каждом бенчмаркесовершенно бесплатно и без СМС.
result.Benchmarks[index] содержит информацию о Job'e и параметрах, result.Reports[index] хранит данные о времени тестового прогона и его типе (прогревочный\боевой).
Кроме того, как я уже писал выше, библиотека позволяет сохранять результаты тестов в html, csv, txt форматах, а также поддерживает сохранение в markdown разметке и сформированных в R png-рисунках. Так, все результаты тестов в этой статье скопированы из сгенерированных html файлов.
Подытоживая вышесказанное, 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 файлов.
Примеры рисунков
Подытоживая вышесказанное, BenchmarkDotNet берет на себя рутинные действия при составлении бенчмарков и обеспечивает приличные возможности форматирования результатов ценой минимальных усилий. Так что, если хотите быстро замерить быстродействие метода, получить точные результаты для методов с малым временем исполнения, или получить красивый график для менеджмента — вы уже знаете что делать. :)
PsyHaSTe
Я правильно понимаю, что настроить экспорт в CSV/этц можно только создавая ManualConfig, через атрибуты этого никак не добиться? И второй вопрос, можно ли группировать методы по BaseLine? Например я тестирую методы A1 A2 и B1 B2 в одном прогоне. Я могу сравнить A1 с A2, а B1 с B2, попарно? Или Baseline выбирается один на весь прогон?
DreamWalker
ManualConfig
можно его выключить.[Params(1,2,3)] int X
, то на каждое значение X будет собственный Baseline. Если вы хотите сравнивать A1 с A2, а B1 с B2 попарно, то нужно делать два отдельных класса. Группировку методов сделать не так сложно, но сложно сделать так, чтобы для общего случая результаты отображались бы так, чтобы всем было понятно, к чему какой Baseline-относится (если у вас есть хорошие идеи, то я их с радостью послушаю).Но если очень хочется, то всегда можно прописать собственный
IColumn
по аналогии с BaselineDiffColumn и добавить в конфиг (кода нужно совсем немного, зато вы сможете заточить колонку под собственные нужды).PsyHaSTe
Ну мой юзкейс на данный момент такой: у меня есть мой класс, который повторяет функционал другого, АПИ одинаковый. Я хочу сравнить их с точки зрения производительности, допустим 10 моих методов и 10 методов этого класса. Соответственно каждый сравнить со своим аналогом и сделать какие-то выводы. На данный момент формируется все в одну стенку, и без baseline немного неудобно.
На данный момент я еще думаю, есть ли смысл делать делегат возможным вариантом Params'ов? Ну например, анализируем метод Max, Соответственно пишем
Либо это блажь и через методы все прекрасно делается?.. Честно, сейчас не могу привести конкретного примера, на котором именно атрибуты лучше, но читаемость на мой взгляд не страдает. А, вспомнил. Очень удобно, когда функция показана в отдельном стобце, а не кодируется в названии метода LinqMax, CustomMax и т.п.
DreamWalker
А есть возможность сделать так, чтобы все методы были в интерфейсе, который реализуется в каждом из классов? Если да, то проще всего было бы завести параметр, который бы определял имплементацию. Ну, и в каждом бенчмарк-методе использовать этот параметр, чтобы использовался бы инстанс нужного класса. И можно было бы легко написать свой BaselineColumn, который сравнивает первую имплементацию со второй. Если это не нанобенчмарки (на метод уходит хотя бы 50ns), то накладными расходами на выбор имплементации вполне можно пренебречь.
А можете привести пример того, как вы это себе представляете?
PsyHaSTe
Ну например так:
Ну в моем случае нет, т.к. тестируются статические extension методы. Нет, не нанобенчмарки, но ООП тут использовать, увы, нельзя.
Я планирую написать статью на эту тему (со скриншотами вашей классной библиотечки :) ), там подробнее обо всем расскажу. Но в двух словах вот так.
DreamWalker
Ну, выдирать имя функции из тела — это достаточно частная задача, не думаю, что имеет смысл включать её в ядро библиотеки. В вашем случае я бы предложил использовать TagColumn для формирования дополнительных колонок из названий методов, см. IntroTags. Свой Baseline будет писаться очень просто: если нам дали Custom-класс, то возвращаем в новой чудо-колонке 1, а если Linq, то находим бенчмарк с такой же функцией в Custom-случае и считаем отношение.
Если по мере подготовки статьи будут возникать какие-то дополнительные вопросы/замечания/предложения, то не стесняйтесь обращаться. В BenchmarkDotNet мне хочется сделать такой API, который можно действительно легко адаптировать под любые кастомные задачи.
PsyHaSTe
С удовольствием оставлю фидбек, 2 issue уже завел на гитхабе :) По мере возникновения вопросов конечно можно вырабатывать какую-то общую точку зрения. Статья пока в самом зачаточном состоянии, я только-только закончил писать тесты и определил некоторые необходимые константы, собственно с помощью бенчмарков я и пытаюсь это определить, например, начиная с какого размера коллекции имеет смысл распараллеливать алгоритм. Т.к. задача зависит от нескольких факторов, например от сложности делегата, то для определения оптимальной константы требуется работа с делегатами. В связи с чем на данный момент используется подход, что есть просто строковое представление функций и словарь, который в нужный момент времени подставляет нужную функцию. Но если вы считаете, что это достаточно частная задача, что ж, не буду спорить, не я разработчик :) Я не знаю всех нюансов.
DreamWalker
По поводу Issues. #97 уже сделан в develop ветке, #96 скоро сделаю. Постараюсь выложить новую версию в NuGet на этих выходных. Помимо прочего, у нас там идёт работа над поддержкой DNX и CoreCLR, что занимает много времени.
По поводу вашего случая: если у вас получится придумать удобный API который бы покрывал ваш случай и не мешал бы работать обычным бенчмарком, то я с удовольствием включу его в библиотеку. Но лично я пока плохо представляю, как такой функционал следовало бы реализовать. Было бы чудно, если бы вы накидали в виде кода то, как вы всё это видите (+ желаемый вид summary-таблички).
PsyHaSTe
Ну я себе это так представляю:
В результате ищутся все методы помеченные как
[Benchmark]
, в остальных классах ищутся соответствующие им (по названию, например) методы, после чего замеряются, причем вызов первого класса является эталонным, а остальные замеряются в пропорции к нему, как у вас уже сделано. Пример вывода выше: первичны функции, вторичны классы. То есть нужно наглядно сравнить, что вот ага, этот метод быстрее чем аналог в 10 раз, а вот этот медленнее в полтора раза, его надо пофиксить.Еще есть мысль сортировать по выигрышу. То есть например в самом верху метод, который в 100 раз выиграл у дефолтной реализации. А в самом низу метод, который в 10000 раз проиграл. Ну и дальше тиражировать с помощью Params всё вот это дело, как сейчас собственно и работает.
Самый простой вариант: сравнивается 2 класса. Менее типичный, но все же реальный: сравниваются 3-4 класса и выбирается наилучший. Ну и для извращенцев можно сделать
params Type[]
, чтобы аналогично сколько угодно классов можно было сравнивать. В результате первые 3 это просто удобная обертка для последнего метода, который должен учесть, что есть N реализаций какого-то метода, причем не обязательно с одинаковой сигнатурой: например я сравниваю стандартный метод, который принимаетFunc<T, TResult>
, а в моей реализации используетсяExpression<Func<T, TResult>>
, соответственно нужно ориентироваться только на имя. Ну или можно еще атрибут навесить, с названием метода, которое должно быть уникальным в пределах класса и соответствовать именам тестируемых функций в соревнующихся классах.DreamWalker
Ок, я подумаю, что с этим можно сделать.
Oxoron
В комментарии выше дан разумный совет.
У вас есть Method1, Method2… Method10 методы, которые реализуются классами OldClass, NewClass. Нужно сравнить OldClass.Method1 с NewClass.Method1, OldClass.Method2 с NewClass.Method2, etc.
По факту, нужны 10 бенчмарков (по бенчмарку для каждого из методов). Вы же не хотите сравнивать быстродействие OldClass.Method3 с NewClass.Method4, например. Так что вам нужен код вроде
PsyHaSTe
Это копипаста. А любая копипаста намекает на то, что программист что-то делает неправильно. DRY придумали давно. Соответственно проблема может быть только в двух вещах: либо неправильное АПИ у библиотеки, либо криворукий Я не может правильно воспользоваться данным инструментом. Собственное, я бы хотел возможность сравнить два массива методов попарно. Я не думаю, что задача написать более быстрый аналог какого-то класса и обоснование его "лучшести" по результатам бенчмарка такая редкая.
Oxoron
Ваши аргументы понятны, но есть пара моментов.
Но желание получить сводную таблицу по всем методам понятно. Попробуйте все десять тестов совместить в один, а статистическую информацию выдирать из результатов каждого запуска.
Что-то вроде
Думаю, Вы сможете отрефакторить пример парочкой foreach, избавившись от копипасты.