Почему Rust быстр, но не всегда?
Rust — это язык, который обещает производительность C++ с безопасностью памяти. Но даже на Rust можно написать медленный код. Пара неверных решений — и ваш сервис начинает «тормозить» под нагрузкой. Хорошая новость: Rust даёт вам все инструменты, чтобы найти и устранить узкие места. В этой статье мы разберём, как профилировать код на Rust и какие техники оптимизации действительно работают.
Мы не будем говорить абстрактно. Вы увидите реальные примеры кода, узнаете, как использовать perf, flamegraph, valgrind и встроенные бенчмарки. После прочтения вы сможете самостоятельно провести аудит производительности своего проекта.
Инструментарий для профилирования Rust
Прежде чем что-то оптимизировать, нужно понять, что именно тормозит. Вот три рабочих инструмента, которые помогут вам заглянуть внутрь программы.
1. Бенчмарки с cargo bench
Стандартный способ измерить производительность — написать бенчмарк. В Rust для этого используется крейт criterion или встроенный тестовый фреймворк. Пример:
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn fibonacci(n: u64) -> u64 {
match n {
0 => 0,
1 => 1,
_ => fibonacci(n - 1) + fibonacci(n - 2),
}
}
fn bench_fib(c: &mut Criterion) {
c.bench_function("fib 20", |b| b.iter(|| fibonacci(black_box(20))));
}
criterion_group!(benches, bench_fib);
criterion_main!(benches);
Запуск: cargo bench. На выходе вы получите время выполнения, отклонение и сравнение с предыдущими запусками. Это база, с которой стоит начинать любую оптимизацию.
2. Профилирование с perf и flamegraph
Когда нужно узнать, какие функции едят процессор, используйте perf. Соберите данные, а затем визуализируйте их в виде флеймграфа:
# Сбор данных
perf record -g ./target/release/my_app
# Генерация flamegraph
perf script | stackcollapse-perf.pl | flamegraph.pl > profile.svg
Флеймграф покажет вам «горячие» участки кода. Чем шире блок, тем больше времени тратится на эту функцию. Это самый наглядный способ найти узкое место.
3. Поиск утечек с Valgrind
Rust защищает от утечек памяти на уровне компиляции, но не от всех. Например, если вы используете Rc или Arc с циклическими ссылками, память может не освободиться. Valgrind поможет это обнаружить:
valgrind --tool=massif ./target/debug/my_app
Massif покажет, сколько памяти потребляет программа в каждый момент времени. Если график не падает после завершения работы — ищите утечку.
Практические техники оптимизации кода на Rust
Теперь, когда вы знаете, что искать, давайте разберём, как это чинить. Вот пять проверенных методов, которые реально ускоряют код.
1. Zero-cost абстракции: используйте итераторы правильно
Итераторы в Rust компилируются в такой же эффективный код, как ручные циклы. Но только если вы не делаете лишних копий. Сравните:
// Плохо: создаём Vec, копируем, фильтруем
let result: Vec<u32> = data.iter().filter(|x| x % 2 == 0).collect();
// Хорошо: работаем с итератором без промежуточных коллекций
let sum: u32 = data.iter().filter(|x| x % 2 == 0).sum();
Второй вариант не выделяет память под временный вектор. Если вам нужно только одно значение — не коллекционируйте.
2. Кэширование результатов
Если функция вызывается многократно с одними и теми же аргументами, кэшируйте результат. Используйте HashMap или крейт lru для ограничения размера кэша:
use std::collections::HashMap;
fn expensive_calc(x: u64) -> u64 {
// ... долгие вычисления
x * x
}
fn cached_calc(cache: &mut HashMap<u64, u64>, x: u64) -> u64 {
*cache.entry(x).or_insert_with(|| expensive_calc(x))
}
Это особенно полезно в рекурсивных алгоритмах (например, динамическое программирование) или при работе с внешними API.
3. Работа с памятью: избегайте аллокаций
Каждая аллокация на куче стоит дорого. В горячих циклах старайтесь переиспользовать буферы:
// Плохо: каждый раз создаём новую строку
for _ in 0..1000 {
let s = format!("value: {}", x);
process(&s);
}
// Хорошо: переиспользуем буфер
let mut buffer = String::with_capacity(64);
for _ in 0..1000 {
buffer.clear();
write!(&mut buffer, "value: {}", x).unwrap();
process(&buffer);
}
Разница может быть в десятки раз. Используйте with_capacity, чтобы избежать переаллокаций при росте строки.
4. Параллелизм с rayon
Если у вас многоядерный процессор, используйте крейт rayon. Он превращает последовательные итераторы в параллельные одной строкой:
use rayon::prelude::*;
let sum: u64 = data.par_iter().map(|x| x * x).sum();
Важно: не используйте параллелизм для слишком маленьких задач — накладные расходы на синхронизацию могут превысить выгоду. Профилируйте.
5. Оптимизация компилятора: LTO и codegen-units
В Cargo.toml можно включить Link Time Optimization (LTO). Это увеличивает время сборки, но даёт +5-15% производительности:
[profile.release]
lto = true
codegen-units = 1
Параметр codegen-units = 1 заставляет компилятор оптимизировать весь крейт как единое целое. Это замедляет компиляцию, но улучшает производительность.
Реальный кейс: оптимизация обработки логов
Представьте, что вы пишете парсер логов. На входе — файл на 10 ГБ, нужно извлечь все IP-адреса. Наивная реализация:
use std::fs::File;
use std::io::{BufRead, BufReader};
fn extract_ips(path: &str) -> Vec<String> {
let file = File::open(path).unwrap();
let reader = BufReader::new(file);
let mut ips = Vec::new();
for line in reader.lines() {
let line = line.unwrap();
if let Some(ip) = line.split_whitespace().find(|w| w.contains('.')) {
ips.push(ip.to_string());
}
}