Поговорим о бенчмаркинге кода: о том, что это такое и для чего нужно. А также покажем, как на основе результатов бенчмаркинга оценить производительность кода в проекте, написанном на C#.

Итак, у вас есть решение какой-то задачи. И теперь вам нужно оценить оптимальность этого решения c точки зрения производительности. Самый очевидный вариант — использовать StopWatch вот таким образом:

Но с этим методом есть сразу несколько проблем:

  • Он довольно не точен, поскольку оцениваемый код выполняется только один раз, и на время этого выполнения могут влиять различные сайд-эффекты (жесткий диск, не прогретый кэш и переключение контекста процессора, другие приложения и т.д.).

  • Не заставляет вас проверять приложение в Production-режиме. Во время компиляции значительная часть кода оптимизируется автоматически, без нашего участия, и это может серьезно повлиять на финальный результат. 

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

И какие же у нас есть другие варианты? Как правильно оценить производительность нашего кода? На помощь в этой ситуации нам придет BenchmarkDotNet.

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

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

Вот как будет выглядеть класс для бенчмаркинга в простейшем виде:

Давайте разбираться,  что у нас в этом классе есть. Начнем с атрибутов класса:

MemoryDiagnoser  собирает информацию о работе Garbage Collector-а и выделяемой памяти во время выполнения вашего кода.

Orderer определяет порядок вывода финальных результатов в таблице. В нашем случае стоит FastestToSlowest. Это значит, что  самый быстрый вариант кода будет первым в результатах, а самый медленный — последним

RankColumn добавляет колонку в финальный отчет и нумерует результаты от 1 до Х.

На самом методе у нас добавлен атрибут Benchmark. Он  помечает метод, как один из тестовых кейсов, который нужно проверить. А параметр Baseline=true говорит, что производительность этого метода мы будем считать за 100%.  А затем уже относительно него будем оценивать другие варианты алгоритмов.

Чтобы запустить бенчмарк нам необходима вторая часть пазла — Runner. С ним все просто: идем в наш Program.cs (мы все еще говорим о консольном приложении) и добавляем одну строчку с BenchmarkRunner:

После этого мы можем собрать наше приложение в Production-режиме и запустить код на выполнение.

Анализ результатов

Если выше мы все сделали правильно, то после запуска мы увидим, как BenchmarkRunner выполняет наш код множество раз и в конце выдает вот такой отчет:

Важно: любые аномальные выполнения кода (которые были сильно быстрее или сильно медленнее среднего выполнения) будут отсекаться из финального отчета. Отсеченные аномалии можно будет увидеть ниже под под результирующей таблицей.

В отчете довольно много данных о производительности кода, версии ОС, на которой выполнялся тест, об используемом процессоре и версии .Net. Но основная информация, которая нам интересна, — это последняя табличка.  В ней мы видим:

  • Mean — среднее время, за которое выполняется наш код;

  • Error — ошибку оценки(половина от 99.9 перцентиля);

  • StdDev — стандартное отклонение оценки;

  • Ratio — оценка в процентах улучшения или ухудшения производительности относительно Baseline — базового метода, который мы считаем за точку отсчета (помните Baseline=true выше?);

  • Rank — ранкинг;

  • Allocated — выделенная память во время выполнения нашего метода.

Реальный тест

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

Теперь класс бенчмарка будет выглядеть таким образом:

Наша задача на эту статью — разобраться именно с бенчмаркингом. Сами алгоритмы, которые мы оцениваем, оставим пока в стороне — это тема для следующей статьи в цикле.

И вот результат выполнения такого бенчмаркинга:

Тут мы видим, что GetYearFromDateTime, который мы взяли за точку отсчета, — самый медленный и выполняется порядка 218 наносекунд, в то время, как самый быстрый вариант. GetYearFromSpanWithManualConversion требует всего лишь 6.2 наносекунды — в 35 раз быстрее, чем оригинальный метод.

Также мы можем видеть,  сколько памяти было выделено для двух методов GetYearFromSplit и GetYearFromSubstring, и сколько времени потребовалось Garbage Collector-у на  очистку этой памяти (что тоже снижает общую производительность системы).

Работа с различными входными данными

Напоследок я бы хотел рассказать о том,  как можно оценивать результат работы своего алгоритма на больших и малых наборах данных. Для этого BenchmarkDotNet предлагает нам два атрибута — Params и GlobalSetup

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

В нашем случае поле Size параметризованное и влияет на код, которые выполняется в GlobalSetup

В результате выполнения GlobalSetup у нас генерируется исходный массив из 10, 1000 и 10000 элементов для выполнения всех тестовых сценариев. Как я и говорил в начале статьи, некоторые алгоритмы могут вести себя эффективно только при большом или малом кол-ве элементов.

Попробуем выполнить такой бенчмарк и посмотрим на результаты:

Тут мы уже чётко видим, как каждый метод был выполнен с 10, 1000 и 10000 элементами: метод Span лидирует вне зависимости от размерности входных данных, а метод NewArray работает все хуже и хуже с ростом количества данных.

Графики

Библиотека BenchmarkDotNet позволяет анализировать полученные данные не только в текстовом и табличном виде, но и в графическом - в виде графиков. 

Для демонстрации мы создадим бенчмарк класс для измерения времени работы разных алгоритмов сортировки на платформе .NET8 и настроим его таким образом, чтобы он выполнился 3 раза для разного количества сортируемых элементов: 1000, 5000, 10000. Список используемых алгоритмов сортировки:

  • DefaultSort - алгоритм сортировки, используемый по умолчанию в .NET8

  • InsertionSort - сортировка вставками

  • MergeSort - сортировка слиянием

  • QuickSort - быстрая сортировка

  • SelectSort - сортировка выбором

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

Также BenchmarkDotNet сгенерировал графики по каждому бенчмарку отдельно (в нашем случае - по каждому алгоритму сортировки) в разрезе количества сортируемых элементов:

Заключение

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

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


  1. dabrahabra
    30.03.2024 07:08

    Вы мягкое с теплым сравнили


  1. Malstream
    30.03.2024 07:08

    Он довольно не точен, поскольку оцениваемый код выполняется только один раз, и на время этого выполнения могут влиять различные сайд-эффекты (жесткий диск, не прогретый кэш и переключение контекста процессора, другие приложения и т.д.).

    Это все второстепенно. Первостепенно - JIT-компиляция, которую учитывают нормальные бенчмарк библиотеки и "прогревают" метод.