Как правильно замерить скорость работы кода в .NET

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

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

7c1b44f7f0a70e19e87a8e273f7f4c2b.png

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

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

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

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

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

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

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

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

3df20b63036046de1a3c9922ee4617a2.png

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

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

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

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

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

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

4bb8e0b486f5341c88e0c47af29bf4b0.png

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

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

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

7c838593c18d0bf4509e9bede99095b2.png

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

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

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

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

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

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

  • Rank — ранкинг;

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

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

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

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

e566d41596c895831700ef51d1b19735.png

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

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

61d538cb522e9181e05b43ec0aa0e0ad.png

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

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

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

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

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

9b934f45274c4021df70e5a7ee206777.png

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

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

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

525f9fdb6b24e0ff4cce99eb63da85fb.png

Графики

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

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

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

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

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

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

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

a7d3d3a8dda8c3234a18f1ef4d7147dd.png

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

7f5c2fac2ca9a9f8342b8ed4b9e02001.png

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

7a074c75d900c21e6c6a96a9bf9f32a2.png

Заключение

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

© Habrahabr.ru