[Перевод] Привязка ресурсов в Microsoft DirectX 12. Вопросы производительности

a1b9d204269c42e1ae6393cce639dfdd.png
Давайте подробнее рассмотрим привязку ресурсов на платформах Intel. Сейчас это особенно актуально в связи с выпуском 6-го поколения процессоров семейства Intel Core (Skylake) и с выпуском операционной системы Windows 10, который состоялся 29 июля.
В предыдущей статье Введение в привязку ресурсов в Microsoft DirectX* 12 были описаны новые способы привязки ресурсов в DirectX 12. Вывод из этой статьи был таким: при наличии настолько широкого выбора основная задача заключается в том, чтобы выбрать наилучший механизм привязки для целевого GPU, оптимальные типы ресурсов и частоту их обновления.
В этой статье описывается выбор различных механизмов привязки ресурсов для эффективного запуска приложений на определенных GPU Intel.
Для разработки игр на основе DirectX 12 требуется следующее:

  • Windows 10.
  • Visual Studio* 2013 или более поздней версии.
  • Пакет DirectX 12 SDK, входящий в состав Visual Studio.
  • GPU и драйверы, поддерживающие DirectX 12.


Дескриптор — это блок данных, описывающий объект для GPU, в «непрозрачном» формате, предназначенном для GPU. В DirectX 12 поддерживаются следующие дескрипторы, которые ранее назывались «представлениями ресурсов» в DirectX 11:

  • Представление буфера констант (CBV).
  • Представление ресурсов шейдера (SRV).
  • Представление неупорядоченного доступа (UAV).
  • Представление семплера (SV).
  • Представление цели рендеринга (RTV).
  • Представление шаблона глубины (DSV).
  • И другие.


Эти дескрипторы или представления ресурсов можно считать структурой (блоком), которая потребляется front end графического процессора. Размер дескрипторов составляет приблизительно 32–64 байта. Дескрипторы содержат информацию о размерах текстур, их формате и разметке.
Дескрипторы хранятся в куче дескрипторов, которая представляет собой последовательность структур в памяти.

Таблица дескрипторов указывает на дескрипторы в куче с помощью значений смещения. Таблица сопоставляет непрерывный диапазон дескрипторов с ячейками шейдера, делая их доступными с помощью рутовой подписи (root signature). Рутовая подпись также может содержать рутовые константы (root constants), рутовые дескрипторы (root descriptors) и статические семплеры.

a163cbce5e9a4ae490b669af95a4027b.png
Рисунок 1. Дескрипторы, куча дескрипторов, таблицы дескрипторов, рутовая подпись

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

// the init function sets the shader registers
// parameters: type of descriptor, num of descriptors, base shader register
// the first descriptor table entry in the root signature in
// image 1 sets shader registers t1, b1, t4, t5
// performance: order from most frequent to least frequent used
D3D12_DESCRIPTOR_RANGE Param0Ranges[3]; 
Param0Ranges[0].Init(D3D12_DESCRIPTOR_RANGE_SRV, 1, 1); // t1 Param0Ranges[1].Init(D3D12_DESCRIPTOR_RANGE_CBV, 1, 1); // b1 Param0Ranges[2].Init(D3D12_DESCRIPTOR_RANGE_SRV, 2, 4); // t4-t5 

// the second descriptor table entry in the root signature
// in image 1 sets shader registers u0 and b2
D3D12_DESCRIPTOR_RANGE Param1Ranges[2]; Param1Ranges[0].Init(D3D12_DESCRIPTOR_RANGE_UAV, 1, 0); // u0 Param1Ranges[1].Init(D3D12_DESCRIPTOR_RANGE_CBV, 1, 2); // b2 

// set the descriptor tables in the root signature
// parameters: number of descriptor ranges, descriptor ranges, visibility
// visibility to all stages allows sharing binding tables
// with all types of shaders
D3D12_ROOT_PARAMETER Param[4]; 
Param[0].InitAsDescriptorTable(3, Param0Ranges, D3D12_SHADER_VISIBILITY_ALL); 
Param[1].InitAsDescriptorTable(2, Param1Ranges, D3D12_SHADER_VISIBILITY_ALL); // root descriptor
Param[2].InitAsShaderResourceView(1, 0); // t0
// root constants
Param[3].InitAsConstants(4, 0); // b0 (4x32-bit constants)

// writing into the command list
cmdList->SetGraphicsRootDescriptorTable(0, [srvGPUHandle]); 
cmdList->SetGraphicsRootDescriptorTable(1, [uavGPUHandle]);
cmdList->SetGraphicsRootConstantBufferView(2, [srvCPUHandle]);
cmdList->SetGraphicsRoot32BitConstants(3, {1,3,3,7}, 0, 4);


