Многопоточность в DirectX приложениях

Статья опубликована на сайте dtf.ru 17.05.2006

Рекомендуется прочитать первую часть статьи.

Часть 2. Многопоточность в DirectX приложениях

В первой части мы нашли способ выполнять фоновый поток, используя 10% процессорного времени. Теперь хотелось бы применить эту систему на практике: мы будем загружать уровень, создавать текстуры и заполнять данными вершинные буфера во втором потоке.

Потоковая безопасность

При работе со сторонней библиотекой сразу необходимо поинтересоваться, является ли она потоково-безопасной (thread safe), то есть позволяет обращаться к ней из разных потоков.

При создании IDirect3dDevice9 можно указать флаг D3DCREATE_MULTITHREADED. Согласно документации, в этом случае библиотека становится потоково-безопасной. Это вроде бы решает наш вопрос.

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

Когда мы указываем флаг D3DCREATE_MULTITHREADED, DirectX начинает использовать глобальную критическую секцию при вызове всех методов. Для нас это обозначает, что при вызове во втором потоке метода UpdateTexture(), который занимает 3ms, система не сможет продолжить выполнение первого потока, который хочет вызвать DrawIndexedPrimitve(), потому что второй поток захватил глобальную критическую секцию на время выполнения метода UpdateTexture(). Это опять приводит к потере контроля на количеством процессорного времени, выделяемого второму потоку, и, в свою очередь, к нестабильному FPS.

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

На самом деле, DirectX не запрещает вызывать его методы из разных потоков. Запрещается только несинхронизированный вызов методов. Можно не указывать флаг D3DCREATE_MULTITHREADED, а вместо этого использовать свою критическую секцию, с помощью которой защищать целые блоки вызовов, например VertexBuffer->Lock), memcpy(), VertexBuffer->Unlock(). Отладочная версия DirectX будет выдавать предупреждения, но их можно просто игнорировать.

Примечание. Библиотека D3DX не является потоково-безопасной. Вы должны самостоятельно защищать вызовы функций D3DXxxxx своей критической секцией.

Из этого можно сделать вывод: не используйте флаг D3DCREATE_MULTITHREADED. Обращайтесь к DirectX только из одного потока. Максимально подготовьте данные во втором потоке, и положите в очередь. Первый поток обработает все записи в очереди (например, запишет вершины в вершинный буфер) между кадрами, контролируя время выполнения операций.

Можно ли выиграть время за счет сокращения простоев?

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

Для этого необходимо, чтобы во время ожидания, DirectX и драйвер использовали какие-либо объекты синхронизации, например WaitForMultipleObjects() или Sleep(). Происходит ли это?

Я не буду приводить последовательность экспериментов, которые показывают логику поведения DirectX и драйвера, и сразу дам ответ.

Драйвер НИКОГДА не использует объекты синхронизации при ожидании, вместо этого везде используется т.н. busy wait, то есть что-то подобное:


1
while (flag==false) { UpdateFlag();};

Возможно, в каких-то специально модифицированных драйверах для ноутбуков для сокращения энергопотребления все по-другому, но на настольных системах все именно так и в ATI Catalyst, и в nVidia Forceware. Оно и понятно – производители видеокарт прежде всего заботятся о высоком FPS, и возлагать надежды на планировщик потоков системы довольно рискованно.

Что касается DirectX, то он вызывает Sleep(n), n=0..10 в методе IDirect3DDevice->Present(), если включен режим ожидания обратного хода луча, и только в оконном приложении. Тут понятно – в оконном режиме DirectX старается вести себя как любезное Windows-приложение, отдавая часть процессорного времени другим задачам.

Во всех остальных случаях никакие объекты синхронизации не используются.

Рисунок 15.  Тест №10. Немного модифицированный пример Enchanced mesh из DirectX 9.0 SDK. Приложение рендерит несложную модель с FPS, близким к частоте обновления экрана (85 Гц), поскольку включен режим ожидания обратного хода луча.

 

