Как создать пользовательский хранилище для тестирования производительности в Rust

Everett Pompeii

Everett Pompeii


Что такое бенчмаркинг?

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

Бенчмаркинг в Rust

Три популярных варианта для бенчмаркинга в Rust: libtest bench, Criterion, и Iai.

libtest — это встроенная в Rust система для модульного тестирования и бенчмаркинга. Хотя libtest bench является частью стандартной библиотеки Rust, она все еще считается нестабильной, поэтому доступна только в выпусках компилятора nightly. Чтобы работать с стабильным компилятором Rust, необходимо использовать отдельную систему для бенчмаркинга. Однако ни одна из них не находится в активной разработке.

Самой популярной системой для бенчмаркинга в экосистеме Rust является Criterion. Она работает как на стабильных, так и на nightly выпусках компилятора Rust, и стала фактическим стандартом в сообществе Rust. Criterion также намного более функциональна по сравнению с libtest bench.

Экспериментальной альтернативой Criterion является Iai от того же создателя. Однако она использует подсчет инструкций вместо времени с помощью системных часов: инструкции ЦП, L1-доступы, L2-доступы и доступ к оперативной памяти. Это позволяет проводить одноразовые измерения, так как эти метрики должны оставаться почти идентичными между запусками.

Учитывая все вышесказанное, если вы хотите измерить время выполнения вашего кода по стендовым часам, то вам, вероятно, стоит использовать Criterion. Если вы хотите измерять производительность вашего кода в CI с общими раннерами, то стоит обратить внимание на Iai. Однако учтите, что Iai не обновлялся более 3 лет. Поэтому вы можете рассмотреть возможность использования Iai-Callgrind вместо этого.

Но что, если вы не хотите измерять время выполнения или количество инструкций? Что если вы хотите отслеживать какой-то совершенно другой показатель производительности‽ К счастью, Rust делает невероятно легким создание пользовательского фреймворка для измерения производительности.

Как работает cargo bench

Прежде чем создать собственный тестовый хост, нам нужно понять, как работают бенчмарки в языке Rust. Для большинства разработчиков на Rust это означает выполнение команды команды cargo bench. Команда cargo bench компилирует и выполняет ваши бенчмарки. По умолчанию, cargo bench попытается использовать встроенный (но нестабильный) хост libtest bench. libtest bench затем пройдет через ваш код и выполнит все функции, помеченные атрибутом #[bench]. Чтобы использовать собственный хост бенчмарков, нам нужно указать cargo bench, чтобы не использовать libtest bench.

Использование пользовательской системы тестирования с cargo bench

Чтобы cargo bench не использовал libtest bench, нам нужно добавить следующее в наш файл Cargo.toml:

Cargo.toml
[[bench]]
harness = false

К сожалению, мы не можем использовать атрибут #[bench] с нашей пользовательской системой тестирования. Возможно, в ближайшем будущем, но не сегодня. Вместо этого нам нужно создать отдельную директорию benches для размещения наших тестов производительности. Директория benches для тестов производительности аналогична тому, чем является директория tests для интеграционных тестов. Каждый файл внутри директории benches рассматривается как отдельный крейт. Поэтому крейт, для которого проводится тестирование производительности, должен быть библиотечным крейтом. То есть он должен иметь файл lib.rs.

Например, если у нас есть основной библиотечный крейт с именем game, то мы можем добавить файл пользовательского теста с именем play_game в директорию benches. Структура нашей директории будет выглядеть так:

game
├── Cargo.lock
├── Cargo.toml
└── benches
└── play_game.rs
└── src
└── lib.rs

Далее, нужно сообщить cargo bench о нашем пользовательском крейте тестирования play_game. Поэтому мы обновляем наш файл Cargo.toml:

Cargo.toml
[[bench]]
name = "play_game"
harness = false

Написание кода для бенчмаркинга

