[Перевод] Разбираем выравнивание данных и структуру памяти в Rust
Мне нравится оптимизировать код — определение и исправление неэффективных участков кода приносит некое особое чувство удовлетворения в отличие от закидывания проблемы железом. Ведь последнее — пустая трата ресурсов и выбросов углерода!
В процессе моей работы я много раз оптимизировал использование памяти датафреймов Python. Не учитывая различные особенности, зачастую наиболее быстрым решением является понижающее приведение — к примеру, конвертация столбца нулей и единиц из int
в bool
. И хотя это срабатывает, недавно к своему удивлению я узнал, что булевы числа не всегда отображаются в качестве одиночных битов. Так как же отображаются типы данных в памяти?
Подобно тому, как аккуратно организованные стеллажи книг в библиотеке помогают легко найти нужную информацию, отображение данных в памяти может сильно повлиять на производительность и эффективность использования памяти вашего приложения.
Данная статья будет разбита на две части и посвящена отображению данных в приложениях.
В этой (первой) части мы разберем:
что такое выравнивание данных и почему оно важно
что такое структура памяти на нескольких примерах
как оценивать использование памяти для встроенных и собственных типов данных в Rust
различные сюрпризы, в зависимости от ваших прошлых знаний. Как и я, вы могли быть удивлены, что булевы числа не всегда занимают только 1 бит в памяти!
На этой ноте, пришло время погрузиться в то, как данные отображаются в памяти — поехали!
Как интерпретируются байты в памяти?
Компьютеры хранят данные в памяти в виде последовательности бит. Но сами по себе биты не несут какого‑либо значения. Типы используются программами для интерпретации этой последовательности во что‑то полезное программисту. К примеру, последовательность 10 100 011 может быть интерпретирована как 163 в случае целого числа без знака или -35 в случае целого числа со знаком.
Концепция выравнивания
Выравнивание — то, как именно биты располагаются в памяти. В теории, точное расположение последовательности бит в памяти не должно ни на что влиять, до тех пор пока сохраняются их порядок и непрерывность.
Но на практике железо устанавливает свои правила, касающиеся того, как данные в памяти читаются и записываются, делая выравнивание важным. Для начала, указатели всегда указывают на байты, а не произвольные биты — указатель позволяет ссылаться на байт N или N+1, но не на какой‑то бит между ними. Данные, начинающиеся на границе байта называются выровненными.
Есть еще одно правило — большая часть компьютеров читает данные чанками, также известными как слова. Слово — количество бит, которое процессор может обработать за одну инструкцию. Вы, возможно, слышали, что ваше устройство имеет 64-битную архитектуру, что означает, что ширина адреса памяти составляет 64 бита и устройство читает данные чанками по 64 бита.
Визуальное отображение адресов памяти. Каждая линия отображает чанк данных, с которым может работать процессор.
Влияние не выровненных данных
Представьте, что у нас есть переменная типа i64
(8 байт). Когда данные выровнены, компьютер читает биты в первом 64-битном чанке (зеленый ряд ниже) для определения значения типа i64
. Это выполняется за одну операцию чтения.
Когда данные выровнены, они читаются из первого 64-битного блока (строки) за одну операцию чтения.
Но что если данные не выровнены, как на картинке ниже?
Когда данные не выровнены, для чтения нужных битов требуется больше операций.
Для того, чтобы интерпретировать значение, нужно:
Прочитать биты первого слова
Применить маску, оставив только вторую половину
Прочитать биты второго слова
Применить маску, оставив только первую половину
Объединить биты из шагов 2 и 4
Как можно, заметить, это требуется значительно больше операций, что приводит к снижению производительности чтения/записи в приложении. Более того, существует риск повреждения данных в многопоточных приложениях, если данные по второму адресу будут изменены другим потоком, пока текущий читает данные по первому адресу.
На примере выше мы можем видеть, как выравнивание создает основу производительных и стабильных приложений.
Выравнивание по степеням двойки
Большая часть основных встроенных типов «естественно» выровнена по своему размеру. u8
выровнен побайтово, i16
— по два байта, u32
— по четыре, а f64
— по 8 байт. Обратите внимание, что все это — степени двойки и это не совпадение. Многие современные компьютеры используют выравнивание по степеням двойки для оптимизации производительности.
Если адрес данных mod n
равен 0, то данные выровнены по n
‑байт. Так тип u32
считается выровненным, если его данные начинаются с первого или пятого байта слова. Для другого любого начального байта внутри слова данные u32
не выравнены.
Корректные и некорректные варианты выравнивания для 4-байтового типа данных (например, u32)
Структура для оптимального использования памяти
В то время как выравнивание относится к тому, как биты организованы относительно границ байт в памяти, структура памяти относится к тому, как данные для разных полей организованы внутри типа.
Проводя аналогию с тетрисом, блок может быть выровнен только по определенным позициям в сетке. В то же время то, как блоки располагаются относительно друг друга это структура.
Выравнивание и структура памяти, визуализированные как блоки Тетриса
Отображение данных в памяти влияет на использование памяти типом данных и приложением в целом, что мы и увидим ниже.
Структура памяти для структуры
Предположим, у нас есть следующая структура:
#[repr(C)]
struct Foo {
tiny: bool,
normal: u32,
small: u8,
long: u64,
short: u16,
}
Компилятору необходимо определить структуру памяти для структуры Foo
, а именно как разные поля расположены внутри нее.
Компилятор следует нескольким правилам для принятия решения:
Биты должны быть выровнены побайтово.
Встроенные типы данных «естественно» выравниваются по своему размеру (как упоминалось ранее,
u16
— по два байта,f64
— по восемь).Для сложных типов, составленных из нескольких других, выравнивание приравнивается к наибольшему из его составляющих.
Для начала мы рассмотрим выравнивание для структуры Foo
при использовании структуры памяти в стиле C, о чем и указывает нам атрибут #[repr(C)]
. Позже мы глянем, чем оно отличается от нативных структур в Rust.
В C поля располагаются следующим образом:
tiny
— булево число, так что оно должно использовать только 1 бит, ведь так? Не совсем — согласно первому правилу, оно занимает 1 байт (8 бит). Если бы его длина составляла 1 бит, следующие за ним данные не были бы выровнены.normal
занимает 4 байта и выровнено по 4 байта, согласно второму правилу. Так что мы не можем поместить данные сразу послеtiny
— нам нужно «заполнить» место после него тремя байтами, чтобы следовать выравниванию. На данный момент использование памяти составляет 1 + 3 + 4 = 8 байт.small
занимает один байт и побайтово выровнено, так что может быть добавлено без дополнительных действий.long
занимает 8 байт и выравнивается по 8 байт. Поскольку на данный момент мы используем 1 + 3 + 4 + 1 = 9 байт, нам потребуется 7 байт «заполнения», чтобы обеспечить выравнивание в 8 байт дляlong
.short
занимает 2 байта и может быть добавлено сразу послеlong
.
На данный момент использование памяти составляет (1 + 3) + 4 + (1 + 7) + 8 + 2 = 26 байт.
Наконец, сама структура Foo
должна быть выравнена. Это нужно, чтобы данные после Foo
также попадали в рамки выравнивания. Согласно третьему правилу, Foo
использует выравнивание наибольшего поля long
, то есть восемь байт. Соответственно, нам нужно 6 байт «заполнения», чтобы «округлить» структуру памяти до 32 байт.
Ниже визуальное отображение результата.
Структура памяти в стиле C для структуры Foo
Проверяем размер и выравнивание в Rust.
На данном этапе у вас мог появиться вопрос —, а как нам проверить, действительно ли это так. В Rust это можно сделать с помощью структуры
std::alloc::Layout
.Playground
Структура памяти в стиле Rust
Как вы могли заметить, отображение в стиле C требует, чтобы все поля были расположены в том же порядке, в котором они были объявлены, что приводит к предсказуемой, но не совсем оптимальной структуре памяти.
В то же время, отображение в стиле Rust (#[repr(Rust)]
) не требует подобных строгих гарантий, позволяя компилятору оптимизировать расположение полей, делая его более компактным и эффективным. Это достигается сортировкой полей по их размеру в порядке убывания.
#[repr(Rust)] // Omitting this line has the same effect
struct Foo {
tiny: bool,
normal: u32,
small: u8,
long: u64,
short: u16,
}
Вот так выглядит структура памяти Foo
в отображении Rust:
Структура памяти в стиле Rust для структуры Foo
Как видите, структура в стиле Rust значительно компактнее — требуется в 2 раза меньше памяти по сравнению с отображением в стиле C.
Объяснение структуры памяти для стандартных типов данных
Теперь, когда мы знаем, как размещаются в памяти собственные типы, что насчет других встроенных типов, а в особенности кортежей, массивов и enum
?
Кортежи
В случае кортежей значения размещаются подобно полям структур — например, Foo
и кортеж (bool, u32, u8, u64, u16)
имели бы одинаковую структуру памяти.
Enums
Структура памяти dnum
состоит из двух частей — дискриминатора и варианта enum
. Дискриминатор — индекс варианта enum
и на практике имеет длину в один байт (если у вас больше 256 вариантов в enum
, стоит задуматься о пересмотре дизайна…). Структура памяти для каждого из вариантов определяется независимо от других. Размер варианта enum
соответствует размеру наибольшего из них, а выравнивание дискриминатора следует выравниванию наибольшего из вариантов.
К примеру, enum Shape
ниже:
enum Shape {
Circle { radius: f32 },
Rectangle { width: f32, height: f32 },
Square(f32),
}
Имеет следующую структуру:
Структура памяти для enum Shape
Каждый вариант структуры памяти рассчитывается независимо друг от друга:
Вариант | Размер (в байтах) | Выравнивание |
---|---|---|
| 4 | 4-byte |
| 8 | 4-byte (Its largest field is 4-byte aligned) |
| 4 | 4-byte |
Часть, отвечающая за вариант enum имела бы длину в 8 байт. Это необходимо, чтобы выделить достаточно памяти для любого из них.
Для отображения трех возможных значений дискриминатор имел бы тип u8. И хотя u8 выравнивается побайтово, ему придется следовать выравниванию наибольшего варианта enum (4 байта). Соответственно, к дискриминатору добавляется 3 байта заполнения.
Массивы
Массивы отображены в памяти как непрерывная последовательность байт без заполнения между элементами. Массив из элементов Shape
из enum выше выглядел бы следующим образом:
Распределение памяти для массива перечислений Shape.Последующие элементы размещаются сразу без выравнивания.
Заключение
Теперь у вас должно быть чуть лучшее представление о том, что такое выравнивание и структура памяти. Также, возможно, вам теперь чуть легче предугадать, как ваши данные располагаются в памяти в вашей программе.
Рассмотрев примеры выше, можно заметить, что структура памяти имеет большое влияние на использование памяти приложением в целом. Неэффективное отображение в памяти может быстро раздуть используемую память, особенно когда существует много экземпляров типа — например, в массиве.
Несмотря на это, компактность данных в памяти не всегда является оптимальным вариантом — как часто бывает в инженерном деле — у каждого подхода есть свои плюсы и минусы.
В следующей статье цикла мы посмотрим на другие доступные представления в Rust, когда их стоит использовать и почему иногда стоит использовать не самые эффективные с точки зрения использования памяти отображения.