Синхронизация с несколькими движками

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

Обработчики GPU

На следующей схеме показаны потоки ЦП заголовка, каждый из которых заполняет одну или несколько очередей копирования, вычислений и трехмерных очередей. 3D-очередь может управлять всеми тремя ядрами GPU; очередь вычислений может управлять подсистемами вычислений и копирования; и очередь копирования — просто подсистема копирования.

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

четыре потока, отправляя команды в три очереди

На следующем рисунке показано, как заголовок может планировать работу в нескольких подсистемах GPU, включая синхронизацию между обработчиками, если это необходимо. На нем показаны рабочие нагрузки для каждого обработчика с зависимостями между обработчиками. В этом примере модуль копирования сначала копирует некоторые геометрические объекты, необходимые для отрисовки. Трехмерный модуль ожидает завершения этих копий и отрисовывает предварительный проход по геометрии. Затем он используется подсистемой вычислений. Результаты диспетчеризации вычислительной подсистемы, а также несколько операций копирования текстур в механизме копирования используются трехмерным обработчиком для окончательного вызова Draw .

Обмен данными между модулями копирования, графики и вычислений

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

// Get per-engine contexts. Note that multiple queues may be exposed
// per engine, however that design is not reflected here.
copyEngine = device->GetCopyEngineContext();
renderEngine = device->GetRenderEngineContext();
computeEngine = device->GetComputeEngineContext();
copyEngine->CopyResource(geometry, ...); // copy geometry
copyEngine->Signal(copyFence, 101);
copyEngine->CopyResource(tex1, ...); // copy textures
copyEngine->CopyResource(tex2, ...); // copy more textures
copyEngine->CopyResource(tex3, ...); // copy more textures
copyEngine->CopyResource(tex4, ...); // copy more textures
copyEngine->Signal(copyFence, 102);
renderEngine->Wait(copyFence, 101); // geometry copied
renderEngine->Draw(); // pre-pass using geometry only into rt1
renderEngine->Signal(renderFence, 201);
computeEngine->Wait(renderFence, 201); // prepass completed
computeEngine->Dispatch(); // lighting calculations on pre-pass (using rt1 as SRV)
computeEngine->Signal(computeFence, 301);
renderEngine->Wait(computeFence, 301); // lighting calculated into buf1
renderEngine->Wait(copyFence, 102); // textures copied
renderEngine->Draw(); // final render using buf1 as SRV, and tex[1-4] SRVs

Следующий псевдокод иллюстрирует синхронизацию между копией и 3D-подсистемами для выполнения выделения памяти в куче с помощью кольцевого буфера. Заголовки имеют гибкость, чтобы выбрать правильный баланс между максимизацией параллелизма (с помощью большого буфера) и сокращением потребления памяти и задержки (с помощью небольшого буфера).

device->CreateBuffer(&ringCB);
for(int i=1;i++){
  if(i > length) copyEngine->Wait(fence1, i - length);
  copyEngine->Map(ringCB, value%length, WRITE, pData); // copy new data
  copyEngine->Signal(fence2, i);
  renderEngine->Wait(fence2, i);
  renderEngine->Draw(); // draw using copied data
  renderEngine->Signal(fence1, i);
}

// example for length = 3:
// copyEngine->Map();
// copyEngine->Signal(fence2, 1); // fence2 = 1  
// copyEngine->Map();
// copyEngine->Signal(fence2, 2); // fence2 = 2
// copyEngine->Map();
// copyEngine->Signal(fence2, 3); // fence2 = 3
// copy engine has exhausted the ring buffer, so must wait for render to consume it
// copyEngine->Wait(fence1, 1); // fence1 == 0, wait
// renderEngine->Wait(fence2, 1); // fence2 == 3, pass
// renderEngine->Draw();
// renderEngine->Signal(fence1, 1); // fence1 = 1, copy engine now unblocked
// renderEngine->Wait(fence2, 2); // fence2 == 3, pass
// renderEngine->Draw();
// renderEngine->Signal(fence1, 2); // fence1 = 2
// renderEngine->Wait(fence2, 3); // fence2 == 3, pass
// renderEngine->Draw();
// renderEngine->Signal(fence1, 3); // fence1 = 3
// now render engine is starved, and so must wait for the copy engine
// renderEngine->Wait(fence2, 4); // fence2 == 3, wait

Сценарии с несколькими подсистемами

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

