Перейти к содержимому

Основы производительности в Rust

У Rust репутация быстрого языка, и она заслуженная. Но «Rust быстрый» не значит, что любая программа на Rust работает быстро. Медленный алгоритм будет медленным на любом языке, а несколько вредных привычек могут незаметно стоить очень дорого.

Хорошая новость: самые большие выигрыши получаются простыми шагами. Собирай в release-режиме. Измеряй, прежде чем что-то менять. Не копируй данные, которые нужно только прочитать. Выбирай подходящую коллекцию. На этой странице мы разберём каждый шаг, с настоящими замерами там, где они помогают. Это как настройка велосипеда: сначала проверь, накачаны ли шины (release-режим), и только потом думай о деталях полегче.

Прежде чем что-то ускорять, нужно знать, насколько оно медленное. std::time::Instant — это секундомер, встроенный в стандартную библиотеку. Вызови Instant::now() перед работой и .elapsed() после неё:

use std::time::Instant;
fn main() {
let start = Instant::now();
// Создаём 5 миллионов псевдослучайных чисел простым xorshift.
let mut x: u64 = 12345;
let mut numbers = Vec::with_capacity(5_000_000);
for _ in 0..5_000_000 {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
numbers.push(x % 1_000_000);
}
numbers.sort();
let sum: u64 = numbers.iter().take(1000).sum();
println!("сумма 1000 самых маленьких: {sum}");
println!("заняло {:?}", start.elapsed());
}

Формат {:?} печатает Duration в удобном виде, например 250.271745ms. Кнопки «Попробуй сам» здесь нет специально: время меняется при каждом запуске и на каждом компьютере, поэтому его нельзя сравнить с заранее известным выводом. Лучше запусти программу у себя через cargo run.

Это самый важный совет по производительности в Rust, и это всего лишь один флаг.

Terminal window
cargo run # debug-сборка: быстро компилируется, медленно работает
cargo run --release # release-сборка: дольше компилируется, работает намного быстрее

Debug-сборка (отладочная) пропускает большинство оптимизаций, поэтому быстро компилируется и её легко отлаживать. Ещё она проверяет переполнение целых чисел. Release-сборка включает оптимизатор на полную.

Вот пример замера: программу выше мы запустили по три раза в каждом режиме на машине, где писали эту страницу (облачный сервер с процессором Intel Xeon). У тебя цифры будут другими — важна только разница между ними:

debug build: took 5.02s, 4.27s, 4.83s
release build: took 250ms, 297ms, 265ms

Это примерно в 15–20 раз быстрее — от одного флага. Разница сильно зависит от кода: больше всего выигрывают циклы и итераторы. Но она никогда не бывает настолько маленькой, чтобы её игнорировать.

Отсюда простое правило: никогда не суди о скорости по debug-сборке. Если кто-то говорит «Rust медленный», замерив cargo run, причина почти всегда в этом.

.clone() делает полную копию. Для String или Vec это значит новое выделение памяти в куче и копирование каждого байта. Иногда копия действительно нужна — но часто данные нужно только прочитать, а заимствование (borrowing) ничего не стоит.

Самый частый случай — параметры функций. Если функция только читает строку, принимай &str, а не String:

fn count_long_words(text: &str, min_len: usize) -> usize {
text.split_whitespace().filter(|w| w.len() >= min_len).count()
}
fn main() {
let owned = String::from("the quick brown fox jumps over the lazy dog");
// &String сам превращается в &str, копировать ничего не нужно.
println!("{}", count_long_words(&owned, 5));
// Строковый литерал — это уже &str.
println!("{}", count_long_words("borrowing is cheap and cloning is not", 6));
// Мы не отдавали `owned`, поэтому им всё ещё можно пользоваться.
println!("{owned}");
}

Вывод:

3
2
the quick brown fox jumps over the lazy dog
▶ Попробуй сам

Строки здесь остались на английском, потому что функция считает длину слов через w.len(), а это байты: русская буква занимает 2 байта, и ответы бы изменились.

Параметр &str принимает и String (заимствованную через &), и строковый литерал. А параметр String заставил бы вызывающий код отдать свою строку или клонировать её. Та же идея работает для списков: принимай &[T] вместо Vec<T>. Подробности — в уроках Заимствование и Срезы.

Следи и за клонированием в циклах. for name in names.clone() копирует весь список только ради того, чтобы на него посмотреть. for name in &names заимствует его бесплатно.

Выделяй память один раз, а не снова и снова

Заголовок раздела «Выделяй память один раз, а не снова и снова»

Когда в Vec заканчивается место, он выделяет блок побольше и переносит туда все элементы. Если размер известен заранее, Vec::with_capacity зарезервирует его за один раз. Эта программа считает, сколько раз каждому Vec пришлось расти, пока в него добавляли 1000 чисел:

