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

в 12:02, , рубрики: disney, pbrt, Компьютерная анимация, моана, Работа с 3D-графикой, рендерер, рендеринг
image

Сегодня мы рассмотрим ещё два места, в которых pbrt тратит много времени при парсинге сцены из диснеевского мультфильма «Моана». Посмотрим, удастся ли и здесь улучшить производительность. На этом мы закончим с тем, что разумно делать в pbrt-v3. Ещё в одном посте я буду разбираться с тем, насколько далеко мы можем зайти, если откажемся от запрета на внесение изменений. При этом исходный код будет слишком отличаться от системы, описанной в книге Physically Based Rendering.

Оптимизация самого парсера

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

Я наконец-то собрался с силами и реализовал написанный вручную токенизатор и парсер для сцен pbrt. Формат файлов сцен pbrt парсить довольно просто: если не учитывать закавыченных строк, токены разделяются пробелами, а грамматика очень прямолинейна (никогда не возникает потребности заглядывать вперёд дальше, чем на один токен), но собственный парсер — это всё равно тысяча строк кода, которые нужно написать и отладить. Мне помогло то, что его можно было протестировать на множестве сцен; после исправления очевидных сбоев я продолжал работу, пока мне не удалось отрендерить в точности те же изображения, что и раньше: не должно возникать никаких различий в пикселях по причине замены парсера. На этом этапе я был абсолютно уверен, что всё сделано верно.

Я старался, чтобы новая версия была как можно более эффективной, по возможности подвергая входные файлы mmap() и пользуясь новой реализацией std::string_view из C++17 для минимизации создания копий строк из содержимого файла. Кроме того, поскольку в предыдущих трассировках много времени уходило на strtod(), я писал функцию parseNumber() с особой аккуратностью: одноразрядные целые числа и обычные целые числа обрабатываются по отдельности, а в стандартном случае, когда pbrt компилируется для использования 32-битных float, применял strtof() вместо strtod()1.

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

К моей радости, собственный парсер оказался огромной победой: обобщённость flex и bison так сильно снижала производительность, что новая версия легко их обгоняла. Благодаря новому парсеру время запуска снизилось до 13 мин 21 с, то есть ускорилось ещё в 1,5 раза! Дополнительный бонус заключался в том, что из системы сборки pbrt теперь можно было убрать всю поддержку flex и bison. Она всегда была головной болью, особенно под Windows, где у большинства людей они по умолчанию не установлены.

Управление состоянием графики

После значительного ускорения работы парсера всплыла новая раздражающая деталь: на этом этапе примерно 10% времени настройки тратилось на функции pbrtAttributeBegin() и pbrtAttributeEnd(), и бОльшую часть этого времени занимало выделение и освобождение динамической памяти. Во время первого запуска, занимавшего 35 минут, на эти функции уходило всего около 3% времени выполнения, поэтому на них можно было не обращать внимания. Но при оптимизации всегда так: когда начинаешь избавляться от больших проблем, мелкие становятся важнее.

Описание сцены pbrt основано на иерархическом состоянии графики, в котором указывается текущее преобразование, текущий материал и так далее. В нём можно делать снэпшоты текущего состояния (pbrtAttributeBegin()), вносить в него изменения, прежде чем добавлять в сцену новую геометрию, а затем возвращаться к исходному состоянию (pbrtAttributeEnd()).

Состояние графики хранится в структуре с неожиданным названием… GraphicsState. Для хранения копий объектов GraphicsState в стеке сохранённых состояний графики используется std::vector. Взглянув на члены GraphicsState, можно предположить источник проблем — три std::map, от имён до экземпляров текстур и материалов:

struct GraphicsState {
    // ...
    std::map<std::string, std::shared_ptr<Texture<Float>>> floatTextures;
    std::map<std::string, std::shared_ptr<Texture<Spectrum>>> spectrumTextures;
    std::map<std::string, std::shared_ptr<MaterialInstance>> namedMaterials;
};

Исследуя эти файлы сцен, я обнаружил, что большинство случаев сохранения и восстановления состояния графики выполняется в этих строках:

AttributeBegin
    ConcatTransform [0.981262 0.133695 -0.138749 0.000000 -0.067901 0.913846 0.400343 0.000000 0.180319 -0.383420 0.905800 0.000000 11.095301 18.852249 9.481399 1.000000]
    ObjectInstance "archivebaycedar0001_mod"
AttributeEnd

