Partilhar via


Sincronização com vários motores

A maioria das GPUs modernas contém vários mecanismos independentes que fornecem funcionalidade especializada. Muitos têm um ou mais mecanismos de cópia dedicados e um mecanismo de computação, geralmente distinto do mecanismo 3D. Cada um desses mecanismos pode executar comandos em paralelo uns com os outros. O Direct3D 12 fornece acesso refinado aos mecanismos 3D, de computação e cópia, usando filas e listas de comandos.

Mecanismos de GPU

O diagrama a seguir mostra os threads da CPU de um título, cada um preenchendo uma ou mais filas de cópia, computação e 3D. A fila 3D pode acionar todos os três mecanismos de GPU; a fila de computação pode conduzir os mecanismos de computação e cópia; e a fila de cópias simplesmente o mecanismo de cópia.

Como os diferentes threads preenchem as filas, não pode haver uma garantia simples da ordem de execução, daí a necessidade de mecanismos de sincronização — quando o título os exige.

quatro threads enviando comandos para três filas

A imagem a seguir ilustra como um título pode agendar o trabalho em vários mecanismos de GPU, incluindo a sincronização entre mecanismos, quando necessário: ela mostra as cargas de trabalho por mecanismo com dependências entre mecanismos. Neste exemplo, o mecanismo de cópia primeiro copia alguma geometria necessária para a renderização. O mecanismo 3D aguarda a conclusão dessas cópias e renderiza uma pré-passagem sobre a geometria. Isso é então consumido pelo mecanismo de computação. Os resultados do mecanismo de computação Dispatch, juntamente com várias operações de cópia de textura no mecanismo de cópia, são consumidos pelo mecanismo 3D para a chamada final do Draw.

mecanismos de cópia, gráficos e computação que se comunicam

O pseudocódigo a seguir ilustra como um título pode enviar tal carga de trabalho.

// 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

O pseudocódigo a seguir ilustra a sincronização entre os mecanismos de cópia e 3D para realizar a alocação de memória tipo pilha por meio de um buffer de anel. Os títulos têm a flexibilidade de escolher o equilíbrio certo entre maximizar o paralelismo (através de um buffer grande) e reduzir o consumo de memória e a latência (através de um buffer pequeno).

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

Cenários com vários motores

O Direct3D 12 permite que você evite acidentalmente encontrar ineficiências causadas por atrasos de sincronização inesperados. Ele também permite que você introduza a sincronização em um nível mais alto, onde a sincronização necessária pode ser determinada com maior certeza. Uma segunda questão que o multimotor aborda é tornar as operações caras mais explícitas, o que inclui transições entre 3D e vídeo que eram tradicionalmente caras devido à sincronização entre vários contextos do kernel.

Em particular, os seguintes cenários podem ser resolvidos com o Direct3D 12.

  • Trabalho assíncrono e de baixa prioridade da GPU. Isso permite a execução simultânea de trabalho de GPU de baixa prioridade e operações atômicas que permitem que um thread de GPU consuma os resultados de outro thread não sincronizado sem bloquear.
  • Trabalho de computação de alta prioridade. Com a computação em segundo plano, é possível interromper a renderização 3D para fazer uma pequena quantidade de trabalho de computação de alta prioridade. Os resultados deste trabalho podem ser obtidos antecipadamente para processamento adicional na CPU.
  • Trabalho de computação em segundo plano. Uma fila de baixa prioridade separada para cargas de trabalho de computação permite que um aplicativo utilize ciclos de GPU sobressalentes para executar computação em segundo plano sem impacto negativo nas tarefas primárias de renderização (ou outras). As tarefas em segundo plano podem incluir a descompressão de recursos ou a atualização de simulações ou estruturas de aceleração. As tarefas em segundo plano devem ser sincronizadas na CPU com pouca frequência (aproximadamente uma vez por quadro) para evitar a paralisação ou a lentidão do trabalho em primeiro plano.
  • Streaming e upload de dados. Uma fila de cópias separada substitui os conceitos D3D11 de dados iniciais e recursos de atualização. Embora o aplicativo seja responsável por mais detalhes no modelo Direct3D 12, essa responsabilidade vem com energia. O aplicativo pode controlar quanta memória do sistema é dedicada ao buffer de dados de upload. O aplicativo pode escolher quando e como (CPU vs GPU, bloqueio vs não-bloqueio) para sincronizar, e pode acompanhar o progresso e controlar a quantidade de trabalho em fila.
  • Aumento do paralelismo. Os aplicativos podem usar filas mais profundas para cargas de trabalho em segundo plano (por exemplo, decodificação de vídeo) quando têm filas separadas para trabalho em primeiro plano.

No Direct3D 12, o conceito de fila de comandos é a representação API de uma sequência aproximadamente serial de trabalho enviado pelo aplicativo. Barreiras e outras técnicas permitem que esse trabalho seja executado em um pipeline ou fora de ordem, mas o aplicativo vê apenas um único cronograma de conclusão. Isto corresponde ao contexto imediato em D3D11.