В частности, следующие сценарии можно решить с помощью Direct3D 12.

  • Асинхронная и низкоприоритетная работа GPU. Это позволяет параллельно выполнять низкоприоритетные операции GPU и атомарные операции, которые позволяют одному потоку GPU использовать результаты другого несинхронизированного потока без блокировки.
  • Высокоприоритетная вычислительная работа. С помощью фоновых вычислений можно прервать трехмерную отрисовку, чтобы выполнить небольшой объем высокоприоритетных вычислительных работ. Результаты этой работы можно получить на ранней стадии для дополнительной обработки на ЦП.
  • Фоновая работа вычислений. Отдельная очередь с низким приоритетом для вычислительных рабочих нагрузок позволяет приложению использовать резервные циклы GPU для выполнения фоновых вычислений без негативного влияния на основные задачи отрисовки (или другие). Фоновые задачи могут включать распаковку ресурсов, обновление имитаций или структур ускорения. Фоновые задачи следует синхронизировать на ЦП нечасто (примерно один раз на кадр), чтобы избежать замедления работы переднего плана.
  • Потоковая передача и отправка данных. Отдельная очередь копирования заменяет понятия D3D11, связанные с начальными данными и обновлением ресурсов. Хотя приложение отвечает за дополнительные сведения в модели Direct3D 12, эта ответственность приходится на мощность. Приложение может управлять объемом системной памяти, выделяемой для буферизации передаваемых данных. Приложение может выбрать, когда и как (ЦП и GPU, блокирующие и неблокирующие) синхронизировать, а также может отслеживать ход выполнения и контролировать объем работы в очереди.
  • Повышенный параллелизм. Приложения могут использовать более глубокие очереди для фоновых рабочих нагрузок (например, декодирования видео), если у них есть отдельные очереди для работы на переднем плане.

В Direct3D 12 концепция очереди команд — это представление API примерно последовательной последовательности работы, отправленной приложением. Барьеры и другие методы позволяют выполнять эту работу в конвейере или не по порядку, но приложение видит только одно завершение временная шкала. Это соответствует непосредственному контексту в D3D11.

API синхронизации

Устройства и очереди

Устройство Direct3D 12 имеет методы для создания и извлечения очередей команд с различными типами и приоритетами. Большинство приложений должны использовать очереди команд по умолчанию, так как они позволяют совместно использовать другие компоненты. Приложения с дополнительными требованиями к параллелизму могут создавать дополнительные очереди. Очереди задаются типом списка команд, который они используют.

Ознакомьтесь со следующими методами создания ID3D12Device.

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

Ознакомьтесь со следующими методами ID3D12CommandQueue.

  • ExecuteCommandLists : отправляет массив списков команд для выполнения. Каждый список команд определяется id3D12CommandList.
  • Сигнал : задает значение ограждения, когда очередь (запущенная на GPU) достигает определенной точки.
  • Ожидание : очередь ожидает, пока указанное ограждение не достигнет указанного значения.

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

Ограждения

Api с несколькими обработчиками предоставляет явные API для создания и синхронизации с помощью ограждений. Ограждение — это конструкция синхронизации, управляемая значением UINT64. Значения ограждения задаются приложением. Сигнальная операция изменяет значение ограждения, а операция ожидания блокируется, пока забор не достигнет запрошенного значения или больше. Событие может быть активировано, когда ограждение достигает определенного значения.

См. методы интерфейса ID3D12Fence .

  • GetCompletedValue : возвращает текущее значение ограждения.
  • SetEventOnCompletion : вызывает срабатывание события, когда ограждение достигает заданного значения.
  • Сигнал : задает для ограждения заданное значение.

Ограждения обеспечивают доступ ЦП к текущему значению ограждения, а ЦП ожидает и сигнализирует.

Метод Signal в интерфейсе ID3D12Fence обновляет ограждение со стороны ЦП. Это обновление происходит немедленно. Метод Signal в ID3D12CommandQueue обновляет ограждение со стороны GPU. Это обновление происходит после завершения всех других операций в очереди команд.

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

Приложения устанавливают собственные значения забора. Хорошей отправной точкой может быть увеличение забора один раз на кадр.

Забор может быть перематывался. Это означает, что значение ограждения не требуется только увеличивать. Если операция Signal помещается в очередь в двух разных очередях команд или если два потока ЦП вызывает сигнал на заборе, может возникнуть гонка, чтобы определить, какой сигнал завершается последним, и, следовательно, какое значение ограждения будет оставаться. Если забор перемотается, все новые ожидания (включая запросы SetEventOnCompletion ) будут сравниваться с новым более низким значением забора, и поэтому могут быть не удовлетворены, даже если значение забора ранее было достаточно высоким, чтобы удовлетворить их. В случае возникновения гонки между значением, которое удовлетворяет ожидающему ожиданию, и более низким значением, которое не будет, ожидание будет выполнено независимо от того, какое значение остается после этого.

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

Списки команд копирования и вычисления