Другими словами, здесь выполняется обновление текущего преобразования и создание экземпляра объекта; в содержимое этих std::map никаких изменений не вносится. Создание их полной копии — выделение узлов красно-чёрного дерева, увеличение счётчиков ссылок общих указателей, выделение пространства и копирование строк — почти всегда является лишней тратой времени. Всё это освобождается при восстановлении предыдущего состояния графики.

Я заменил каждый из этих map указателем std::shared_ptr на map и реализовал подход copy-on-write, при котором копирование внутри блока begin/end атрибута происходит только тогда, когда его содержимое должно быть изменено. Изменение оказалось не особо сложным, но снизило время запуска больше чем на минуту, что дало нам 12 мин 20 с обработки перед началом рендеринга — снова ускорение в 1,08 раза.

А как насчёт времени рендеринга?

Внимательный читатель заметит, что пока я ничего не говорил о времени рендеринга. К моему удивлению, оно оказалось вполне терпимым даже «из коробки»: pbrt может рендерить изображения сцен кинематографического качества с несколькими сотнями сэмплов на пиксель на двенадцати ядрах процессора за период в два-три часа. Например, это изображение, одно из самых медленных, отрендерилось за 2 ч 51 мин 36 с:

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

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

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

При профилировании выяснилось, что примерно 60% времени рендеринга тратилось на пересечения лучей с объектами (большинство операций выполнялось при обходе BVH), а 25% тратилось на поиск текстур ptex. Эти соотношения похожи на показатели более простых сцен, поэтому на первый взгляд ничего очевидно проблемного здесь нет. (Однако я уверен, что Embree сможет оттрассировать эти лучи за чуть меньшее время.)

К сожалению, параллельная масштабируемость не так хороша. Обычно я вижу, что на рендеринг тратится 1400% ресурсов ЦП, по сравнению с идеалом в 2400% (на 24 виртуальных ЦП в Google Compute Engine). Похоже, что проблема связана с конфликтами при блокировках в ptex, но подробнее я её пока не исследовал. Очень вероятно, свой вклад вносит то, что pbrt-v3 не вычисляет в трассировщике лучей разность лучей для непрямых лучей; в свою очередь, такие лучи всегда получают доступ к самому детализированному MIP-уровню текстур, что не очень полезно для кэширования текстур.

Заключение (для pbrt-v3)

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

В целом прогресс был серьёзным: время запуска перед рендерингом снизилось с 35 минут до 12 мин 20 с, то есть общее ускорение составило 2,83 раза. Большее того, благодаря умной работе с кэшем преобразований использование памяти снизилось 80 ГБ до 69 ГБ. Все эти изменения доступны уже сейчас, если вы синхронизируетесь с последней версией pbrt-v3 (или если вы это сделали в течение последних нескольких месяцев.) И мы приходим к пониманию того, насколько мусорной является память Primitive для этой сцены; мы выяснили, как сэкономить ещё 18 ГБ памяти, но не реализовали это в pbrt-v3.

Вот, на что тратятся эти 12 мин 20 с после всех наших оптимизаций:

Функция / операция Процент времени выполнения
Построение BVH 34%
Парсинг (например, strtof()) 21%
strtof() 20%
Кэш преобразований 7%
Считывание файлов PLY 6%
Выделение динамической памяти 5%
Обращение преобразований 2%
Управление состоянием графики 2%
Прочее 3%

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

В какой-то момент я посмотрю, существуют ли более быстрые реализации strtof(); pbrt использует только то, что предоставляет ему система. Однако стоит быть аккуратным с выбором замен, которые протестированы не очень тщательно: парсинг float-значений — это один из тех аспектов, в надёжности которых программист должен быть полностью уверен.

Также привлекательным выглядит дальнейшее снижение нагрузки на парсер: у нас по-прежнему есть 17 ГБ текстовых входных файлов для парсинга. Мы можем добавить поддержку двоичного кодирования входных файлов pbrt (возможно, по аналогии с подходом RenderMan), однако я испытываю относительно этой идеи смешанные чувства; возможность открытия и изменения файлов описания сцен в текстовом редакторе довольно полезна, и я беспокоюсь, что иногда двоичное кодирование будет сбивать с толку студентов, использующих pbrt в процессе обучения. Это один из тех случаев, когда правильное решение для pbrt может отличаться от решений для коммерческого рендерера производственного уровня.

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

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

Примечание

  1. На системе Linux, в которой я выполнял тестирование, strtof() не быстрее, чем strtod(). Примечательно, что на OS X strtod() примерно в два раза быстрее, что совершенно нелогично. Исходя из практических соображений, я продолжил использовать strtof().

Автор: PatientZero

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js