Рисунок 16. Тест №11. Оконный режим, включена синхронизация с обратным ходом луча (VSYNC). Второй поток имеет idle приоритет (на графике показан только он). Поток получает большое количество процессорного времени (3-х секундная задержка в начале связана с ожиданием загрузки и начала рендеринга). 

 

 Рисунок 17. Тот же тест, но с выключенным ожиданием обратного хода луча. Высокий FPS, но второй поток совсем не получает процессорного времени. Небольшие периоды активности объясняются CPU time starvation detection.

Поскольку сцена не сложная, задержек в GPU не происходит. На этот раз нагрузим GPU, увеличив количество треугольников 10 раз.

 

 Рисунок 18. Тест №12. GPU перегружен, низкий (5-6) FPS. Включение VSYNC не оказывает влияния на FPS (он значительно меньше частоты вертикальной развертки). Если у вас FPS выше 60, найдите видеокарту попроще 🙂

 

Рисунок 19. Тест №13. GPU перегружен, низкий (5-6) FPS. VSYNC выключен. Второй поток с idle приоритетом совсем не получает процессорного времени, хотя процессор сильно простаивает в ожидании GPU. Этот тест доказывает, что драйвер никогда не использует объекты синхронизации Windows при ожидании. Небольшие периоды активности объясняются CPU time starvation detection.

Рисунок 20. Тест №14 показывает, что при переключении в полноэкранный режим, DirectX перестает вызывать Sleep() в методе Present(), и на это уже никак не влияет включение VSYNC.

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

 Создаем фоновый поток

 Нам осталось проверить, работает ли разработанная нами ранее схема двухпотокового приложения с DirectX.

 

Рисунок 21.Тест №15. Основной поток с нормальным приоритетом, фоновый поток с idle приоритетом. Основной поток вызывает Sleep(2) на каждом кадре, чтобы выделить процессорное время второму потоку. Как видно, второй поток почти стабильно получает свои 2мс на каждом кадре – наша схема работает (на графике показан второй поток).

Здесь следует добавить, что DirectX самостоятельно вызывает timeBeginPeriod(1), и нам не требуется этого делать. В реальном приложении необходимо изменять аргумент Sleep() в зависимости от среднего FPS, т.к. доля процессорного времени второго потока зависит от длины кадра. Например, при низком FPS (тест №16), второй поток получает слишком мало времени.

Примечание. Отладочная версия DirectX вызывает Sleep() в методе Present() и в полноэкранном режиме.

Выводы

 Из всего описанного можно сделать следующие выводы:

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

Вместо этого, уделите внимание параллельности работы CPU-GPU. Отдайте все треугольники в начале кадра, затем считайте AI и физику, потом Present().


1
2
3
4
5
6
while (bExit==false)
{
  DrawAll();
  CalcAI_Physics()
  Device->Present()
}

CPU будет считать физику, изредка прерываясь (аппаратно прерывание) на посылку очередной порции треугольников в GPU. 

Это работает, потому что DrawIndexedPrimitive() не выполняется при вызове, а записывается в очередь. Туда же записываются и все другие команды, включая SetRenderTarget(), StretchRect() и даже IVertexBuffer->Lock(), IVertexBuffer->Unlock() для DYNAMIC буферов (драйвер использует renaming буферов).

При сильной загрузке DirectX и драйвер могут (и будут, если с этим специально не бороться) буферизировать до 3-х кадров вперед.

2. Вызывайте методы DirectX только из одного потока. Не используйте флаг D3DCREATE_MULTITHREADED.

3. Чтобы избежать использования многозадачности, разбейте алгоритмы на набор мелких (1-2мс) задач. Выполняйте задачи между кадрами, до Present(), контролируя затраченное время.

4. Не создавайте более одного активного потока! Используйте свой менеджер задач, выполняющихся во втором потоке.

5. Если многопоточность реально необходима (из-за невозможности разбить на мелкие задачи фоновый алгоритм), используйте схему, предложенную в этой статье.

