Сегодня мы рассмотрим ещё два места, в которых 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 с:
Дюны из «Моаны», отрендеренные 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 более радикальных изменений.
Примечание
- На системе Linux, в которой я выполнял тестирование,
strtof()
не быстрее, чемstrtod()
. Примечательно, что на OS Xstrtod()
примерно в два раза быстрее, что совершенно нелогично. Исходя из практических соображений, я продолжил использоватьstrtof()
.