Synchronisation multi-moteur

La plupart des GPU modernes contiennent plusieurs moteurs indépendants qui fournissent des fonctionnalités spécialisées. Beaucoup ont un ou plusieurs moteurs de copie dédiés, et un moteur de calcul, généralement distinct du moteur 3D. Chacun de ces moteurs peut exécuter des commandes en parallèle. Direct3D 12 fournit un accès précis aux moteurs 3D, de calcul et de copie, à l’aide de files d’attente et de listes de commandes.

Moteurs GPU

Le diagramme suivant montre les threads de processeur d’un titre, chacun remplit une ou plusieurs files d’attente de copie, de calcul et 3D. La file d’attente 3D peut piloter les trois moteurs GPU ; la file d’attente de calcul peut piloter les moteurs de calcul et de copie ; et la file d’attente de copie simplement le moteur de copie.

À mesure que les différents threads remplissent les files d’attente, il ne peut y avoir de garantie simple de l’ordre d’exécution, d’où la nécessité de mécanismes de synchronisation, lorsque le titre les requiert.

quatre threads envoyant des commandes à trois files d’attente

L’image suivante illustre la façon dont un titre peut planifier le travail sur plusieurs moteurs GPU, y compris la synchronisation inter-moteur si nécessaire : elle montre les charges de travail par moteur avec des dépendances entre moteurs. Dans cet exemple, le moteur de copie copie d’abord une géométrie nécessaire au rendu. Le moteur 3D attend que ces copies se terminent et restitue un pré-passage sur la géométrie. Celui-ci est ensuite consommé par le moteur de calcul. Les résultats du dispatch du moteur de calcul, ainsi que plusieurs opérations de copie de texture sur le moteur de copie, sont consommés par le moteur 3D pour l’appel draw final.

copie, graphiques et moteurs de calcul communiquant

Le pseudo-code suivant illustre la façon dont un titre peut soumettre une telle charge de travail.

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

Le pseudo-code suivant illustre la synchronisation entre les moteurs de copie et 3D pour obtenir une allocation de mémoire semblable à un tas via une mémoire tampon en anneau. Les titres ont la possibilité de choisir le bon équilibre entre l’optimisation du parallélisme (via une mémoire tampon volumineuse) et la réduction de la consommation de mémoire et de la latence (via une petite mémoire tampon).

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

Scénarios multi-moteurs

Direct3D 12 vous permet d’éviter de rencontrer accidentellement des inefficacités causées par des retards de synchronisation inattendus. Il vous permet également d’introduire la synchronisation à un niveau supérieur où la synchronisation requise peut être déterminée avec plus de certitude. Un deuxième problème que résout multi-moteur consiste à rendre les opérations coûteuses plus explicites, ce qui inclut des transitions entre la 3D et la vidéo qui étaient traditionnellement coûteuses en raison de la synchronisation entre plusieurs contextes de noyau.

En particulier, les scénarios suivants peuvent être traités avec Direct3D 12.

  • Travail GPU asynchrone et de faible priorité. Cela permet l’exécution simultanée de travaux GPU de faible priorité et d’opérations atomiques qui permettent à un thread GPU de consommer les résultats d’un autre thread non synchronisé sans blocage.
  • Travail de calcul à priorité élevée. Avec le calcul en arrière-plan, il est possible d’interrompre le rendu 3D pour effectuer une petite quantité de travail de calcul à priorité élevée. Les résultats de ce travail peuvent être obtenus tôt pour un traitement supplémentaire sur le processeur.
  • Travail de calcul en arrière-plan. Une file d’attente de faible priorité distincte pour les charges de travail de calcul permet à une application d’utiliser des cycles GPU de rechange pour effectuer un calcul en arrière-plan sans impact négatif sur les tâches de rendu primaires (ou autres). Les tâches en arrière-plan peuvent inclure la décompression des ressources ou la mise à jour de simulations ou de structures d’accélération. Les tâches en arrière-plan doivent être synchronisées rarement sur le processeur (environ une fois par image) pour éviter le blocage ou le ralentissement du travail au premier plan.
  • Diffusion en continu et chargement de données. Une file d’attente de copie distincte remplace les concepts D3D11 des données initiales et des ressources de mise à jour. Bien que l’application soit responsable de plus de détails dans le modèle Direct3D 12, cette responsabilité est liée à l’alimentation. L’application peut contrôler la quantité de mémoire système consacrée à la mise en mémoire tampon des données de chargement. L’application peut choisir quand et comment (PROCESSEUR ou GPU, blocage ou non bloquant) à synchroniser, et peut suivre la progression et contrôler la quantité de travail mis en file d’attente.
  • Parallélisme accru. Les applications peuvent utiliser des files d’attente plus approfondies pour les charges de travail en arrière-plan (par exemple, décodage vidéo) lorsqu’elles ont des files d’attente distinctes pour le travail au premier plan.

