Использование многозадачности для масштабирования игровых систем

Скачать статью и посетить домашнюю страницу проекта

Скачать статью Using Tasking to Scale Game Engine Systems [Eng., PDF 436KB]
Домашняя страница Tasking Game Engine (исходный код, исполняемые файлы, видео)
 

Введение

ПК с 6 ядрами и 12 аппаратными потоками уже перестал быть новостью на рынке, а 4-ядерные процессоры часто встречаются даже в ноутбуках. Чтобы пользователь на своей платформе мог получить от игры только самые лучшие впечатления, мы хотим создать игровой движок, который бы не зависел от количества ядер в системе. Использование методов многозадачности позволяет масштабировать работу приложения по мере роста количества ядер, позволяя игроку наслаждаться игрой в той мере, в какой позволяет его оборудование. На приведённом здесь примере мы покажем, как превратить однопоточную анимационную систему в многозадачную.
 

Используемые термины

  • Задача: Задача – это независимый блок работы в системе. Задача реализуется в виде функции обратного вызова (callback). В данном примере, мы будет создавать анимированную сцену и каждая задача будет анимировать одну модель.
  • Набор задач: Набор задач – это базисный элемент для планирования работы приложения. В нем задаются функции задач и содержатся сведения о зависимостях между ними.
  • Граф зависимостей: Наборы задач выполняются только тогда, когда разрешены все зависимости между задачами. Задачи внутри одного наборавыполняются асинхронно.
  • Планировщик: Планировщик входит в состав Tasking API. Он отвечает за создание и управление всеми рабочими потоками, а также за выполнение задач.

В нашем примере в качестве планировщика используется Intel® Threading Building Blocks.

На рис. 1 приведён граф зависимостей набора задач. Под ним дано условное представление 4-ядерной системы. Планировщик запускает каждую задачу из набора не раньше, чем будут разрешены все зависимости.
 

Рис. 1. Иллюстрация графа зависимостей и его исполнение планировщиком.
 

 

Создание задачи

При инициализации задачи задаются четыре параметра (см. листинг 1):

 

 

  • pvInfo: Указатель на данные, глобальные для данного набора задач. Эти данные можно воспринимать как буфер констант. Они не контролируются Tasking API, поэтому приложение должно само контролировать их правильность во время исполнения набора задач.
  • iContext: Индекс, который может меняться от нуля до количества потоков, созданных планировщиком. Он позволяет получать доступ к данным, которые не являются потоково-безопасными, без блокировки. Tasking API гарантирует, что только одна исполняемая задача будет иметь данный контекстный ID. Типичное использование контекстного ID - D3D11DeviceContext. Приложение может создать массив объектов D3D11DeviceContext. Задача, которая пишет в список команд, может использовать контекстный ID для выбора объекта D3D11DeviceContext из массива без установки блокировок..
  • uTaskId: ID, который присваивается текущей задаче. Он используется для выбора блока работы задачи из списка. В нашем примере он используется для выбора модели для анимации.
  • uTaskCount: Счётчик задач показывает общее количество задач, запланированных к выполнению в наборе задач. Значение счётчика можно использовать для привязки ID задач к набору обрабатываемых объектов.

 

Void
AnimateModel(
    VOID*                       pvInfo,
    INT                         iContext,
    UINT                        uTaskId,
    UINT                        uTaskCount );

Листинг 1. Сигнатура задачной функции


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

 

  • Время выполнения задачи должно быть некоторой фиксированной долей от общего времени выполнения набора задач. Это позволит планировщику оптимально распределять работу между ядрами процессора. Важно иметь сбалансированное число задач, чтобы максимально увеличить масштабируемость и одновременно минимизировать время планирования. Для простоты, в нашем примере задача занимается анимированием модели, и данная эвристика выполняется, только если в сцене присутствуют 20 или более моделей. Наилучший способ определить правильный баланс является использование ID и счётчика задач для корректировки количества данных, обрабатываемых в каждой задаче..
  • Использовать только ¼ кэша второго уровня на одну задачу. Во многих CPU встроена технология Intel® Hyper-Threading, которая создаёт по два рабочих потока на ядро. Использование только ¼ кэша на задачу обеспечивает оптимальное его использование. Для определения оптимального количества данных, обрабатываемых в одной задаче, удобно использовать ID и счетчик задач.
  • Использовать контекстный ID для избежания блокировок и инструкций с взаимной блокировкой. LИспользование блокировки внутри задачи останавливает работу потока, что существенно снижает эффективность выполнения задачи. Сюда включаются и операции выделения памяти, которые обычно также требуют блокировки. Также, инструкции с взаимой блокировкой могут вызвать перегрузку кэша. Если в наборе задач нужно использовать блокировку, то стоит рассмотреть возможность создания двух наборов задач (см. ниже)
  • Избегать простоя из-за нехватки задач, совмещая фреймы. Состояние простоя возникает, когда один набор задач заканчивается, а планировщик не располагает новыми готовыми к запуску наборами задач (см. ниже)

 

 

