Глубокое погружение в систему рендеринга WPF

в 7:43, , рубрики: .net, C#, windows, wpf, разработка под windows, метки:

На перевод этой статьи меня подтолкнуло обсуждение записей «Почему WPF живее всех живых?» и «Семь лет WPF: что изменилось?» Исходная статья написана в 2011 году, когда Silverlight еще был жив, но информация по WPF не потеряла актуальности.

Сначала я не хотел публиковать эту статью. Мне казалось, что это невежливо — о мертвых надо говорить либо хорошо, либо ничего. Но несколько бесед с людьми, чье мнение я очень ценю, заставили меня передумать. Вложившие много усилий в платформу Microsoft разработчики должны знать о внутренних особенностях ее работы, чтобы, зайдя в тупик, они могли понимать причины произошедшего и более точно формулировать пожелания к разработчикам платформы. Я считаю WPF и Silverlight хорошими технологиями, но… Если вы следили за моим Twitter последние несколько месяцев, то некоторые высказывания могли показаться вам безосновательными нападками на производительность WPF и Silverlight. Почему я это писал? Ведь, в конце концов, я вложил тысячи и тысячи часов моего собственного времени в течение многих лет, пропагандируя платформу, разрабатывая библиотеки, помогая участникам сообщества и так далее. Я однозначно лично заинтересован. Я хочу, чтобы платформа стала лучше.

Глубокое погружение в систему рендеринга WPF - 1

Производительность, производительность, производительность

При разработке, затягивающего, ориентированного на потребителя, пользовательского интерфейса, производительность для вас важнее всего. Без нее все остальное не имеет смысла. Сколько раз вам приходилось упрощать интерфейс, потому что он лагал? Сколько раз вы придумывали «новую, революционную модель пользовательского интерфейса», которую приходилось выкинуть на помойку, так как имеющаяся технология не позволяла ее реализовать? Сколько раз вы говорили клиентам, что для полноценной работы нужен четырехядерный процессор с частотой 2,4 ГГц? Клиенты неоднократно спрашивали меня, почему на WPF и Sliverlight они не могут получить такой же плавный интерфейс, как в приложении для iPad, даже имея вчетверо более мощный PC. Эти технологии может и подходят для бизнес-приложений, но они явно не подходят для пользовательских приложений следующего поколения.

Но ведь WPF использует аппаратное ускорение. Почему же вы считаете его неэффективным?

WPF действительно использует аппаратное ускорение и некоторые моменты его внутренней реализации сделаны очень неплохо. К сожалению, эффективность использования GPU намного ниже, чем могла бы быть. Система рендеринга WPF использует очень грубую силу. Я надеюсь объяснить это утверждение чуть ниже.

Анализируем единичный проход рендеринга WPF

Для анализа производительности нам надо понять, что на самом деле происходит внутри WPF. Для этого я использовал PIX, профайлер Direct3D, поставляемый вместе с DirectX SDK. PIX запускает ваше D3D-приложение и внедряет ряд перехватчиков во все вызовы Direct3D для их анализа и мониторинга.

Я создал простое WPF-приложение, в котором слева направо выводятся два эллипса. Оба эллипса одного цвета (#55F4F4F5) с черным контуром.

Глубокое погружение в систему рендеринга WPF - 2

И как WPF рендерит это?

Прежде всего WPF очищает (#ff000000) грязную область, которую он собирается перерисовать. Грязные области нужны для сокращения числа пикселей, посылаемых на финальную стадию слияния (output merger stage) в конвейере GPU. Мы даже может предположить, что это сокращает объем геометрии, которую придется перетесселировать (be re-tessellated), подробнее об этом чуть позже. После очистки грязной области наш кадр выглядит вот так

Глубокое погружение в систему рендеринга WPF - 3

После этого WPF делает что-то непонятное. Сначала он заполняет вершинный буфер (vertex buffer), после чего рисует что-то, выглядящее как прямоугольник поверх грязной области. Теперь кадр выглядит вот так (захватывающе, не правда ли?):

Глубокое погружение в систему рендеринга WPF - 4

После этого он тесселирует эллипс на GPU. Тесселяция, как вы уже возможно знаете — это превращение геометрии нашего 100х100 эллипса в набор треугольников. Делается это по следующим причинам: 1) треугольники являются естественной единицей рендеринга для GPU 2) тесселяция эллипса может вылится всего в несколько сотен треугольников, что намного быстрее растеризации 10 000 пикселов с антиалиасингом средствами CPU (что делает Silverlight). На скриншоте ниже видно, как выглядит тесселяция. Знакомые с 3D графикой читатели могли заметить, что это треугольные полосы (triangle strip). Обратите внимание, что в тесселяции эллипс выглядит незавершенным. В качестве следующего шага WPF берет тесселяцию, загружает ее в вершинный буфер GPU и делает еще один вызов отрисовки (draw call) с использованием пиксельного шейдера, который настроен для использования «кисти» настроенной в XAML.

