Библиотека Criterion для Rust: статистический бенчмаркинг
В мире системного программирования производительность — это не просто пожелание, а часто жесткое требование. Rust, будучи языком, который дает разработчику контроль над памятью и временем выполнения, требует инструментов, способных точно измерить этот контроль. Здесь на сцену выходит Criterion — библиотека для статистического бенчмаркинга, которая превращает процесс измерения производительности из грубого "секундомера" в научный эксперимент.
Зачем нужна эта библиотека? Стандартный бенчмаркинг в Rust (через #[bench]) полезен, но он имеет фундаментальный недостаток: он не учитывает статистический разброс. Современные процессоры имеют сложную архитектуру с кэшами, предсказателями переходов и турбо-бустом. Один и тот же код может выполняться за разное время в зависимости от "шума" операционной системы, температуры CPU или случайного состояния кэша. Criterion решает эту проблему, запуская каждый тест множество раз, собирая выборку времени выполнения и применяя к ней статистические методы (среднее, медиана, стандартное отклонение, процентили). Результат — не просто "время в микросекундах", а доверительный интервал, который говорит: "С вероятностью 95% истинное время выполнения лежит в диапазоне от X до Y".
Кроме того, Criterion предоставляет мощные инструменты для регрессионного анализа. Вы можете сравнить производительность двух версий вашего кода (например, до и после оптимизации) и получить статистически значимый ответ на вопрос: "Стала ли новая версия быстрее, или разница в пределах погрешности?". Библиотека генерирует красивые HTML-отчеты с графиками, гистограммами и таблицами, что делает анализ производительности наглядным и убедительным. Это незаменимый инструмент для разработчиков библиотек, игровых движков, баз данных, криптографических алгоритмов и любого другого кода, где каждая наносекунда на счету.
Установка
Установка Criterion в проект Rust выполняется через менеджер пакетов cargo. Вам нужно добавить зависимость в файл Cargo.toml вашего проекта. Рекомендуется добавлять её как dev-зависимость, так как бенчмарки не являются частью конечного приложения.
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "my_benchmark"
harness = false
Пояснение:
version = "0.5"— указывает версию библиотеки. На момент написания статьи актуальна версия 0.5.x.features = ["html_reports"]— включает генерацию HTML-отчетов с графиками. Это опциональная, но очень полезная функция.[[bench]]— секция, которая говорит Cargo, что у нас есть бенчмарк с именемmy_benchmark.harness = false— отключает встроенный бенчмарк-раннер Cargo. Это необходимо, чтобы Criterion мог взять управление на себя.
После этого создайте файл benches/my_benchmark.rs (путь должен соответствовать имени, указанному в [[bench]]).
Быстрый старт: минимальный рабочий пример
Давайте напишем простейший бенчмарк, который измеряет скорость вычисления суммы чисел от 1 до 1000.
// benches/my_benchmark.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn sum_up_to(n: u64) -> u64 {
let mut sum = 0;
for i in 1..=n {
sum += i;
}
sum
}
fn bench_sum(c: &mut Criterion) {
// black_box предотвращает оптимизацию компилятора
c.bench_function("sum 1000", |b| b.iter(|| sum_up_to(black_box(1000))));
}
criterion_group!(benches, bench_sum);
criterion_main!(benches);
Как запустить:
cargo bench
После выполнения в консоли вы увидите что-то вроде:
sum 1000 time: [1.2345 µs 1.2456 µs 1.2567 µs]
Found 1 outliers among 100 measurements (1.00%)
А в директории target/criterion появится HTML-отчет с детальной статистикой и графиками.
Основные методы и функции
Рассмотрим ключевые компоненты библиотеки Criterion. Каждый из них играет важную роль в построении качественных бенчмарков.
1. Criterion::bench_function
Сигнатура: pub fn bench_function<F>(&mut self, name: &str, f: F) where F: FnMut(&mut Bencher)
Что делает: Регистрирует и запускает один бенчмарк с заданным именем. Это основной метод для создания простых тестов производительности.
use criterion::{Criterion, black_box};
fn bench_addition(c: &mut Criterion) {
c.bench_function("add two numbers", |b| {
b.iter(|| black_box(2 + 2))
});
}
2. Bencher::iter
Сигнатура: pub fn iter<R, F>(&mut self, f: F) where F: FnMut() -> R
Что делает: Выполняет замыкание многократно (тысячи или миллионы раз), замеряя общее время. Это "сердце" каждого бенчмарка. Замыкание должно быть идемпотентным (выполняться без побочных эффектов, влияющих на последующие вызовы).
use criterion::{Criterion, Bencher, black_box};
fn bench_vec_push(c: &mut Criterion) {
c.bench_function("vec push", |b: &mut Bencher| {
b.iter(|| {
let mut v = Vec::new();
for i in 0..100 {
v.push(black_box(i));
}
});
});
}
3. black_box
Сигнатура: pub fn black_box<T>(dummy: T) -> T
Что делает: Критически важная функция, которая "обманывает" оптимизатор компилятора. Без неё LLVM может вычислить результат константного выражения на этапе компиляции и заменить вызов функции на готовое число, что сделает бенчмарк бессмысленным. black_box заставляет компилятор считать, что значение используется где-то ещё, предотвращая преждевременную оптимизацию.
use criterion::{black_box, Criterion};
fn bench_with_black_box(c: &mut Criterion) {
let data = vec![1, 2, 3, 4, 5];
c.bench_function("sum with black_box", |b| {
b.iter(|| {
let sum: i32 = black_box(&data).iter().sum();
black_box(sum) // Возвращаем результат через black_box
})
});
}
4. Criterion::bench_with_input
Сигнатура: pub fn bench_with_input<F, I>(&mut self, name: &str, input: &I, f: F) where F: FnMut(&mut Bencher, &I)
Что делает: Позволяет передать в бенчмарк внешние данные (например, строку для парсинга или массив для сортировки). Это удобно, когда подготовка данных дороже самого теста, и вы хотите вынести её за пределы цикла iter.
use criterion::{Criterion, black_box};
fn bench_parsing(c: &mut Criterion) {
let input = "12345.6789";
c.bench_with_input("parse float", &input, |b, i| {
b.iter(|| black_box(i).parse::().unwrap())
});
}
5. Criterion::bench_function_over_inputs
Сигнатура: pub fn bench_function_over_inputs<F, I>(&mut self, name: &str, inputs: &[I], f: F) where F: FnMut(&mut Bencher, &I)
Что делает: Запускает один и тот же бенчмарк для набора различных входных данных. Результаты для каждого входа будут отображены отдельно, что позволяет увидеть, как производительность масштабируется с размером данных.
use criterion::{Criterion, black_box};
fn bench_sort(c: &mut Criterion) {
let sizes = vec![10, 100, 1000];
c.bench_function_over_inputs("sort", |b, &size| {
let mut data: Vec = (0..size).rev().collect();
b.iter(|| {
data.sort();
black_box(&data);
// Восстанавливаем порядок для следующей итерации
data.reverse();
});
}, &sizes);
}
6. Criterion::criterion_group! и Criterion::criterion_main!
Сигнатура (макросы): criterion_group!(name, bench1, bench2, ...); и criterion_main!(name);
Что делает: Эти макросы являются точкой входа для Criterion. criterion_group! объединяет несколько функций-бенчмарков в группу. criterion_main! генерирует функцию main, которая запускает все указанные группы. Без них бенчмарки не будут выполнены.
use criterion::{criterion_group, criterion_main, Criterion};
fn bench_one(c: &mut Criterion) { /* ... */ }
fn bench_two(c: &mut Criterion) { /* ... */ }
criterion_group!(benches, bench_one, bench_two);
criterion_main!(benches);
7. Criterion::throughput
Сигнатура: pub fn throughput(&mut self, throughput: Throughput) -> &mut Self
Что делает: Устанавливает метрику пропускной способности для бенчмарка. Вы можете указать количество байт или элементов, обрабатываемых за одну итерацию. Criterion автоматически рассчитает и отобразит скорость (например, MiB/s или элементы/сек).
use criterion::{Criterion, Throughput, black_box};
fn bench_memcpy(c: &mut Criterion) {
let data = vec![0u8; 1024 * 1024]; // 1 MiB
let mut group = c.benchmark_group("memcpy");
group.throughput(Throughput::Bytes(data.len() as u64));
group.bench_function("copy 1MiB", |b| {
let mut dest = vec![0u8; data.len()];
b.iter(|| {
dest.copy_from_slice(black_box(&data));
});
});
group.finish();
}
8. Criterion::benchmark_group
Сигнатура: pub fn benchmark_group(&mut self, name: &str) -> BenchmarkGroup
Что делает: Создает группу бенчмарков с общими настройками (например, throughput, конфигурация выборки). Это позволяет избежать дублирования кода и задать параметры один раз для нескольких связанных тестов.
use criterion::{Criterion, BenchmarkGroup, measurement::WallTime};
fn bench_group_example(c: &mut Criterion) {
let mut group: BenchmarkGroup<WallTime> = c.benchmark_group("sorting");
group.sample_size(50); // Уменьшаем количество измерений для скорости
group.bench_function("bubble", |b| b.iter(|| /* bubble sort */));
group.bench_function("quick", |b| b.iter(|| /* quick sort */));
group.finish(); // Обязательно завершаем группу
}
9. BenchmarkGroup::sample_size
Сигнатура: pub fn sample_size(&mut self, n: usize) -> &mut Self
Что делает: Устанавливает количество измерений (сэмплов), которые Criterion сделает для каждого бенчмарка в группе. По умолчанию — 100. Увеличение числа повышает точность, но замедляет выполнение бенчмарков.
use criterion::{Criterion, BenchmarkGroup};
fn bench_with_custom_samples(c: &mut Criterion) {
let mut group = c.benchmark_group("precise");
group.sample_size(200); // Делаем 200 замеров для большей точности
group.bench_function("calc", |b| b.iter(|| 2 + 2));
group.finish();
}
10. BenchmarkGroup::measurement_time
Сигнатура: pub fn measurement_time(&mut self, duration: Duration) -> &mut Self
Что делает: Устанавливает минимальное время, которое Criterion потратит на сбор измерений для каждого бенчмарка. По умолчанию — 5 секунд. Если бенчмарк очень быстрый, за это время будет сделано много итераций. Если медленный — мало. Это гарантирует, что даже для медленных тестов будет собрано достаточно данных.
use std::time::Duration;
use criterion::{Criterion, BenchmarkGroup};
fn bench_long_running(c: &mut Criterion) {
let mut group = c.benchmark_group("slow");
group.measurement_time(Duration::from_secs(10)); // Собираем данные 10 секунд
group.bench_function("slow_fn", |b| b.iter(|| std::thread::sleep(Duration::from_millis(1))));
group.finish();
}