Dans Direct3D 12, le concept de file d’attente de commandes est la représentation d’API d’une séquence de travail à peu près série envoyée par l’application. Les barrières et d’autres techniques permettent d’exécuter ce travail dans un pipeline ou dans le désordre, mais l’application ne voit qu’une seule chronologie d’achèvement. Cela correspond au contexte immédiat dans D3D11.

API de synchronisation

Appareils et files d’attente

L’appareil Direct3D 12 dispose de méthodes pour créer et récupérer des files d’attente de commandes de différents types et priorités. La plupart des applications doivent utiliser les files d’attente de commandes par défaut, car elles permettent une utilisation partagée par d’autres composants. Les applications avec des exigences de concurrence supplémentaires peuvent créer des files d’attente supplémentaires. Les files d’attente sont spécifiées par le type de liste de commandes qu’elles consomment.

Reportez-vous aux méthodes de création suivantes de ID3D12Device.

Les files d’attente de tous types (3D, calcul et copie) partagent la même interface et sont toutes basées sur une liste de commandes.

Reportez-vous aux méthodes suivantes de ID3D12CommandQueue.

  • ExecuteCommandLists : envoie un tableau de listes de commandes pour exécution. Chaque liste de commandes définie par ID3D12CommandList.
  • Signal : définit une valeur de clôture lorsque la file d’attente (en cours d’exécution sur le GPU) atteint un certain point.
  • Wait : la file d’attente attend que la clôture spécifiée atteigne la valeur spécifiée.

Notez que les bundles ne sont consommés par aucune file d’attente et que ce type ne peut donc pas être utilisé pour créer une file d’attente.

Délimitations

L’API multi-moteur fournit des API explicites pour créer et synchroniser à l’aide de clôtures. Une clôture est une construction de synchronisation contrôlée par une valeur UINT64. Les valeurs de clôture sont définies par l’application. Une opération de signal modifie la valeur de clôture et une opération d’attente se bloque jusqu’à ce que la clôture ait atteint la valeur demandée ou supérieure. Un événement peut être déclenché lorsqu’une clôture atteint une certaine valeur.

Reportez-vous aux méthodes de l’interface ID3D12Fence .

  • GetCompletedValue : retourne la valeur actuelle de la clôture.
  • SetEventOnCompletion : provoque le déclenchement d’un événement lorsque la clôture atteint une valeur donnée.
  • Signal : définit la clôture sur la valeur donnée.

Les clôtures permettent au processeur d’accéder à la valeur de clôture actuelle, et les attentes et les signaux du processeur.

La méthode Signal de l’interface ID3D12Fence met à jour une clôture côté processeur. Cette mise à jour se produit immédiatement. La méthode Signal sur ID3D12CommandQueue met à jour une clôture côté GPU. Cette mise à jour se produit une fois toutes les autres opérations sur la file d’attente de commandes terminées.

Tous les nœuds d’une configuration multi-moteur peuvent lire et réagir à toute clôture atteignant la valeur appropriée.