6. Второй поток получает мало процессорного времени. Не тратьте его на ожидание загрузки! ОС предоставляет функции фонового чтения с диска с функцией обратного вызова по завершению (IO completion callback routine). Используйте эти функции для загрузки файла в буфер, а только потом разбирайте его во втором потоке. Эти же функции, возможно, помогут вообще отказаться от многопоточности.

7. Если у второго потока нет активных заданий, нет необходимости вызывать Sleep() между кадрами.

Когда использование второго потока на однопроцессорной системе действительно оправдывает себя.

В стратегиях реального времени часто используется большое количество юнитов. Обсчет AI юнитов и стратегического AI может занимать много времени. Из-за этого возможна ситуация, когда игра просто не сможет обеспечить приемлемый FPS, т.к. обсчет AI на карте занимает, скажем, 60мс. Очевидным решением является прочитывать только часть стратегии на каждом кадре. К сожалению, такое не всегда возможно, т.к. прерывание алгоритма может потребовать сохранения наполовину рассчитанных структур данных большого размера.

Как известно, воспринимаемая скорость работы программы зависит в большей мере от времени отклика на действие пользователя (feedback), чем от реальной скорости работы. В описанном выше случае имеет смысл выделить обсчет AI в фоновый поток, а в основном просчитывать только анимацию на основе последних полученных данных от AI движка. Визуально, пользователь будет иметь высокий FPS и быстрое время отклика user interface, хотя юниты могут казаться «тугодумами».

Другие способы выполнения фоновых задач.

WinApi также предоставляет несколько видов таймеров, которые можно использовать для «фонового» выполнения [2].

Обычный таймер (сообщение WM_TIMER) из-за особенностей реализации для real-time приложений не подходит. Дело в том, что сообщение WM_TIMER (как и WM_IDLE) не является обычным сообщением, которое попадает в очередь сообщений окна. Вместо этого в процедуре PeekMessage() система проверяет, пуста ли очередь сообщений окна,  и если пуста и период таймера истек, посылает сообщение WM_TIMER. Таким образом, если окну приходят сообщения WM_MOUSEMOVE, оно перестает получать WM_TIMER.

Multimedia timers позволяют назначать callback функции для периодического вызова. Запуск callback выполняет планировщик потоков, поэтому точность таймера не может быть выше quantum. Для того, чтобы получить разрешение 1 мс, необходимо вызвать timeBeginPeriod(1).

Waitable timers позволяют назначать completion routine. Completion routine выполняются в контексте того же потока, когда он останавливается в результате вызова функций SleepEx() или WaitForObject(). Если поток не вызывает эти функции, completion routine никогда не будет вызвана.

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

Третья часть статьи: Технология HyperThreading 

Ссылки

1. Multitasking Discussion

http://www.wideman-one.com/gw/tech/dataacq/multitasking.htm

2. Timers tutorial

http://www.codeproject.com/system/timers_intro.asp

3. Time is the Simplest Thing…

http://www.codeproject.com/system/simpletime.asp

4. Quantifying The Accuracy Of Sleep

http://www.codeproject.com/system/sleepstudy.asp

(похвальное усердие при тестировании, но автор не читал данную статью)

5. Threading Articles

http://www.devx.com/Intel/Door/29081

6. GDC 2004: Multithreading in Games

http://www.extremetech.com/article2/0,1697,1554193,00.asp

7. Threading Basics for Games

http://www.devx.com/Intel/Link/28614

8. Применение многопоточности в играх

http://www.gamedev.ru/articles/?id=70119

(пример увеличения производительности за счет использования потоков, приведенный в статье, некорректен)

9. Технология Hyper-Threading и компьютерные игры

http://www.dtf.ru/articles/read.php?id=113

10. ProcessTamer

http://www.donationcoder.com/Software/Mouser/proctamer/

11. Managing Concurrency:  Latent Futures, Parallel Lives

http://www.gamearchitect.net/Articles/ManagingConcurrency1.html

12. The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software

http://www.gotw.ca/publications/concurrency-ddj.htm

13. Приложение ThreadTest с исходными кодами

http://www.deep-shadows.com/hax/downloads/threadtest.zip