← Назад к списку тем

19. Память и производительность

Layout структур, стек vs куча, zero-cost abstractions, аллокаторы, profiling (perf/flamegraph), инлайнинг.

Stack vs heap — где живут данные

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), может "пережить" фрейм
Локальность кэшаобычно высокаязависит от фрагментации

Layout структур — как компилятор раскладывает поля

По умолчанию 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 abstractions на практике

Принцип "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) не добавляют накладных расходов сверх ручного управления памятью.

✅ Проверяйте на практике: "zero-cost" — это цель дизайна языка, а не гарантия для каждой конкретной абстракции. Смотрите сгенерированный ассемблер (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 — меньше бинарник, быстрее паника

Профилирование: perf и flamegraph

Догадки о том, "что медленно", часто ошибочны — профилирование даёт объективную картину. На 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, не влияет на оптимизации
⚠️ Не профилируйте debug-сборку: cargo build без --release отключает почти все оптимизации LLVM — разница в производительности между debug и release легко достигает 10-50x, а "узкие места", найденные в debug-сборке, часто вообще исчезают после оптимизации.

Cache locality и структура данных

Современные 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> (непрерывный блок памяти) — итерация по первому варианту означает произвольные обращения к памяти на каждый элемент, что для больших коллекций может быть на порядок медленнее.

Box, Rc, Arc — цена разных обёрток

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

ТипРазмер на стекеOverheadКогда использовать
Box<T>8 байт (указатель)одна аллокация, без счётчика ссылокединоличное владение, рекурсивные типы, dyn Trait
Rc<T>8 байтневизуальная heap-аллокация счётчиков, но без atomic-инструкцийразделяемое владение в одном потоке
Arc<T>8 байтatomic inc/dec на каждый clone/dropразделяемое владение между потоками
&T8 байт (или 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 в сигнатурах функций, если владение не требуется — избегаете лишних аллокаций у вызывающей стороны.
  • Резервируйте capacity заранее (Vec::with_capacity), если размер коллекции известен или предсказуем — избегаете повторных реаллокаций с копированием.
  • Избегайте clone() "на автомате" — заимствование почти всегда быстрее и часто ничем не уступает по эргономике после привыкания к borrow checker.
  • Для горячих циклов проверяйте автовекторизацию через cargo asm — иногда небольшая перестановка кода включает SIMD-инструкции.
fn process(items: &[String]) -> usize { // &[String], не Vec<String> — не требует владения
    items.iter().map(|s| s.len()).sum()
}
🔑 Главный принцип: Rust даёт инструменты для контроля памяти на уровне C, но по умолчанию безопасен — сначала пишите идиоматичный safe-код, и лишь после профилирования применяйте специфичные оптимизации (custom allocator, unsafe, SIMD) к реально узким местам.