fn count_growths(mut v: Vec<u32>, n: u32) -> u32 {
let mut growths = 0;
let mut cap = v.capacity();
for i in 0..n {
v.push(i);
if v.capacity() != cap {
growths += 1;
cap = v.capacity();
}
}
growths
}
fn main() {
println!("Vec::new(): вырос {} раз", count_growths(Vec::new(), 1000));
println!("Vec::with_capacity(): вырос {} раз", count_growths(Vec::with_capacity(1000), 1000));
}

Вывод:

Vec::new(): вырос 9 раз
Vec::with_capacity(): вырос 0 раз
▶ Попробуй сам

Девять перевыделений — не катастрофа: Vec каждый раз удваивается, так что это остаётся дёшево. Но в горячем цикле, который строит миллионы векторов, это накапливается. (Точная схема роста — внутренняя деталь стандартной библиотеки и может измениться.)

Ещё проще — поручить работу collect() и join(). Они часто знают итоговый размер и выделяют память один раз:

fn main() {
// collect() строит Vec за один проход.
let squares: Vec<u64> = (1..=5).map(|n| n * n).collect();
println!("{squares:?}");
// join() сначала считает общую длину, а потом копирует один раз.
let colors = ["красный", "зелёный", "синий"];
println!("{}", colors.join(", "));
}

Вывод:

[1, 4, 9, 16, 25]
красный, зелёный, синий
▶ Попробуй сам

Может показаться, что .iter().filter().map().sum() медленнее обычного цикла, ведь это выглядит как множество вызовов функций. Это не так. В release-сборке компилятор встраивает все эти маленькие замыкания, и получается такой же плотный машинный код, как у цикла, написанного вручную. В Rust это называют абстракцией с нулевой стоимостью (zero-cost abstraction): за более приятный стиль ты не платишь.

fn sum_even_squares_loop(nums: &[u64]) -> u64 {
let mut total = 0;
for i in 0..nums.len() {
if nums[i] % 2 == 0 {
total += nums[i] * nums[i];
}
}
total
}
fn sum_even_squares_iter(nums: &[u64]) -> u64 {
nums.iter().filter(|&&n| n % 2 == 0).map(|&n| n * n).sum()
}
fn main() {
let nums: Vec<u64> = (1..=10).collect();
println!("цикл: {}", sum_even_squares_loop(&nums));
println!("итератор: {}", sum_even_squares_iter(&nums));
}

Вывод:

цикл: 220
итератор: 220
▶ Попробуй сам

Версия с итератором может оказаться даже чуть быстрее. При nums[i] Rust при каждом обращении проверяет, что i не выходит за границы среза. Итератор и так знает, что за границы не выйдет, поэтому эти проверки исчезают. Пиши так, как читается лучше, — обычно это итератор.

Правильная структура данных часто важнее любой тонкой настройки. Классический пример — много раз проверять, «есть ли этот элемент в моём списке?».

  • Vec::contains просматривает элементы по одному. Чем длиннее список, тем медленнее поиск.
  • HashSet::contains (и HashMap::get) почти сразу переходит к ответу. Поиск остаётся быстрым при любом размере.
use std::collections::HashSet;
fn main() {
let banned: Vec<u32> = (0..20_000).map(|n| n * 3).collect();
let queries: Vec<u32> = (0..20_000).collect();
// Медленно для больших списков: каждый contains() просматривает весь Vec.
let hits_vec = queries.iter().filter(|q| banned.contains(q)).count();
// Быстро: один раз строим HashSet, и каждый поиск занимает мало времени.
let banned_set: HashSet<u32> = banned.iter().copied().collect();
let hits_set = queries.iter().filter(|q| banned_set.contains(q)).count();
println!("Vec нашёл {hits_vec}, HashSet нашёл {hits_set}");
}

Вывод:

Vec нашёл 6667, HashSet нашёл 6667
▶ Попробуй сам

Ответ одинаковый, а цена очень разная. Мы добавили таймеры Instant вокруг каждой части и запустили release-сборку. Вот пример замера на нашей машине:

Vec: 6667 hits in 27.97ms
HashSet: 6667 hits in 551.60µs

Примерно в 50 раз быстрее — и это вместе со временем на построение множества. Но для списка из 10 элементов Vec вполне подходит и может даже выиграть. Подробнее — на страницах HashSet и HashMap.

Программисты известны тем, что плохо угадывают, куда уходит время. Так что не гадай, а профилируй. Профилировщик наблюдает за работой программы и показывает, какие функции занимают больше всего времени. Обычно это одно-два «горячих места», а остальной код вообще не важен.

Два популярных бесплатных профилировщика, которые работают с Rust:

Terminal window
# flamegraph: рисует картинку того, куда уходит время (Linux, macOS)
cargo install flamegraph
cargo flamegraph
# samply: открывает результаты в Firefox Profiler в браузере
cargo install samply
cargo build --release
samply record ./target/release/my_app

