Группа пользователей хотела реализовать простую видеоигру в терминале, но оказалось, что её производительность в Windows Terminal совершенно не подходит для такой задачи. Проблему с производительностью можно воспроизвести, многократно отрисовывая «радугу» и замеряя количество кадров в секунду (FPS). Показанная на рисунке радуга, состоящая из 20 цветов, на моём Surface Book с Intel i7-6700HQ отрисовывается с частотой 30 FPS. Однако если отрисовывать ту же радугу из 21 или более цветов, то частота упадёт ниже 10 FPS. Такое падение стабильно и ситуация не ухудшается даже при тысячах разных цветов.


Начинаем расследование с 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_USER\Console\UseDx и присвоить ему значение 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.

Комментарии (7)


  1. n1tra
    12.05.2022 06:22
    +8

    Ничего не понял, кроме того, что для отображения цветного текста нужно топовое железо. Как мы до такого докатились? )


    1. Panda_sama
      12.05.2022 06:25

      legacy


    1. Fullmoon
      12.05.2022 09:41
      +8

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

      Вообще я понимаю претензии к разбуханию системных требований и т.п., но вы же хотите не чистый текстовый 80×25, вам нужно фактическое разрешение как минимум 1080p, шрифты со сглаживанием и т.п., быстродействие на уровне чистого текстового, и в отдельном окошке и не мешало остальной системе. Это возможно, но не столь тривиально.


    1. Taywox
      12.05.2022 10:58
      -1

      Сначало то-же ничего не понял ( меня начал мучить вопрос нах.я все это?) , перечитал первые 2 предложения поста и понял: " Группа пользователей хотела реализовать простую видеоигру в терминале" , нам нужно создать программу и топовое железо что бы поиграть в эту игру... Занавес...


  1. karb0f0s
    12.05.2022 11:24

    Почти год назад Windows Terminal уже упрекали в медлительности. Casey Muratori даже набросал прототип терминала, который выдавал тысячи FPS на рендеринге.
    В результате Windows Terminal адаптировал новый rendering engine. Интересно, насколько это изменение связано с проблемой, разобранной в статье?


    1. V1RuS
      12.05.2022 13:46
      +1

      статья ведь как раз про этот новый rendering engine, и даже ссылка есть на ровно этот же пулл-реквест


  1. MsDosLite
    13.05.2022 08:29

    То есть теперь нужны Ryzen 9 5950X, RTX 3080 FE и 64Гб оперативки, чтобы пользоваться терминалом? Ясно, спасибо!