Все три типа списка команд используют интерфейс ID3D12GraphicsCommandList , однако для копирования и вычислений поддерживается только подмножество методов.

Списки команд копирования и вычисления могут использовать следующие методы.

Списки команд вычислений также могут использовать следующие методы.

Списки команд вычислений должны задавать вычислительный PSO при вызове SetPipelineState.

Пакеты нельзя использовать со списками или очередями команд вычислений или копирования.

Пример конвейерных вычислений и графики

В этом примере показано, как можно использовать синхронизацию ограждения для создания конвейера вычислительных работ в очереди (на которую ссылается pComputeQueue), которая используется графическими работами в очереди pGraphicsQueue. Вычислительные и графические операции конвейерируются с очередью графики, которая использует результат вычислительной работы из нескольких кадров, а событие ЦП используется для регулирования общего объема работы в очереди.

void PipelinedComputeGraphics()
{
    const UINT CpuLatency = 3;
    const UINT ComputeGraphicsLatency = 2;

    HANDLE handle = CreateEvent(nullptr, FALSE, FALSE, nullptr);

    UINT64 FrameNumber = 0;

    while (1)
    {
        if (FrameNumber > ComputeGraphicsLatency)
        {
            pComputeQueue->Wait(pGraphicsFence,
                FrameNumber - ComputeGraphicsLatency);
        }

        if (FrameNumber > CpuLatency)
        {
            pComputeFence->SetEventOnFenceCompletion(
                FrameNumber - CpuLatency,
                handle);
            WaitForSingleObject(handle, INFINITE);
        }

        ++FrameNumber;

        pComputeQueue->ExecuteCommandLists(1, &pComputeCommandList);
        pComputeQueue->Signal(pComputeFence, FrameNumber);
        if (FrameNumber > ComputeGraphicsLatency)
        {
            UINT GraphicsFrameNumber = FrameNumber - ComputeGraphicsLatency;
            pGraphicsQueue->Wait(pComputeFence, GraphicsFrameNumber);
            pGraphicsQueue->ExecuteCommandLists(1, &pGraphicsCommandList);
            pGraphicsQueue->Signal(pGraphicsFence, GraphicsFrameNumber);
        }
    }
}

Для поддержки этой конвейеризации должен существовать буфер ComputeGraphicsLatency+1 с разными копиями данных, передаваемых из очереди вычислений в очередь графики. Списки команд должны использовать БПЛА и косвенное использование для чтения и записи из соответствующей "версии" данных в буфере. Очередь вычислений должна дождаться завершения чтения графики из данных для кадра N, прежде чем она сможет записать кадр N+ComputeGraphicsLatency.

Обратите внимание, что объем рабочей очереди вычислений относительно ЦП не зависит напрямую от требуемого объема буферизации, однако работа GPU в очереди за пределами доступного буферного пространства менее важна.

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

Пример асинхронных вычислений и графики

Следующий пример позволяет асинхронно отображать графику из очереди вычислений. Между двумя этапами по-прежнему существует фиксированный объем буферизируемых данных, однако теперь работа с графикой выполняется независимо и использует самый актуальный результат этапа вычислений, известный на ЦП, когда работа графики находится в очереди. Это было бы полезно, если работа с графикой обновляется другим источником, например пользовательскими данными. Должно быть несколько списков команд, чтобы ComputeGraphicsLatency кадры графической работы могли находиться в рабочем состоянии в один момент времени, а функция UpdateGraphicsCommandList представляет собой обновление списка команд для включения последних входных данных и чтения из вычислительных данных из соответствующего буфера.

Очередь вычислений по-прежнему должна ждать завершения очереди графики с буферами канала, но вводится третье ограждение (pGraphicsComputeFence), чтобы можно было отслеживать ход выполнения операций вычислений по чтению графики и прогресс графики в целом. Это отражает тот факт, что теперь последовательные графические кадры могут считывать один и тот же результат вычислений или пропускать результат вычислений. Более эффективный, но немного более сложный дизайн будет использовать только один графический забор и сохранить сопоставление с вычислительными кадрами, используемыми каждым графическим кадром.

