Профилирование Rust кода: находим узкие места на практике

Онлайн-тренажер Python для начинающих

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

Начать курс

Профилирование 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% времени.

Дополнительные техники профилирования