Начинаем расследование с Windows Performance Analyzer
Разумеется, изначально виновник не был очевиден. Производительность падает потому, что мы неправильно используем Direct2D или DirectWrite? Может быть, у парсера последовательностей virtual terminal (VT) возникают проблемы с быстрой обработкой цветов? Обычно мы начинаем любые связанные с производительностью расследования с Windows Performance Analyzer (WPA). Он требует создания файла трассировки .etl
; эту операцию можно выполнить при помощи Windows Performance Recorder (WPR).
Лично мне больше всего нравится в WPA режим «Flame by Process». Во flame-графике каждый горизонтальный столбец обозначает вызов отдельной функции. Ширина столбцов соответствует общему времени ЦП, потраченному в этой функции, в том числе времени, потраченному на все функции, которые она вызывает рекурсивно. Благодаря этому можно легко заметить различия между двумя flame-графиками одного приложения или найти выбросы, которые чётко видны как слишком широкие столбцы.
Чтобы повторить это расследование, вам нужно будет установить Windows Terminal 1.12, а также инструмент rainbowbench. После компиляции rainbowbench с помощью cmake и компилятора на ваш выбор нужно выполнять в Windows Terminal команды
rainbowbench 20
иrainbowbench 21
в течение не менее 10 секунд. В процессе выполнения у вас должен быть запущен Windows Performance Recorder (WPR), записывающий трассировку производительности. После этого можно открыть файл.etl
в Windows Performance Analyzer (WPA). В панели меню можно дать WPA команду «Load Symbols».
В левой части показанного выше изображения мы видим загрузку ЦП потока рендеринга текста, когда он постоянно перерисовывает одни и те же 20 цветов, а справа показана загрузка ЦП при отрисовке 21 цветов. Благодаря flame-графику мы сразу же замечаем существенные различия в поведении внутри Direct2D; с большой вероятностью их виновником является функция TextLookupTableAtlas::Fill6x5ContrastRow
в Direct2D. Атласом («atlas») в графическом приложении обычно называют атлас текстур, а учитывая то, что Direct2D по умолчанию для рендеринга использует GPU, это скорее всего код, обрабатывающий атлас текстур в GPU. К счастью, уже существует множество инструментов для удобной отладки приложений, запущенных в GPU.
PIX и RenderDoc — удобная отладка проблем с производительностью графики
PIX — это приложение, похожее на мощный опенсорсный проект RenderDoc. Оба этих приложения чрезвычайно полезны для отладки и понимания подобных проблем производительности.
Хотя PIX имеет поддержку упакованных приложений наподобие Windows Terminal (которые в PIX называются UWP) и большое количество полезных метрик, мне показалось удобнее генерировать визуализации с помощью RenderDoc. Впрочем, в работе оба приложения практически идентичны, поэтому между ними легко переключаться.
Windows Terminal поставляется с современной версией
conhost.exe
под названиемOpenConsole.exe
; он содержит множество улучшений, отсутствующих в conhost.exe, в том числе альтернативные движки рендеринга. OpenConsole.exe можно открыть и запустить внутри пакета приложения Windows Terminal или из одного из архивов релизов Terminal. Затем можно создать ключ DWORD вHKEY_CURRENT_USERConsoleUseDx
и присвоить ему значение 0, чтобы получить классический рендерер текста GDI, 1 для выбора стандартного рендерера Direct2D или 2 для выбора нового движка Direct3D, устраняющего эту проблему. Этот трюк оказался полезным для RenderDoc, который не поддерживает упакованные приложения наподобие Windows Terminal.
Достаточно просто перетащить исполняемый файл в RenderDoc и выбрать Launch. После чего будет выполнен захват снэпшотов, которые позже подвергаются анализу и отладке.
При открытии захваченных данных отображаются команды отрисовки, которые Direct2D выполнил в GPU (верхнее изображение). При выборе Texture Viewer мы изначально ничего не получим, но, как оказывается, некоторые события во вкладке Output, например, DrawIndexedInstanced
, похоже, сообщают нам о состоянии рендерера в процессе исполнения. Более того, во вкладке Input содержится текстура D2D Internal: Grayscale Lookup Table:
Существование такой «lookup table» (таблицы поиска), похоже, сильно связано с тем, что отображение больше 20 цветов существенно замедляет приложение, и с проблемной функцией TextLookupTableAtlas::Fill6x5ContrastRow
, которую мы нашли с помощью WPA. Что если размер таблицы ограничен? Чтобы подтвердить наши подозрения, достаточно проскроллить все события. Таблица в каждом кадре сотни раз заполняется новыми цветами, потому что нельзя уместить 21 цвет в таблицу, где помещается лишь 20 цветов:
Если ограничить тестовое приложение 20 цветами, то содержимое таблицы будет неизменным:
Итак, оказывается, наш терминал сталкивается с пограничным для Direct2D случаем: в общем случае он оптимизирован на обработку до 20 цветов (на апрель 2022 года). Такое решение в Direct2D не является совпадением, поскольку использование для раскрашивания таблицы поиска постоянного размера снижает её вычислительную сложность и энергозатраты, особенно на старом оборудовании, для которого оно писалось. Кроме того, большинство приложений, веб-сайтов и пр. не превышает этого ограничения, а если превысят, то текст чаще всего бывает статичным и его не надо перерисовывать по 60 раз в секунду. В терминальном приложении такое наоборот случается довольно часто.
Решаем проблему при помощи более агрессивного кэширования
Решение тривиально: мы просто создадим собственную таблицу поиска гораздо большего размера и обернём её вокруг Direct2D! К сожалению, мы не можем приказать Direct2D использовать наш собственный кэш. На самом деле, полагаться в этом на его логику рендеринга вообще будет здесь проблематично, поскольку максимальное количество цветов должно всегда оставаться конечным. Поэтому в итоге нам придётся писать собственный рендерер текста.
Мы хотели бы поблагодарить Джо Вилма из Alacritty за создание рендеринга терминалов на современных GPU, Кристиана Парпарта из Contour за длительную поддержку и советы, а также Тома Силадьи за описание идеи. Особая благодарность Кейси Муратори за предложение такого решения и Мартиншу Можейко за предоставление примера HLSL-шейдера.
Превращение шрифтов и содержащихся в них глифов в растеризованные изображения обычно является очень затратной задачей, поэтому критически важной для производительности будет реализация своего рода «кэша глифов». Примитивный способ кэширования глифа может заключаться в отрисовке его на небольшой текстуре при первой встрече с ним. При последующих вхождениях мы можем ссылаться на кэшированную текстуру. Точно так же, как Direct2D использует атлас таблицы поиска для раскрашивания, мы можем использовать собственный атлас текстур для кэширования глифов. Вместо отрисовки 1000 глифов в 1000 крошечных текстур мы просто выделим одну огромную текстуру и подразделим её на сетку из 1000 ячеек глифов.
Допустим, у нас есть крошечный терминал размером 6 на 2 ячеек и мы просто хотим отрисовать цветной текст «Hello, World!». Мы уже знаем, что первым делом нужно создать атлас текстур для глифов:
После замены символов и их глифов в терминале ссылками на атлас текстур у нас остаётся только «буфер метаданных», имеющий тот же размер, что и терминал, и содержащий информацию о цвете. Атлас текстур содержит лишь уникальные и бесцветные растеризованные текстуры глифов. Но постойте… Разве мы не можем перевернуть эту систему и вернуться к исходным входным данным? И именно так работает наш GPU-шейдер:
Написав примитивный пиксельный шейдер, мы можем копировать глифы из текстуры атласа на дисплейный вывод непосредственно в GPU. Если опустить более сложные темы наподобие гамма-коррекции или ClearType, то для раскрашивания глифов достаточно умножить альфа-маску глифа на любой нужный нам цвет. А буфер метаданных содержит оба элемента — и индекс глифа, который нужно скопировать для каждой ячейки сетки, и цвет, в который он должен быть раскрашен.
Результат
Рост производительности, вызванный этим решением, сильно зависит от оборудования. Однако в общем случае он, по крайней мере, находится на равных с рендерером на основе Direct2D, при этом избегая всех ограничений, связанных с раскрашиванием глифов.
Мы замеряли производительность на следующем оборудовании:
- ЦП: AMD Ryzen 9 5950X
- GPU: NVIDIA RTX 3080 FE
- ОЗУ: 64GB 3200 MHz CL16
- Дисплей: 3840×2160, 60 Гц
Загрузку ЦП и GPU мы замеряли по значениям в «Диспетчере задач», поскольку именно в него в первую очередь заглядывают пользователи при возникновении проблем с производительностью. Кроме того, мы измеряли общее энергопотребление GPU, потому что оно является наилучшим показателем потенциальной экономии энергии, не зависящим от масштабирования частот и т. п.
ЦП (%) | GPU (%) | GPU (Вт) | FPS | ||
---|---|---|---|---|---|
DxEngine | Мерцание курсора | 0,0% | 0,1% | 17 Вт | |
DxEngine | ≤ 20 цветов | 1,5% | 7,0% | 24 Вт | 60 |
DxEngine | ≥ 21 цвет | 5,5% | 27% | 27 Вт | 30 |
AtlasEngine | Мерцание курсора | 0,0% | 0,0% | 17 Вт | |
AtlasEngine | ≤ 20 цветов | 0,6% | 0,3% | 21 Вт | ≥60 |
AtlasEngine | ≥ 21 цвет | 0,6% | 0,3% | 21 Вт | ≥60 |
DxEngine — это внутреннее название старого рендерера на основе Direct2D, а AtlasEngine — название нового рендерера. Согласно этим показателям, новый рендерер не просто снижает общую загрузку ЦП и GPU, но и делает её независимой от того, что отрисовывается.
Заключение
Direct2D реализует рендеринг текста при помощи встроенного атласа текстур, в который кэшируются растеризованные глифы, и таблицы поиска для раскрашивания этих глифов. Таблица используется, поскольку она снижает вычислительные затраты на раскрашивание глифов, но, к сожалению, требует задания верхней границы количества цветов, которые в ней могут храниться. Если превзойти эту границу и отрисовывать очень разноцветный текст, то Direct2D вынужден удалять часть цветов, чтобы освободить место для новых, что может привести к чрезмерно большому времени обновления таблицы поиска, вызывая резкое снижение производительности.
Для большинства приложений это не является проблемой, потому что текст обычно достаточно статичен или не превышает верхнюю границу, но терминальные приложения часто раскрашивают весь фон блочными символами, анимируют текст с частотой выше 60 FPS, и т. п., поэтому это становится проблематичным.
Наш новый рендерер написан с учётом современного оборудования и поддерживает только отрисовку моноширинного текста в прямоугольной сетке. Это позволяет нам использовать преимущества современных GPU с их быстрыми вычислениями, поддержкой условных операторов и ветвлений, а также относительно большими объёмами памяти. Благодаря этому мы можем безопасно повышать производительность, кэшируя больше данных и выполняя раскрашивание глифов без таблиц поиска, пусть и ценой повышения затрат вычислительных ресурсов. А благодаря тому, что поддерживаются только прямоугольные сетки моноширинного текста, мы смогли существенно упростить реализацию, снизив дополнительные вычислительные затраты; при этом новое решение равно по производительности и эффективности со старым рендерером на основе Direct2D или даже превосходит его.
Исходную реализацию можно посмотреть в пул-реквесте #11623. Этот пул-реквест достаточно сложен, однако самые важные части можно найти в подпапке renderer/atlas
. «Парсер» (часть движка, выполняемая на стороне ЦП) находится в AtlasEngine.cpp
в виде AtlasEngine::_flushBufferLine
, пиксельный шейдер (часть движка, выполняемая на стороне GPU) находится в shader_ps.hlsl
.
После исходного пул-реквеста было добавлено множество усовершенствований. Текущее состояние движка на момент написания можно найти здесь. В него вкючена реализация алгоритма смешения текста Direct2D и DirectWrite с гамма-коррекцией, она находится внутри трёх файлов dwrite; также там присутствует реализация смешение ClearType в виде GPU-шейдера. Его независимую демонстрацию можно посмотреть в демо-проекте dwrite-hlsl.
Автор:
PatientZero