Использование зависимостей для исключения операций с взаимоблокировкой

Рассмотрим случай расчёта средней яркости изображения, используя многозадачность. Первый метод состоит в том, чтобы заставить каждую задачу обрабатывать некоторый набор строк изображения, а затем добавлять полученную сумму в трек, используяобмен с взаимоблокировкой. Работа задач, скорее всего, будет зависеть от времени на блокировку кэш-линии, которая содержит увеличиваемую сумму. Однако есть решение получше – создать массив сумм, и каждая задача, используя контекстный ID, будет добавлять текущую сумму без использования операций с взаимной блокировкой. Для получения общей суммы массива и расчёта среднего значения создаётся второй набор, содержащий только одну задачу. Второй набор задач будет зависеть от первого.

 

Как избежать нехватки задач

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

 

 

 

 

 

 

Рис. 2. Выполнение графа зависимостей с выделенными красным участками простоя

 

 

Рис. 3. Совместное выполнение графов зависимостей
 

 

Использование многозадачности в нашем примере

В нашем примере одна задача анимирует одну модель (см. листинг 2). Для анимирования сцены создаётся набор задач, в котором количество задач равно количеству анимируемых моделей. Задача использует ID (uModel) для определения, какую модель из списка анимировать. Указатель pvInfo содержит время анимации данного фрейма.

 

void
AnimateModel(
    VOID*                       pvInfo,
    INT                         iContext,
    UINT                        uModel,
    UINT                        uTaskCount )
{
    D3DXMATRIXA16               mIdentity;
    PerFrameAnimationInfo*      pInfo = (PerFrameAnimationInfo*)pvInfo;

    D3DXMatrixIdentity( &mIdentity );
    
    gModels[ uModel ].Mesh.TransformMesh( 
        &mIdentity, 
        pInfo->dTime + gModels[ uModel ].dTimeOffset );

    for( UINT uMesh = 0; uMesh < gModels[ uModel ].Mesh.GetNumMeshes(); ++uMesh )
    {
        for( UINT uMat = 0; uMat < gModels[ uModel ].Mesh.GetNumInfluences( uMesh ); ++uMat )
        {
            const D3DXMATRIX    *pMat;

            pMat = gModels[ uModel ].Mesh.GetMeshInfluenceMatrix( 
                    uMesh, 
                    uMat );

            D3DXMatrixTranspose(
                &gModels[ uModel ].AnimatedBones[ uMesh ][ uMat ],
                pMat );
        }
    }
}

Листинг 2. Задача AnimateModel

Для выполнения набора анимационных задач, в функции OnFrameMove с помощью указателя на глобальные данные и числа отображаемых моделей создаётся набор задач. В листинге 3 показано, что указатель на набор задач (ghAnimateSet) позже используется в функции OnD3D11FrameRender() в WaitForSet для получения времени от основного потока для обработки оставшихся анимационных задач. Когда функция WaitForSet возвращается, это означает, что выполнение набора задач завершено, и главный поток может передавать данные в D3D.

 

 

OnFrameMove()
{
    ...
    gTaskMgr.CreateTaskSet(
            AnimateModel,
            &gAnimationInfo,
            guModels,
            NULL,
            0,
            "Animate Models",
            &ghAnimateSet );
    ...
}

OnD3D11FrameRender()
{
    ...
    gTaskMgr.WaitForSet( ghAnimateSet );
    ...
}

Листинг 3. Создание набора задач анимации

FНа рис. 4 показана работа нашего приложения. Для изменения количества моделей можно использовать ползунок Model Count. При активации "Enable Tasking" происходит смена однопоточного режима на многопоточный. Отметим, что анимирование даже одной модели работает быстрее в многозадачном режиме, чем при выполнении только на главном потоке. "Force CPU Bound" ограничивает рисование моделей первым треугольником, что позволяет оценить эффект применения многозадачности в ситуации с большой загруженностью центрального процессора.

 

 

 

 

Рис. 4. Пример анимации, исполняемой в системе с графикой ATI Radeon* HD 5870
 


На рис. 5 показана зависимость производительности от количества анимационных моделей. Данные были получены в системе на базе процессора Intel® Core™i7 с графикой ATI Radeon* HD 5870. Отметим, что, по мере увеличения количества анимированных объектов, работа эффективно масштабируется, при этом код никак не привязан к конкретному количеству ядер.

 

Рис. 5. Данные производительности нашего приложения. Время дано в миллисекундах на фрейм. Более низкие значения на графике означают более высокую производительность.

 

 

 

 

 

Ссылки

[1] GDC 2011 презентация автора.
[2] Intel® Threading Building Blocks http://threadingbuildingblocks.org/
 

 

 

 

Для получения подробной информации о возможностях оптимизации компилятора обратитесь к нашему Уведомлению об оптимизации.