Профилирование Rust кода: находим узкие места на практике
Rust славится своей производительностью, но даже самый быстрый код может содержать неожиданные узкие места. Профилирование — это искусство и наука выявления этих проблем. В этой статье мы на практике разберём, как находить и устранять «бутылочные горлышки» в Rust приложениях, используя современные инструменты и техники.
Почему профилирование важно для Rust?
Хотя Rust гарантирует отсутствие сборщика мусора и даёт низкоуровневый контроль над памятью, это не защищает от неэффективных алгоритмов, лишних аллокаций или неправильного использования кэша. Без профилирования вы рискуете оптимизировать не то, что нужно. Профилирование даёт ответы на вопросы:
- Какие функции потребляют больше всего CPU?
- Где происходят частые аллокации памяти?
- Какие участки кода блокируют выполнение (например, из-за мьютексов)?
- Насколько эффективно используется кэш процессора?
Инструменты для профилирования Rust
Рассмотрим три основных подхода, которые покрывают 90% задач профилирования.
1. Встроенный бенчмаркинг с cargo bench
Самый простой способ — использовать cargo bench из экосистемы Rust. Он позволяет измерить время выполнения отдельных функций.
Пример бенчмарка для функции, сортирующей вектор:
// benches/my_bench.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn sort_benchmark(c: &mut Criterion) {
let mut data: Vec<i32> = (0..10000).rev().collect();
c.bench_function("sort 10k", |b| {
b.iter(|| {
// black_box предотвращает оптимизацию компилятора
let mut clone = black_box(data.clone());
clone.sort();
black_box(clone)
})
});
}
criterion_group!(benches, sort_benchmark);
criterion_main!(benches);
Запуск: cargo bench
Вы получите отчёт с временем, стандартным отклонением и сравнением с предыдущими запусками. Это идеально для регрессионного тестирования производительности.
2. Системный профилировщик perf (Linux)
perf — это мощный инструмент ядра Linux. Он собирает выборки счётчиков производительности (CPU cycles, cache misses, инструкции) без модификации кода.
Базовое использование:
# Сбор данных
perf record -F 99 --call-graph dwarf ./my_rust_program
# Просмотр отчёта
perf report --stdio
# Генерация flamegraph
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg
Флаг -F 99 задаёт частоту семплирования 99 Гц (обычно достаточно). --call-graph dwarf включает запись стека вызовов. На выходе вы получаете интерактивный flamegraph (SVG), где по оси X — время, по оси Y — стек вызовов. Ширина прямоугольника показывает, сколько времени заняла функция.
3. Инструмент flamegraph для Rust
Существует специализированная утилита cargo-flamegraph, которая упрощает создание flamegraph для Rust проектов:
cargo install flamegraph
cargo flamegraph --bin my_binary
После выполнения в корне проекта появится файл flamegraph.svg. Откройте его в браузере — вы сразу увидите «горячие» участки кода.
Практический пример: оптимизация парсера
Представим, что мы пишем парсер JSON на Rust, и он работает медленно. Используем профилирование, чтобы найти проблему.
Шаг 1: Бенчмарк
Создаём бенчмарк для парсинга большого JSON-файла (например, 10 МБ). Запускаем cargo bench и видим, что парсинг занимает 2.5 секунды. Это много.
Шаг 2: Flamegraph
Запускаем cargo flamegraph на бинарнике, который парсит этот файл. Открываем SVG и видим, что 60% времени уходит на функцию parse_number. Внутри неё — вызовы to_string() и parse::<f64>().
Шаг 3: Анализ кода
Смотрим исходник:
fn parse_number(input: &str) -> f64 {
// Проблема: создание новой строки для каждого числа
let s = input.chars().take_while(|c| c.is_digit(10) || *c == '.').collect::<String>();
s.parse::<f64>().unwrap()
}
Очевидно, collect::<String>() аллоцирует память для каждого числа. В большом JSON таких чисел миллионы.
Шаг 4: Оптимизация
Заменяем аллокацию на парсинг без копирования:
fn parse_number_fast(input: &str) -> f64 {
let end = input.find(|c: char| !c.is_digit(10) && c != '.').unwrap_or(input.len());
// Используем &str, а не String
input[..end].parse::<f64>().unwrap()
}
Шаг 5: Повторный замер
Запускаем cargo bench снова — время парсинга падает до 0.8 секунд. Flamegraph подтверждает, что parse_number теперь занимает лишь 15% времени.
Дополнительные техники профилирования
- Профилирование памяти: используйте
valgrind --tool=massifилиheaptrackдля поиска утечек и лишних аллокаций. - Профилирование конкурентности: для async-кода применяйте
tokio-console— он показывает, какие таски блокируются и сколько времени ждут. - Счётчики кэша:
perf stat -e cache-misses,cycles,instructions ./binaryпокажет, насколько эффективно используется кэш L1/L2. - Инструмент
pprof-rs: встроенный профилировщик для Rust, который можно запускать прямо из кода для профилирования конкретных участков.