Замеряем производительность с помощью BenchmarkDotNet

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

Как видим, для простого запуска достаточно навесить на тестируемые методы атрибут [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();


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

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

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

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

© Habrahabr.ru