APIs de sincronização

Dispositivos e filas

O dispositivo Direct3D 12 tem métodos para criar e recuperar filas de comandos de diferentes tipos e prioridades. A maioria dos aplicativos deve usar as filas de comandos padrão porque elas permitem o uso compartilhado por outros componentes. Aplicativos com requisitos adicionais de simultaneidade podem criar filas adicionais. As filas são especificadas pelo tipo de lista de comandos que consomem.

Consulte os seguintes métodos de criação de ID3D12Device.

Filas de todos os tipos (3D, computação e cópia) compartilham a mesma interface e são todas baseadas em lista de comandos.

Consulte os seguintes métodos de ID3D12CommandQueue.

  • ExecuteCommandLists : envia uma matriz de listas de comandos para execução. Cada lista de comandos sendo definida por ID3D12CommandList.
  • Signal : define um valor de cerca quando a fila (em execução na GPU) atinge um determinado ponto.
  • Aguarde : a fila aguarda até que a cerca especificada atinja o valor especificado.

Observe que os pacotes não são consumidos por nenhuma fila e, portanto, esse tipo não pode ser usado para criar uma fila.

Vedações

A API de vários mecanismos fornece APIs explícitas para criar e sincronizar usando cercas. Uma cerca é uma construção de sincronização controlada por um valor UINT64. Os valores de cerca são definidos pelo aplicativo. Uma operação de sinal modifica o valor da cerca e uma operação de espera bloqueia até que a cerca atinja o valor solicitado ou maior. Um evento pode ser disparado quando uma cerca atinge um determinado valor.

Consulte os métodos do interface ID3D12Fence.

As cercas permitem o acesso da CPU ao valor atual da cerca, e a CPU espera e sinaliza.

O método Signal na interface deID3D12Fence doatualiza uma cerca do lado da CPU. Esta atualização ocorre imediatamente. O método Signal em ID3D12CommandQueue atualiza uma cerca do lado da GPU. Esta atualização ocorre após todas as outras operações na fila de comandos terem sido concluídas.

Todos os nós em uma configuração de vários mecanismos podem ler e reagir a qualquer cerca que atinja o valor certo.

Os aplicativos definem seus próprios valores de cerca, um bom ponto de partida pode ser aumentar uma cerca uma vez por quadro.

Uma cerca pode ser rebobinada. Isso significa que o valor da cerca não precisa apenas aumentar. Se uma operação Signal estiver enfileirada em duas filas de comandos diferentes, ou se dois threads da CPU estiverem chamando Signal em uma cerca, pode haver uma corrida para determinar qual Signal conclui por último e, portanto, qual valor de cerca é o que permanecerá. Se uma cerca for rebobinada, quaisquer novas esperas (incluindo solicitações de SetEventOnComplete) serão comparadas com o novo valor de cerca inferior e, portanto, podem não ser satisfeitas, mesmo que o valor da cerca tenha sido anteriormente alto o suficiente para satisfazê-las. Se ocorrer uma corrida, entre um valor que satisfaça uma espera pendente e um valor mais baixo que não o fará, a de espera será satisfeita, independentemente do valor que permanecer depois.

As APIs de cerca fornecem uma poderosa funcionalidade de sincronização, mas podem criar problemas potencialmente difíceis de depurar. Recomenda-se que cada cerca seja usada apenas para indicar o progresso em uma linha do tempo para evitar corridas entre sinalizadores.

Copiar e calcular listas de comandos

Todos os três tipos de lista de comandos usam o ID3D12GraphicsCommandList interface, no entanto, apenas um subconjunto dos métodos é suportado para cópia e computação.

As listas de comandos de cópia e computação podem usar os seguintes métodos.

As listas de comandos de computação também podem usar os seguintes métodos.

As listas de comandos de computação devem definir uma PSO de computação ao chamar SetPipelineState.

Os pacotes não podem ser usados com listas de comandos ou filas de computação ou cópia.

Exemplo de computação e gráficos em pipeline

Este exemplo mostra como a sincronização de cerca pode ser usada para criar um pipeline de trabalho de computação em uma fila (referenciada por pComputeQueue) que é consumida pelo trabalho gráfico na fila pGraphicsQueue. O trabalho de computação e gráficos é canalizado com a fila de gráficos consumindo o resultado do trabalho de computação de vários quadros de volta, e um evento de CPU é usado para limitar o trabalho total enfileirado em geral.

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);
        }
    }
}

Para dar suporte a esse pipelining, deve haver um buffer de ComputeGraphicsLatency+1 cópias diferentes dos dados que passam da fila de computação para a fila de gráficos. As listas de comandos devem usar UAVs e indirection para ler e gravar a partir da "versão" apropriada dos dados no buffer. A fila de computação deve aguardar até que a fila de gráficos termine de ler os dados do quadro N antes de poder gravar o quadro N+ComputeGraphicsLatency.