Прежде чем мы сможем написать тест производительности, нам нужно иметь некоторый библиотечный код для бенчмаркинга. В нашем примере мы будем играть в игру FizzBuzzFibonacci.

Правила игры FizzBuzzFibonacci следующие:

Напишите программу, которая выводит целые числа от 1 до 100 (включительно):

  • Для кратных трем выводите Fizz
  • Для кратных пяти выводите Buzz
  • Для кратных одновременно трем и пяти, выводите FizzBuzz
  • Для чисел, которые являются частью последовательности Фибоначчи, выводите только Fibonacci
  • Для всех остальных чисел, выводите число

Вот как выглядит наша реализация в src/lib.rs:

src/lib.rs
pub fn play_game(n: u32, print: bool) {
let result = fizz_buzz_fibonacci(n);
if print {
println!("{result}");
}
}
fn fizz_buzz_fibonacci(n: u32) -> String {
if is_fibonacci_number(n) {
"Fibonacci".to_string()
} else {
match (n % 3, n % 5) {
(0, 0) => "FizzBuzz".to_string(),
(0, _) => "Fizz".to_string(),
(_, 0) => "Buzz".to_string(),
(_, _) => n.to_string(),
}
}
}
fn is_fibonacci_number(n: u32) -> bool {
let (mut previous, mut current) = (0, 1);
while current < n {
let next = previous + current;
previous = current;
current = next;
}
current == n
}

Создание пользовательской системы бенчмаркинга

Мы собираемся создать пользовательскую систему бенчмаркинга внутри benches/play_game.rs. Эта пользовательская система бенчмаркинга будет измерять выделения кучи с использованием библиотеки dhat-rs. dhat-rs — это фантастический инструмент для отслеживания выделений кучи в программах на Rust, созданный экспертом по производительности на Rust Николасом Нетеркотом. Чтобы помочь нам управлять нашими функциями бенчмаркинга, мы будем использовать библиотеку inventory, созданную невероятно продуктивным Дэвидом Толнэем.

Давайте добавим dhat-rs и inventory в наш файл Cargo.toml как dev-dependencies:

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"

Создание собственного аллокатора

Поскольку наш настраиваемый бенчмаркинговый фреймворк будет измерять выделения в куче, нам потребуется использовать собственный аллокатор кучи. Rust позволяет настраивать собственный глобальный аллокатор кучи с помощью атрибута #[global_allocator]. Добавьте следующее в начало файла benches/play_game.rs:

benches/play_game.rs
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;

Это говорит Rust использовать dhat::Alloc в качестве нашего глобального аллокатора кучи.

🐰 Вы можете установить только один глобальный аллокатор кучи одновременно. Если вы хотите переключаться между несколькими глобальными аллокаторами, их нужно управлять с помощью условной компиляции с [фичами Rust].

Создание пользовательского сборщика бенчмарков

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

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}

CustomBenchmark имеет имя и функцию бенчмарка, которая возвращает dhat::HeapStats в качестве своего вывода.

Затем мы используем крейт inventory, чтобы создать коллекцию для всех наших CustomBenchmark:

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}
inventory::collect!(CustomBenchmark);

Создание функции бенчмарка

Теперь мы можем создать функцию бенчмарка, которая играет в игру FizzBuzzFibonacci:

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}

Построчный разбор:

  • Создайте функцию бенчмарка, которая соответствует сигнатуре, используемой в CustomBenchmark.
  • Создайте dhat::Profiler в режиме тестирования, чтобы собирать результаты нашего кастомного глобального аллокатора dhat::Alloc.
  • Запустите нашу функцию play_game внутри “черного ящика”, чтобы компилятор не оптимизировал наш код.
  • Итерируйте от 1 до 100 включительно.
  • Для каждого числа вызовите play_game с параметром print, установленным в false.
  • Верните статистику выделения кучи в виде dhat::HeapStats.

