Раскладка в памяти и repr
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Раскладка в памяти и repr
Прошлый урок был про код: инструкции, регистры, переходы. Сегодня вторая половина машинного мира, данные: как структура ложится в байты, откуда в ней пустые места, зачем нужны
repr(C)иrepr(packed)и за счёт какого фокусаOption<Box<T>>не платит за свойNoneни байта.
Идея
Начнём с задачки на одну строку. Сколько байтов занимает эта структура?
struct Player {
health: u8,
position: u64,
armor: u8,
}
Интуиция складывает поля: 1 + 8 + 1, десять байт. Компилятор отвечает иначе:
use std::mem::size_of;
fn main() {
println!("{}", size_of::<Player>()); // 16
}
Шестнадцать. А пометь ту же структуру атрибутом #[repr(C)], и станет 24. Одна структура, три ответа, и ни один не совпал с суммой полей.
Зазор берётся не из каприза компилятора, а из устройства железа: процессору удобно читать u64 с адреса, кратного восьми, и компилятор обязан это удобство обеспечить. Поэтому у каждого типа две машинные характеристики, размер и выравнивание, и только их Rust гарантирует. Всё остальное, порядок полей и расположение пустот, по умолчанию отдано компилятору на откуп. Раскладка это контракт между типом и машиной: для своих структур хватает умолчания, а на границе, где байты уходят к другому языку, процессу или на диск, контракт прибивают явно атрибутом repr.
Зачем это знать, помимо любопытства: чтобы читать дампы и FFI-сигнатуры, чтобы не раздувать горячие структуры вдвое на ровном месте, и чтобы в следующем уроке осознанно взять раскладку в свои руки. Инструментов понадобится три: size_of, align_of и offset_of. Весь урок это три функции плюс короткий набор правил.
Размер и выравнивание
Каждый тип отвечает машине на два вопроса: сколько байтов занимает значение и на какие адреса оно имеет право ложиться. Второй ответ называется выравнивание. У примитивов оба ответа просты:
| Тип | Размер | Выравнивание |
|---|---|---|
u8, i8, bool | 1 | 1 |
u16, i16 | 2 | 2 |
u32, i32, f32, char | 4 | 4 |
u64, i64, f64, usize | 8 | 8 |
Цифры даны для 64-битной машины: usize повторяет ширину адреса. У примитивов выравнивание совпадает с размером, и это не совпадение: память приезжает в процессор кусками, и значение, лежащее по кратному адресу, целиком помещается в один кусок. Невыровненное чтение x86 переживёт, но медленнее, особенно на границе кэш-линии; часть архитектур и все атомарные операции не переживут вовсе. Поэтому Rust даёт жёсткую гарантию: значение типа T всегда живёт по адресу, кратному align_of::<T>().
Теперь указатели, и здесь стоит проговорить размеры вслух, потому что они объясняют половину «толстых» типов:
use std::mem::size_of;
assert_eq!(size_of::<&u64>(), 8); // обычная ссылка: одно слово
assert_eq!(size_of::<Box<u64>>(), 8); // владеющий указатель: тоже одно
assert_eq!(size_of::<&[u8]>(), 16); // срез: указатель плюс длина
assert_eq!(size_of::<&str>(), 16); // строковый срез: то же самое
assert_eq!(size_of::<String>(), 24); // указатель, длина, ёмкость
assert_eq!(size_of::<Vec<u64>>(), 24); // как String, только дженерик
Ссылка весит слово. Срез весит два: это толстый указатель из урока про трейт-объекты, и именно его ты видел в дизассемблере, когда срез приехал в функцию двумя регистрами. String и Vec весят три слова, а вот в каком порядке внутри лежат указатель, длина и ёмкость, не обещано: это обычные структуры с раскладкой по умолчанию.
На другом полюсе живут типы нулевого размера: (), пустые структуры, PhantomData. Их size_of равен нулю, и это не курьёз, а рабочий инструмент: Vec<()> не аллоцирует ни байта, HashSet<T> внутри это HashMap<T, ()> и пустые значения не стоят ничего. Машина о таких значениях не знает, они существуют только для системы типов.
Стек против кучи, теперь честно
Картинку из урока про владение теперь можно не рисовать, а измерить:
let v: Vec<u8> = vec![1, 2, 3];
let on_stack = &v as *const Vec<u8> as usize; // адрес самой структуры
let on_heap = v.as_ptr() as usize; // адрес данных
println!("{on_stack:#x}");
println!("{on_heap:#x}");
Два адреса из разных миров. В стековом кадре, том самом, который ты разглядывал в прошлом уроке, лежат ровно 24 байта заголовка Vec: указатель, длина, ёмкость. Сами элементы живут в куче, по второму адресу. Теперь у фразы «move это дёшево» есть точная цена: let w = v; копирует 24 байта заголовка и не трогает кучу, сколько бы гигабайт там ни лежало. И отсюда же видно, почему Vec не Copy: две копии заголовка означали бы два владельца одной кучи и двойное освобождение.
Попутно умирает популярный миф «куча это медленная память». Память та же самая, дорога не куча, а аллокация: поход к аллокатору против сдвига rsp на восемь байт. Плюс данные за указателем лежат не подряд с остальными полями, и кэшу приходится прыгать. Цена кучи это цена аллокаций и прыжков, а не какой-то особой медленной микросхемы.
Padding: пустоты между полями
Вернёмся к Player и разберём, откуда 24 байта в версии с #[repr(C)], где компилятор обязан класть поля строго в порядке объявления:
#[repr(C)]
struct PlayerC {
health: u8,
position: u64,
armor: u8,
}
health ложится на смещение 0 и занимает байт. Следующее свободное место, смещение 1, для position запрещено: u64 требует адрес, кратный восьми. Компилятор вставляет семь пустых байт и кладёт position на смещение 8. Эти пустоты называются padding. armor занимает смещение 16, и остаётся хвост: размер структуры обязан быть кратен её выравниванию, иначе во втором элементе массива [PlayerC; 2] поле position съехало бы с кратного адреса. Итого ещё семь байт в хвост и размер 24:
байт: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
поле: h . . . . . . . p p p p p p p p a . . . . . . .
Буква это байт поля, точка это padding. Полезных байтов десять, пустых четырнадцать. Формула, которой компилятор считает каждое смещение, тебе уже знакома: это align_up из урока про биты, округление вверх до кратного через (offset + align - 1) & !(align - 1).
Расчёт проверяется макросом offset_of:
use std::mem::offset_of;
assert_eq!(offset_of!(PlayerC, health), 0);
assert_eq!(offset_of!(PlayerC, position), 8);
assert_eq!(offset_of!(PlayerC, armor), 16);
В C с этим живут так: сортируют поля руками, от толстых к тонким. Поставь position первым, и health с armor лягут подряд за ним, структура похудеет с 24 до 16. В Rust по умолчанию эта работа не твоя, и вот почему.
repr(Rust): компилятор пересобирает структуру
У раскладки по умолчанию одно обещание: каждое поле выровнено. Порядок полей не обещан вовсе, и rustc этим пользуется, переставляя поля так, чтобы padding съёжился. Для Player без атрибутов:
use std::mem::offset_of;
println!("{}", offset_of!(Player, position)); // 0
println!("{}", offset_of!(Player, health)); // 8
println!("{}", offset_of!(Player, armor)); // 9
position уехал в начало, два однобайтовых поля легли подряд за ним: между полями паддинга не осталось, только шесть байт в хвосте до кратного восьми. Отсюда и 16 из начала урока. Цифры выше намеренно напечатаны, а не зашиты в assert: смещения в раскладке по умолчанию не зафиксированы. Компилятор вправе поменять стратегию в следующей версии, а флагом -Z randomize-layout поля перемешивают нарочно, чтобы выловить код, который незаконно полагается на текущий порядок.
Из этого следуют две вещи. Приятная: в Rust не нужно сортировать поля руками, перестановка полей в исходнике не меняет размер. Опасная: раз раскладка не обещана, структуру нельзя записать на диск «как лежит» и прочитать другой сборкой, нельзя отдать C-библиотеке, нельзя послать по сети. Для всех этих границ существует семейство repr.
Семейство repr
#[repr(C)] включает правила C: поля в порядке объявления, padding по формуле из прошлого раздела. Это язык границ: FFI, где extern "C" функции обмениваются структурами; общая память между процессами; ручной контроль раскладки, когда он нужен по делу. Цена: padding снова твоя забота. И одна ловушка на всю жизнь: содержимое padding-байтов не определено, компилятор в них не пишет. Сдампить repr(C) структуру на диск или в хеш-функцию вместе с паддингом значит сдампить мусор, который меняется от запуска к запуску.
#[repr(packed)] сжимает align всех полей до единицы, padding исчезает:
#[repr(C, packed)]
struct WireHeader {
kind: u8,
length: u32,
}
assert_eq!(size_of::<WireHeader>(), 5);
Звучит как бесплатная экономия, но length теперь лежит по невыровненному адресу. Прочитать поле копией можно, let len = h.length; компилируется в специальное невыровненное чтение. А ссылку взять нельзя: &h.length это ошибка компиляции E0793, потому что ссылка по контракту обещает выровненный адрес, и нарушенное обещание стало бы неопределённым поведением где-то дальше по коду. packed это инструмент последней мили для бинарных форматов, и даже там в следующем уроке мы предпочтём способ честнее: явное чтение байтов.
#[repr(transparent)] это атрибут для newtype из урока про структуры: гарантия, что обёртка с единственным непустым полем раскладывается ровно как это поле. Meters(f64) с таким атрибутом можно передавать через FFI-границу всюду, где ждут голый f64. Тогда обёртка «исчезала при компиляции» молча, по воле компилятора, теперь это исчезновение записано в контракте типа.
#[repr(u8)] на enum без данных прибивает тип дискриминанта:
#[repr(u8)]
enum Opcode {
Nop = 0x00,
Load = 0x01,
Store = 0x02,
}
assert_eq!(size_of::<Opcode>(), 1);
assert_eq!(Opcode::Store as u8, 2);
Без атрибута размер тега не обещан. С атрибутом enum становится честным байтом: так будет выглядеть декодер опкодов в эмуляторе, байт из памяти в одну сторону, as u8 в другую.
#[repr(align(64))] решает обратную задачу, поднимает выравнивание:
#[repr(align(64))]
struct PerCoreCounter(u64);
64 это размер кэш-линии: такой счётчик гарантированно не делит линию с соседом. Зачем это всерьёз, увидишь в блоке про многопоточность: когда два ядра пишут в одну кэш-линию, они дерутся за неё на каждом обращении, и repr(align) это штатное лекарство.
Правило выбора короткое: по умолчанию никакого repr. Атрибут появляется на границе: с другим языком, с другим процессом, с диском, с сетью, с железом.
Раскладка enum
Из урока про enum ты знаешь, что вариант хранится в скрытом поле, дискриминанте. Теперь посчитаем его цену:
enum Command {
Nop,
Move { x: i32, y: i32 },
Write(u64),
}
assert_eq!(size_of::<Command>(), 16);
Раскладка наивная и честная: тег плюс место под самый толстый вариант, всё выровнено по самому требовательному полю:
байт: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Nop: tag . . . . . . . . . . . . . . .
Move: tag . . . x x x x y y y y . . . .
Write: tag . . . . . . . w w w w w w w w
Каждое значение Command весит 16 байт, даже пустой Nop: размер типа один на все варианты, иначе он не лёг бы в массив. А match по такому enum читает тег и дальше работает та механика, которую ты видел в дизассемблере: дерево сравнений или таблица переходов.
Ниша: дискриминант за ноль байт
Теперь обещанный фокус. По логике выше Option<Box<u64>> должен весить 16: восемь на указатель, байт тега, семь паддинга. Проверяем:
assert_eq!(size_of::<Box<u64>>(), 8);
assert_eq!(size_of::<Option<Box<u64>>>(), 8);
Option не стоил ничего. Разгадка в том, что Box не бывает нулевым: аллокатор не выдаёт нулевой адрес, и тип это знает. Значит, комбинация из восьми нулевых байт никогда не встречается у настоящего Box, она свободна. Такая свободная комбинация называется ниша, и компилятор кодирует None прямо в ней: Some(p) это байты самого указателя, None это нули, отдельного тега нет, а match компилируется в сравнение с нулём.
Для указательных типов это документированная гарантия языка, а не деталь реализации: Option<&T>, Option<&mut T>, Option<Box<T>>, Option<NonNull<T>> и Option<fn()> весят ровно столько же, сколько тип внутри. На этой гарантии стоит весь FFI: C-функция возвращает нулевой указатель, а Rust-сторона видит честный None.
Ниши находятся не только у указателей:
assert_eq!(size_of::<Option<u64>>(), 16); // все комбинации заняты: честный тег
assert_eq!(size_of::<Option<bool>>(), 1); // у bool свободны 254 комбинации
assert_eq!(size_of::<Option<Option<bool>>>(), 1); // хватило и вложенному Option
assert_eq!(size_of::<Option<char>>(), 4); // char кончается на 0x10FFFF
У u64 заняты все шестьдесят четыре бита, прятать тег некуда, и Option<u64> честно платит: байт тега и семь паддинга. У bool обещаны значения 0 и 1, остальные 254 комбинации байта свободны, в них помещается и один Option, и второй. У char потолок 0x10FFFF, всё выше свободно.
Самое приятное: нишу можно завести себе. В std для этого живёт семейство NonZero:
use std::num::NonZeroU64;
struct UserId(NonZeroU64);
assert_eq!(size_of::<UserId>(), 8);
assert_eq!(size_of::<Option<UserId>>(), 8);
NonZeroU64 это u64 с обещанием «не ноль», и ниша протекает сквозь обёртки: компилятор нашёл её внутри UserId и спрятал тег туда. Сравни с Option<u64> на 16 байт: на векторе в миллион идентификаторов разница составляет восемь мегабайт. И заметь, это снова дизайн через типы: тип несёт инвариант «идентификатор не бывает нулевым», а раскладка получает выгоду бесплатно, как побочный эффект честности.
Это и есть ответ на вопрос, отложенный в уроке про enum: фокусы, которыми компилятор ужимает дискриминант до нуля лишних байт, называются niche-оптимизацией.
Держим размер в руках
Два приёма, которые превращают сегодняшнюю теорию в привычку.
Первый: жирный вариант раздувает весь enum, и лечится это коробкой.
enum Event {
Tick,
Snapshot([u8; 4096]),
}
assert_eq!(size_of::<Event>(), 4097);
Снимок случается раз в тысячу тиков, но каждый Tick всё равно весит 4097 байт: размер enum равен размеру худшего варианта. Канал на миллион событий протащит четыре гигабайта пустоты. Убираем массив за указатель:
enum Event {
Tick,
Snapshot(Box<[u8; 4096]>),
}
assert_eq!(size_of::<Event>(), 8);
Не 16, а 8: вместе с Box в enum вернулась ниша, Tick закодировался нулевым указателем, и тег исчез целиком. clippy подсказывает такие места линтом large_enum_variant, но цену вариантов полезно чувствовать и без подсказки.
Второй приём: прибить размер тестом, который выполняется на этапе компиляции.
const _: () = assert!(size_of::<Event>() <= 16);
assert! в const-контексте вычисляет компилятор: если кто-нибудь дольёт в Event жирный вариант, сборка упадёт с внятной ошибкой прямо на этой строке. Дешёвая страховка для типов на горячем пути: сообщения каналов, узлы деревьев, элементы больших векторов.
ДЗ
Дальше
Ты видишь данные так же, как прошлый урок научил видеть код: байты, смещения, пустоты. Финал блока соединяет обе половины: в следующем уроке мы возьмём раскладку в свои руки и напишем библиотечку сериализации, bit reader и bit writer, varint и zigzag. Этот код потом заработает в эмуляторе, сетевом протоколе и блокчейне.