Чтобы показывать имена функций, профилировщикам нужны отладочные символы. Добавь это в Cargo.toml, и release-сборки будут их сохранять (код от этого не замедлится, только файл станет больше):

[profile.release]
debug = true

Один замер через Instant получается шумным. Бенчмарк запускает код много раз и с помощью статистики даёт надёжное число. Встроенный в Rust #[bench] работает только на nightly, поэтому большинство пользуется крейтом criterion (на английском). Настрой его так:

[dev-dependencies]
criterion = "0.8"
[[bench]]
name = "fib_bench"
harness = false

Положи код, который хочешь проверить, в свою библиотеку (src/lib.rs), а бенчмарк напиши в benches/fib_bench.rs:

use criterion::{criterion_group, criterion_main, Criterion};
use my_crate::fibonacci;
use std::hint::black_box;
fn bench_fib(c: &mut Criterion) {
c.bench_function("fibonacci 20", |b| b.iter(|| fibonacci(black_box(20))));
}
criterion_group!(benches, bench_fib);
criterion_main!(benches);

black_box не даёт компилятору схитрить и вычислить ответ заранее — иначе бенчмарк потерял бы смысл. Запусти его так:

Terminal window
cargo bench

Пример результата на нашей машине (criterion показывает нижнюю оценку, лучшую оценку и верхнюю оценку):

fibonacci 20 time: [24.417 µs 25.079 µs 25.737 µs]

Запусти его снова после изменения, и criterion скажет, стал код быстрее или медленнее — и настоящая ли это разница или просто шум.

Стандартные настройки release уже хороши. Когда нужно ещё чуть-чуть, вот частые «ручки» в Cargo.toml:

[profile.release]
opt-level = 3 # по умолчанию для release; "s" или "z" оптимизируют размер
lto = true # оптимизация при компоновке: по всем крейтам (сборка медленнее)
codegen-units = 1 # одна единица = больше шансов на оптимизацию, но компиляция медленнее
panic = "abort" # без кода раскрутки стека; бинарник меньше, но панику не поймать

Обычно они дают несколько процентов, а не чудо, и замедляют сборку. Пробуй их по одной и измеряй каждую. lto = "thin" — популярный компромисс, который собирается быстрее, чем lto = true.

Замерять debug-сборку. Мы уже говорили об этом, но это ошибка номер один: всегда измеряй с --release.

Клонировать, чтобы компилятор успокоился. Допустим, функция принимает String, а ты вызываешь её с заимствованием:

fn shout(text: String) -> String {
text.to_uppercase()
}
fn main() {
let name = String::from("ferris");
let loud = shout(&name);
println!("{loud} {name}");
}

Компилятор отвечает:

error[E0308]: mismatched types
--> src/main.rs:7:22
|
7 | let loud = shout(&name);
| ----- ^^^^^ expected `String`, found `&String`
| |
| arguments to this function are incorrect

Он говорит, что типы не совпадают: функция ждёт String, а получила &String. Так и хочется написать shout(name.clone()). Это сработает, но строка будет копироваться при каждом вызове. Лучше поменять параметр на text: &str — тогда shout(&name) скомпилируется, и ничего не скопируется.

println! в горячем цикле. Каждый println! блокирует stdout и сразу записывает строку. Для нескольких строк это нормально. Для миллиона — медленно. Оберни stdout в BufWriter, чтобы он писал большими кусками:

use std::io::{self, BufWriter, Write};
fn main() {
let mut out = BufWriter::new(io::stdout().lock());
for i in 1..=5 {
writeln!(out, "строка {i}").unwrap();
}
}

Вывод:

строка 1
строка 2
строка 3
строка 4
строка 5
▶ Попробуй сам

В нашем примере замера вывод миллиона строк в файл занял около 570–670 мс с println! и около 40–60 мс с BufWriter (release-сборка).

  • Всегда измеряй скорость через cargo run --release — debug-сборка может быть во много раз медленнее.
  • Для быстрого замера бери Instant::now() и .elapsed(), для надёжных бенчмарков — criterion.
  • Заимствуй (&str, &[T]) вместо клонирования, если данные нужно только прочитать.
  • Выделяй память один раз: Vec::with_capacity, collect() и join() избавляют от повторного роста.
  • Итераторы ничего не стоят; для множества поисков выбирай HashSet/HashMap.
  • Сначала профилируй, потом оптимизируй горячие места, которые покажет профилировщик.

Проверь себя

1. Программа кажется медленной, когда ты запускаешь её через cargo run. Что попробовать первым делом?

2. Функция только читает строку. Какой тип параметра избавит от лишних копий?

3. Нужно проверить, есть ли 50 000 ID в списке из 50 000 запрещённых ID. Что лучше выбрать для списка запрещённых?

4. Что значит «итераторы ничего не стоят» (zero-cost)?

5. Зачем бенчмарку на criterion нужен black_box?

Результат: 0 / 5