🐰 Устанавливаем print в false для функции play_game. Это предотвращает вывод play_game на стандартный вывод. Параметризация функций вашей библиотеки таким образом может сделать их более подходящими для бенчмаркинга. Однако это также означает, что мы можем не тестировать библиотеку точно так же, как она используется в продакшене.

В этом случае мы должны задать себе следующие вопросы:

  1. Важны ли для нас ресурсы, необходимые для вывода на стандартный вывод?
  2. Является ли вывод на стандартный вывод возможным источником шума?

Для нашего примера мы выбрали следующее:

  1. Нет, нас не интересует вывод на стандартный вывод.
  2. Да, это очень вероятный источник шума.

Поэтому мы исключили вывод на стандартный вывод как часть этого бенчмарка. Проведение бенчмаркинга сложно, и часто нет одного правильного ответа на подобные вопросы. Это зависит.

Зарегистрируйте Функцию Тестирования Производительности

Теперь, когда мы написали нашу функцию тестирования производительности, нам нужно создать CustomBenchmark и зарегистрировать ее в нашей коллекции тестов производительности с помощью inventory.

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}
inventory::submit!(CustomBenchmark {
name: "bench_play_game",
benchmark_fn: bench_play_game
});

Если бы у нас было несколько тестов производительности, мы бы повторили этот же процесс:

  1. Создайте функцию тестирования производительности.
  2. Создайте CustomBenchmark для функции тестирования производительности.
  3. Зарегистрируйте CustomBenchmark в коллекции inventory.

Создание настраиваемого запускающего субъекта для тестов производительности

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

Мы хотим, чтобы наши результаты выводились в формате Bencher Metric Format (BMF) JSON. Для этого нам нужно добавить одну последнюю зависимость, пакет serde_json от… вы угадали, Дэвида Толная!

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"
serde_json = "1.0"

Далее мы реализуем метод для CustomBenchmark, чтобы запустить его функцию тестирования и затем вернуть результаты в виде BMF JSON.

benches/play_game.rs
impl CustomBenchmark {
fn run(&self) -> serde_json::Value {
let heap_stats = (self.benchmark_fn)();
let measures = serde_json::json!({
"Final Blocks": {
"value": heap_stats.curr_blocks,
},
"Final Bytes": {
"value": heap_stats.curr_bytes,
},
"Max Blocks": {
"value": heap_stats.max_blocks,
},
"Max Bytes": {
"value": heap_stats.max_bytes,
},
"Total Blocks": {
"value": heap_stats.total_blocks,
},
"Total Bytes": {
"value": heap_stats.total_bytes,
},
});
let mut benchmark_map = serde_json::Map::new();
benchmark_map.insert(self.name.to_string(), measures);
benchmark_map.into()
}
}

Результаты BMF JSON содержат шесть измерений для каждого теста производительности:

  • Итоговые блоки: итоговое количество выделенных блоков при завершении теста.
  • Итоговые байты: итоговое количество выделенных байтов при завершении теста.
  • Максимальные блоки: максимальное количество блоков, выделенных в любое время во время теста.
  • Максимальные байты: максимальное количество байтов, выделенных в любое время во время теста.
  • Всего блоков: общее количество блоков, выделенных во время теста.
  • Всего байтов: общее количество байтов, выделенных во время теста.

Наконец, мы можем создать функцию main для выполнения всех тестов в нашей коллекции inventory и вывести результаты в виде BMF JSON.

benches/play_game.rs
fn main() {
let mut bmf = serde_json::Map::new();
for benchmark in inventory::iter::<CustomBenchmark> {
let mut results = benchmark.run();
bmf.append(results.as_object_mut().unwrap());
}
let bmf_str = serde_json::to_string_pretty(&bmf).unwrap();
std::fs::write("results.json", &bmf_str).unwrap();
println!("{bmf_str}");
}

Запуск пользовательского тестового стенда

Теперь все готово. Мы наконец можем запустить наш пользовательский тестовый стенд.