Observe que a quantidade de fila de computação trabalhada em relação à CPU não depende diretamente da quantidade de buffer necessária, no entanto, o trabalho da GPU em fila além da quantidade de espaço de buffer disponível é menos valioso.

Um mecanismo alternativo para evitar indirecionamento seria criar várias listas de comandos correspondentes a cada uma das versões "renomeadas" dos dados. O próximo exemplo usa essa técnica enquanto estende o exemplo anterior para permitir que as filas de computação e gráficos sejam executadas de forma mais assíncrona.

Exemplo de computação e gráficos assíncronos

Este próximo exemplo permite que os gráficos sejam renderizados de forma assíncrona a partir da fila de computação. Ainda há uma quantidade fixa de dados armazenados em buffer entre os dois estágios, no entanto, agora o trabalho gráfico prossegue de forma independente e usa o resultado de data mais up-todo estágio de computação, como conhecido na CPU quando o trabalho gráfico é enfileirado. Isso seria útil se o trabalho gráfico estivesse sendo atualizado por outra fonte, por exemplo, entrada do usuário. Deve haver várias listas de comandos para permitir que os quadros ComputeGraphicsLatency do trabalho gráfico estejam em voo de cada vez, e a função UpdateGraphicsCommandList representa a atualização da lista de comandos para incluir os dados de entrada mais recentes e ler os dados de computação do buffer apropriado.

A fila de computação ainda deve aguardar que a fila de gráficos termine com os buffers de pipe, mas uma terceira cerca (pGraphicsComputeFence) é introduzida para que o progresso do trabalho de computação de leitura de gráficos versus o progresso gráfico em geral possa ser rastreado. Isso reflete o fato de que agora quadros gráficos consecutivos podem ler a partir do mesmo resultado de computação ou podem ignorar um resultado de computação. Um design mais eficiente, mas um pouco mais complicado, usaria apenas a cerca gráfica única e armazenaria um mapeamento para os quadros de computação usados por cada quadro gráfico.

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 + CommandListIndex);
            pGraphicsQueue->Signal(pGraphicsFence, FrameNumbers[Stage]);
            break;
        }
        }
    }
}

Acesso a recursos de várias filas

Para acessar um recurso em mais de uma fila, um aplicativo deve aderir às seguintes regras.

  • O acesso a recursos (consulte Direct3D 12_RESOURCE_STATES) é determinado pela classe de tipo de fila e não pelo objeto de fila. Existem duas classes de tipo de fila: Compute/3D queue é uma classe de tipo, Copy é uma classe de segundo tipo. Portanto, um recurso que tenha uma barreira para o estado NON_PIXEL_SHADER_RESOURCE em uma fila 3D pode ser usado nesse estado em qualquer fila 3D ou Compute, sujeito aos requisitos de sincronização que exigem que a maioria das gravações seja serializada. Os estados de recurso que são compartilhados entre as duas classes de tipo (COPY_SOURCE e COPY_DEST) são considerados estados diferentes para cada classe de tipo. Para que, se um recurso transitar para COPY_DEST em uma fila de cópia, ele não estará acessível como um destino de cópia de filas 3D ou de computação e vice-versa.

    Resumindo.

    • Um "objeto" de fila é qualquer fila única.
    • Um "tipo" de fila é qualquer um destes três: Computação, 3D e Cópia.
    • Uma "classe de tipo" de fila é qualquer uma destas duas: Computação/3D e Cópia.
  • Os sinalizadores COPY (COPY_DEST e COPY_SOURCE) usados como estados iniciais representam estados na classe de tipo 3D/Compute. Para usar um recurso inicialmente em uma fila de cópia, ele deve começar no estado COMUM. O estado COMMON pode ser usado para todos os usos em uma fila de cópia usando as transições de estado implícitas. 

  • Embora o estado do recurso seja compartilhado em todas as filas de computação e 3D, não é permitido gravar no recurso simultaneamente em filas diferentes. "Simultaneamente" aqui significa não sincronizado, observando que a execução não sincronizada não é possível em algum hardware. Aplicam-se as seguintes regras.

    • Apenas uma fila pode gravar em um recurso de cada vez.
    • Várias filas podem ler a partir do recurso, desde que não leiam os bytes que estão sendo modificados pelo gravador (a leitura de bytes sendo gravados simultaneamente produz resultados indefinidos).
    • Uma cerca deve ser usada para sincronizar após a gravação antes que outra fila possa ler os bytes gravados ou fazer qualquer acesso de gravação.
  • Os buffers traseiros que estão sendo apresentados devem estar no estado 12_RESOURCE_STATE_COMMON Direct3D. 

Guia de programação do Direct3D 12

Usando barreiras de recursos para sincronizar estados de recursos no Direct3D 12

Gerenciamento de memória no Direct3D 12