Глубокое погружение в систему рендеринга WPF - 5

Помните, что я отметил незавершенность эллипса? Это действительно так. WPF генерирует то, что программисты Direct3D знают как «набор линий» (line list). GPU понимает линии так же хорошо, как и треугольники. WPF заполняет вершинный буфер этими линиями и догадайтесь что? Правильно, выполняет еще один вызов отрисовки (draw call)? Набор линий выглядит вот так:

Глубокое погружение в систему рендеринга WPF - 6

Теперь-то WPF закончил рисовать эллипс, не так ли? Нет! Вы забыли о контуре! Контур тоже является набором линий. Его тоже посылают в вершинный буфер и выполняют еще один вызов отрисовки. Контур выглядит вот так

Глубокое погружение в систему рендеринга WPF - 7

К этому моменту мы нарисовали один эллипс, так что наш кадр выглядит вот так:

Глубокое погружение в систему рендеринга WPF - 8

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

Я не понял. Почему это плохо для производительности?

Первое, что вы могли заметить — для рендеринга одного эллипса нам потребовались три вызова отрисовки и два обращения к вершинному буферу. Чтобы объяснить неэффективность этого подхода мне придется немного рассказать о работе GPU. Для начала, современные GPU работают ОЧЕНЬ БЫСТРО и асинхронно с GPU. Но при некоторых операциях происходят дорогостоящие переключения из пользовательского режима в режим ядра (user-mode to kernel mode transitions). При заполнении вершинного буфера он должен быть заблокирован. Если буфер в этот момент используется GPU, это вынуждает GPU синхронизироваться с CPU и резко снижает производительность. Вершинный буфер создается с D3DUSAGE_WRITEONLY | D3DUSAGE_DYNAMIC, но когда он блокируется (что случается нередко), D3DLOCK_DISCARD не используется. Это может вызвать потерю скорости (синхронизацию GPU и CPU) в GPU, если буфер уже используется GPU. В случае большого количества вызовов отрисовки у нас есть высокая вероятность получить множество переходов в режим ядра и большую нагрузку в драйверах. Для повышения производительности нам надо послать на GPU настолько много работы, насколько возможно, иначе ваш CPU будет занят, а GPU будет простаивать. Не забывайте, что в этом примере речь шла только об одном кадре. Типичный интерфейс на WPF пытается выводить 60 кадров в секунду! Если вы когда-либо пытались выяснить, отчего ваш поток рендеринга так сильно загружает процессор, то скорее всего обнаруживали, что большая часть нагрузки идет от вашего драйвера GPU.

А что с кэшированным построением (Cached Composition)? Оно ведь повышает производительность!

Вне всяких сомнений, повышает. Кэшированное построение или BitmapCache кэширует обьекты в текстуру GPU. Это означает, что вашему CPU не надо перетесселировать, а GPU не надо перерастрировать (re-rasterize). При выполнении одного прохода рендеринга WPF просто использует текстуру из видеопамяти, увеличивая производительность. Вот BitmapCache эллипса:

Глубокое погружение в систему рендеринга WPF - 9

Но у WPF есть темные стороны и в этом случае. Для каждого BitmapCache он выполняет отдельный вызов отрисовки. Не буду врать, иногда вам действительно надо выполнять вызов отрисовки для рендеринга единичного объекта (visual). Всякое бывает. Но давайте представим себе сценарий, в котором у нас есть <Canvas/> с 300 анимированными BitmapCached-эллипсами. Продвинутая система поймет, что ей надо отрендерить 300 текстур и все они z-упорядочены (z-ordered) одна за другой. После этого она соберет их пакеты максимального размера, насколько я помню, DX9 может принимать до 16 входящих элементов (sampler inputs) за раз. В этом случае мы получим 16 вызовов отрисовки вместо 300, что заметно снизит нагрузку на CPU. В терминах 60 кадров в секунду мы снизим нагрузку с 18 000 вызовов отрисовки в секунду до 1125. В Direct 3D 10 количество входящих элементов намного выше.

