Путешествие по графическому конвейеру

Начинаем публикацию перевода знаменитой серии статей A trip through the Graphics Pipeline 2011, написанной когда-то Фабианом Гизеном (ryg).

Речь пойдет об аппаратном обеспечении класса DX11, запускающем D3D9/10/11 в Windows, потому что с ним я лучше всего знаком. Не то, чтобы информация об API и прочем имела большое значение в этой первой части; но для GPU это все родные команды.

Приложение

Это ваш код. Это же ваши ошибки. Правда. Во время выполнения API и драйверы содержат ошибки, но речь идет не о них.

Среда выполнения API

Вы создаете ресурсы / задаете состояние / делаете вызов API. API среды выполнения отслеживает текущее состояние вашего приложения с его собственным набором параметров, проверяет параметры и наличие ошибок или несогласованности, управляет видимыми пользователю ресурсами, может или не может проверить код и связь шейдеров (по крайней мере, в D3D; в OpenGL это делается на уровне драйвера). Возможно еще немного пакетирует работу, а потом передает все в графический драйвер — точнее, в драйвер пользовательского режима.

Графический драйвер пользовательского режима (или UMD)

Здесь происходит основная часть «магии» на стороне CPU. Если ваше приложение падает из-за каких-то API, вы вызываете их. Они называются «nvd3dum.dll» (для NVidia) или «atiumd *.dll» ( для AMD). Как предполагает название, это код пользовательского режима. Он работает в том же окружении и адресном пространстве (и API среды выполнения), что ваше приложение, и не имеет повышенных привилегий. Он реализует API низкого уровня (DDI), который вызывается в D3D. Этот API очень похож на тот, на поверхности которого вы находитесь сейчас. Но более аккуратен в управлении памятью и тому подобными вещами.

В этом модуле происходят такие вещи, как компиляция шейдеров. D3D передает UMD предварительно проверенный поток c маркерами шейдеров. Т.е. код уже проверен на синтаксическую правильность и подчинение ограничениям D3D (использование правильных типов, не использование больше чем доступно текстур/сэмплеров, не превышение количества свободных буферов для констант и все такое). Он компилируется из HLSL кода и обычно имеет довольно много оптимизаций высокого уровня (различные оптимизации циклов, удаление «мертвого» кода, подстановку констант, предсказание if-ов и т. д.) Все относительно дорогостоящие оптимизации, которые были проведены во время компиляции.

Однако он также имеет ряд низкоуровневых оптимизаций (таких как распределение регистров и развертывание циклов), которые драйверы могли бы выполнить сами. Короче говоря, код обычно превращается в промежуточное представление и затем компилируется немного дальше. Аппаратные средства создают шейдеры с двоичным кодом настолько близким к D3D, что ему не нужно работать слишком хорошо, чтобы получить хорошие результаты. Оптимизации, проведенные HLSL компилятором, также значительно помогают. Но есть еще множество низкоуровневых особенностей (например, аппаратные ограничения ресурсов и планирование ограничений), о которых D3D не знает или не может позаботиться, так что все не так просто.

Разумеется, если ваше приложение является хорошо известной игрой, у программистов NV/AMD есть вручную оптимизированные замены для своего оборудования — чтобы не было скандала 🙂 Эти шейдеры тоже обнаруживаются и заменяются UMD.

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

Кстати, это также причина, по которой вы часто увидите задержку при первом использовании нового шейдера или ресурса. Многие работы по созданию/компиляции откладываются драйвером и выполняются только тогда, когда это на самом деле необходимо (вы не поверите, сколько неиспользуемого дерьма создают некоторые приложения!). Графические программисты знают другую сторону истории. Если вы хотите убедиться, что что-то на самом деле создано (в отличие от резервирования памяти), вам нужно сделать фиктивный вызов, который используется для «разогрева». Ужасно, но так было еще в те времена, когда я впервые начал заниматься 3D в 1999 году, и к сегодняшнему дню это почти правда жизни — привыкайте 🙂

