Главная » Хабрахабр » [Перевод] Оптимизация рендеринга сцены из диснеевского мультфильма «Моана». Часть 2

[Перевод] Оптимизация рендеринга сцены из диснеевского мультфильма «Моана». Часть 2

image

Вдохновлённый первой победой при парсинге с помощью описания сцены острова из мультфильма «Моана» компании Disney, я далее углубился в изучение использования памяти. Можно было сделать ещё очень многое со временем выполнения, но я решил, что будет полезно сначала разведать обстановку.

Вот, каким изначально был отчёт о выделении памяти для этой сцены: Исследование времени выполнения я начал со встроенной статистики pbrt; в pbrt есть ручная настройка значительных выделений памяти для отслеживания использования памяти, а после завершения рендеринга выводится отчёт о выделении памяти.

Память
BVH-дерево 9,01 ГиБ
Кривые 1,44 ГиБ
MIP-текстуры 2,00 ГиБ
Меши треугольников 11,02 ГиБ

top сообщил, что на самом деле использовано около 70 ГБ памяти, то есть в статистике не учтено 45 ГБ. Что касается времени выполнения, то встроенная статистика оказалась краткой и выдала отчёт только о выделении памяти под известные объекты размером в 24 ГБ. Но 45 ГБ? Небольшие отклонения вполне объяснимы: распределителям динамической памяти требуется дополнительное место для регистрации использования ресурсов, часть теряется из-за фрагментации, и так далее. Он довольно медленный, но, по крайней мере, хорошо работает. Здесь определённо скрывается что-то нехорошее.
Чтобы понять, что же мы упускаем (и чтобы убедиться в правильности отслеживания найденного), я воспользовался massif для трассировки действительных выделений динамической памяти.

Примитивы

Первое, что я обнаружил при трассировке massif — две строки кода, располагавшие в памяти экземпляры базового класса Primitive, не учитываемого в статистике. Небольшой недосмотр, который довольно легко устранить. После этого мы видим следующее:

Primitives 24,67 ГиБ

Так что же такое примитив, и зачем вся эта память? Ой-ёй.

pbrt различает Shape, который является чистой геометрией (сферой, треугольником и т.д.) и Primitive, который является сочетанием геометрии, материала, иногда функции излучения и задействованной среды находящейся внутри и снаружи поверхности геометрии.

Оказывается, что в этой сцене оба этих типа являются пустой тратой пространства. Существует несколько вариантов базового класса Primitive: GeometricPrimitive, который является стандартным случаем: «ванильным» сочетанием геометрии, материала и т.д., а также TransformedPrimitive, который представляет собой примитив с применёнными к нему преобразованиями, или как экземпляр объекта, или для подвижных примитивов с преобразованиями, изменяющимися во времени.

GeometricPrimitive: 50% лишнего пространства

Примечание: в этом анализе сделаны некоторые ошибочные допущения; они пересмотрены в четвёртом посте серии.

Забавно жить в мире, где 4,3 ГБ использованной ОЗУ — это не самая большая твоя проблема, но давайте тем не менее посмотрим, откуда у нас 4,3 ГБ GeometricPrimitive. 4,3 ГБ использовано на GeometricPrimitive. Вот соответствующие части определения класса:

class GeometricPrimitive : public Primitive { std::shared_ptr<Shape> shape; std::shared_ptr<Material> material; std::shared_ptr<AreaLight> areaLight; MediumInterface mediumInterface;
};

У нас есть указатель на vtable, ещё три указателя, а затем MediumInterface, содержащий ещё два указателя общим размером 48 байтов. В этой сцене всего несколько излучающих освещение мешей, поэтому areaLight почти всегда является нулевым указателем, а влияющей на сцену среды нет, поэтому оба указателя mediumInterface также являются null. Таким образом, если бы у нас была специализированная реализация класса Primitive, которую можно было использовать при отсутствии функции излучения и среды, то мы бы сэкономили почти половину места на диске, занимаемого GeometricPrimitive — в нашем случае примерно 2 ГБ.