Показанный выше исходный код настраивает рутовую подпись таким образом, чтобы у нее было две таблицы дескрипторов, один рутовый дескриптор и одна рутовая константа. Код также показывает, что у рутовых констант нет косвенного обращения, они предоставляются напрямую вызовом SetGraphicsRoot32bitConstants. Они связаны напрямую с регистрами шейдера; нет ни самого буфера констант, ни дескриптора буфера констант, ни привязки. Рутовые дескрипторы имеют только один уровень косвенного обращения, поскольку они хранят указатель на память (дескриптор → память), а таблицы дескрипторов имеют два уровня косвенного обращения (таблица дескрипторов → дескриптор → память).

Дескрипторы находятся в разных кучах в зависимости от их типов, например SV и CBV/SRV/UAV. Это обусловлено очень большими различиями между размерами дескрипторов разных типов на разных аппаратных платформах. Для каждого типа кучи дескрипторов должна быть выделена только одна куча, поскольку изменение кучи может быть крайне ресурсоемкой операцией.

В целом в DirectX 12 поддерживается заблаговременное выделение более одного миллиона дескрипторов, что вполне достаточно для целого игрового уровня. В прежних версиях DirectX выделение ресурсов происходило при работе драйвера на его собственных «условиях», а в DirectX 12 можно вообще избежать выделения ресурсов при выполнении. Это означает, что выделение дескрипторов больше не влияет на производительность.

Примечание. В процессорах Intel® Core™ 3-го поколения (Ivy Bridge) и 4-го поколения (Haswell) при использовании DirectX 11 и архитектуры драйверов WDDM (Windows Display Driver Model) версии 1.x ресурсы динамически сопоставлялись с памятью на основе ссылок на ресурсы в буфере команд с операцией сопоставления таблицы страниц. За счет этого удавалось избежать копирования данных. Динамическое сопоставление имело большое значение, поскольку эти архитектуры выделяли графическому процессору только 2 ГБ памяти (в семействе процессоров Intel® Xeon® E3–1200 v4 (Broadwell) выделялось больше).
В DirectX 12 и WDDM версии 2.x теперь невозможно переназначать ресурсы в виртуальное адресное пространство GPU по мере необходимости, поскольку ресурсам при создании необходимо назначать статический виртуальный адрес и этот виртуальный адрес невозможно изменить после создания. Даже если ресурс вытеснен из памяти GPU, он сохраняет свой виртуальный адрес для более позднего срока, когда он снова становится резидентным.
Поэтому ограничивающим фактором может стать общий объем памяти в 2 ГБ, выделяемый графическому процессору в семействах Ivy Bridge/Haswell.

Как сказано в предыдущей статье, идеально сбалансированное приложение может использовать сочетание всех типов привязок: рутовые константы, рутовые дескрипторы, таблицы дескрипторов для дескрипторов, получаемых на лету по мере выдачи вызовов отрисовки, а также динамическое индексирование крупных таблиц дескрипторов.
Производительность различных архитектур может различаться при использовании крупных наборов рутовых констант и рутовых дескрипторов по сравнению с использованием таблиц дескрипторов. По этой причине может потребоваться оптимально настроить соотношение между рутовыми параметрами и таблицами дескрипторов в зависимости от целевых аппаратных платформ.


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

Начнем с так называемых постоянных (константных) данных. В большинстве игр все постоянные данные обычно хранятся в «системной памяти». Игровой движок изменяет данные в памяти, доступной для ЦП, а затем — в кадре. Целый блок постоянных данных копируется или сопоставляется в память GPU, а затем прочитывается графическим процессором с помощью представления буфера констант или рутового дескриптора.

Если постоянные данные предоставляются с помощью SetGraphicsRoot32BitConstants () в качестве рутовой константы, запись в рутовом дескрипторе не изменяется, но данные могут изменяться. Если они предоставляются с помощью дескриптора CBV == и таблицы дескрипторов, то дескриптор не изменяется, но данные могут изменяться.

В случае если нам нужно несколько представлений буфера констант (например, для двойной или тройной буферизации при рендеринге), CBV или дескриптор могут изменяться для каждого кадра в рутовой подписи.

Для данных текстур предполагается выделение памяти GPU при запуске. Затем будет создан дескриптор SV ==, он будет сохранен в таблице дескрипторов или в статическом семплере, и на него будет указывать ссылка в рутовом дескрипторе. После этого данные и дескриптор или статический семплер не изменяются.

