James McMurray
В этом посте мы исследуем основные концепции «Data-Oriented Design» (далее «дата-ориентированное проектирование» на языке Rust.
Весь исходный код для этого поста выложен на Github.
Что такое дата-ориентированное проектирование?
Дата-ориентированное проектирование – это подход к оптимизации программ, предполагающий, что расположение структур данных в памяти должно тщательно оптимизироваться. Также требуется учитывать, как такой подход отражается на автоматической векторизации и использовании кэша ЦП. Настоятельно рекомендую посмотреть лекцию Майка Эктона «Data-Oriented Design and C++», если вы ее еще не видели.
В этом посте будет рассмотрено 4 случая, и для бенчмаркинга будет применяться библиотека criterion. Эти случаи таковы:
- Структура массивов и массив структур – в чем разница;
- Цена ветвления внутри горячего цикла;
- Сравнение связного списка и перебора вектора;
- Сравнение цены динамической диспетчеризации и мономорфизации.
Структура массивов и массив структур
В примере со структурой массивов и массивом структур мы говорим о двух контрастных способов организации данных объекта, над которыми будут производиться операции.
Представьте, например, что мы пишем видеоигру и хотели бы иметь структуру Player со следующими полями:
pub struct Player {
name: String,
health: f64,
location: (f64, f64),
velocity: (f64, f64),
acceleration: (f64, f64),
}
Тогда в каждом кадре мы собираемся обновлять положения и скорости каждого из Players. Можно было бы написать нечто подобное:
pub fn run_oop(players: &mut Vec<Player>) {
for player in players.iter_mut() {
player.location = (
player.location.0 + player.velocity.0,
player.location.1 + player.velocity.1,
);
player.velocity = (
player.velocity.0 + player.acceleration.0,
player.velocity.1 + player.acceleration.1,
);
}
}
Это был бы обычный объектно-ориентированный подход к данной задаче. Проблема в том, что в памяти структуры хранятся следующим образом (предполагается, что не происходит никакого переупорядочивания полей, т. е. #[repr©]), в 64-разрядной архитектуре в каждом поле будет по 64 бита (8 байт, так что у каждого Player 64 байт):
-- Vec<Player>
name (pointer to heap) -- Player 1
health
location0 (tuple split for clarity)
location1
velocity0
velocity1
acceleration0
acceleration1
name (pointer to heap) -- Player 2
location0
location1
velocity0
velocity1
acceleration0
acceleration1
...
Обратите внимание: те части кода, которыми мы собираемся оперировать (позиции, скорости и ускорения) для различных игроков не хранятся одним непрерывным куском. Это не позволяет нам воспользоваться векторными операциями при обращении с несколькими игроками одновременно (поскольку они не могут в одно и то же время быть загружены в одну кэш-линию ЦП, ведь ее обычный размер составляет около 64 байт).
Напротив, дата-ориентированный метод предполагает проектирование в обход данного ограничения и оптимизацию с упором на автовекторизацию. Вместо того, чтобы использовать по структуре на игрока, мы применяем одну структуру на всех игроков, и значения для каждого игрока будут храниться по соответствующему индексу в отдельном атрибуте Vectors:
pub struct DOPlayers {
names: Vec<String>,
health: Vec<f64>,
locations: Vec<(f64, f64)>,
velocities: Vec<(f64, f64)>,
acceleration: Vec<(f64, f64)>,
}
Теперь можно сделать те же вычисления, как и при ООП-подходе, вот так:
pub fn run_dop(world: &mut DOPlayers) {
for (pos, (vel, acc)) in world
.locations
.iter_mut()
.zip(world.velocities.iter_mut().zip(world.acceleration.iter()))
{
*pos = (pos.0 + vel.0, pos.1 + vel.1);
*vel = (vel.0 + acc.0, vel.1 + acc.1);
}
}
В данном случае имеем такую компоновку в памяти:
-- DOPlayers
name1 -- names
name2
...
health1 -- health
health2
...
location1 -- locations
location2
...
Теперь все релевантные поля сохранены сплошным куском. Учитывая, что размер каждого кортежа местоположений составит 16 байт, теперь вполне реально загрузить 4 таких кортежа в одну и ту же кэш-линию, чтобы оперировать ими одновременно при помощи ОКМД-команд.
Бенчмарк
Вот результаты бенчмарка для библиотеки criterion в вышеприведенном коде (полный код и код бенчмарка доступны в репозитории Github):
Вообще, как видим, при помощи дата-ориентированного подхода мы смогли управиться за половину времени. Вероятно, дело в том, что в дата-ориентированном случае мы оперируем над двумя игроками одновременно – если хочется в этом убедиться, можно просто посмотреть скомпилированный ассемблерный код.
Просматривая вывод в Godbolt, видим следующее:
// Релевантный цикл в ООП
.LBB0_2:
movupd xmm0, xmmword ptr [rax + rdx + 32]
movupd xmm1, xmmword ptr [rax + rdx + 48]
movupd xmm2, xmmword ptr [rax + rdx + 64]
addpd xmm0, xmm1
movupd xmmword ptr [rax + rdx + 32], xmm0
addpd xmm2, xmm1
movupd xmmword ptr [rax + rdx + 48], xmm2
add rdx, 80
cmp rcx, rdx
jne .LBB0_2
// ...
// Релевантный цикл в ДОП
.LBB1_7:
movupd xmm0, xmmword ptr [rcx + rdx - 16]
movupd xmm1, xmmword ptr [rax + rdx - 16]
addpd xmm1, xmm0
movupd xmmword ptr [rcx + rdx - 16], xmm1
movupd xmm0, xmmword ptr [r9 + rdx - 16]
movupd xmm1, xmmword ptr [rax + rdx - 16]
addpd xmm1, xmm0
movupd xmm0, xmmword ptr [rax + rdx]
movupd xmmword ptr [rax + rdx - 16], xmm1
add rdi, 2
movupd xmm1, xmmword ptr [rcx + rdx]
addpd xmm1, xmm0
movupd xmmword ptr [rcx + rdx], xmm1
movupd xmm0, xmmword ptr [rax + rdx]
movupd xmm1, xmmword ptr [r9 + rdx]
addpd xmm1, xmm0
movupd xmmword ptr [rax + rdx], xmm1
add rdx, 32
cmp rsi, rdi
jne .LBB1_7
test r8, r8
je .LBB1_5
Как видим, в дата-ориентированном случае цикл разматывается так, чтобы оперировать двумя элементами одновременно – благодаря чему достигается общее 50%-е ускорение кода!
Авторское дополнение: как было отмечено в /u/five9a2 на Reddit, вышеприведенный вывод специфичен для цели, заданной по умолчанию – и это сбивает, поскольку cargo bench по умолчанию использует нативную цель (т. e., все возможные фичи нашего ЦП), так что в бенчмарках не применяется вышеуказанный ассемблерный код.
Установив флаг компилятора в значение
-C target-cpu=skylake-avx512
, чтобы активировать возможности Skylake, получим следующий вывод:// ООП-цикл
.LBB0_2:
vmovupd ymm0, ymmword ptr [rax + rdx + 32]
vaddpd ymm0, ymm0, ymmword ptr [rax + rdx + 48]
vmovupd ymmword ptr [rax + rdx + 32], ymm0
add rdx, 80
cmp rcx, rdx
jne .LBB0_2
...
// ДОП-цикл
.LBB1_19:
vmovupd zmm0, zmmword ptr [rsi + 4*rax - 64]
vaddpd zmm0, zmm0, zmmword ptr [rcx + 4*rax - 64]
vmovupd zmmword ptr [rsi + 4*rax - 64], zmm0
vmovupd zmm0, zmmword ptr [rcx + 4*rax - 64]
vaddpd zmm0, zmm0, zmmword ptr [r10 + 4*rax - 64]
vmovupd zmmword ptr [rcx + 4*rax - 64], zmm0
vmovupd zmm0, zmmword ptr [rsi + 4*rax]
vaddpd zmm0, zmm0, zmmword ptr [rcx + 4*rax]
vmovupd zmmword ptr [rsi + 4*rax], zmm0
vmovupd zmm0, zmmword ptr [rcx + 4*rax]
vaddpd zmm0, zmm0, zmmword ptr [r10 + 4*rax]
vmovupd zmmword ptr [rcx + 4*rax], zmm0
add r11, 8
add rax, 32
add rdi, 2
jne .LBB1_19
test r9, r9
je .LBB1_22
Здесь видим, что ООП-цикл использует 256-разрядные регистры ymm, в одном из которых размещает кортеж положений и кортеж скорости, а в другом – кортеж скорости и кортеж ускорения. Это возможно сделать, поскольку они смежны в памяти (благодаря тому, как именно упорядочены поля). В ДОП-цикле используется 512-разрядный регистр zmm.
Может показаться, что разница в производительности обусловлена тем, что полоса передачи данных на разных уровнях кэша отличается, ведь для маленьких примеров производительность идентична. Это можно лишний раз продемонстрировать, удалив из структуры лишние поля – в таком случае будем наблюдать разницу производительности лишь 25% (ссылка на godbolt), и это означает, что структура Player в данном случае занимает 384 разряда (следовательно, 1/4 из 512 разрядов на чтение/запись остаются неиспользованными).
Здесь стоит акцентировать, насколько важно учитывать, для какой целевой платформы развертывается приложение, и, если развертывается критичный по производительности код – попробовать явно задать target-cpu, чтобы воспользоваться всеми его возможностями.
Также здесь демонстрируется, насколько важным с точки зрения производительности может быть порядок полей. По умолчанию Rust переупорядочивает поля автоматически, но, чтобы этого не происходило, можно установить
#[repr(C)]
(например, это необходимо для интероперабельности с C).Итог примера
В этом примере продемонстрировано, насколько важно учитывать компоновку данных в памяти, если требуется высокопроизводительный код и автоматическая векторизация.
Обратите внимание: та же логика применима к работе с массивами структур – если вы уменьшите вашу структуру, то сможете загрузить больше элементов в одну и ту же кэш-линию, что может поспособствовать автовекторизации. Вот пример контейнера (которым поделились в одном подреддите по Rust), производительность которого удалось повысить на 40%, ограничившись лишь этой операцией.
Данная конкретная реорганизация прямо перекликается с проектированием баз данных. Серьезное отличие между базами данных, приспособленными для транзакционных (OLTP) и аналитических (OLAP) рабочих нагрузок заключается в том, что при вторых данные, как правило, хранятся в колоночном виде. Как и в случае, рассмотренном выше, это означает: при операциях над столбцами можно выгодно пользоваться областями сплошного хранения данных, а также векторными операциями, и такой паттерн доступа является основным для аналитических рабочих нагрузок (например, если нужно рассчитать средний размер покупки по всем строкам, а не обновлять и не извлекать цельные конкретные строки).
На самом деле, в случае с аналитическими базами данных мы здесь выигрываем вдвойне, поскольку такой подход применим и к сериализации данных при записи их на диск. Ведь в данном случае можно осуществлять сжатие данных по столбцам (в одном столбце все данные гарантированно будут относиться к одному и тому же типу), благодаря чему можно добиться гораздо более выгодных коэффициентов сжатия.
Если вы занимаетесь решением задачи, в которой может быть полезен подход со «структурой массивов» – и хотите прогнать быстрый бенчмарк – то вас может заинтересовать контейнер soa-derive, позволяющий произвести структуру массивов из имеющейся у вас структуры.
Ветвление в горячем цикле
Другая тактика оптимизации – избегать ветвления в любых «горячих» частях кода (то есть, в участках кода, каждый из которых будет выполняться много-много раз).
Ветвление может возникать довольно неожиданным образом – например, при попытке использовать одну и ту же структуру во множестве разных случаев. Например, можно следующим образом определить некоторый общий тип Node:
enum NodeType {
Player,
PhysicsObject,
Script,
}
struct Node {
node_type: NodeType,
position: (f64, f64),
// ...
}
А затем выполнить сопоставление по шаблону с
node_type
, когда требуется произвести операцию над Node. Проблемы начинаются, когда у нас возникает Vec с десятками тысяч элементов, и оперировать этими элементами нам требуется в каждом кадре. Задействуя тип node.node_type
, чтобы решать, какой логикой воспользоваться, необходимо выполнять такую проверку для каждого элемента (поскольку порядок экземпляров node_type
внутри Vec<Node>
неизвестен).Это сравнение не только означает, должны проводить дополнительную операцию сравнения для каждого элемента, но и препятствует автовекторизации, поскольку релевантные узлы (относящиеся все к тому же типу
node_type
) могут храниться в памяти не сплошным куском.При дата-ориентированном подходе требуется разделить эти узлы по
node_type
. В идеале нужно создать отдельную структуру на каждый тип узла, или, как минимум, развести их по отдельным векторам до входа в горячий цикл. Таким образом, нам не потребуется проверять node_type
в горячем цикле, а также нам на руку может сыграть расположение узлов, которыми мы оперируем: они будут в одном непрерывном блоке памяти.Бенчмарк
В рамках данного бенчмарка используется следующий код:
#[derive(Copy, Clone)]
pub struct Foo {
x: i32,
calc_type: CalcType,
}
#[derive(Copy, Clone)]
pub enum CalcType {
Identity,
Square,
Cube,
}
// ...
pub fn run_mixed(x: &[Foo]) -> Vec<i32> {
x.into_iter()
.map(|x| match x.calc_type {
CalcType::Identity => x.x,
CalcType::Square => x.x * x.x,
CalcType::Cube => x.x * x.x * x.x,
})
.collect()
}
pub fn run_separate(x: &[Foo], y: &[Foo], z: &[Foo]) -> (Vec<i32>, Vec<i32>, Vec<i32>) {
let x = x.into_iter().map(|x| x.x).collect();
let y = y.into_iter().map(|x| x.x * x.x).collect();
let z = z.into_iter().map(|x| x.x * x.x * x.x).collect();
(x, y, z)
}
Сравниваем два случая: смешанный вектор Foos и отдельный вектор Foos, разделенный split by
calc_type
.Результаты бенчмарка таковы:
Overall, we see that the data-oriented approach finishes in about a quarter of the time.
На этот раз вывод в Godbolt получился не таким ясным, но здесь явно заметна некоторая размотка цикла в случае с отдельными Foo, а в случае со смешанными Foo она невозможна, поскольку в смешанном случае требовалось бы проверять
calc_type
.Итог примера
Вам должна быть знакома концепция, при которой из горячих циклов выводятся все инструкции, какие возможно. Но этот пример также демонстрирует, как такой подход может повлиять на векторизацию.
Косвенность: перебор в связном списке
В данном примере сравнивается перебор (двойного) связного списка и работа с вектором. Этот случай хорошо известен и упоминается, например, в документации Rust по связным спискам, документации Rust по std::collections и в объявлении Learn Rust With Entirely Too Many Linked Lists’ Public Service Announcement. В последнем документе освещено множество случаев, в которых принято использовать связные списки, поэтому рекомендую прочитать его, если вы еще не успели. Тем не менее, поверьте моему опыту, лучше посмотреть сам бенчмарк.
В связном списке элементы хранятся косвенно. То есть, элемент хранится вместе с указателем на следующий элемент. Таким образом, элементы, последовательно расположенные в связном списке, не хранятся в последовательных участках памяти.
Это приводит нас к двум факторам, осложняющим векторизацию:
- Элементы связного списка могут быть сохранены в произвольном порядке, далеко друг от друга. Поэтому мы не сможем просто загрузить в кэш ЦП блок памяти, чтобы оперировать всеми этими элементами одновременно.
- Чтобы получить следующий элемент списка, требуется разыменовать указатель на него.
Например, можно следующим образом сохранить в куче вектор из i32 (удерживая в стеке указатель на начало вектора, емкость вектора и длину вектора):
0x00 1
0x01 2
0x02 3
...
Значения хранятся сплошным куском, тогда как для (одно)связного списка у нас мог бы сложиться следующий случай.
0x00 1
0x01 0x14
0x0C 3
0x0D 0
...
0x14 2
0x15 0x0C
Здесь значения не хранятся в непрерывном участке в памяти (более того, их порядок может не совпадать с тем, в котором расположены в списке указатели на них).
Бенчмарк
В данном случае бенчмарк очень прост: мы просто возводим в квадрат все элементы связного списка и вектора:
pub fn run_list(list: &mut LinkedList<i32>) {
list.iter_mut().for_each(|x| {
*x = *x * *x;
});
}
pub fn run_vec(list: &mut Vec<i32>) {
list.iter_mut().for_each(|x| {
*x = *x * *x;
});
}
Результаты таковы:
В целом видим, что при дата-ориентированном подходе нам требуется вдесятеро меньше времени, чем при объектно-ориентированном.
Вывод в Godbolt показывает в случае с Vec такую размотку, которая невозможна в случае с LinkedList.
Итог примера
Этот случай хорошо известен, и в нем наблюдается наибольшая разница между двумя бенчмарками. Обратите внимание: здесь мы оцениваем только перебор, а не другие операции, которые смотрелись бы несколько лучше именно в случае связного списка. Такова, например, вставка, при которой в связном списке избегаются (амортизированные) издержки на изменение размера вектора. Правда, как утверждается в Learn Rust With Entirely Too Many Linked Lists, этого можно избежать и при работе с векторами.
Надеюсь, этот момент станет общеизвестным, и на собеседованиях станет поменьше вопросов и практических задач на связные списки и косвенность, ведь они учитывают только сложность задачи по «Big O», а не реальную производительность.
Косвенность: сравнение динамической диспетчеризации и мономорфизации
Когда мы пишем обобщенные функции (например, для любых типов, реализующих определенные типажи), у нас есть выбор между динамической диспетчеризацией и мономорфизацией.
Динамическая диспетчеризация позволяет работать со смешанной коллекцией объектов-типажей. То есть, у нас может быть
Vec<Box<dyn MyTrait>>
, в котором могут содержаться ссылки на различные типы, и все эти типы реализуют MyTrait. Объект-типаж содержит указатель на экземпляр структуры как таковой (реализующей MyTrait) и указатель на таблицу виртуальных методов структуры (она же vtable, таблица поиска, указывающая на реализацию каждого метода в MyTrait). Затем, когда мы вызываем метод в одном из этих объектов-типажей, во время выполнения выясняется, какую именно реализацию метода мы будем использовать, и эта реализация подыскивается в vtable.Обратите внимание: это приводит нас к косвенности. Наш вектор должен состоять из указателей на сами экземпляры структуры (поскольку разные структуры, реализующие MyTrait, могут отличаться как по размеру, так и по полям). Также нам приходится разыменовывать указатель в vtable, чтобы выяснить, какую реализацию вызывать.
В свою очередь, мономорфизация – это создание отдельной реализации обобщенной функции на каждый возможный тип. Например, в следующем коде фактически создается две отдельные функции для
run_vecs_square():
для типов Foo
и Bar
соответственно:pub struct Foo {
id: usize,
}
pub struct Bar {
id: usize,
}
pub trait MyTrait {
fn square_id(&mut self);
}
impl MyTrait for Foo {
fn square_id(&mut self) {
self.id = self.id * self.id;
}
}
impl MyTrait for Bar {
fn square_id(&mut self) {
self.id = self.id * self.id * self.id;
}
}
pub fn run_vecs_square<T: MyTrait>(x: &mut Vec<T>) {
x.iter_mut().for_each(|x| x.square_id())
}
В результате увеличивается размер двоичного файла, зато нам становится просто генерировать множественные реализации функции для разных типов, а также избегать косвенности (например, обратите внимание: можно использовать
Vec<Tgt;
и при этом обходиться без Vec<Box<T>>)
.А вот в следующем коде используется динамическая диспетчеризация. Там всего одна реализация
run_dyn_square
, но, какую именно реализацию square_id()
следует вызывать – определяется во время исполнения, по итогам сверки с таблицей vtable объекта-типажа.pub fn run_dyn_square(x: &mut Vec<Box<dyn MyTrait>>) {
x.iter_mut().for_each(|x| x.square_id())
}
Так может быть удобнее, поскольку мы создаем вектор, содержащий ссылки на различные типы – и можем не беспокоиться, каковы именно конкретные типы (нам важно лишь то, что они реализуют MyTrait), поэтому и двоичный файл у нас не разбухает. Правда, мы вынуждены прибегать к косвенности, поскольку у базовых типов могут быть разные размеры – а, как мы видели в примере с LinkedList, это может существенным образом сказываться на автовекторизации.
Бенчмарк
При прогоне вышеприведенного примера бенчмарк выглядит так:
В целом видим, что на выполнение мономорфизированного примера нужно примерно вчетверо меньше времени, чем на вариант с динамической диспетчеризацией. Мономорфизированный случай с применением косвенности (
Vec<Box<T>>
) только чуть-чуть быстрее случая с динамической диспетчеризацией, и именно дополнительной косвенностью обусловлен в основном провал производительности – ведь косвенность мешает векторизации. В свою очередь, при поиске в vtable добавляются лишь небольшие издержки, и они постоянны.К сожалению, в данном случае Godbolt не включает в сгенерированный вывод целевые функции.
Итог примера
Этот бенчмарк показывает, что основные издержки динамической диспетчеризации связаны с осложнением векторизации, так как такой подход обязательно требует косвенности. А издержки на поиск в таблице vtable относительно невелики.
Таким образом, определенно попробуйте проектировать с прицелом на мономорфизацию, если операции ваших методов именно таковы, что им сильно пошла бы на пользу векторизация (как, например, математические операции, показанные выше). С другой стороны, если нужно выполнять операции, которые не векторизуются (например, составлять строки), то издержки на динамическую диспетчеризацию могут в целом оказаться пренебрежимыми.
Как всегда, расставляйте бенчмарки для конкретных практических случаев и паттернов доступа, когда сравниваете различные возможные реализации.
Заключение
В этой статье было рассмотрено четыре случая, в которых при учете расположения данных в памяти, а также реального состояния и слабых сторон кэша ЦП удалось существенно повысить производительность.
Здесь мы только слегка копнули дата-ориентированное проектирование и оптимизацию. В частности, не были затронуты вопросы упаковки структур, расстановки отступов и выравнивания. Книга Ричарда Фабиана о дата-ориентированном проектировании также освещает некоторые дополнительные темы.
Важно отметить, что во всех рассмотренных здесь примерах мы не дорабатывали алгоритмов, которыми пользовались. У всех реализаций на все случаи была одинаковая алгоритмическая сложность по Big O, но на практике их производительность может быть ускорена в 2-10 раз – благодаря несложной оптимизации, рассчитанной только на векторизацию и другие возможности современных ЦП.
Резюме:
- Предпочитайте структуры данных, в которых косвенности нет или почти нет, а также такие, которые хранят данные в памяти сплошными кусками (также вам будет проще справиться с проверкой заимствования переменных!).
- Избегайте ветвления внутри циклов.
- Расставляйте бенчмарки для конкретных случаев использования и паттернов доступа.
- При развертывании кода, производительность которого критична, старайтесь нацеливаться на сильные стороны именно того ЦП, что установлен на машине назначения (напр. используйте RUSTFLAGS).
- Criterion – отличный инструмент для бенчмаркинга.
- cargo-asm и Godbolt отлично подходят для изучения сгенерированного ассемблерного кода (и промежуточного представления LLVM).
- Для профилирования производительности можно воспользоваться perf и flamegraph.
Kerrigan
Для таких оптимизаций есть готовые библиотеки, тот же soa_derive https://github.com/lumol-org/soa-derive