Box в Rust: как уменьшить память программы с 895 до 420 МБ

Минус 53% памяти без переписывания логики — только layout структур и custom Deserializer. Разбираем приём, который вытягивает любой проект с deeply-nested DTO.

Обложка: Box в Rust: как уменьшить память программы с 895 до 420 МБ

Если ваши Rust-структуры с Option<BigStruct> заполнены процентов на 20–30 — следующие 5 минут вероятно вернут вам сотни мегабайт RAM. Разработчик Дени «Canop» де Майе (автор файлового менеджера broot) сократил потребление памяти своей программы с 895 МБ до 420 МБ — это минус 475 МБ. Изменил только layout структур и стратегию десериализации serde. Перевод его статьи с пояснениями.

Главная идея: в Rust поле типа Option<BigStruct> занимает столько же, сколько BigStruct — даже когда оно None. Чтобы пустое поле «весило одно слово», нужно обернуть значение в Box. Компилятор тогда применяет niche-оптимизацию (использует нулевой указатель как маркер None) — Option<Box<T>> весит ровно как один указатель. На задачах с массой опциональных полей это даёт двукратное сокращение памяти.

Кратко
  • Кейс: десериализация всех JSON-моделей AWS SDK (Smithy Shapes) через serde в Rust
  • Было: 895 МБ. Стало: 420 МБ. Экономия 475 МБ (-53%)
  • Изменения: только layout структур + кастомный Deserializer для пустых полей
  • Ключевая мысль: Option<Big>Option<Box<Big>> по памяти
  • Niche-оптимизация компилятора: Option<Box<T>> весит как один указатель
  • Verification: jemalloc через feature-флаг profile, замер до/после загрузки
  • Цена: десериализация чуть дороже по CPU (значение всё равно создаётся, потом отбрасывается)

Реальный кейс

Программа автора десериализует все JSON-файлы из репозитория awslabs/aws-sdk-rust/aws-models в структуры «Smithy Shape». Эти файлы содержат тысячи объявлений вроде такого:

			"com.amazonaws.iam#EnableOrganizationsRootSessionsResponse": {
  "type": "structure",
  "members": {
    "OrganizationId": {
      "target": "com.amazonaws.iam#OrganizationIdType",
      "traits": {
        "smithy.api#documentation": "<p>The unique identifier (ID) of an organization.</p>"
      }
    },
    "EnabledFeatures": {
      "target": "com.amazonaws.iam#FeaturesListType",
      "traits": {
        "smithy.api#documentation": "<p>The features you have enabled for centralized root access.</p>"
      }
    }
  },
  "traits": {
    "smithy.api#output": {}
  }
}
		

Как принято в Rust-коде, для разбора используется serde. Соответствующие структуры выглядят естественно — это обычная вложенность структур со множеством опциональных полей и serde-атрибутов. Не нужно вчитываться в код: обратите внимание только на сами имена полей и на россыпь Option<...>:

			#[derive(Clone, Deserialize, Serialize)]
pub struct SmithyShape {
    #[serde(rename = "type")]
    pub shape_type: SmithyShapeType,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub operations: Vec<SmithyReference>,
    #[serde(default)]
    pub members: FxHashMap<String, SmithyReference>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub key: Option<SmithyReference>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub value: Option<SmithyReference>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub member: Option<SmithyReference>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub input: Option<SmithyReference>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub output: Option<SmithyReference>,
    #[serde(default)]
    pub traits: SmithyTraits,
}

#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct SmithyTraits {
    #[serde(rename = "smithy.api#title", skip_serializing_if = "Option::is_none")]
    pub title: Option<String>,
    #[serde(rename = "aws.api#service", skip_serializing_if = "Option::is_none")]
    pub service: Option<SmithyServiceTrait>,
    #[serde(rename = "smithy.api#sensitive", skip_serializing_if = "Option::is_none")]
    pub sensitive: Option<SmithySensitiveTrait>,
    #[serde(rename = "smithy.api#documentation", skip_serializing_if = "Option::is_none")]
    pub documentation: Option<String>,
    #[serde(rename = "smithy.api#pattern", skip_serializing_if = "Option::is_none")]
    pub pattern: Option<String>,
    #[serde(rename = "aws.iam#iamAction", skip_serializing_if = "Option::is_none")]
    pub iam_action: Option<SmithyIamAction>,
}
		

Это типичный для Rust код. Но именно такой layout приводит к тому, что после десериализации структуры занимают 895 МБ. При этом анализ показывает: большинство опциональных строк отсутствуют — это и стало рычагом для радикального сокращения объёма. Чтобы понять механизм, нужен короткий теоретический отступ.

Как устроены структуры в памяти Rust

На 64-битной платформе одно «слово» — это 8 байт. Например, столько занимает usize.

Тип String занимает 3 слова: указатель на буфер, длину и ёмкость (capacity). Это 24 байта на сам тип String (проверить можно через dbg!(std::mem::size_of::<String>())) — не считая место под содержимое строки на heap. Подробности в документации Rust.