Для динамических данных, таких как изменяющиеся данные текстур или буфера (например, текстуры с отображаемым локализованным текстом, буферы с анимированными вершинами или с создаваемыми моделями), мы выделяем цель рендеринга или буфер, предоставляем RTV или UAV, то есть дескрипторы, после чего эти дескрипторы могут уже не изменяться. Данные в цели рендеринга или в буфере могут изменяться.
Если нам нужно несколько целей рендеринга или буферов (например, для двойной или тройной буферизации при рендеринге), дескрипторы могут изменяться для каждого кадра в рутовой подписи.

В дальнейшем обсуждении изменение считается важным для привязки ресурсов, если оно связано со следующим:

  • Изменение или замена дескриптора в таблице дескрипторов, например CBV, RTV или UAV.
  • Изменение записи в рутовой подписи.


На платформах Haswell/Broadwell при изменении одной таблицы дескрипторов в рутовой подписи затрачивается столько же ресурсов, сколько при изменении всех таблиц дескрипторов. Изменение одного аргумента приводит к тому, что оборудованию приходится создавать копию (версию) всех текущих аргументов. Количество рутовых параметров в рутовой подписи — это количество данных, для которых оборудованию приходится создавать новую версию (т. е. полную копию) при изменении любого подмножества.

Примечание. Для всех остальных типов памяти в DirectX 12, таких как кучи дескрипторов, ресурсы буферов и т. п., оборудование не создает новые версии.

Другими словами, на изменение всех параметров затрачивается примерно столько же ресурсов, сколько на изменение одного (см. [Лоритцен] и [MSDN]). Меньше всего ресурсов тратится, разумеется, если ничего не изменять, но это бесполезно.

Примечание. На другом оборудовании, где память разделяется на быструю и медленную, хранилище рутовых аргументов (root arguments) будет создавать новую версию только той области памяти, где изменился аргумент, то есть либо быстрой области, либо медленной области.

На платформах Haswell/Broadwell дополнительные затраты на изменение таблиц дескрипторов могут быть обусловлены ограниченным размером таблицы привязки в оборудовании.

Таблицы дескрипторов на этих аппаратных платформах используют аппаратные «таблицы привязки». Каждая запись таблицы привязки является одиночным значением DWORD, которое можно считать смещением в куче дескрипторов. В кольце размером 64 КБ может храниться 16 384 записи таблиц привязки.

Другими словами, объем памяти, потребляемый каждым вызовом отрисовки, зависит от суммарного количества дескрипторов, которые проиндексированы в таблице дескрипторов и на которые указывают ссылки в рутовой подписи.
Если мы исчерпаем 64 КБ памяти для записей таблицы привязки, драйвер выделит еще одну таблицу привязки размером 64 КБ. Переключение между этими таблицами приводит к остановке конвейера, как показано на рисунке 2.

3c235f68249b45bebdc6e0293d23b39c.png
Рисунок 2. Остановка конвейера (рисунок Эндрю Лоритцена)

Предположим, что рутовая подпись ссылается на 64 дескриптора в таблице дескрипторов. Остановка будет происходить через каждые 16 384/64 = 256 вызовов отрисовки.

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

Следовательно, на платформах Haswell/Broadwell желательно, чтобы в таблицах дескрипторов было как можно меньше ссылок на дескрипторы.
Что это означает с точки зрения рендеринга? При использовании большего количества таблиц дескрипторов с меньшим числом дескрипторов в каждой таблице (и, следовательно, при большем количестве рутовых подписей) увеличивается количество объектов состояния конвейера (PSO), поскольку между такими объектами и рутовыми подписями поддерживаются отношения «один к одному».
Увеличение количества объектов состояния конвейера может привести к увеличению количества шейдеров, которые в этом случае могут быть более специализированными вместо более длинных шейдеров, обладающих более широким набором функций, согласно общей рекомендации.


Как было сказано выше, с точки зрения затрачиваемых ресурсов изменение одной таблицы дескрипторов равноценно изменению всех таблиц дескрипторов. Аналогичным образом изменение одной рутовой константы или рутового дескриптора равноценно изменению всех (см. [Лоритцен]).

Рутовые константы реализованы с помощью «вещательных констант», которые представляют собой буфер, используемый оборудованием для заблаговременного заполнения регистров операционных блоков (EU). Поскольку значения доступны сразу же после запуска потока EU, можно добиться повышения производительности, храня постоянные данные в качестве рутовых констант, а не в таблицах дескрипторов.
Рутовые дескрипторы также реализованы с помощью «вещательных констант». Они представляют собой указатели, передаваемые в виде констант шейдерам с чтением данных посредством обычного пути доступа к памяти.


