Box в Rust: как уменьшить память программы с 895 до 420 МБ
Минус 53% памяти без переписывания логики — только layout структур и custom Deserializer. Разбираем приём, который вытягивает любой проект с deeply-nested DTO.
Если ваши 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». Эти файлы содержат тысячи объявлений вроде такого:
Как принято в Rust-коде, для разбора используется serde. Соответствующие структуры выглядят естественно — это обычная вложенность структур со множеством опциональных полей и serde-атрибутов. Не нужно вчитываться в код: обратите внимание только на сами имена полей и на россыпь Option<...>:
Это типичный для 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):
Композиция структур
Дальше — самое важное. Что будет, если такая структура встроена в другую? Пусть это будет Container1, у которой одно поле Option<String> и одно — SmithyServiceTrait:
Размер Container1 — 24 + 120 = 144 байта: 24 под Option<String> и 120 под SmithyServiceTrait. А что, если поле trait сделать опциональным — на случай, когда trait отсутствует?
Сколько занимает Container2, когда оба поля None? Те же 144 байта. Опциональность ничего не сэкономила: место под структуру уже зарезервировано в layout родителя, и компилятор всё равно держит его готовым. Везёт ещё, что внутри SmithyServiceTrait только поля Option<String> — это позволяет компилятору не добавлять дополнительный байт-discriminant для внешнего Option.
Применив эту арифметику к SmithyTraits, видно, почему наивная реализация так разбухает в памяти.
Почему в Java и других языках с GC такой проблемы нет
В языках с reference-семантикой полей композиция работает иначе. Когда у класса Container есть поле-объект, это поле — указатель на heap. Если поле null, оно занимает только одно слово.
Чтобы добиться того же поведения в Rust — то есть чтобы пустое поле занимало одно слово, — нужно явно вынести содержимое на heap, обернув в Box:
Теперь, когда оба поля None, Container3 занимает всего 32 байта: 3 слова под Option<String> и одно слово под Option<Box<...>>. Та же niche-оптимизация работает и здесь: Option<Box<...>> весит столько же, сколько голый Box<...>.
Что именно поменялось
Изменение свелось к трём шагам:
- Найти структуры, которые часто оказываются «пустыми» (все поля —
None). - Сделать их опциональными в родительской структуре, переместив на heap через
Box. - Написать кастомный
Deserializer, чтобы пустые структуры вообще не попадали в результат.
Было:
Стало:
Аналогично, в SmithyShape все поля Option<SmithyReference> заменены на Option<Box<SmithyReference>>. Местами пришлось поправить акцессоры, потому что путь к данным удлинился на одну разыменовку. И всё — память на хранение десериализованных AWS-shape снизилась вдвое, экономия 475 МБ.
Подводные камни
- CPU чуть дороже. Десериализация полного объекта всё равно происходит — а потом он отбрасывается, если оказался пустым. Парадокс: суммарно задача стала быстрее, потому что меньше памяти = меньше работы аллокатора, меньше TLB-промахов, лучше попадания в L2/L3-кеш. На больших объёмах эти эффекты перевешивают накладные расходы на лишний разбор.
- Фрагментация heap. Много мелких
Boxведёт к фрагментированной куче. В авторском случае это не было проблемой, но в долгоживущих сервисах — стоит держать в уме.
Как проверить, что экономия реальная
Чутьё подсказывает, где можно сэкономить и сколько примерно. Но без замеров — не работа. В Rust нет простого способа узнать, сколько весит композитный объект со всеми полями и аллокациями за указателями. Решение автора — использовать аллокатор, умеющий отдавать статистику. В стандартной поставке такие данные ограничены, поэтому подключается jemalloc:
В Cargo.toml объявляется feature-флаг profile, чтобы не таскать jemalloc в обычные сборки:
В main.rs аллокатор подключается под этим же флагом:
И в самой функции загрузки структур делается замер: до и после.
Совет автора: tikv_jemalloc_ctl отдаёт ещё кучу деталей, которые полезно мониторить в серверных приложениях.
FAQ
Box даёт overhead на каждый доступ — это того стоит?
Один доступ через Box — это разыменовка указателя, на хороших cache hit-ах она почти бесплатна. Реальная цена видна только в hot loop с миллиардами обращений и плохой локальностью данных. В случае автора 475 МБ выигрыша перевесили — программа в итоге стала быстрее, потому что меньше памяти = меньше работы аллокатора и кеша.
Почему Option<Big> не сжимается до одного указателя автоматически?
Потому что Rust держит layout структур плоским — это и есть фундаментальное отличие от Java/Python/JS. Поле в структуре всегда занимает в памяти своё место, независимо от значения. Option для типа без niche добавляет один байт под discriminant. Niche-оптимизация (как у Option<String> или Option<Box>) — счастливый частный случай, не общее правило.
Зачем кастомный Deserializer? Достаточно просто заменить тип на Option<Box>?
Если просто заменить — serde будет добросовестно создавать пустые структуры в куче и оборачивать их в Some(Box::new(empty_struct)). Это хуже исходного варианта: добавляется аллокация на каждое пустое поле. Кастомный Deserializer проверяет, что структура «пустая» (все её опциональные поля — None), и возвращает None на верхнем уровне.
Мои структуры не из serde — приём работает?
Да, идея универсальная. Любая структура с тяжёлыми опциональными полями (deeply-nested DTO, AST, дерево настроек, конфиги, дерево виджетов в GUI) выиграет от Option<Box<...>>. Serde — просто частый источник таких структур, потому что разбор внешнего формата редко оптимизируется по layout. В рукописном коде вместо кастомного Deserializer просто делаем поле опциональным и сами решаем, когда обернуть в Box.
Где смотреть, как объект реально лежит в памяти?
Для статического размера типа — 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.