void AsyncPipelinedComputeGraphics()
{
    const UINT CpuLatency{ 3 };
    const UINT ComputeGraphicsLatency{ 2 };

    // The compute fence is at index 0; the graphics fence is at index 1.
    ID3D12Fence* rgpFences[]{ pComputeFence, pGraphicsFence };
    HANDLE handles[2];
    handles[0] = CreateEvent(nullptr, FALSE, TRUE, nullptr);
    handles[1] = CreateEvent(nullptr, FALSE, TRUE, nullptr);
    UINT FrameNumbers[]{ 0, 0 };

    ID3D12GraphicsCommandList* rgpGraphicsCommandLists[CpuLatency];
    CreateGraphicsCommandLists(ARRAYSIZE(rgpGraphicsCommandLists),
        rgpGraphicsCommandLists);

    // Graphics needs to wait for the first compute frame to complete; this is the
    // only wait that the graphics queue will perform.
    pGraphicsQueue->Wait(pComputeFence, 1);

    while (true)
    {
        for (auto i = 0; i < 2; ++i)
        {
            if (FrameNumbers[i] > CpuLatency)
            {
                rgpFences[i]->SetEventOnCompletion(
                    FrameNumbers[i] - CpuLatency,
                    handles[i]);
            }
            else
            {
                ::SetEvent(handles[i]);
            }
        }


        auto WaitResult = ::WaitForMultipleObjects(2, handles, FALSE, INFINITE);
        if (WaitResult > WAIT_OBJECT_0 + 1) continue;
        auto Stage = WaitResult - WAIT_OBJECT_0;
        ++FrameNumbers[Stage];

        switch (Stage)
        {
        case 0:
        {
            if (FrameNumbers[Stage] > ComputeGraphicsLatency)
            {
                pComputeQueue->Wait(pGraphicsComputeFence,
                    FrameNumbers[Stage] - ComputeGraphicsLatency);
            }
            pComputeQueue->ExecuteCommandLists(1, &pComputeCommandList);
            pComputeQueue->Signal(pComputeFence, FrameNumbers[Stage]);
            break;
        }
        case 1:
        {
            // Recall that the GPU queue started with a wait for pComputeFence, 1
            UINT64 CompletedComputeFrames = min(1,
                pComputeFence->GetCompletedValue());
            UINT64 PipeBufferIndex =
                (CompletedComputeFrames - 1) % ComputeGraphicsLatency;
            UINT64 CommandListIndex = (FrameNumbers[Stage] - 1) % CpuLatency;
            // Update graphics command list based on CPU input and using the appropriate
            // buffer index for data produced by compute.
            UpdateGraphicsCommandList(PipeBufferIndex,
                rgpGraphicsCommandLists[CommandListIndex]);

            // Signal *before* new rendering to indicate what compute work
            // the graphics queue is DONE with
            pGraphicsQueue->Signal(pGraphicsComputeFence, CompletedComputeFrames - 1);
            pGraphicsQueue->ExecuteCommandLists(1,
                rgpGraphicsCommandLists + PipeBufferIndex);
            pGraphicsQueue->Signal(pGraphicsFence, FrameNumbers[Stage]);
            break;
        }
        }
    }
}

Доступ к ресурсам из нескольких очередей

Для доступа к ресурсу в нескольких очередях приложение должно соответствовать следующим правилам.

  • Доступ к ресурсам (см. 12_RESOURCE_STATES Direct3D) определяется классом типа очереди, а не объектом очереди. Существует два типа классов очереди: Compute/3D queue — это класс одного типа, Copy — второй класс типа. Таким образом, ресурс, который имеет барьер для состояния NON_PIXEL_SHADER_RESOURCE в одной трехмерной очереди, можно использовать в этом состоянии в любой трехмерной или вычислительной очереди с учетом требований синхронизации, требующих сериализации большинства операций записи. Состояния ресурсов, которые совместно используются двумя классами типов (COPY_SOURCE и COPY_DEST), считаются различными состояниями для каждого класса типов. Таким образом, если ресурс переходит на COPY_DEST в очереди копирования, он будет недоступен в качестве места назначения копирования из трехмерных очередей или очередей вычислений и наоборот.

    Для подведения итогов.

    • Объект очереди — это любая отдельная очередь.
    • Тип очереди — это любой из следующих трех вариантов: вычислительная, трехмерная и копировать.
    • "Класс типа" очереди — это любой из следующих двух: Compute/3D и Copy.
  • Флаги COPY (COPY_DEST и COPY_SOURCE), используемые в качестве начальных состояний, представляют состояния в классе типа 3D/Compute. Чтобы использовать ресурс изначально в очереди копирования, он должен запускаться в состоянии COMMON. Общее состояние можно использовать для всех вариантов использования в очереди копирования с помощью неявных переходов состояния. 

  • Хотя состояние ресурса совместно используется во всех вычислительных и трехмерных очередях, не разрешено одновременно выполнять запись в ресурс в разных очередях. "Одновременно" здесь означает несинхронизированную, отметив, что несинхронизированное выполнение невозможно на некотором оборудовании. Применяются следующие правила.

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

Руководство по программированию Direct3D 12

Использование барьеров ресурсов для синхронизации состояний ресурсов в Direct3D 12

Управление памятью в Direct3D 12