Мы как можно сильнее стремимся минимизировать различия между исходным кодом pbrt-v3 на github и системой, описанной в моей книге, по очень простой причине — поддержание их синхронизации упрощает чтение книги и работу с кодом. Однако я не стал вносить исправление и добавлять в pbrt новую реализацию Primitive. Но это исправление определённо появится в новой версии pbrt. В этом случае я решил, что совершенно новая реализация Primitive, ни разу не упоминаемая в книге, будет слишком большим расхождением.

Прежде чем двигаться дальше, выполним проверочный рендеринг:

Пляж с острова из фильма «Моана», отрендеренный pbrt-v3 с разрешением 2048x858 и 256 сэмплами на пиксель. Общее время рендеринга на 12-ядерном/24-поточном инстансе Google Compute Engine с частотой 2 ГГц с последней версией pbrt-v3 составило 2 ч 25 мин 43 с.

TransformedPrimitives: 95% потраченного впустую пространства

Память, выделенная под 4,3 ГБ GeometricPrimitive, была довольно болезненным ударом, но как насчёт 17,4 ГБ под TransformedPrimitive?

В обоих случаях нам нужно применить дополнительное преобразование к существующему Primitive. Как сказано выше, TransformedPrimitive используется как для преобразований с изменением во времени, так и для экземпляров объектов. В классе TransformedPrimitive есть только два члена:

std::shared_ptr<Primitive> primitive; const AnimatedTransform PrimitiveToWorld;

Пока всё хорошо: указатель на примитив и изменяющееся со временем преобразование. Но что на самом деле хранится в AnimatedTransform?

const Transform *startTransform, *endTransform; const Float startTime, endTime; const bool actuallyAnimated; Vector3f T[2]; Quaternion R[2]; Matrix4x4 S[2]; bool hasRotation; struct DerivativeTerm ; DerivativeTerm c1[3], c2[3], c3[3], c4[3], c5[3];

В дополнение к указателям на две матрицы перехода и связанным с ними временем здесь также присутствует разложение матриц на компоненты переноса, поворота и масштабирования, а также предварительно вычисленные значения, используемые для ограничивания объёма, занятого подвижными ограничивающими параллелепипедами (см. раздел 2.4.9 нашей книги Physically Based Rendering). Всё это добавляет до 456 байт.

С точки зрения преобразований для экземпляров объектов нам необходим один указатель на преобразование, а значения для разложения и подвижных ограничивающих параллелепипедов не нужны. Но в этой сцене ничего не движется. Если создать отдельную реализацию Primitive для неподвижных экземпляров объектов, 17,4 ГБ сжимаются всего до 900 МБ (!). (То есть всего нужно 8 байт).

По крайней мере, мы теперь понимаем, что происходит с хаосом из 24,7 ГБ памяти Primitive. Что касается GeometricPrimitive, то его исправление — это нетривиальное изменение по сравнению с описанным в книге, поэтому мы тоже отложим его на следующую версию pbrt.

Неприятность с кэшем преобразований

Следующим по размеру блоком неучтённой памяти, определённым massif, был TransformCache, который занимал примерно 16 ГБ. (Вот ссылка на исходную реализацию.) Идея заключается в том, что одна и та же матрица преобразований часто используется в сцене несколько раз, поэтому лучше всего иметь в памяти её единственную копию, чтобы все использующие её элементы просто хранили указатель на одно и то же преобразование.

Это ужасно много: 60% процентов этого объёма используется для самих преобразований. Для хранения кэша TransformCache использовал std::map, а massif сообщил, что 6 из 16 ГБ использовалось для узлов чёрно-красного дерева в std::map. Давайте посмотрим на объявление для этого распределения:

std::map<Transform, std::pair<Transform *, Transform *>> cache;

Здесь работа выполнена на отлично: Transform целиком используются как ключи для распределения. Ещё лучше то, что в pbrt Transform хранит две матрицы 4x4 (матрицу преобразования и её обратную матрицу), что приводит хранению в каждом узле дерева 128 байт. Всё это абсолютно излишне для хранимого для него значения.

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