Ладно, я дочитал до этого места. Расскажите мне, как WPF использует пиксельные шейдеры!

У WPF есть расширяемый API пиксельных шейдеров и некоторые встроенные эффекты. Это позволяет разработчикам добавлять по-настоящему уникальные эффекты в их пользовательский интерфейс. При примернии шейдера к существующей текстуре в Direct 3D обычно используется промежуточная цель отрисовки (intermediate rendertarget)… и конце концов вы не можете использовать в качестве образца (sample from) текстуру, в которую пишете! WPF тоже делает это, но, к несчастью, он создает полностью новую текстуру КАЖДЫЙ КАДР и уничтожает ее по завершении. Создание и уничтожение ресурсов GPU — это одна из самых медленных вещей, которые только можно делать при обработке каждого кадра. Я обычно не поступаю так даже с выделением системной памяти схожего объема. При повторном использовании этих промежуточных поверхностей можно было бы достигнуть очень значительного повышения производительности. Если вы когда-либо задавались вопросом, почему ваши аппаратно-ускоренные шейдеры создают заметную нагрузку на CPU, то теперь знаете ответ.

Но может быть именно так и надо рендерить векторную графику на GPU?

Microsoft приложила немало усилий для исправлений этих проблем, к несчастью это было сделано не в WPF, а в Direct 2D. Посмотрите на эту группу из 9 эллипсов, отрендеренных Direct2D:

Глубокое погружение в систему рендеринга WPF - 10

Помните, как много вызовов отрисовки требовалось WPF для рендеринга одного эллипса с контуром? А блокировок вершинного буфера? Direct2D делает это за ОДИН вызов отрисовки. Тесселяция выглядит вот так

Глубокое погружение в систему рендеринга WPF - 11

Direct 2D старается нарисовать как можно больше за один раз, максимизируя использование GPU и минимизируя загрузку CPU. Прочитайте Insights: Direct2D Rendering в конце вот этой страницы, там Марк Лавренс (Mark Lawrence) объясняет многие внутренние детали работы Direct 2D. Вы можете заметить, что несмотря на всю скорость Direct 2D есть еще больше областей, где она будет улучшена во второй версии. Вполне возможно, что версия 2 Direct 2D будет использовать аппаратное ускорение тесселяции DX11.

Глядя на API Direct 2D вполне можно предположить, что значительная часть кода была взята из WPF. Посмотрите это старое видео про Avalon, в нем Майкл Воллент (Michael Wallent) рассказывает о разработке замены GDI на основе этой технологии. У него очень похожий геометрический API и терминология. Внутри он похожий, но очень оптимизированный и современный.

А что с Silverlight?

Я мог бы заняться Silverlight, но это будет излишним. Производительность рендеринга в Silverlight тоже низкая, но причины иные. Он использует для отрисовки CPU (даже для шейдеров, насколько я помню, они частично написаны на ассемблере), но CPU как минимум в 10-30 раз медленнее GPU. Это оставляем вам гораздо меньше процессорной мощности для рендеринга пользовательского интерфейса и еще меньше для логики вашего приложения. Его аппаратное ускорение очень слабо развито и почти в точности повторяет кэшированное построение WPF и ведет себя аналогичным образом, осуществляя вызов отрисовки для каждого объекта с BitmapCache (BitmapCached visual).

И что же нам теперь делать?

Этот вопрос очень часто задают мне клиенты, столкнувшиеся с проблемами WPF и Silverlight. К несчастью, у меня нет однозначного ответа. Те кто могут, делают собственные фреймворки, заточенные под свои специфические потребности. Остальным приходится смириться, так как альтернатив WPF и SL в их нишах нет. Если мои клиенты просто разрабатывают бизнес-приложения, то у них не так много проблем с скоростью и они просто наслаждаются производительностью работы программистов. Настоящие проблемы у тех, кто хочет строить действительно интересные интерфейсы (то есть приложения для потребителей или киосков (consumer apps or kiosk apps)).

Уже после начала перевода появились новости про планируемую оптимизацию производительности и использование DX10-11 в WPF 4.6. Будут ли решены описанные в статье проблемы из новостей не совсем понятно.

Исходная статья: A Critical Deep Dive into the WPF Rendering System

Автор: Vedomir

Источник

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


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