Но двигаемся дальше. UMD также приходится иметь дело с такими замечательными вещами, как «унаследованные» от D3D9 версии шейдеров и фиксированные функции конвейера — да, все это добросовестно пройдет через D3D. Эмулятор шейдеров 3.0 не так плох (вполне хорош на самом деле), а 2.0 вызывал много проблем и различные 1.х версии шейдеров были настоящей головной болью — помните пиксельные шейдеры 1.3? Или, если на то пошло, вершинные шейдеры в фиксированном конвейере и тому подобные вещи? Да, поддержка всего этого до сих пор есть в D3D и в недрах каждого современного графического драйвера, просто сейчас они переведут их в более современные версии.

Далее есть такие вещи, как управление памятью. UMD будет получать команды создания текстуры, и ему нужно обеспечить пространство для них. На самом деле, UMD просто перераспределяет некоторые большие блоки памяти, которые получает от KMD (драйвера режима ядра), потому что разметка страниц памяти (и право решать, к каким частям видео памяти может получить доступ UMD, а к каким частям системной — GPU) является привилегией в режиме ядра и не может быть сделано UMD.

Но  UMD может делать переупорядочивание пикселей для быстрой загрузки текстур — свизлинг (GPU может выполнить это аппаратными средствами, обычно с использованием 2D блиттирования, а не реального 3D-конвейера) и запланированные перемещения между системной памятью и (размеченной) видео памятью и тому подобное. Самое главное, он также может делать записи в командные буферы (или DMA буферы) после того, как KMD выделил их и передал. Командный буфер содержит… команды :). Все ваши операции изменения состояний и отрисовки будут преобразованы UMD в команды, которые понимают аппаратные средства. Так же, как и множество не управляемых вручную вещей — например, загрузка текстур и шейдеров видео память.

В общем, драйверы стараются отдать как можно больше фактической обработки UMD. Все, что работает в нем, не требует каких-либо дорогостоящих переходов в режиме ядра, может свободно распределять память, работать в несколько потоков и т. д. Это код пользовательского режима, поэтому он ведет себя как обычная библиотека DLL (даже если он загружен API, а не напрямую из вашего приложения). В этом есть определенные преимущества. Если UMD падает, с ним падает приложение, но не вся система; он может быть просто заменен пока система работает (это же DLL!); может быть отлажен в обычном отладчике и так далее. Это не только эффективно, но и удобно.

Но есть один большой слон, которого мы до сих пор не приметили.

Я сказал «драйвер пользовательского режима»? Но я имел в виду «драйверы пользовательского режима».

Как говорилось ранее, UMD — это просто DLL. Хорошо, у него есть благословение D3D и прямой контакт с KMD, но это все-таки обычная DLL, и она работает в адресном пространстве своего процесса.

Но в наши дни стали использоваться многозадачные ОС.

Я все еще говорю про GPU? Это разделяемый ресурс. Только один GPU управляет вашим основным дисплеем (даже при использовании технологии SLI/CrossFire). Еще у нас есть несколько приложений, которые пытаются получить к нему доступ (и притвориться, что они единственные, кто делает это). Это работает не автоматически. В былые времена было принято решение давать доступ к 3D только одному приложению, и пока это приложение активно, все остальные не имеют доступа. Но их попытки не пресекаются. Вот почему вам нужен какой-либо компонент, который управляет доступом к GPU и выделяет временные интервалы и так далее.

Идем в планировщик

Это компонент системы. Я говорю о графическом планировщике здесь, а не о CPU или IO планировщиках. Он делает именно то, что вы думаете: распределяет доступ к 3D-конвейеру путем нарезки времени между различными приложениями. Переключение контекста несет, самое малое, некоторые изменения состояния GPU (который генерирует дополнительные команды для командного буфера) и, возможно, также заменяет некоторые ресурсы на входе и выходе из видеопамяти. И, конечно, только один процесс может на самом деле отправлять команды по 3D конвейеру в один момент времени.

