Ответ на вопрос.
Rust применяет стратегию оптимизации представления, известную как заполнение нишевых значений, для устранения затрат на хранение дискриминантов перечислений, когда варианты содержат типы с недопустимыми битовыми шаблонами. Компилятор выявляет "нишевые" значения в представимом диапазоне типа—например, нулевое значение для NonZeroU32 или нулевой указатель для ссылок—и перерабатывает эти битовые шаблоны для кодирования других вариантов перечислений, таких как None. Эта трансформация зависит от того, что тип нагрузки обладает ограниченным диапазоном действительности, определяемым его внутренними свойствами или атрибутами rustc_layout. Чтобы тип мог служить действительным носителем ниши, он должен иметь хотя бы один битовый шаблон, который представляет неопределенное поведение, позволяя компилятору зарезервировать этот шаблон для альтернативных вариантов перечисления без выделения дополнительного пространства для дискриминанта.
Ситуация из жизни
Во время разработки высокочастотного торгового движка наша команда столкнулась с сильным давлением на кэш при хранении миллионов временных меток заказа в Vec<Option<u64>>. Каждая опциональная временная метка занимала 16 байт из-за выравнивания и накладных расходов на дискриминант, несмотря на то, что сами временные метки были строго положительными значениями времени Unix. Нам срочно нужно было сократить объем памяти, не жертвуя безопасностью и не переходя на сырые указатели, что усложнило бы гарантии Send и Sync, необходимые для обработки между потоками.
Один из рассмотренных подходов заключался в ручной упаковке битов с использованием сырых u64 значений и фиксированных нулевых значений с небезопасными функциями преобразования. Это решение обещало максимальную эффективность использования памяти, но ввело катастрофические риски: логическая ошибка могла создать недопустимый NonZeroU64 или разыменовать нулевой указатель, замаскированный под ноль, что нарушало бы инварианты безопасности памяти Rust. Более того, это требовало бы обширных журналов проверок и unsafe блоков, которых команда старалась избежать.
Другим вариантом было использование Optionstd::num::NonZeroU64 напрямую, используя гарантированную оптимизацию ниши стандартной библиотеки. Этот подход сохранял полную безопасностью типа и эргономичные выражения match, при этом гарантируя, что Option занимал ровно 8 байт вместо 16. Основным ограничением было то, что мы должны были гарантировать, что временные метки никогда не равны нулю, что действительно соблюдалось нашей логикой домена, поскольку все временные метки были после 1970 года.
Мы выбрали второе решение, рефакторируя наш новый тип Timestamp для оборачивания NonZeroU64 и валидации вводов на границе системы. В результате мы снизили использование памяти на 50% для нашего основного кэша книги заказов. Эта оптимизация устранила тряску кэша и улучшила задержку поиска на 30%, все это было достигнуто без единой строки unsafe кода.
Что часто упускают кандидаты
Почему Option<u32> занимает 8 байт, в то время как Option<NonZeroU32> занимает всего 4, и как эта оптимизация работает с вложенными типами, такими как Option<Option<NonZeroU32>>?
Тип u32 допускает все 2^32 битовых шаблонов как действительные, не оставляя "свободного" битового шаблона для компилятора для переработки в вариант None. Следовательно, компилятору необходимо добавить дискриминантный байт (выравненный до 4 байт), в результате чего получается 8 байт в общем. Напротив, NonZeroU32 явно указывает, что битовый шаблон 0x00000000 недопустим, создавая нишю, которую Rust использует для кодирования None, что позволяет результирующему Option занимать ровно 4 байта.
Для вложенных структур оптимизация эффективно цепляется: Option<Option<NonZeroU32>> остается 4 байта, поскольку внешний Option использует другой недопустимый битовый шаблон (например, 0x00000001) из доступного пространства ниши NonZeroU32. Эта рекурсивная оптимизация продолжается, пока носитель типа обладает достаточным количеством недопустимых битовых шаблонов, чтобы вмещать все значения дискриминанта перечисления.
Как явные атрибуты представления, такие как #[repr(C)] или #[repr(u8)], взаимодействуют с оптимизацией ниш и почему это взаимодействие важно для границ FFI?
При применении #[repr(C)] или #[repr(u8)] программист требует фиксированное представление памяти, где дискриминант занимает определенный смещение с заданным размером. Это явное представление фактически отключает оптимизацию ниш, обеспечивая совместимость ABI с C-структурами, которые ожидают явные метки, но принуждая перечисление занимать дополнительное пространство для дискриминанта.
В контексте FFI это различие становится критически важным, потому что код на C ожидает дискриминант в предсказуемом, стабильном смещении. Передача оптимизированного по нишам Rust перечисления без явных атрибутов repr через границу приводит к неопределенному поведению, в то время как #[repr(C)] гарантирует стабильность представления при необходимой ценой снижения эффективности памяти.
Что мешает MaybeUninit<T> служить в качестве носителя ниши для оптимизации перечисления, даже когда T сам по себе имеет недопустимые битовые шаблоны, такие как в Option<MaybeUninit<NonZeroU32>>?
MaybeUninit<T> архитектурно спроектирован для удержания любого битового шаблона без вызова неопределенного поведения, так как его цель — представлять потенциально неинициализированную память. Следовательно, компилятор трактует MaybeUninit<T> как не имеющий недопустимых битовых шаблонов, что означает, что его диапазон действительности охватывает все 2^(8*sizeof(T)) возможные комбинации битов. Эта общая действительность исключает любые доступные ниши, которые могли бы быть переработаны для оптимизации перечисления, независимо от свойств T.
Таким образом, Option<MaybeUninit<NonZeroU32>> занимает 8 байт—размер MaybeUninit<u32> плюс выравнивание для дискриминанта—несмотря на то, что основное NonZeroU32 имеет ограниченную действительность. Это поведение иллюстрирует, что оптимизация ниши работает строго на основании ограничений действительности непосредственного типа, а не транзитивных свойств его потенциального содержимого.