Сократите процент промахов кэша благодаря дизайну, ориентированному на данные

от Shachar Langbeheim

В Lightricks главное — это производительность приложений. Это означает, что мы всегда ищем более эффективные способы программирования, которые подходят для любого уровня пользовательского устройства. Сегодня мы обсудим преимущества проектирования, ориентированного на данные — парадигмы программирования, которая учитывает выполнение кода компьютером, а не создание менее производительных абстракций.

И, поскольку мы также говорим об эффективности обучения, мы сделали видео, в котором обсуждается именно эта тема. Итак, если вы предпочитаете аудиовизуальный опыт и не боитесь немного C++, вы тоже можете следовать ему.

Сортировка соответствующих данных

Инкапсуляция, как принцип проектирования, великолепна. Это гарантирует, что все модификации данных объекта обрабатываются через объект, и позволяет нам анализировать любые мутации. Но у него есть потенциал быть медленным. Когда функции необходимо получить доступ или изменить одно поле в одном объекте из большой коллекции объектов, весь содержащийся объект должен быть загружен в память. Затем ваш ЦП должен загружать все поля объекта, а не только соответствующие, что приводит к гораздо большему количеству операций доступа к памяти при доступе к большому количеству объектов, что дорого для современной архитектуры ЦП.

Чтобы обойти это, мы использовали дизайн, ориентированный на данные: парадигму программирования, которая пытается максимизировать производительность, оценивая, как компьютер выполняет код. И самое замечательное в этом то, что его можно относительно легко интегрировать в объектно-ориентированное программирование.

Иерархия доступа к данным ЦП

Прежде чем мы углубимся в предлагаемую архитектуру, давайте попробуем лучше понять проблему. Прежде чем ЦП сможет выполнить операцию с некоторыми данными, ему необходимо загрузить данные в свою непосредственную память — регистры ЦП. Данные могут находиться в нескольких местах: в одном из различных кэшей памяти ЦП (обычно L1-L3), в оперативной памяти компьютера или даже на диске. Каждая из этих ячеек памяти постепенно увеличивается, но время, необходимое для извлечения данных из каждой ячейки, также постепенно уменьшается. Таким образом, ЦП предпочтет загружать память из кеша L1, а не L3, и любые кеши ЦП перед ОЗУ или диском. Чтобы повысить производительность, когда ЦП загружает данные дальше по цепочке памяти, он будет загружать память на все промежуточные уровни, перезаписывая часть памяти, которая уже загружена там. Этот промах кэша в современной архитектуре ЦП требует нескольких циклов ЦП, что замедляет операции, требующие данных.

На практике

Итак, теперь давайте начнем с объекта Foo (используя нотацию в стиле C):

Размер каждого объекта Foo составляет 128 бит. Если мы хотим перебрать большой массив объектов Foo и выполнить операцию, для которой требуется поле data1, нам нужно загрузить в память все 128 бит каждого объекта, даже если нам нужны только 32 бита, составляющие data1. Кэши ЦП будут заполняться в четыре раза быстрее, что приведет к примерно в четыре раза большему количеству промахов кеша, чем если бы мы просто загрузили соответствующие данные. Предполагая, что промах кэша происходит медленнее, чем фактическое вычисление данных, это может означать, что выборка памяти является самой большой потерей времени в процессе. И учитывая, насколько умными стали процессоры в оптимизации своих вычислений, это разумное предположение.

Как это улучшить?

Мы хотим убедиться, что ЦП извлекает только те данные, которые относятся к текущей операции. Это называется эталонной местностью — когда данные, которые используются вместе, располагаются вместе, чтобы ускорить операции.

Вернемся к функции, которая принимает массив объектов Foo:

(Для простоты это не строго C. Предположим, что все массивы размещаются в куче.)

Эта функция принимает на вход массив структур. Сдвиг парадигмы, который позволит нам улучшить производительность, заключается в преобразовании нашей структуры в структуру массивов, что-то вроде этого:

Что позволяет нам повторно реализовать функцию Bar как:

Это означает, что мы получаем только необходимые данные. Ура!

Адаптация для большей кодовой базы
Следует признать, что это очень упрощенный сценарий. Вполне вероятно, что у вас есть большая кодовая база, которую вы не хотите реструктурировать. К счастью, для этого у нас есть объектно-ориентированное решение — простой шаблон получения/установки. (Если вы хотите повысить производительность и предсказуемость здесь, мы рассмотрели и это.)

Это позволяет нам поддерживать тот же внешний вид объектов Foo, в то же время используя структуру массивов за кулисами для лучшего управления необработанными данными. Кроме того, поле data можно удалить, сделав FooArrays глобальным или статическим, уменьшив размер каждого объекта Foo на 75 %, что позволит компилятору и ЦП лучше управлять загрузкой памяти. Таким образом, в то время как существующие функции будут использовать объектно-ориентированный фасад Foo, новые функции могут использовать FooArrays напрямую для повышения эффективности.

Хотя из программирования, ориентированного на данные, мы можем извлечь гораздо больше, основной принцип остается неизменным: когда важна производительность, она помогает учитывать конкретные операции, выполняемые компьютером, а не только абстрактное поведение, которое определяет язык программирования.

Создавайте волшебство вместе с нами
Мы всегда в поиске многообещающих новых талантов. Если вы заинтересованы в разработке революционных новых инструментов для авторов, мы хотим услышать от вас. От написания кода до исследования новых функций вас будет окружать поддерживающая команда, которая живет и дышит технологиями.

Похоже на вас? Подать заявку здесь.