К счастью, о TransformCache мало написано в книге, поэтому вполне допустимо полностью переписать его. Кроме того, что пространство впустую тратится на ключи, с поиском в std::map для обхода красно-чёрного дерева связано много операций переходов по указателям, поэтому кажется логичным попробовать что-то совершенно новое.

И последнее, прежде чем мы приступим: после исследования сигнатуры метода Lookup() становится очевидной ещё одна проблема:

void Lookup(const Transform &t, Transform **tCached, Transform **tCachedInverse)

Когда вызывающая функция предоставляет Transform, кэш сохраняет и возвращает указатели на преобразование, равные переданному, но также передаёт и обратную матрицу. Чтобы это было возможно, в исходной реализации при добавлении преобразования в кэш всегда вычисляется и сохраняется обратная матрица, чтобы её можно было вернуть.

То есть разные виды памяти впустую тратятся на неприменяемые обратные преобразования. Глупость здесь заключается в том, что большинство точек вызова, использующих кэш преобразований, не запрашивают и не используют обратную матрицу.

В новой реализации добавлены следующие улучшения:

  • Она использует хэш-таблицу, позволяющую ускорить поиск, и не требует хранения ничего, кроме массива Transform *, что, по сути, снижает объём используемой памяти до величины, действительно необходимой для хранения всех Transform.
  • Сигнатура метода поиска теперь имеет вид Transform *Lookup(const Transform
    &t)
    ; в одном месте, где вызывающая функция хочет получить из кэша обратную матрицу, он просто вызывает Lookup() дважды.

Для хэширования я использовал хэш-функцию FNV1a. После её реализации я обнаружил пост Араса о хэш-функциях; возможно, мне просто стоило использовать xxHash или CityHash, потому что их производительность выше; возможно, когда-нибудь мой стыд победит и я исправлю это.

То есть мы сэкономили ещё 5 мин 7 с, или ускорились в 1,27 раза. Благодаря новой реализации TransformCache значительно снизилось общее время запуска системы, — до 21 мин 42 с. Это позволило нам не пытаться воспользоваться тем, что на самом деле они не проективные, и хранить матрицы 3x4 вместо 4x4. Более того, более эффективное использование памяти снизило занимаемое матрицами преобразования место с 16 до 5,7 ГБ, что почти равно объёму хранящихся данных. Это определённо стоит сделать в рендерере продакшена.) (В обычном случае я бы скептически отнёсся к значимости такого рода оптимизации, но здесь она бы сэкономила нам больше гигабайта — куча памяти!

Небольшая оптимизация производительности для завершения

Слишком обобщённая структура TransformedPrimitive стоит нам и памяти, и времени: профайлер сообщил, что солидная часть времени при запуске тратилась в функции AnimatedTransform::Decompose(), которая разлагает преобразования матрицы в поворот кватерниона, перенос и масштаб. Поскольку в этой сцене ничего не движется, эта работа не нужна, и тщательная проверка реализации AnimatedTransform показала, что ни к одному из этих значений доступ не осуществляется, если две матрицы преобразования на самом деле идентичны.

Добавив к конструктору две строки, чтобы разложения преобразований не выполнялись, когда они не требуются, мы сэкономили ещё 1 мин 31 с времени запуска: в результате мы пришли к 20 мин 9 с, то есть в целом ускорились в 1,73 раза.

В следующей статье мы всерьёз возьмёмся за парсер и проанализируем то, что стало важным, когда мы ускорили работу других частей.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Нейронные сети с нуля. Обзор курсов и статей на русском языке, бесплатно и без регистрации

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

Как не выстрелить себе в ногу из конечного автомата

Конечный автомат редко применяется мобильными разработчиками. Хотя большинство знает принципы работы и легко реализует его самостоятельно. В статье разберемся, какие задачи решаются конечным автоматом на примере iOS-приложений. Рассказ носит прикладной характер и посвящен практическим аспектам работы. Под катом вы найдете дополненную расшифровку выступления Александра Сычева ...