Существует niche-оптимизация компилятора: Option<String> весит столько же, сколько String. Дополнительный байт под пометку «это None» не нужен — пустое значение определяется по нулевому указателю.

Поэтому такая структура, когда все строки None, занимает ровно 120 байт (5 × 24):

			pub struct SmithyServiceTrait {
    pub sdk_id: Option<String>,
    pub arn_namespace: Option<String>,
    pub cloud_formation_name: Option<String>,
    pub cloud_trail_event_source: Option<String>,
    pub endpoint_prefix: Option<String>,
}
		

Композиция структур

Дальше — самое важное. Что будет, если такая структура встроена в другую? Пусть это будет Container1, у которой одно поле Option<String> и одно — SmithyServiceTrait:

			pub struct Container1 {
    pub some_string: Option<String>,
    #[serde(default)]
    pub r#trait: SmithyServiceTrait,
}
		

Размер Container124 + 120 = 144 байта: 24 под Option<String> и 120 под SmithyServiceTrait. А что, если поле trait сделать опциональным — на случай, когда trait отсутствует?

			pub struct Container2 {
    pub some_string: Option<String>,
    #[serde(default)]
    pub r#trait: Option<SmithyServiceTrait>,
}
		

Сколько занимает Container2, когда оба поля None? Те же 144 байта. Опциональность ничего не сэкономила: место под структуру уже зарезервировано в layout родителя, и компилятор всё равно держит его готовым. Везёт ещё, что внутри SmithyServiceTrait только поля Option<String> — это позволяет компилятору не добавлять дополнительный байт-discriminant для внешнего Option.

Применив эту арифметику к SmithyTraits, видно, почему наивная реализация так разбухает в памяти.

Почему в Java и других языках с GC такой проблемы нет

В языках с reference-семантикой полей композиция работает иначе. Когда у класса Container есть поле-объект, это поле — указатель на heap. Если поле null, оно занимает только одно слово.

			class Container {
    String someString;
    SmithyServiceTrait trait;
}
		

Чтобы добиться того же поведения в Rust — то есть чтобы пустое поле занимало одно слово, — нужно явно вынести содержимое на heap, обернув в Box:

			pub struct Container3 {
    pub some_string: Option<String>,
    pub r#trait: Option<Box<SmithyServiceTrait>>,
}
		

Теперь, когда оба поля None, Container3 занимает всего 32 байта: 3 слова под Option<String> и одно слово под Option<Box<...>>. Та же niche-оптимизация работает и здесь: Option<Box<...>> весит столько же, сколько голый Box<...>.

Что именно поменялось

Изменение свелось к трём шагам:

  1. Найти структуры, которые часто оказываются «пустыми» (все поля — None).
  2. Сделать их опциональными в родительской структуре, переместив на heap через Box.
  3. Написать кастомный Deserializer, чтобы пустые структуры вообще не попадали в результат.

Было:

			#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SmithyReference {
    pub target: ShortShapeId,
    #[serde(default)]
    pub traits: SmithyTraits,
}
		

Стало:

			#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SmithyReference {
    pub target: ShortShapeId,
    #[serde(
        default,
        deserialize_with = "deserialize_boxed_traits",
        serialize_with = "serialize_boxed_traits"
    )]
    pub traits: Option<Box<SmithyTraits>>,
}

fn deserialize_boxed_traits<'de, D: Deserializer<'de>>(
    deserializer: D,
) -> Result<Option<Box<SmithyTraits>>, D::Error> {
    let traits = SmithyTraits::deserialize(deserializer)?;
    if traits.is_empty() {
        // i.e. when all optional strings are None
        Ok(None)
    } else {
        Ok(Some(Box::new(traits)))
    }
}
		

Аналогично, в SmithyShape все поля Option<SmithyReference> заменены на Option<Box<SmithyReference>>. Местами пришлось поправить акцессоры, потому что путь к данным удлинился на одну разыменовку. И всё — память на хранение десериализованных AWS-shape снизилась вдвое, экономия 475 МБ.

Подводные камни

  • CPU чуть дороже. Десериализация полного объекта всё равно происходит — а потом он отбрасывается, если оказался пустым. Парадокс: суммарно задача стала быстрее, потому что меньше памяти = меньше работы аллокатора, меньше TLB-промахов, лучше попадания в L2/L3-кеш. На больших объёмах эти эффекты перевешивают накладные расходы на лишний разбор.
  • Фрагментация heap. Много мелких Box ведёт к фрагментированной куче. В авторском случае это не было проблемой, но в долгоживущих сервисах — стоит держать в уме.

Как проверить, что экономия реальная

Чутьё подсказывает, где можно сэкономить и сколько примерно. Но без замеров — не работа. В Rust нет простого способа узнать, сколько весит композитный объект со всеми полями и аллокациями за указателями. Решение автора — использовать аллокатор, умеющий отдавать статистику. В стандартной поставке такие данные ограничены, поэтому подключается jemalloc:

