Введение: почему производительность Rust — это не магия, а дисциплина
Rust заслуженно считается одним из самых быстрых языков системного программирования. Однако, как и в любом другом языке, «сырой» код не всегда работает на пределе возможностей. Многие начинающие разработчики, переходя на Rust из Python или JavaScript, ожидают мгновленного прироста скорости, но забывают, что оптимизация — это процесс, а не подарок компилятора.
Эта статья предназначена для тех, кто уже написал свои первые программы на Rust и хочет понять, как выжать из них максимум. Мы разберём три ключевых направления: профилирование (чтобы знать, что исправлять), zero-cost абстракции (чтобы не платить за то, что не используешь) и работу с памятью (чтобы избежать узких мест). Все примеры будут сопровождаться кодом, который можно сразу запустить в своей среде.
1. Профилирование: прежде чем оптимизировать, измерь
Главное правило оптимизации: не оптимизируй вслепую. Без профилирования вы рискуете потратить часы на «ускорение» кода, который и так работает быстро, в то время как реальное узкое место останется незамеченным.
Инструменты для профилирования
В экосистеме Rust есть несколько отличных инструментов. Для начала рекомендую cargo-flamegraph — он визуализирует, где программа тратит больше всего времени. Установка:
cargo install flamegraph
Запуск профилирования для вашего бинарного файла:
cargo flamegraph --bin my_app
После выполнения вы получите SVG-файл с «флейм-графом». Широкие «столбики» показывают функции, которые потребляют больше всего CPU.
Пример: находим узкое место
Предположим, у нас есть функция, которая суммирует квадраты чисел:
fn sum_of_squares(n: u64) -> u64 {
let mut sum = 0;
for i in 1..=n {
sum += i * i;
}
sum
}
Если профилировщик покажет, что 90% времени тратится на эту функцию, мы знаем, куда смотреть. Если же узкое место — ввод-вывод (например, чтение файла), оптимизация вычислений не даст результата.
2. Zero-cost абстракции: как компилятор помогает (и мешает)
Одна из главных фишек Rust — концепция zero-cost абстракций. Это означает, что высокоуровневые конструкции (итераторы, closures, трейты) не должны добавлять накладных расходов по сравнению с ручным написанием циклов. Однако это работает только при правильном использовании.
Итераторы vs. циклы
Многие новички пишут так:
let v: Vec<i32> = (0..1000).collect();
let mut result = Vec::new();
for i in &v {
result.push(i * 2);
}
Этот код можно переписать с помощью итераторов, и компилятор Rust оптимизирует его до того же машинного кода:
let v: Vec<i32> = (0..1000).collect();
let result: Vec<i32> = v.iter().map(|x| x * 2).collect();
Более того, если вы используете filter, map и fold последовательно, компилятор может «слить» их в один проход — это называется loop fusion. Ручные циклы такой оптимизации не дают.
Box<dyn Trait> vs. generics
Ещё одна распространённая ошибка — злоупотребление трейт-объектами (Box<dyn SomeTrait>). Они вызывают динамическую диспетчеризацию, что добавляет накладные расходы. Если количество вариантов известно на этапе компиляции, используйте generics (статическая диспетчеризация):
// Плохо: динамическая диспетчеризация
fn process(items: Vec<Box<dyn Processor>>) {
for item in items {
item.run();
}
}
// Хорошо: статическая диспетчеризация
fn process<T: Processor>(items: Vec<T>) {
for item in items {
item.run();
}
}
3. Работа с памятью: аллокации и кэш
В Rust вы контролируете память, а значит — можете её оптимизировать. Две главные проблемы: частые аллокации (выделение памяти в куче) и плохая локальность данных (кэш-промахи).
Минимизация аллокаций
Каждый вызов Vec::push или String::push_str может вызвать переаллокацию. Решение: заранее резервировать память.
// Плохо: много аллокаций
let mut data = Vec::new();
for i in 0..100_000 {
data.push(i);
}
// Хорошо: одна аллокация
let mut data = Vec::with_capacity(100_000);
for i in 0..100_000 {
data.push(i);
}
То же самое работает для строк. Если вы собираете строку из множества частей, используйте String::with_capacity или хотя бы format! — он оптимизирован лучше, чем конкатенация через +.
Работа с кэшем процессора
Современные CPU намного быстрее обрабатывают данные, которые находятся в кэше L1/L2. Чтобы улучшить локальность, храните данные в плоских массивах (Vec<T>), а не в векторах указателей (Vec<Box<T>>).
Пример: если вам нужно хранить много точек (x, y), лучше сделать так:
struct Point { x: f64, y: f64 }
let points: Vec<Point> = ...; // Плоский массив
Чем так:
let points: Vec<(f64, f64)> = ...; // То же самое, но менее читаемо
Или тем более так (катастрофа для кэша):
let xs: Vec<f64> = ...;
let ys: Vec<f64> = ...; // Раздельное хранение — плохо
Используйте Struct of Arrays (