
Enum — одна из самых популярных фич Rust. Тип enum может иметь одно из значений в заданном множестве вариантов.
/// Foo имеет значение или 32-битного integer, или символа. enum Foo { Int(u32), Char(char), }
Значениями типа Foo могут быть или integer (например, вариант Foo::Int(3) с полезной нагрузкой 3), или символы (например, вариант Foo::Char('A') с полезной нагрузкой 'A'). struct можно считать AND-комбинациями их полей, а enum — OR-комбинациями их вариантов.
Этот пост посвящён удивительной оптимизации, выполняемой компилятором Rust с представлением в памяти значений enum, чтобы они занимали меньше места в памяти (спойлер: это не нишевая оптимизация). В общем случае, уменьшение размера значений может привести к ускорению программ, потому что значения передаются в регистрах CPU и в одну линию кэша CPU умещается больше значений.
Обычно размер enum равен размеру самой большой его полезной нагрузки плюс несколько дополнительных байтов для хранения тэга, указывающего, к какому варианту относится значение. В случае показанного выше типа Foo полезные нагрузки обоих вариантов занимают 4 байта, и нам понадобится ещё как минимум один дополнительный бит под тэг. Из-за требования выравнивания типов (type alignment, я не буду рассказывать о нём подробно) Rust использует под тэг 4 байта. То есть общий размер становится равным 8 байтам:
assert_eq!(std::mem::size_of::<Foo>(), 8);
(Все примеры кода можно запустить в Rust Playground.)
Чтобы объяснить оптимизацию, полезно и интересно будет разобраться, как представлены в памяти различные значения enum. Вот функция, выводящая представление любого значения Rust в сырых байтах:
/// Выводим представление в памяти значения типа T. fn print_memory_representation<T: std::fmt::Debug>(t: T) { print!("type={} value={t:?}: ", std::any::type_name::<T>()); let start = &t as *const _ as *const u8; for i in 0..std::mem::size_of::<T>() { print!("{:02x} ", unsafe {*start.offset(i as isize)}); } println!(); }
(Эта функция написана на основе поста десятилетней давности с Reddit.)
Давайте выполним её для нашего enum Foo.
print_memory_representation(Foo::Int(5)); // type=Foo value=Int(5): 00 00 00 00 05 00 00 00 // |-- tag --| |- value -| print_memory_representation(Foo::Char('A')); // type=Foo value=Char('A'): 01 00 00 00 41 00 00 00 // |-- tag --| |- value -|
Первым делом стоит отметить .что память компьютера имеет формат little endian, то есть младшие байты идут первыми. В 32-битном шестнадцатеричном виде число 5 выглядит, как 0x00000005, но в little endian оно представлено, как 05 00 00 00.
Из этого мы можем понять. что первые 4 байта — это тэг. Варианту integer был назначен тэг 0, а варианту-символу — тэг 1. Вторая группа из 4 байтов — это обычные значения полезной нагрузки. Стоит отметить, что заглавная буква «A» в шестнадцатеричном виде имеет в ASCII значение 41, поэтому и представлена в памяти, как 41 00 00 00.
Нишевая оптимизация
Наряду с общей схемой тэгов существует одна известная оптимизация размеров enum, называемая нишевой оптимизацией (niche optimization). Эта оптимизация работает для типов, в которых полезную нагрузку имеет только один из вариантов. Хорошим примером будет встроенный тип option:
enum Option<char> { None, Some(char), }
Исходя из анализа тэгов в предыдущем разделе, мы можем предположить, что enum будет иметь размер 8 байтов (самая большая нагрузка составляет 4 байта плюс 4 байта на тэг). Но на самом деле значения этого типа суммарно занимают в памяти всего 4 байта:
assert_eq!(std::mem::size_of::<Option<char>>(), 4);
Что же происходит? Компилятор Rust знает, что хотя char занимает до 4 байтов в памяти, не каждое значение из этих 4 байтов будет валидным значением char. Символ имеет всего примерно 221 валидных значений (по одному для каждой кодовой точки Unicode), а 4 байта поддерживают 232 различных значений. Компилятор выбирает один из этих недопустимых паттернов битов в качестве ниши, и представляет значение enum без использования тэгов. Вариант Some он представляет идентично char, а вариант None представляет с использованием ниши.
Возникает интересный вопрос: какую конкретно нишу использует Rust? Чтобы разобраться, давайте выведем представление в памяти:
let a: char = 'A' print_memory_representation(a); // type=char value='A': 41 00 00 00 print_memory_representation(Some(a)); // type=Option<char> value=Some('A'): 41 00 00 00 let none: Option<char> = None; print_memory_representation(none); // type=Option<char> value=None: 00 00 11 00
Как мы видим, представления 'A' и Some('A') в памяти идентичны. Rust представляет None при помощи 32-битного числа 0x00110000. Можно поискать и убедиться, что это число ровно на единицу больше максимальной валидной кодовой точки Unicode.
Что-то ещё, кроме нишевой оптимизации?
Я считал, что Rust больше не выполняет никаких оптимизаций, поэтому был приятно удивлён, когда обнаружил ещё одну.
Контекст кода — вложенные enum. Начнём с внутреннего enum
enum Inner { A(u32), B(u32), }
Если взглянуть на представление в памяти, то там всё будет так, как мы и ожидали: 8 байтов, в первых 4 байтах хранится тэг, а в последних — полезная нагрузка.
assert_eq!(std::mem::size_of::<Inner>(), 8); print_memory_representation(Inner::A(2)); // type=Inner value=A(2): 00 00 00 00 02 00 00 00 // |-- tag --| |- value -| print_memory_representation(Inner::B(3)); // type=Inner value=B(3): 01 00 00 00 03 00 00 00 // |-- tag --| |- value -|
А теперь добавим ещё одно enum, содержащее Inner в качестве полезной нагрузки:
enum Outer { C(u32), D(Inner), }
Я думал, что значения этого типа будут иметь размер 12 байтов — 8 байтов под максимальную полезную нагрузку Inner плюс 4 байта под тэг. Но это оказалось не так: значения занимали всего 8 байтов!
assert_eq!(std::mem::size_of::<Outer>(), 8);
Что здесь происходит?
Для начала давайте проверим, как значения типа Outer::C выглядят в памяти:
print_memory_representation(Outer::C(5)); // type=Outer value=C(5): 02 00 00 00 05 00 00 00 // |-- tag --| |- value -|
Мы уже видим что-то странное: Rust решил использовать тэг номер 2 для Outer::C , а не начинать с 0, как он делал это в случае Inner::A. Теперь взглянем на Outer::D:
print_memory_representation(Outer::D(Inner::A(2))); // type=Outer value=D(A(2)): 00 00 00 00 02 00 00 00 // |-- tag --| |- value -| print_memory_representation(Outer::D(Inner::B(3))); // type=Outer value=D(B(3)): 01 00 00 00 03 00 00 00 // |-- tag --| |- value -|
Представление значения Outer::D(inner) идентично представлению inner!
Предполагаю, что компилятор Rust рассуждал так:
-
Первые 4 байта
Innerобразуют тэг, значениями которого могут быть только 0 или 1. В нём можно хранить гораздо больше значений, как и в случае нишевой оптимизации. -
Полезная нагрузка для каждого другого варианта
Outerне больше, чем любая из полезных нагрузокInner. В частности, если значенияInnerимеют вид<Inner tag><Inner payload>, то полезная нагрузка для всех других вариантовOuterумещается внутри<Inner payload>.
Таким образом, мы можем представить значения Outer в виде <Outer tag><Outer remainder>
-
Если
<Outer tag>соответствует какому-то из<Inner tag>, то значение равноOuter::D, а полезная нагрузка представляет собой полный паттерн битов<Outer tag><Outer remainder>. -
В противном случае, значение — это ещё один вариант, а полезная нагрузка — это
<Outer remainder>.
ссылка на оригинал статьи https://habr.com/ru/articles/899834/
Добавить комментарий