Rust не заставляет писать new/malloc явно для каждой аллокации, но выбор стек/куча остаётся принципиальным для производительности. На стеке размещаются значения с известным на этапе компиляции размером и временем жизни, привязанным к области видимости — выделение и освобождение стоят буквально одной инструкции сдвига указателя стека. Куча нужна для данных с размером, известным только в рантайме, или с временем жизни, выходящим за пределы текущего стек-фрейма.
fn main() {
let a = 42; // стек: i32, 4 байта, известный размер
let arr = [0u8; 16]; // стек: массив фиксированного размера
let s = String::from("hello"); // стек: (ptr, len, cap) — 24 байта; данные "hello" — в куче
let boxed = Box::new([0u8; 4096]); // стек: указатель 8 байт; 4096 байт — в куче
println!("{a} {} {} {}", arr.len(), s, boxed.len());
}
String и Vec<T> — это не "куча" сами по себе, а тонкие структуры на стеке (указатель, длина, capacity), которые владеют буфером в куче. Это отличает Rust от Go/Java, где почти всё нетривиальное живёт в куче под управлением GC: в Rust расположение — явный выбор архитектуры типа, а не решение рантайма.
| Критерий | Стек | Куча |
|---|---|---|
| Скорость аллокации | ~1 инструкция (сдвиг sp) | вызов аллокатора, поиск свободного блока |
| Размер известен | во время компиляции | может быть только в рантайме |
| Время жизни | ограничено областью видимости | управляется владением (ownership), может "пережить" фрейм |
| Локальность кэша | обычно высокая | зависит от фрагментации |
По умолчанию Rust использует repr(Rust) — компилятор вправе переупорядочивать поля структуры для минимизации padding (выравнивающих "дыр"), и это поведение не гарантировано между версиями компилятора. Это принципиально отличается от C, где порядок полей в памяти всегда совпадает с порядком объявления.
struct Bad {
a: u8, // 1 байт + padding
b: u64, // 8 байт, требует выравнивания на 8
c: u8, // 1 байт + padding
}
// naive-раскладка (как в C, repr(C)) заняла бы 24 байта из-за паддинга;
// repr(Rust) сам переупорядочит поля и обычно уложится в 16 байт
#[repr(C)]
struct ForFFI {
a: u8,
b: u64,
c: u8,
} // репрезентация фиксирована — обязательна для передачи через FFI
Проверить фактический размер и выравнивание можно через std::mem::size_of::<T>() и std::mem::align_of::<T>(), а для полного анализа паддинга и порядка полей — крейт #[derive(Debug)] + std::mem::offset_of! или инструмент cargo-show-asm / внешний pahole.
fn main() {
println!("{}", std::mem::size_of::<Bad>()); // обычно 16
println!("{}", std::mem::size_of::<ForFFI>()); // 24 (repr(C) не переупорядочивает)
}
repr(C) не требуется (нет FFI), группируйте поля по убыванию размера при проектировании "горячих" структур — это снижает риск паддинга даже с учётом автоматического переупорядочивания и делает layout предсказуемым при чтении кода.
Принцип "zero-cost abstraction" в Rust означает: то, чем вы не пользуетесь, не стоит ничего, а то, чем пользуетесь, не может быть реализовано вручную эффективнее. Классическая иллюстрация — итераторы: цепочка комбинаторов компилируется в код, идентичный ручному циклу.
fn sum_of_squares_iter(data: &[i32]) -> i32 {
data.iter()
.map(|x| x * x)
.filter(|x| x % 2 == 0)
.sum()
}
fn sum_of_squares_loop(data: &[i32]) -> i32 {
let mut total = 0;
for &x in data {
let sq = x * x;
if sq % 2 == 0 {
total += sq;
}
}
total
}
// в release-сборке обе функции обычно компилируются в идентичный машинный код —
// комбинаторы итераторов инлайнятся и разворачиваются компилятором
Другие примеры zero-cost: generics через мономорфизацию (нет диспетчеризации в рантайме), Option<&T> занимает столько же памяти, сколько &T (null pointer optimization — компилятор использует 0 как представление None, ведь ссылка не может быть null), RAII-обёртки (Vec, Box) не добавляют накладных расходов сверх ручного управления памятью.
cargo asm / godbolt.org) или профилируйте, если производительность критична — иногда высокоуровневый код действительно оптимизируется хуже ожидаемого из-за неудачного инлайнинга или границ модулей.
По умолчанию Rust использует системный аллокатор ОС (обычно обёртку над malloc). Трейт GlobalAlloc позволяет заменить аллокатор для всей программы — популярный выбор для высоконагруженных сервисов — jemalloc или mimalloc, которые лучше справляются с многопоточной аллокацией и фрагментацией.
// Cargo.toml: mimalloc = "0.1"
use mimalloc::MiMalloc;
#[global_allocator]
static GLOBAL: MiMalloc = MiMalloc;
fn main() {
let v: Vec<u8> = Vec::with_capacity(1024); // теперь аллокация идёт через mimalloc
println!("{}", v.capacity());
}
Для узкоспециализированных структур данных можно писать собственный аллокатор через трейт Allocator (nightly API) или вручную работать с std::alloc::{alloc, dealloc, Layout} — это уже территория unsafe-кода с ручным контролем выравнивания и размера.
use std::alloc::{alloc, dealloc, Layout};
fn manual_alloc_demo() {
let layout = Layout::array::<u32>(10).unwrap();
unsafe {
let ptr = alloc(layout);
if ptr.is_null() {
std::alloc::handle_alloc_error(layout);
}
// ... работа с ptr ...
dealloc(ptr, layout);
}
}
Стоит различать arena/bump-аллокаторы (bumpalo) — выделяют память последовательно из большого блока и освобождают всё разом, что идеально для краткоживущих деревьев/графов внутри одного прохода (парсеры, компиляторы) и на порядок быстрее обращения к общему аллокатору на каждую отдельную структуру.
Инлайнинг — подстановка тела функции в место вызова вместо реального call — устраняет накладные расходы вызова и открывает дорогу дальнейшим оптимизациям (constant folding, устранение мёртвого кода). LLVM решает, инлайнить ли функцию, на основе эвристик (размер тела, частота вызова), но решение можно направлять атрибутами.
#[inline] // подсказка компилятору — инлайнить, если возможно (в т.ч. между крейтами)
fn square(x: i32) -> i32 { x * x }
#[inline(always)] // настойчивая просьба — почти всегда инлайнить
fn hot_path_check(x: u32) -> bool { x & 1 == 0 }
#[inline(never)] // запрет инлайнинга — полезно для "холодных" веток (например, паники)
fn cold_error_path(msg: &str) -> ! {
panic!("{msg}");
}
Функции без #[inline], определённые в одном крейте и вызываемые из другого, по умолчанию не инлайнятся между крейтами без LTO (Link-Time Optimization), потому что компилятор компилирует крейты независимо. Включение lto = true в профиле release позволяет инлайнинг через границы крейтов ценой значительно более долгой сборки.
// Cargo.toml
[profile.release]
lto = true
codegen-units = 1 # меньше параллелизма компиляции, но лучше оптимизации
panic = "abort" # убирает код unwinding — меньше бинарник, быстрее паника
Догадки о том, "что медленно", часто ошибочны — профилирование даёт объективную картину. На Linux базовый инструмент — perf, который сэмплирует стек вызовов с заданной частотой без остановки программы (statistical profiling), а cargo-flamegraph визуализирует результат в виде flame graph.
$ cargo install flamegraph
$ cargo flamegraph --bin my_app
# открывает flamegraph.svg — ширина блока = доля времени в этой функции
# (включая вложенные вызовы), высота = глубина стека вызовов
$ perf record --call-graph=dwarf ./target/release/my_app
$ perf report
Для точных бенчмарков на уровне функции используется criterion (см. тему "Тестирование"), а для профилирования аллокаций — dhat (heap profiler, интегрируется через dhat::Alloc как #[global_allocator] в debug-сборке) или valgrind --tool=massif.
// Обязательное условие для осмысленного профиля:
// профилировать release-сборку с debug-символами
// Cargo.toml
[profile.release]
debug = true # символы для perf/flamegraph, не влияет на оптимизации
cargo build без --release отключает почти все оптимизации LLVM — разница в производительности между debug и release легко достигает 10-50x, а "узкие места", найденные в debug-сборке, часто вообще исчезают после оптимизации.
Современные CPU читают память кэш-линиями по 64 байта, и предсказуемый последовательный доступ (prefetching) на порядок быстрее произвольного. Отсюда — принцип "structure of arrays" (SoA) вместо "array of structures" (AoS) в горячих циклах, обрабатывающих не все поля структуры сразу.
// AoS — типичный, но не всегда cache-friendly вариант
struct Particle { x: f32, y: f32, mass: f32, active: bool }
let particles: Vec<Particle> = /* ... */;
// Если горячий цикл трогает только x и y — грузятся лишние байты mass/active
// на каждой кэш-линии, снижая полезную плотность данных
// SoA — каждое поле в своём непрерывном массиве
struct Particles {
x: Vec<f32>,
y: Vec<f32>,
mass: Vec<f32>,
active: Vec<bool>,
}
// цикл по x и y читает только нужные данные — выше плотность полезной информации
// в кэш-линии, лучше автовекторизация LLVM
Аналогично, Vec<Box<T>> (массив указателей на разбросанные по куче объекты) уступает по локальности Vec<T> (непрерывный блок памяти) — итерация по первому варианту означает произвольные обращения к памяти на каждый элемент, что для больших коллекций может быть на порядок медленнее.
Выбор умного указателя — это выбор компромисса между стоимостью аллокации, размером на стеке и накладными расходами доступа. Важно понимать разницу не только семантически (владение), но и по факту генерируемого кода.
| Тип | Размер на стеке | Overhead | Когда использовать |
|---|---|---|---|
Box<T> | 8 байт (указатель) | одна аллокация, без счётчика ссылок | единоличное владение, рекурсивные типы, dyn Trait |
Rc<T> | 8 байт | невизуальная heap-аллокация счётчиков, но без atomic-инструкций | разделяемое владение в одном потоке |
Arc<T> | 8 байт | atomic inc/dec на каждый clone/drop | разделяемое владение между потоками |
&T | 8 байт (или 16 для fat pointer) | нулевой — просто заимствование | когда владение не нужно вообще |
use std::rc::Rc;
use std::sync::Arc;
fn main() {
let shared_single_thread = Rc::new(42);
let _clone = Rc::clone(&shared_single_thread); // инкремент обычного счётчика, не atomic
let shared_multi_thread = Arc::new(42);
let _clone2 = Arc::clone(&shared_multi_thread); // atomic fetch_add — дороже,
// но безопасно между потоками
}
Использование Arc там, где достаточно Rc (однопоточный код) — частая неоптимальность: atomic-операции требуют синхронизации кэш-линий между ядрами CPU (cache coherency traffic), что заметно на высокочастотных clone/drop даже без реальной многопоточности.
--release, желательно с lto = true и codegen-units = 1 для финальных бенчмарков.&[T]/&str вместо Vec<T>/String в сигнатурах функций, если владение не требуется — избегаете лишних аллокаций у вызывающей стороны.Vec::with_capacity), если размер коллекции известен или предсказуем — избегаете повторных реаллокаций с копированием.clone() "на автомате" — заимствование почти всегда быстрее и часто ничем не уступает по эргономике после привыкания к borrow checker.cargo asm — иногда небольшая перестановка кода включает SIMD-инструкции.fn process(items: &[String]) -> usize { // &[String], не Vec<String> — не требует владения
items.iter().map(|s| s.len()).sum()
}