Les applications définissent leurs propres valeurs de clôture. Un bon point de départ peut être l’augmentation d’une clôture une fois par image.

Une clôture peut être ressourdée. Cela signifie que la valeur de clôture n’a pas besoin d’incrémenter uniquement. Si une opération Signal est mise en file d’attente sur deux files d’attente de commandes différentes, ou si deux threads de processeur appellent tous les deux Signal sur une clôture, il peut y avoir une course pour déterminer quel signal se termine en dernier et, par conséquent, quelle valeur de clôture est celle qui restera. Si une clôture est rewound, toutes les nouvelles attentes (y compris les demandes SetEventOnCompletion ) sont comparées à la nouvelle valeur de clôture inférieure et, par conséquent, peuvent ne pas être satisfaites, même si la valeur de clôture était auparavant suffisamment élevée pour les satisfaire. Si une course se produit, entre une valeur qui répond à une attente en suspens et une valeur inférieure qui ne le sera pas, l’attente sera satisfaite, quelle que soit la valeur restante par la suite.

Les API de clôture fournissent de puissantes fonctionnalités de synchronisation, mais peuvent créer des problèmes potentiellement difficiles à déboguer. Il est recommandé d’utiliser chaque clôture uniquement pour indiquer la progression sur un chronologie afin d’éviter les courses entre signaleurs.

Copier et calculer des listes de commandes

Les trois types de liste de commandes utilisent l’interface ID3D12GraphicsCommandList , mais seul un sous-ensemble des méthodes est pris en charge pour la copie et le calcul.

Les listes de commandes de copie et de calcul peuvent utiliser les méthodes suivantes.

Les listes de commandes de calcul peuvent également utiliser les méthodes suivantes.

Les listes de commandes de calcul doivent définir une psO de calcul lors de l’appel de SetPipelineState.

Les offres groupées ne peuvent pas être utilisées avec des listes de commandes ou des files d’attente de calcul ou de copie.

Exemple de calcul et de graphiques pipelines

Cet exemple montre comment la synchronisation de clôture peut être utilisée pour créer un pipeline de travail de calcul sur une file d’attente (référencée par pComputeQueue) qui est consommée par le travail graphique sur la file d’attente pGraphicsQueue. Le travail de calcul et de graphiques est pipelineé avec la file d’attente graphique qui consomme le résultat du travail de calcul à partir de plusieurs images, et un événement d’UC est utilisé pour limiter le travail total mis en file d’attente.

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

Pour prendre en charge ce pipeline, il doit y avoir une mémoire tampon de ComputeGraphicsLatency+1 différentes copies des données passant de la file d’attente de calcul à la file d’attente graphique. Les listes de commandes doivent utiliser des UAV et l’indirection pour lire et écrire à partir de la « version » appropriée des données dans la mémoire tampon. La file d’attente de calcul doit attendre la fin de la lecture de la file d’attente graphique à partir des données de l’image N avant de pouvoir écrire le cadre N+ComputeGraphicsLatency.

Notez que la quantité de file d’attente de calcul travaillée par rapport au processeur ne dépend pas directement de la quantité de mise en mémoire tampon requise. Toutefois, le travail de mise en file d’attente gpu au-delà de la quantité d’espace tampon disponible est moins précieux.

Un autre mécanisme pour éviter l’indirection consisterait à créer plusieurs listes de commandes correspondant à chacune des versions « renommées » des données. L’exemple suivant utilise cette technique lors de l’extension de l’exemple précédent pour permettre aux files d’attente de calcul et de graphiques de s’exécuter de manière plus asynchrone.

Exemple de calcul asynchrone et de graphiques