Консольные программисты часто жалуются на довольно заносчивую, неприступную натуру 3D API, и что она отражается на производительности. Но дело в том, что с 3D API/драйверами на компьютере действительно есть проблемы. На минуточку! Им приходится иметь дело с ошибками приложений и попытками исправить невидимые проблемы с производительностью. Это довольно неприятное занятие для всех, включая драйверы — люди ждут, что все что работает, продолжит работу (и сделает это без проблем).

Но идем по конвейеру дальше.

Драйвер режима ядра (KMD)

Это та часть, которая собственно и занимается «железом». Одновременно может быть несколько экземпляров UMD, но один и только один KMD, и если он падает, то бум и вы мертвы — появлялся «синий экран». К настоящему времени Windows уже знает, как убить аварийный драйвер и перезагрузить его (прогресс!). До тех, пока это просто проблемы с драйвером, а не повреждение ну хотя бы памяти ядра – тогда уже точно конец.

KMD работает со всеми вещами одновременно. Есть только одна GPU память, даже если за нее борется несколько приложений. Кто-то должен командовать и действительно выделять (и размечать) физическую память. Аналогично, кто-то должен инициализировать процессор при запуске, установить режимы отображения (и получать информацию о режиме от дисплеев), управлять аппаратным курсором мыши (да, это обрабатывается аппаратно и да, вы действительно получите только один! :), программировать аппаратный сторожевой таймер для перезагрузки неотвечающего какое-то время GPU, реагировать на прерывания и так далее. Это все делает KMD.

KMD также принимает участие во всем, что относится к защите контента / DRM и настройке защищенного пути между видеоплеером и GPU. Чтобы ни один из драгоценных расшифрованных видео пикселей не был виден коварному пользовательскому режиму, способному переписать их на жесткий диск или сделать что-нибуть еще.

Для нас важнее всего, что KMD управляет настоящим командным буфером. Ну тем, который на самом деле передает команды аппаратным средствам. Командные буферы UMD не могут этого делать — они просто случайные кусочки GPU-адресуемой памяти. В действительности, UMD обрабатывает их и передает планировщику, который ожидает пока процесс выполняется, а затем отправляет командный буфер от UMD в KMD.

Вызов к командному буферу KMD затем записывает в главный буфер команд, и в зависимости от того, способен ли процессор команд GPU считывать из основной памяти или нет, может также потребоваться сначала прямой доступ в видеопамять. Главный командный буфер обычно является (совсем небольшим) кольцевым буфером. Единственное, что когда-либо может быть в него записано — это системные команды, команды инициализации и вызовы к «настоящим», мясистым 3D командным буферам.

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

Шина

… но, конечно, данные не попадают прямиком на видеокарту, так как они должны пройти через шину. В наши дни это обычно PCI Express. DMA трансферы и тому подобные вещи идут по тому же маршруту. Это не занимает много времени, но все-таки еще один этап в нашем путешествии. И наконец…

Командный процессор!

Это внешний интерфейс GPU — та часть, которая в действительности читает записанные KMD команды. В следующей статье мы продолжим с этого места, так как эта уже получилась достаточно длинной 🙂

Небольшое отступление: OpenGL

OpenGL работает по похожим на принципам, только в нем нет такого резкого различия между API и UMD слоями. И в отличие от D3D, (GLSL) компиляции шейдеров не обрабатываются API вообще, все делается драйвером. Недостаток в том, что есть так же много реализаций GLSL, как и поставщиков 3D-оборудования. Все они воплощают одну и ту же спецификацию, но каждый со своими ошибками и особенностями. Грустно. Это также означает, что драйверам приходится выполнять все оптимизации самостоятельно, как только они встретятся с шейдерами, включая затратные оптимизации. Байт-код в формате D3D является самым корректным методом решения этой проблемы: есть только один компилятор (поэтому нет разных, несовместимых диалектов у разных производителей оборудования!) и он позволяет провести анализ потока данных лучше.

Упущения и упрощения

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

Источник: A Trip through the Graphics Pipeline

Ребята! Пожалуйста, если Вам понравилась статья — пошарьте её в соц. сетях, особенно ценны Facebook и Google+
Это очень поможет нашему блогу, огромное спасибо!