Мы рассмотрели реализацию таблиц дескрипторов, рутовых констант и дескрипторов. Теперь можно ответить на основной вопрос этой статьи: «Что предпочтительнее использовать?». В силу ограниченного размера аппаратной таблицы привязки и возможных остановок, связанных с превышением этого ограничения, изменение рутовых констант и рутовых дескрипторов представляется менее ресурсоемкой операцией на платформах Haswell/Broadwell, поскольку им не требуется аппаратная таблица привязки. Наибольший выигрыш при следовании этому принципу достигается в случае, если данные изменяются при каждом вызове отрисовки.
Как было описано в предыдущей статье, можно определить семплеры в рутовой подписи или непосредственно в шейдере с помощью языка рутовой подписи HLSL. Такие семплеры называются статическими.

На платформах Haswell/Broadwell драйвер помещает статические семплеры в обычную кучу семплеров. Это равноценно помещению их в дескрипторы вручную. На других аппаратных платформах семплеры помещаются в регистры шейдера, поэтому статические семплеры можно компилировать непосредственно в шейдер.

В целом статические семплеры обеспечивают высокую производительность на любых платформах, поэтому можно их использовать без каких-либо оговорок. Тем не менее на платформах Haswell/Broadwell существует вероятность, что при увеличении количества дескрипторов в таблице дескрипторов мы будем чаще сталкиваться с остановкой конвейера, поскольку аппаратная таблица дескрипторов содержит только 16 384 ячейки.
Вот синтаксис статического семплера в HLSL.

StaticSampler( sReg,
        [ filter = FILTER_ANISOTROPIC, 
        addressU = TEXTURE_ADDRESS_WRAP,
        addressV = TEXTURE_ADDRESS_WRAP,
        addressW = TEXTURE_ADDRESS_WRAP,
        mipLODBias = 0.f,   maxAnisotropy = 16,
        comparisonFunc = COMPARISON_LESS_EQUAL,
        borderColor = STATIC_BORDER_COLOR_OPAQUE_WHITE,
        minLOD = 0.f, maxLOD = 3.402823466e+38f,
        space = 0, visibility = SHADER_VISIBILITY_ALL ])

Большая часть параметров не нуждается в пояснении, поскольку они аналогичны используемым на уровне C++. Основное различие заключается в цвете границ: на уровне C++ поддерживается полный цветовой диапазон, тогда как на уровне HLSL доступен только непрозрачный белый, непрозрачный черный и прозрачный черный. Пример статического шейдера.

StaticSampler(s4, filter=FILTER_MIN_MAG_MIP_LINEAR)


В Skylake поддерживается динамическое индексирование всей кучи дескрипторов (около 1 миллиона ресурсов) в одной таблице дескрипторов. Это означает, что одной таблицы дескрипторов может быть достаточно для индексирования всей доступной памяти кучи дескрипторов.
По сравнению с прежними архитектурами нет необходимости настолько часто изменять записи таблиц дескрипторов в рутовой подписи. Кроме того, можно также уменьшить количество рутовых подписей. Разумеется, для разных материалов потребуются разные шейдеры и, следовательно, разные объекты состояния конвейера (PSO). Но эти объекты PSO могут ссылаться на одни и те же рутовые подписи.
Современные графические движки используют меньше шейдеров, чем предыдущие версии в DirectX 9 и DirectX 11, поэтому можно избежать расходования ресурсов на смену шейдеров и связанных состояний, уменьшить количество рутовых подписей и (соответственным образом) объектов PSO, что обеспечит прирост производительности на любой аппаратной платформе.
Если говорить о платформах Haswell/Broadwell и Skylake, то рекомендации по повышению производительности приложений DirectX 12 зависят от используемой платформы. Для Haswell/Broadwell желательно, чтобы количество дескрипторов в таблице дескрипторов было небольшим, тогда как для Skylake рекомендуется, чтобы таблицы дескрипторов содержали как можно больше дескрипторов, но самих таблиц было меньше.
Для достижения оптимальной производительности разработчик приложений может проверять тип аппаратной платформы при запуске, а затем соответственным образом выбирать способ привязки ресурсов. (Пример определения GPU, показывающий, как обнаруживать различную архитектуру оборудования Intel®, доступен здесь.) Выбор способа привязки ресурсов определяет, каким образом будут записаны шейдеры системы.

  1. [Лоритцен] Эндрю Лоритцен и др., «Эффективный рендеринг с помощью DirectX 12 на GPU Intel Graphics», GDC, 2015 г.
  2. [MSDN] MSDN, «Расширенное использование таблиц дескрипторов».
  3. Блог Microsoft DirectX
  4. DirectX 12 в Twitter: @DirectX12.
  5. Direct3D* 12 — эффективность и производительность консольных API на ПК
  6. Учебные материалы по графике Microsoft DirectX 12 (канал YouTube).

© Habrahabr.ru