Cet exemple suivant permet aux graphiques de s’afficher de manière asynchrone à partir de la file d’attente de calcul. Il existe encore une quantité fixe de données mises en mémoire tampon entre les deux phases, mais maintenant le travail graphique se déroule indépendamment et utilise le résultat le plus à jour de la phase de calcul, tel que connu sur le processeur lorsque le travail graphique est mis en file d’attente. Cela serait utile si le travail graphique était mis à jour par une autre source, par exemple une entrée utilisateur. Il doit y avoir plusieurs listes de commandes pour permettre ComputeGraphicsLatency aux images du travail graphique d’être en cours d’exécution à la fois, et la fonction UpdateGraphicsCommandList représente la mise à jour de la liste de commandes pour inclure les données d’entrée les plus récentes et lire les données de calcul à partir de la mémoire tampon appropriée.

La file d’attente de calcul doit toujours attendre que la file d’attente graphique se termine avec les mémoires tampons de canal, mais une troisième clôture (pGraphicsComputeFence) est introduite afin que la progression des graphiques lisant le travail de calcul par rapport à la progression des graphiques en général puisse être suivie. Cela reflète le fait que les images graphiques consécutives peuvent désormais lire à partir du même résultat de calcul ou ignorer un résultat de calcul. Une conception plus efficace, mais légèrement plus compliquée, utiliserait uniquement la seule clôture graphique et stockerait un mappage aux images de calcul utilisées par chaque image graphique.

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

Accès aux ressources multi-file d’attente

Pour accéder à une ressource sur plusieurs files d’attente, une application doit respecter les règles suivantes.

  • L’accès aux ressources (reportez-vous à Direct3D 12_RESOURCE_STATES) est déterminé par la classe de type de file d’attente et non par l’objet file d’attente. Il existe deux classes de type de file d’attente : la file d’attente compute/3D est une classe de type, et Copy est une deuxième classe de type. Ainsi, une ressource qui présente un obstacle à l’état NON_PIXEL_SHADER_RESOURCE sur une file d’attente 3D peut être utilisée dans cet état sur n’importe quelle file d’attente 3D ou de calcul, sous réserve des exigences de synchronisation qui nécessitent la sérialisation de la plupart des écritures. Les états de ressource partagés entre les deux classes de type (COPY_SOURCE et COPY_DEST) sont considérés comme différents pour chaque classe de type. Ainsi, si une ressource passe à COPY_DEST dans une file d’attente de copie, elle n’est pas accessible en tant que destination de copie à partir de files d’attente 3D ou de calcul, et vice versa.

    Pour résumer.

    • Un « objet » de file d’attente est n’importe quelle file d’attente unique.
    • Un « type » de file d’attente est l’un des trois suivants : Calcul, 3D et Copie.
    • Une « classe de type » de file d’attente est l’une des deux suivantes : Compute/3D et Copy.
  • Les indicateurs COPY (COPY_DEST et COPY_SOURCE) utilisés comme états initiaux représentent des états dans la classe de type 3D/Compute. Pour utiliser une ressource initialement dans une file d’attente de copie, elle doit commencer à l’état COMMON. L’état COMMON peut être utilisé pour toutes les utilisations d’une file d’attente de copie à l’aide des transitions d’état implicites. 

  • Bien que l’état des ressources soit partagé entre toutes les files d’attente de calcul et 3D, il n’est pas autorisé à écrire simultanément dans la ressource sur différentes files d’attente. « Simultanément » signifie ici non synchronisé, notant que l’exécution non synchronisée n’est pas possible sur certains matériels. Les règles suivantes s’appliquent.

    • Une seule file d’attente peut écrire dans une ressource à la fois.
    • Plusieurs files d’attente peuvent lire à partir de la ressource tant qu’elles ne lisent pas les octets modifiés par l’enregistreur (la lecture d’octets écrits simultanément produit des résultats non définis).
    • Une clôture doit être utilisée pour synchroniser après l’écriture avant qu’une autre file d’attente puisse lire les octets écrits ou effectuer un accès en écriture.
  • Les mémoires tampons arrière présentées doivent être à l’état 12_RESOURCE_STATE_COMMON Direct3D. 

Guide de programmation Direct3D 12

Utilisation de barrières de ressources pour synchroniser les états des ressources dans Direct3D 12

Gestion de la mémoire dans Direct3D 12