В Cargo.toml объявляется feature-флаг profile, чтобы не таскать jemalloc в обычные сборки:

			[features]
profile = ["tikv-jemallocator", "tikv-jemalloc-ctl"]

[dependencies]
tikv-jemallocator = { optional = true, version = "0.6", features = ["stats", "profiling"] }
tikv-jemalloc-ctl = { optional = true, version = "0.6", features = ["stats"] }
		

В main.rs аллокатор подключается под этим же флагом:

			#[cfg(feature = "profile")]
#[global_allocator]
static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
		

И в самой функции загрузки структур делается замер: до и после.

			#[cfg(feature = "profile")]
fn allocated_mb() -> usize {
    tikv_jemalloc_ctl::epoch::advance().unwrap();
    tikv_jemalloc_ctl::stats::allocated::read().unwrap_or(0) / (1024 * 1024)
}

#[cfg(feature = "profile")]
let base = allocated_mb();
// ... load all the shapes ...
#[cfg(feature = "profile")]
eprintln!(
    "Memory used for the shapes = {} MB (total)",
    allocated_mb() - base
);
		

Совет автора: tikv_jemalloc_ctl отдаёт ещё кучу деталей, которые полезно мониторить в серверных приложениях.

FAQ
1
Box даёт overhead на каждый доступ — это того стоит?

Один доступ через Box — это разыменовка указателя, на хороших cache hit-ах она почти бесплатна. Реальная цена видна только в hot loop с миллиардами обращений и плохой локальностью данных. В случае автора 475 МБ выигрыша перевесили — программа в итоге стала быстрее, потому что меньше памяти = меньше работы аллокатора и кеша.

2
Почему Option&lt;Big&gt; не сжимается до одного указателя автоматически?

Потому что Rust держит layout структур плоским — это и есть фундаментальное отличие от Java/Python/JS. Поле в структуре всегда занимает в памяти своё место, независимо от значения. Option для типа без niche добавляет один байт под discriminant. Niche-оптимизация (как у Option<String> или Option<Box>) — счастливый частный случай, не общее правило.

3
Зачем кастомный Deserializer? Достаточно просто заменить тип на Option&lt;Box&gt;?

Если просто заменить — serde будет добросовестно создавать пустые структуры в куче и оборачивать их в Some(Box::new(empty_struct)). Это хуже исходного варианта: добавляется аллокация на каждое пустое поле. Кастомный Deserializer проверяет, что структура «пустая» (все её опциональные поля — None), и возвращает None на верхнем уровне.

4
Мои структуры не из serde — приём работает?

Да, идея универсальная. Любая структура с тяжёлыми опциональными полями (deeply-nested DTO, AST, дерево настроек, конфиги, дерево виджетов в GUI) выиграет от Option<Box<...>>. Serde — просто частый источник таких структур, потому что разбор внешнего формата редко оптимизируется по layout. В рукописном коде вместо кастомного Deserializer просто делаем поле опциональным и сами решаем, когда обернуть в Box.

5
Где смотреть, как объект реально лежит в памяти?

Для статического размера типа — std::mem::size_of::<T>(). Для динамического (с учётом heap-аллокаций) — внешний инструментарий: jemalloc-ctl, как в статье; для тяжёлых случаев — heaptrack или dhat-rs.

Что запомнить из статьи

  • Применяйте Option<Box<T>> только там, где поле пусто в большинстве случаев — иначе heap-fragmentation и разыменовка перевесят выигрыш.
  • Размер типа узнать просто: std::mem::size_of::<T>(). Реальное потребление с heap — только через jemalloc-ctl, dhat-rs или heaptrack.
  • Niche-оптимизация работает не для всего: String, Box, Vec, &T — да; кастомный enum без явных #[repr] — обычно нет.
  • При desered-через-serde: голая замена типа на Option<Box> не даёт выигрыша — нужен кастомный Deserializer, который вернёт None для «пустых» структур.
  • Перед оптимизацией — всегда замер. После оптимизации — снова замер. Без цифр это карго-культ.

От редакции

Один практический совет от редакции: чтобы быстро найти кандидатов на оптимизацию в большом проекте, прогоните код через dhat-rs — это профайлер аллокаций от автора «Rust Performance Book». Он покажет, какие типы съедают больше всего heap. Альтернатива Option<Box<T>>arena-аллокаторы вроде bumpalo: если вся группа объектов живёт один пакетный жизненный цикл, разовый bump-аллокатор может оказаться эффективнее множества мелких Box.

Если интересна тема Rust в крупных продакшенах, у Tproger есть свежий материал про то, как WhatsApp переписал медиадвижок на Rust и выкинул 160 тысяч строк C++ — там тоже про экономию памяти и скорость, только в другом масштабе.

Источник: «Box to save memory» — dystroy.org/blog. Автор оригинала — Дени «Canop» де Майе, разработчик файлового менеджера broot и других open-source утилит на Rust.