Terminal window
cargo bench

Вывод как в стандартный вывод, так и в файл с названием results.json должен выглядеть следующим образом:

{
"bench_play_game": {
"Current Blocks": {
"value": 0
},
"Current Bytes": {
"value": 0
},
"Max Blocks": {
"value": 1
},
"Max Bytes": {
"value": 9
},
"Total Blocks": {
"value": 100
},
"Total Bytes": {
"value": 662
}
}
}

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

Отслеживание результатов произвольных бенчмарков

Большинство результатов бенчмарков эфемерны. Они исчезают, как только предел прокрутки вашего терминала достигается. Некоторые инструменты для бенчмаркинга позволяют кэшировать результаты, но это требует много усилий для реализации. И даже тогда мы могли бы хранить результаты только локально. К счастью для нас, наш собственный инструмент для бенчмаркинга будет работать с Bencher! Bencher — это набор инструментов для непрерывного бенчмаркинга, который позволяет нам отслеживать результаты наших бенчмарков с течением времени и выявлять регрессии производительности до того, как они попадут в производство.

После того как вы настроились, используя Bencher Cloud или Bencher Self-Hosted, вы можете отслеживать результаты нашего собственно настроенного инструмента для бенчмаркинга, запустив:

Terminal window
bencher run --file results.json "cargo bench"

Вы также можете прочитать более подробную информацию о том, как отслеживать пользовательские бенчмарки с Bencher и об адаптере бенчмарков JSON.

Завершение

Мы начали этот пост с рассмотрения трёх самых популярных инструментов для бенчмаркинга в экосистеме Rust: libtest bench, Criterion, и Iai. Несмотря на то, что они могут покрывать большинство случаев использования, иногда вам может потребоваться измерить что-то другое, кроме времени на часах или количества инструкций. Это привело нас к созданию пользовательского инструмента для бенчмаркинга.

Наш пользовательский инструмент для бенчмаркинга измеряет выделения кучи с использованием dhat-rs. Функции для бенчмаркинга собирались с использованием inventory. При запуске наши бенчмарки выводят результаты в формате Bencher Metric Format (BMF) JSON. Мы могли затем использовать Bencher для отслеживания наших пользовательских результатов бенчмаркинга с течением времени и выявлять регрессии производительности в CI.

Весь исходный код для этого руководства доступен на GitHub.

Bencher: Непрерывное тестирование производительности

🐰 Bencher

Bencher - это набор инструментов для непрерывного тестирования производительности. Когда-нибудь регрессия производительности влияла на ваших пользователей? Bencher мог бы предотвратить это. Bencher позволяет вам обнаруживать и предотвращать регрессии производительности до того, как они попадут в продакшн.

  • Запустить: Запустите свои тесты производительности локально или в CI, используя ваши любимые инструменты для этого. CLI bencher просто оборачивает ваш существующий аппарат тестирования и сохраняет его результаты.
  • Отслеживать: Отслеживайте результаты ваших тестов производительности со временем. Мониторите, запрашивайте и строите графики результатов с помощью веб-консоли Bencher на основе ветки исходного кода, испытательного стенда и меры.
  • Поймать: Отлавливайте регрессии производительности в CI. Bencher использует инструменты аналитики, работающие по последнему слову техники, чтобы обнаружить регрессии производительности, прежде чем они попадут в продакшн.

По тем же причинам, по которым модульные тесты запускаются в CI, чтобы предотвратить регрессии функций, тесты производительности должны быть запущены в CI с Bencher, чтобы предотвратить регрессии производительности. Ошибки производительности – это тоже ошибки!

Начните отлавливать регрессии производительности в CI — попробуйте Bencher Cloud бесплатно.

🤖 Этот документ был автоматически создан OpenAI GPT-4. Оно может быть неточным и содержать ошибки. Если вы обнаружите какие-либо ошибки, откройте проблему на GitHub.