Profilatura precisa delle chiamate API Direct3D (Direct3D 9)

Dopo aver creato un'applicazione Microsoft Direct3D funzionale e si vuole migliorare le prestazioni, in genere si usa uno strumento di profilatura non disponibile o una tecnica di misurazione personalizzata per misurare il tempo necessario per eseguire una o più chiamate API (Application Programming Interface). Se questa operazione è stata eseguita, ma si ottengono risultati di intervallo che variano da una sequenza di rendering alla successiva oppure si stanno facendo ipotesi che non contengono risultati effettivi dell'esperimento, le informazioni seguenti possono aiutare a comprendere perché.

Le informazioni fornite qui si basano sul presupposto di avere conoscenza e esperienza con quanto segue:

  • Programmazione C/C++
  • Programmazione API Direct3D
  • Misurazione della tempistica API
  • Scheda video e il relativo driver software
  • Possibili risultati inspiegabili dall'esperienza di profilatura precedente

La profilatura diretta3D con precisione è difficile

Un profiler segnala il tempo trascorso in ogni chiamata API. Questa operazione viene eseguita per migliorare le prestazioni individuando e ottimizzando i punti caldi. Esistono diversi tipi di profilatura e profilatura.

  • Un profiler di campionamento si trova inattiva gran parte del tempo, risvegliandosi a intervalli specifici per l'esempio (o per registrare) le funzioni eseguite. Restituisce la percentuale di tempo trascorso in ogni chiamata. In genere, un profiler di campionamento non è molto invasivo per l'applicazione e ha un impatto minimo sul sovraccarico per l'applicazione.
  • Un profiler di strumentazione misura il tempo effettivo necessario per restituire una chiamata. Richiede la compilazione di delimitatori di avvio e arresto in un'applicazione. Un profiler di strumentazione è relativamente più invasivo per un'applicazione rispetto a un profiler di campionamento.
  • È anche possibile usare una tecnica di profilatura personalizzata con un timer ad alte prestazioni. Ciò produce risultati molto simili a uno strumento profiler.

Il tipo di tecnica di profilatura o profiler usato è solo parte della sfida di generare misurazioni accurate.

La profilatura offre risposte che consentono di ottenere prestazioni di budget. Si supponga, ad esempio, di sapere che una chiamata API prevede una media di un migliaio di cicli di clock da eseguire. È possibile affermare alcune conclusioni sulle prestazioni, ad esempio quanto segue:

  • Una CPU a 2 GHz (che spende il 50% del rendering del tempo) è limitata a chiamare questa API 1 milione di volte al secondo.
  • Per ottenere 30 fotogrammi al secondo, non è possibile chiamare questa API più di 33.000 volte per fotogramma.
  • È possibile eseguire il rendering solo di oggetti 3.3K per frame (presupponendo 10 di queste chiamate API per la sequenza di rendering di ogni oggetto).

In altre parole, se si dispone di tempo sufficiente per chiamata API, è possibile rispondere a una domanda di budgeting, ad esempio il numero di primitive che possono essere sottoposte a rendering interattivo. Ma i numeri non elaborati restituiti da un profiler di strumenti non risponderanno in modo accurato alle domande di budget. Ciò è dovuto al fatto che la pipeline grafica presenta problemi di progettazione complessi, ad esempio il numero di componenti che devono eseguire operazioni, il numero di processori che controllano il modo in cui i flussi di lavoro tra i componenti e le strategie di ottimizzazione implementati nel runtime e in un driver progettato per rendere la pipeline più efficiente.

Ogni chiamata API passa attraverso diversi componenti

Ogni chiamata viene elaborata da diversi componenti nel suo modo dall'applicazione alla scheda video. Si consideri, ad esempio, la sequenza di rendering seguente contenente due chiamate per il disegno di un singolo triangolo:

SetTexture(...);
DrawPrimitive(D3DPT_TRIANGLELIST, 0, 1);

Il diagramma concettuale seguente illustra i diversi componenti attraverso i quali le chiamate devono passare.

diagram of graphics components that api calls go through

L'applicazione richiama Direct3D che controlla la scena, gestisce le interazioni utente e determina la modalità di esecuzione del rendering. Tutto questo lavoro viene specificato nella sequenza di rendering, che viene inviato al runtime usando chiamate API Direct3D. La sequenza di rendering è virtualmente indipendente dall'hardware, ovvero le chiamate API sono indipendenti dall'hardware, ma un'applicazione ha conoscenza delle funzionalità supportate da una scheda video.

Il runtime converte queste chiamate in un formato indipendente dal dispositivo. Il runtime gestisce tutte le comunicazioni tra l'applicazione e il driver, in modo che un'applicazione venga eseguita su più di un componente hardware compatibile (a seconda delle funzionalità necessarie). Quando si misura una chiamata a una funzione, un profiler di strumentazione misura il tempo trascorso in una funzione e il tempo per la restituzione della funzione. Una limitazione di un profiler di strumentazione è che potrebbe non includere il tempo necessario per inviare il lavoro risultante alla scheda video né il tempo per l'elaborazione della scheda video. In altre parole, un profiler di strumentazione non riesce ad attribuire tutto il lavoro associato a ogni chiamata di funzione.

Il driver software usa conoscenze specifiche dell'hardware sulla scheda video per convertire i comandi indipendenti dal dispositivo in una sequenza di comandi della scheda video. I driver possono anche ottimizzare la sequenza di comandi inviati alla scheda video, in modo che il rendering sulla scheda video venga eseguito in modo efficiente. Queste ottimizzazioni possono causare problemi di profilatura perché la quantità di lavoro eseguita non è quella che sembra essere (potrebbe essere necessario comprendere le ottimizzazioni da tenere in considerazione). Il driver restituisce in genere il controllo al runtime prima che la scheda video abbia completato l'elaborazione di tutti i comandi.

La scheda video esegue la maggior parte del rendering combinando i dati dal vertice e dai buffer di indice, trame, informazioni sullo stato di rendering e i comandi grafici. Al termine del rendering della scheda video, il lavoro creato dalla sequenza di rendering viene completato.

Ogni chiamata API Direct3D deve essere elaborata da ogni componente (runtime, driver e scheda video) per eseguire il rendering di qualsiasi elemento.

È presente più di un processore che controlla i componenti

La relazione tra questi componenti è ancora più complessa, perché l'applicazione, il runtime e il driver sono controllati da un processore e la scheda video è controllata da un processore separato. Il diagramma seguente illustra due tipi di processori: un'unità di elaborazione centrale (CPU) e un'unità di elaborazione grafica (GPU).

diagram of a cpu and a gpu and their components

I sistemi PC hanno almeno una CPU e una GPU, ma possono avere più di una o entrambe. Le CPU si trovano sulla scheda madre e le GPU si trovano sulla scheda madre o sulla scheda video. La velocità della CPU è determinata da un chip di orologio sulla scheda madre e la velocità della GPU è determinata da un chip di orologio separato. L'orologio della CPU controlla la velocità del lavoro eseguito dall'applicazione, dal runtime e dal driver. L'applicazione invia il lavoro alla GPU tramite il runtime e il driver.

La CPU e la GPU in genere vengono eseguite con velocità diverse, indipendentemente dall'altra. La GPU può rispondere al lavoro non appena il lavoro è disponibile (presupponendo che la GPU abbia completato il lavoro precedente). Il lavoro della GPU viene eseguito in parallelo con il lavoro della CPU come evidenziato dalla linea curva nella figura precedente. Un profiler misura in genere le prestazioni della CPU, non la GPU. Ciò rende difficile la profilatura, perché le misurazioni effettuate da un profiler di strumentazione includono il tempo della CPU, ma potrebbero non includere il tempo della GPU.

Lo scopo della GPU è quello di disattivare l'elaborazione dalla CPU a un processore progettato in modo specifico per il lavoro grafico. Nelle schede video moderne, la GPU sostituisce gran parte del lavoro di trasformazione e illuminazione nella pipeline dalla CPU alla GPU. Questo riduce notevolmente il carico di lavoro della CPU, lasciando più cicli di CPU disponibili per altre elaborazioni. Per ottimizzare un'applicazione grafica per le prestazioni massime, è necessario misurare le prestazioni della CPU e della GPU e bilanciare il lavoro tra i due tipi di processori.

Questo documento non illustra gli argomenti correlati alla misurazione delle prestazioni della GPU o al bilanciamento del lavoro tra la CPU e la GPU. Se si vuole comprendere meglio le prestazioni di una GPU (o una scheda video specifica), visitare il sito Web del fornitore per cercare altre informazioni sulle prestazioni della GPU. Questo documento è invece incentrato sul lavoro eseguito dal runtime e sul driver riducendo il lavoro della GPU a una quantità trascurabile. Ciò è, in parte, basato sull'esperienza che le applicazioni che riscontrano problemi di prestazioni sono generalmente limitate alla CPU.

Le ottimizzazioni del runtime e del driver possono mascherare le misurazioni dell'API

Il runtime ha un'ottimizzazione delle prestazioni incorporata in esso che può sovraccaricare la misurazione di una singola chiamata. Ecco uno scenario di esempio che illustra questo problema. Prendere in considerazione la sequenza di rendering seguente:

  BeginScene();
    ...
    SetTexture(...);
    DrawPrimitive(D3DPT_TRIANGLELIST, 0, 1);
    ...
  EndScene();
  Present();

Esempio 1: sequenza di rendering semplice

Esaminando i risultati delle due chiamate nella sequenza di rendering, un profiler di strumentazione potrebbe restituire risultati simili a questi:

Number of cycles for SetTexture       : 100
Number of cycles for DrawPrimitive    : 950,500

Il profiler restituisce il numero di cicli di CPU necessari per elaborare il lavoro associato a ogni chiamata (tenere presente che la GPU non è inclusa in questi numeri perché la GPU non ha ancora iniziato a lavorare su questi comandi). Poiché IDirect3DDevice9::D rawPrimitive richiede quasi un milione di cicli da elaborare, è possibile concludere che non è molto efficiente. Tuttavia, si vedrà presto perché questa conclusione non è corretta e come è possibile generare risultati che possono essere usati per il budget.

La misurazione delle modifiche dello stato richiede sequenze di rendering accurate

Tutte le chiamate diverse da IDirect3DDevice9::D rawPrimitive, DrawIndexedPrimitiveo Clear (ad esempio SetTexture, SetVertexDeclaration e SetRenderState) generano una modifica dello stato. Ogni stato di modifica imposta lo stato della pipeline che controlla la modalità di esecuzione del rendering.

Le ottimizzazioni nel runtime e/o il driver sono progettate per velocizzare il rendering riducendo la quantità di lavoro necessaria. Di seguito sono riportate alcune ottimizzazioni di modifica dello stato che possono inquinare le medie del profilo:

  • Un driver (o il runtime) potrebbe salvare una modifica dello stato come stato locale. Poiché il driver potrebbe funzionare in un algoritmo "lazy" (rinviando il lavoro fino a quando non è assolutamente necessario), il lavoro associato ad alcune modifiche dello stato potrebbe essere ritardato.
  • Il runtime (o un driver) può rimuovere le modifiche dello stato ottimizzando. Un esempio di questo potrebbe essere rimuovere una modifica dello stato ridondante che disabilita l'illuminazione perché l'illuminazione è stata disabilitata in precedenza.

Non esiste un modo insodibile per esaminare una sequenza di rendering e concludere quali modifiche dello stato impostano un bit sporco e rinviano il lavoro o semplicemente verranno rimosse dall'ottimizzazione. Anche se è possibile identificare le modifiche dello stato ottimizzate nel runtime o nel driver di oggi, è probabile che il runtime o il driver di domani vengano aggiornati. Non si sa anche cosa era lo stato precedente, quindi è difficile identificare le modifiche dello stato ridondanti. L'unico modo per verificare il costo di una modifica dello stato consiste nel misurare la sequenza di rendering che include le modifiche dello stato.

Come si può notare, le complicazioni causate dalla presenza di più processori, comandi elaborati da più componenti e ottimizzazioni integrate nei componenti rendono difficile la stima della profilatura. Nella sezione successiva verranno affrontate ognuna di queste problematiche di profilatura. Verranno visualizzate sequenze di rendering Direct3D di esempio, con le tecniche di misurazione associate. Con questa conoscenza, sarà possibile generare misurazioni accurate e ripetibili su singole chiamate.

Come profilare in modo accurato una sequenza di rendering Direct3D

Ora che alcune delle problematiche di profilatura sono state evidenziate, in questa sezione verranno illustrate le tecniche che consentono di generare misurazioni del profilo che possono essere usate per il budget. Le misurazioni di profilatura ripetibili accurate sono possibili se si comprende la relazione tra i componenti controllati dalla CPU e come evitare ottimizzazioni delle prestazioni implementate dal runtime e dal driver.

Per iniziare, è necessario essere in grado di misurare in modo accurato il tempo di esecuzione di una singola chiamata API.

Selezionare uno strumento di misurazione accurato, ad esempio QueryPerformanceCounter

Il sistema operativo Microsoft Windows include un timer ad alta risoluzione che può essere usato per misurare i tempi trascorsi ad alta risoluzione. Il valore corrente di un timer di questo tipo può essere restituito usando QueryPerformanceCounter. Dopo aver richiamato QueryPerformanceCounter per restituire i valori di avvio e arresto, la differenza tra i due valori può essere convertita nel tempo trascorso effettivo (in secondi) usando QueryPerformanceCounter.

I vantaggi dell'uso di QueryPerformanceCounter sono che è disponibile in Windows ed è facile da usare. È sufficiente racchiudere le chiamate con una chiamata QueryPerformanceCounter e salvare i valori di avvio e arresto. Di conseguenza, questo documento illustra come usare QueryPerformanceCounter per profilare i tempi di esecuzione, in modo analogo al modo in cui un profiler di strumentazione lo misura. Ecco un esempio che illustra come incorporare QueryPerformanceCounter nel codice sorgente:

  BeginScene();
    ...
    // Start profiling
    LARGE_INTEGER start, stop, freq;
    QueryPerformanceCounter(&start);

    SetTexture(...);
    DrawPrimitive(D3DPT_TRIANGLELIST, 0, 1); 

    QueryPerformanceCounter(&stop);
    stop.QuadPart -= start.QuadPart;
    QueryPerformanceFrequency(&freq);
    // Stop profiling
    ...
  EndScene();
  Present();

Esempio 2: Implementazione di profilatura personalizzata con QPC

start e stop sono due numeri interi di grandi dimensioni che conterranno i valori di avvio e arresto restituiti dal timer a prestazioni elevate. Si noti che QueryPerformanceCounter(&start) viene chiamato subito prima che SetTexture e QueryPerformanceCounter(&stop) venga chiamato subito dopo DrawPrimitive. Dopo aver ottenuto il valore di arresto, QueryPerformanceFrequency viene chiamato per restituire freq, ovvero la frequenza del timer ad alta risoluzione. In questo esempio ipotetico si supponga di ottenere i risultati seguenti per l'avvio, l'arresto e il freq:

Variabile locale Numero di tick
Avvio 1792998845094
stop 1792998845102
Freq 3579545

 

È possibile convertire questi valori nel numero di cicli necessari per eseguire le chiamate API come segue:

# ticks = (stop - start) = 1792998845102 - 1792998845094 = 8 ticks

# cycles = CPU speed * number of ticks / QPF
# 4568   = 2 GHz      * 8              / 3,579,545

In altre parole, sono necessari circa 4568 cicli di clock per elaborare SetTexture e DrawPrimitive in questo computer a 2 GHz. È possibile convertire questi valori nel tempo effettivo impiegato per eseguire tutte le chiamate come segue:

(stop - start)/ freq = elapsed time
8 ticks / 3,579,545 = 2.2E-6 seconds or between 2 and 3 microseconds.

L'uso di QueryPerformanceCounter richiede l'aggiunta di misurazioni di avvio e arresto alla sequenza di rendering e l'uso di QueryPerformanceFrequency per convertire la differenza (numero di tick) nel numero di cicli della CPU o nel tempo effettivo. L'identificazione della tecnica di misurazione è un buon punto di partenza per lo sviluppo di un'implementazione di profilatura personalizzata. Ma prima di saltare in e iniziare a fare misurazioni, è necessario sapere come gestire la scheda video.

Concentrarsi sulle misurazioni della CPU

Come indicato in precedenza, la CPU e la GPU funzionano in parallelo per elaborare il lavoro generato dalle chiamate API. Un'applicazione reale richiede la profilatura di entrambi i tipi di processori per verificare se l'applicazione è limitata dalla CPU o limitata dalla GPU. Poiché le prestazioni della GPU sono specifiche del fornitore, sarebbe molto difficile produrre risultati in questo documento che coprono la varietà di schede video disponibili.

Questo documento si concentrerà invece solo sulla profilatura del lavoro eseguito dalla CPU usando una tecnica personalizzata per misurare il runtime e il lavoro del driver. Il lavoro della GPU verrà ridotto a una quantità insignificante, in modo che i risultati della CPU siano più visibili. Un vantaggio di questo approccio è che questa tecnica produce risultati nell'Appendice che dovrebbe essere in grado di correlare con le misurazioni. Per ridurre il lavoro richiesto dalla scheda video a un livello insignificante, è sufficiente ridurre il lavoro di rendering al minimo possibile. Questa operazione può essere eseguita limitando le chiamate di disegno per eseguire il rendering di un singolo triangolo e può essere ulteriormente vincolata in modo che ogni triangolo contenga solo un pixel.

L'unità di misura usata in questo documento per misurare il lavoro della CPU sarà il numero di cicli di clock della CPU anziché l'ora effettiva. I cicli di clock della CPU hanno il vantaggio che è più portabile (per le applicazioni limitate dalla CPU) rispetto al tempo trascorso effettivo tra i computer con velocità di CPU diverse. Questa operazione può essere facilmente convertita in tempo effettivo, se necessario.

Questo documento non illustra gli argomenti relativi al bilanciamento del carico di lavoro tra la CPU e la GPU. Tenere presente che l'obiettivo di questo documento non è misurare le prestazioni complessive di un'applicazione, ma mostrare come misurare accuratamente il tempo necessario per il runtime e il driver per elaborare le chiamate API. Con queste misurazioni accurate, è possibile eseguire l'attività di budget della CPU per comprendere determinati scenari di prestazioni.

Controllo delle ottimizzazioni di runtime e driver

Con una tecnica di misurazione identificata e una strategia per ridurre il lavoro della GPU, il passaggio successivo consiste nel comprendere le ottimizzazioni di runtime e driver che si ottengono nel modo in cui si esegue la profilatura.

Il lavoro della CPU può essere suddiviso in tre bucket: il lavoro dell'applicazione, il lavoro di runtime e il lavoro del driver. Ignorare il lavoro dell'applicazione perché si trova sotto il controllo programmatore. Dal punto di vista dell'applicazione, il runtime e il driver sono come caselle nere, in quanto l'applicazione non ha alcun controllo su ciò che viene implementato in essi. La chiave è comprendere le tecniche di ottimizzazione che possono essere implementate nel runtime e nel driver. Se non si conoscono queste ottimizzazioni, è molto facile passare alla conclusione errata della quantità di lavoro eseguita dalla CPU in base alle misurazioni del profilo. In particolare, esistono due argomenti correlati a un elemento denominato buffer dei comandi e alle operazioni che può eseguire per offuscare la profilatura. tra cui:

  • Ottimizzazione del runtime con il buffer dei comandi. Il buffer dei comandi è un'ottimizzazione di runtime che riduce l'impatto di una transizione in modalità. Per controllare l'intervallo della transizione in modalità, vedere Controllo del buffer dei comandi.
  • Negazione degli effetti di intervallo del buffer dei comandi. Il tempo trascorso di una transizione in modalità può avere un impatto significativo sulle misurazioni di profilatura. La strategia per eseguire questa operazione consiste nel rendere grande la sequenza di rendering rispetto alla transizione in modalità.

Controllo del buffer dei comandi

Quando un'applicazione effettua una chiamata API, il runtime converte la chiamata API in un formato indipendente dal dispositivo (che verrà chiamato un comando) e lo archivia nel buffer dei comandi. Il buffer dei comandi viene aggiunto al diagramma seguente.

diagram of cpu components, including a command buffer

Ogni volta che l'applicazione effettua un'altra chiamata API, il runtime ripete questa sequenza e aggiunge un altro comando al buffer dei comandi. A un certo punto, il runtime svuota il buffer (inviando i comandi al driver). In Windows XP, l'svuotamento del buffer dei comandi comporta una transizione in modalità quando il sistema operativo passa dal runtime (in esecuzione in modalità utente) al driver (in esecuzione in modalità kernel), come illustrato nel diagramma seguente.

  • modalità utente: modalità processore senza privilegi che esegue il codice dell'applicazione. Le applicazioni in modalità utente non possono accedere ai dati di sistema, ad eccezione dei servizi di sistema.
  • modalità kernel: modalità processore con privilegi in cui viene eseguito il codice esecutivo basato su Windows. Un driver o un thread in esecuzione in modalità kernel ha accesso a tutta la memoria di sistema, l'accesso diretto all'hardware e le istruzioni della CPU per eseguire operazioni di I/O con l'hardware.

diagram of transitions between user mode and kernel mode

La transizione avviene ogni volta che la CPU passa dalla modalità utente alla modalità kernel (e viceversa) e il numero di cicli necessari è elevato rispetto a una singola chiamata API. Se il runtime ha inviato ogni chiamata API al driver quando è stato richiamato, ogni chiamata API comporta il costo di una transizione in modalità.

Il buffer dei comandi è invece un'ottimizzazione del runtime progettata per ridurre il costo effettivo della transizione in modalità. Il buffer dei comandi accoda molti comandi driver in preparazione di una singola transizione in modalità. Quando il runtime aggiunge un comando al buffer dei comandi, il controllo viene restituito all'applicazione. Un profiler non ha modo di sapere che i comandi del driver probabilmente non sono stati ancora inviati al driver. Di conseguenza, i numeri restituiti da un profiler di strumentazione off-the-shelf sono fuorvianti perché misura il lavoro di runtime ma non il lavoro del driver associato.

Risultati del profilo senza transizione in modalità

Usando la sequenza di rendering dell'esempio 2, ecco alcune misurazioni di intervallo tipiche che illustrano la grandezza di una transizione in modalità. Supponendo che le chiamate SetTexture e DrawPrimitive non causino una transizione in modalità, un profiler di strumentazione off-the-shelf potrebbe restituire risultati simili ai seguenti:

Number of cycles for SetTexture           : 100
Number of cycles for DrawPrimitive        : 900

Ognuno di questi numeri è la quantità di tempo necessaria per il runtime per aggiungere queste chiamate al buffer dei comandi. Poiché non esiste alcuna transizione in modalità, il driver non ha ancora eseguito alcun lavoro. I risultati del profiler sono accurati, ma non misurano tutte le operazioni che la sequenza di rendering causerà l'esecuzione della CPU.

Risultati del profilo con una transizione in modalità

Esaminare ora cosa accade per lo stesso esempio quando si verifica una transizione in modalità. Questa volta, si supponga che SetTexture e DrawPrimitive causi una transizione in modalità. Ancora una volta, un profiler di strumentazione off-the-shelf potrebbe restituire risultati simili ai seguenti:

Number of cycles for SetTexture           : 98 
Number of cycles for DrawPrimitive        : 946,900

Il tempo misurato per SetTexture è lo stesso, tuttavia, l'aumento significativo della quantità di tempo impiegato in DrawPrimitive è dovuto alla transizione in modalità. Ecco cosa accade:

  1. Si supponga che il buffer dei comandi abbia spazio per un comando prima dell'avvio della sequenza di rendering.
  2. SetTexture viene convertito in un formato indipendente dal dispositivo e aggiunto al buffer dei comandi. In questo scenario, questa chiamata riempie il buffer dei comandi.
  3. Il runtime tenta di aggiungere DrawPrimitive al buffer dei comandi, ma non può, perché è pieno. Il runtime svuota invece il buffer dei comandi. Ciò causa la transizione in modalità kernel. Si supponga che la transizione abbia circa 5000 cicli. Questo tempo contribuisce al tempo trascorso in DrawPrimitive.
  4. Il driver elabora quindi il lavoro associato a tutti i comandi svuotati dal buffer dei comandi. Si supponga che il tempo del driver per elaborare i comandi che quasi riempito il buffer dei comandi sia di circa 935.000 cicli. Si supponga che il lavoro del driver associato a SetTexture sia di circa 2750 cicli. Questo tempo contribuisce al tempo trascorso in DrawPrimitive.
  5. Al termine del funzionamento del driver, la transizione in modalità utente restituisce il controllo al runtime. Il buffer dei comandi è ora vuoto. Si supponga che la transizione abbia circa 5000 cicli.
  6. La sequenza di rendering termina convertendo DrawPrimitive e aggiungendolo al buffer dei comandi. Si supponga che questo richiede circa 900 cicli. Questo tempo contribuisce al tempo trascorso in DrawPrimitive.

Riepilogando i risultati, viene visualizzato:

DrawPrimitive = kernel-transition + driver work    + user-transition + runtime work
DrawPrimitive = 5000              + 935,000 + 2750 + 5000            + 900
DrawPrimitive = 947,950  

Analogamente alla misura per DrawPrimitive senza la transizione in modalità (900 cicli), la misurazione per DrawPrimitive con la transizione in modalità (947.950 cicli) è accurata ma inutile in termini di lavoro della CPU di budget. Il risultato contiene il funzionamento corretto del runtime, il driver funziona per SetTexture, il driver funziona per tutti i comandi che hanno preceduto SetTexture e due transizioni in modalità. Tuttavia, la misurazione manca il lavoro del driver DrawPrimitive .

Una transizione in modalità può verificarsi in risposta a qualsiasi chiamata. Dipende da ciò che era in precedenza nel buffer dei comandi. È necessario controllare la transizione in modalità per comprendere la quantità di lavoro della CPU (runtime e driver) associata a ogni chiamata. A tale scopo, è necessario un meccanismo per controllare il buffer dei comandi e la tempistica della transizione in modalità.

Meccanismo di query

Il meccanismo di query in Microsoft Direct3D 9 è stato progettato per consentire al runtime di eseguire query sulla GPU per lo stato di avanzamento e restituire determinati dati dalla GPU. Durante la profilatura, se il lavoro della GPU viene ridotto al minimo in modo che abbia un impatto trascurabile sulle prestazioni, è possibile restituire lo stato dalla GPU per misurare il lavoro del driver. Dopo tutto, il lavoro del driver viene completato quando la GPU ha visto i comandi del driver. Inoltre, il meccanismo di query può essere coaxed per controllare due caratteristiche del buffer dei comandi importanti per la profilatura: quando il buffer dei comandi svuota e il numero di operazioni nel buffer.

Ecco la stessa sequenza di rendering usando il meccanismo di query:

// 1. Create an event query from the current device
IDirect3DQuery9* pEvent;
m_pD3DDevice->CreateQuery(D3DQUERYTYPE_EVENT, &pEvent);

// 2. Add an end marker to the command buffer queue.
pEvent->Issue(D3DISSUE_END);

// 3. Empty the command buffer and wait until the GPU is idle.
while(S_FALSE == pEvent->GetData( NULL, 0, D3DGETDATA_FLUSH ))
    ;

// 4. Start profiling
LARGE_INTEGER start, stop;
QueryPerformanceCounter(&start);

// 5. Invoke the API calls to be profiled.
SetTexture(...);
DrawPrimitive(D3DPT_TRIANGLELIST, 0, 1);

// 6. Add an end marker to the command buffer queue.
pEvent->Issue(D3DISSUE_END);

// 7. Force the driver to execute the commands from the command buffer.
// Empty the command buffer and wait until the GPU is idle.
while(S_FALSE == pEvent->GetData( NULL, 0, D3DGETDATA_FLUSH ))
    ;
    
// 8. End profiling
QueryPerformanceCounter(&stop);

Esempio 3: Uso di una query per controllare il buffer dei comandi

Ecco una spiegazione più dettagliata di ognuna di queste righe di codice:

  1. Creare una query evento creando un oggetto query con D3DQUERYTYPE_EVENT.
  2. Aggiungere un indicatore di evento di query al buffer dei comandi chiamando Issue(D3DISSUE_END). Questo marcatore indica al driver di tenere traccia quando la GPU termina l'esecuzione di qualsiasi comando ha preceduto il marcatore.
  3. La prima chiamata svuota il buffer dei comandi perché chiama GetData con D3DGETDATA_FLUSH forza che il buffer dei comandi venga svuotato. Ogni chiamata successiva controlla la GPU per verificare al termine dell'elaborazione di tutto il lavoro del buffer dei comandi. Questo ciclo non restituisce S_OK finché la GPU non è inattiva.
  4. Esempio dell'ora di inizio.
  5. Richiamare le chiamate API in fase di profilatura.
  6. Aggiungere un secondo indicatore di evento di query al buffer dei comandi. Questo marcatore verrà usato per tenere traccia del completamento delle chiamate.
  7. La prima chiamata svuota il buffer dei comandi perché chiama GetData con D3DGETDATA_FLUSH forza che il buffer dei comandi venga svuotato. Al termine dell'elaborazione di tutto il lavoro del buffer dei comandi, GetData restituisce S_OK e il ciclo viene chiuso perché la GPU è inattiva.
  8. Esempio dell'ora di arresto.

Ecco i risultati misurati con QueryPerformanceCounter e QueryPerformanceFrequency:

Variabile locale Numero di tick
Avvio 1792998845060
stop 1792998845090
Freq 3579545

 

Conversione di tick in cicli ancora una volta (in una macchina a 2 GHz):

# ticks  = (stop - start) = 1792998845090 - 1792998845060 = 30 ticks
# cycles = CPU speed * number of ticks / QPF
# 16,450 = 2 GHz      * 30             / 3,579,545

Ecco la suddivisione del numero di cicli per chiamata:

Number of cycles for SetTexture           : 100
Number of cycles for DrawPrimitive        : 900
Number of cycles for Issue                : 200
Number of cycles for GetData              : 16,450

Il meccanismo di query ha consentito di controllare il runtime e il lavoro del driver misurato. Per comprendere ognuno di questi numeri, ecco cosa accade in risposta a ognuna delle chiamate API, insieme ai tempi stimati:

  1. La prima chiamata svuota il buffer dei comandi chiamando GetData con D3DGETDATA_FLUSH. Al termine dell'elaborazione di tutto il lavoro del buffer dei comandi, GetData restituisce S_OK e il ciclo viene chiuso perché la GPU è inattiva.

  2. La sequenza di rendering inizia convertendo SetTexture in un formato indipendente dal dispositivo e aggiungendolo al buffer dei comandi. Si supponga che questo richiede circa 100 cicli.

  3. DrawPrimitive viene convertito e aggiunto al buffer dei comandi. Si supponga che questo richiede circa 900 cicli.

  4. Il problema aggiunge un marcatore di query al buffer dei comandi. Si supponga che questo richiede circa 200 cicli.

  5. GetData causa il svuotamento del buffer dei comandi che forza la transizione in modalità kernel. Si supponga che questo richiede circa 5000 cicli.

  6. Il driver elabora quindi il lavoro associato a tutte e quattro le chiamate. Si supponga che il tempo del driver per elaborare SetTexture sia circa 2964 cicli, DrawPrimitive è di circa 3600 cicli, Problema è di circa 200 cicli. Quindi il tempo totale del driver per tutti e quattro i comandi è circa 6450 cicli.

    Nota

    Il driver richiede anche un po 'di tempo per vedere lo stato della GPU. Poiché il lavoro della GPU è semplice, la GPU deve essere già eseguita. GetData restituirà S_OK in base alla probabilità che la GPU venga completata.

     

  7. Al termine del funzionamento del driver, la transizione in modalità utente restituisce il controllo al runtime. Il buffer dei comandi è ora vuoto. Si supponga che questo richiede circa 5000 cicli.

I numeri per GetData includono:

GetData = kernel-transition + driver work + user-transition
GetData = 5000              + 6450        + 5000           
GetData = 16,450  

driver work = SetTexture + DrawPrimitive + Issue = 
driver work = 2964       + 3600          + 200   = 6450 cycles 

Il meccanismo di query usato in combinazione con QueryPerformanceCounter misura tutto il lavoro della CPU. Questa operazione viene eseguita con una combinazione di indicatori di query e confronti sullo stato delle query. Gli indicatori di query di avvio e arresto aggiunti al buffer dei comandi vengono usati per controllare la quantità di lavoro nel buffer. Aspettando che il codice restituito corretto venga restituito, la misurazione iniziale viene eseguita appena prima dell'avvio di una sequenza di rendering pulita e la misurazione dell'arresto viene eseguita subito dopo che il driver ha completato il lavoro associato al contenuto del buffer dei comandi. Ciò acquisisce in modo efficace il lavoro della CPU eseguito dal runtime e dal driver.

Ora che si conosce il buffer dei comandi e l'effetto che può avere sulla profilatura, è necessario sapere che sono presenti alcune altre condizioni che possono causare l'svuotamento del runtime del buffer dei comandi. È necessario prestare attenzione a questi elementi nelle sequenze di rendering. Alcune di queste condizioni sono in risposta alle chiamate API, altre sono in risposta alle modifiche delle risorse nel runtime. Una delle condizioni seguenti causerà una transizione in modalità:

  • Quando uno dei metodi di blocco (Blocco) viene chiamato su un buffer di vertici, un buffer di indice o una trama (in determinate condizioni con determinati flag).
  • Quando viene creato un buffer del dispositivo o del vertice, del buffer di indice o della trama.
  • Quando un buffer del dispositivo o del vertice, del buffer di indice o della trama viene distrutto dall'ultima versione.
  • Quando Viene chiamato ValidateDevice .
  • Quando viene chiamato Presente .
  • Quando il buffer dei comandi si riempie.
  • Quando GetData viene chiamato con D3DGETDATA_FLUSH.

Prestare attenzione a controllare queste condizioni nelle sequenze di rendering. Ogni volta che viene aggiunta una transizione in modalità, verranno aggiunti 10.000 cicli di lavoro del driver alle misurazioni di profilatura. Inoltre, il buffer dei comandi non viene ridimensionato staticamente. Il runtime può modificare le dimensioni del buffer in risposta alla quantità di lavoro generata dall'applicazione. Si tratta di un'altra ottimizzazione dipendente da una sequenza di rendering.

Prestare quindi attenzione alle transizioni della modalità di controllo durante la profilatura. Il meccanismo di query offre un metodo affidabile per svuotare il buffer dei comandi in modo che sia possibile controllare la tempistica della transizione in modalità e la quantità di lavoro contenuta nel buffer. Tuttavia, anche questa tecnica può essere migliorata riducendo il tempo di transizione della modalità per renderlo insignificante rispetto al risultato misurato.

Rendere grande la sequenza di rendering rispetto alla transizione in modalità

Nell'esempio precedente, l'opzione in modalità kernel e il commutatore in modalità utente utilizzano circa 10.000 cicli che non hanno nulla a che fare con il runtime e il funzionamento del driver. Poiché la transizione della modalità è incorporata nel sistema operativo, non può essere ridotta a zero. Per rendere la transizione in modalità insignificante, la sequenza di rendering deve essere modificata in modo che il driver e il lavoro in fase di esecuzione siano un ordine di grandezza maggiore rispetto ai commutatori della modalità. È possibile provare a eseguire una sottrazione per rimuovere le transizioni, ma ammortizzando il costo di una sequenza di rendering molto più grande è più affidabile.

La strategia per ridurre la transizione della modalità fino a quando non diventa insignificante è aggiungere un ciclo alla sequenza di rendering. Ad esempio, esaminare i risultati della profilatura se viene aggiunto un ciclo che ripeterà la sequenza di rendering 1500 volte:

// Initialize the array with two textures, same size, same format
IDirect3DTexture* texArray[2];

CreateQuery(D3DQUERYTYPE_EVENT, pEvent);
pEvent->Issue(D3DISSUE_END);
while(S_FALSE == pEvent->GetData( NULL, 0, D3DGETDATA_FLUSH ))
    ;

LARGE_INTEGER start, stop;
// Now start counting because the video card is ready
QueryPerformanceCounter(&start);

// Add a loop to the render sequence 
for(int i = 0; i < 1500; i++)
{
  SetTexture(taxArray[i%2]);
  DrawPrimitive(D3DPT_TRIANGLELIST, i*3, 1);
}

pEvent->Issue(D3DISSUE_END);

while(S_FALSE == pEvent->GetData( NULL, 0, D3DGETDATA_FLUSH ))
    ;
QueryPerformanceCounter(&stop);

Esempio 4: Aggiungere un Loop alla sequenza di rendering

Ecco i risultati misurati con QueryPerformanceCounter e QueryPerformanceFrequency:

Variabile locale Numero di Tic
Avvio 1792998845000
stop 1792998847084
Freq 3579545

 

L'uso di QueryPerformanceCounter misura ora 2.840 tick. La conversione dei segni di graduazione in cicli è identica a quella già illustrata:

# ticks  = (stop - start) = 1792998847084 - 1792998845000 = 2840 ticks
# cycles    = machine speed * number of ticks / QPF
# 6,900,000 = 2 GHz          * 2840           / 3,579,545

In altre parole, sono necessari circa 6,9 milioni di cicli in questo computer a 2 GHz per elaborare le 1500 chiamate nel ciclo di rendering. Dei 6,9 milioni di cicli, la quantità di tempo nelle transizioni in modalità è di circa 10.000, quindi ora i risultati del profilo misurano quasi interamente il lavoro associato a SetTexture e DrawPrimitive.

Si noti che l'esempio di codice richiede una matrice di due trame. Per evitare un'ottimizzazione di runtime che rimuove SetTexture se imposta lo stesso puntatore alla trama ogni volta che viene chiamato, usa semplicemente una matrice di due trame. In questo modo, ogni volta che si passa attraverso il ciclo, il puntatore alla trama cambia e viene eseguito il lavoro completo associato a SetTexture . Assicurarsi che entrambe le trame siano le stesse dimensioni e formato, in modo che nessun altro stato cambierà quando la trama funziona.

Ora è disponibile una tecnica per la profilatura di Direct3D. Si basa sul contatore delle prestazioni elevate (QueryPerformanceCounter) per registrare il numero di tick necessari alla CPU per elaborare il lavoro. Il lavoro viene controllato attentamente in modo da essere il runtime e il lavoro del driver associati alle chiamate API usando il meccanismo di query. Una query fornisce due mezzi di controllo: prima di svuotare il buffer dei comandi prima dell'avvio della sequenza di rendering e in secondo luogo per restituire al termine del lavoro della GPU.

Finora, questo documento ha illustrato come profilare una sequenza di rendering. Ogni sequenza di rendering è stata abbastanza semplice, contenente una singola chiamata DrawPrimitive e una chiamata SetTexture . Questa operazione è stata eseguita per concentrarsi sul buffer dei comandi e sull'uso del meccanismo di query per controllarlo. Ecco un breve riepilogo di come profilare una sequenza di rendering arbitraria:

  • Usare un contatore ad alte prestazioni, ad esempio QueryPerformanceCounter, per misurare il tempo necessario per elaborare ogni chiamata API. Usare QueryPerformanceFrequency e la frequenza di clock della CPU per convertirla nel numero di cicli della CPU per ogni chiamata API.
  • Ridurre al minimo la quantità di lavoro della GPU eseguendo il rendering di elenchi di triangoli, in cui ogni triangolo contiene un pixel.
  • Usare il meccanismo di query per svuotare il buffer dei comandi prima della sequenza di rendering. Ciò garantisce che la profilatura acquisisca la quantità corretta di runtime e driver associata alla sequenza di rendering.
  • Controllare la quantità di lavoro aggiunta al buffer dei comandi con marcatori di evento di query. Questa stessa query rileva quando la GPU termina il lavoro. Poiché il lavoro della GPU è semplice, ciò equivale praticamente alla misurazione del completamento del lavoro del driver.

Tutte queste tecniche vengono usate per profilare le modifiche dello stato. Supponendo di aver letto e compreso come controllare il buffer dei comandi e aver completato correttamente le misurazioni di base in DrawPrimitive, è possibile aggiungere modifiche dello stato alle sequenze di rendering. Esistono alcuni problemi di profilatura aggiuntivi quando si aggiungono modifiche di stato a una sequenza di rendering. Se si intende aggiungere modifiche dello stato alle sequenze di rendering, assicurarsi di continuare nella sezione successiva.

Profilatura delle modifiche dello stato Direct3D

Direct3D usa molti stati di rendering per controllare quasi tutti gli aspetti della pipeline. Le API che causano modifiche dello stato includono qualsiasi funzione o metodo diverso dalle chiamate Draw*Primitive.

Le modifiche dello stato sono difficili perché potrebbe non essere possibile visualizzare il costo di una modifica dello stato senza il rendering. Si tratta di un risultato dell'algoritmo lazy usato dal driver e dalla GPU per rinviare il lavoro fino a quando non deve essere assolutamente fatto. In generale, è necessario seguire questa procedura per misurare una singola modifica dello stato:

  1. Profilo DrawPrimitive per primo.
  2. Aggiungere una modifica dello stato alla sequenza di rendering e profilare la nuova sequenza.
  3. Sottrarre la differenza tra le due sequenze per ottenere il costo della modifica dello stato.

Naturalmente, tutto ciò che si è appreso sull'uso del meccanismo di query e l'inserimento della sequenza di rendering in un ciclo per negare il costo della transizione in modalità è ancora applicabile.

Profilatura di una modifica dello stato semplice

A partire da una sequenza di rendering che contiene DrawPrimitive, ecco la sequenza di codice per misurare il costo dell'aggiunta di SetTexture:

// Get the start counter value as shown in Example 4 

// Initialize a texture array as shown in Example 4
IDirect3DTexture* texArray[2];

// Render sequence loop 
for(int i = 0; i < 1500; i++)
{
  SetTexture(0, texArray[i%2];
  
  // Force the state change to propagate to the GPU
  DrawPrimitive(D3DPT_TRIANGLELIST, i*3, 1);
}

// Get the stop counter value as shown in Example 4 

Esempio 5: Misurazione di una chiamata API di modifica dello stato

Si noti che il ciclo contiene due chiamate, SetTexture e DrawPrimitive. La sequenza di rendering esegue un ciclo 1500 volte e genera risultati simili ai seguenti:

Variabile locale Numero di Tic
Avvio 1792998860000
stop 1792998870260
Freq 3579545

 

La conversione dei segni di graduazione in cicli restituisce di nuovo:

# ticks  = (stop - start) = 1792998870260 - 1792998860000 = 10,260 ticks
# cycles    = machine speed * number of ticks / QPF
5,775,000   = 2 GHz          * 10,260         / 3,579,545

Dividendo per il numero di iterazioni nel ciclo restituisce:

5,775,000 cycles / 1500 iterations = 3850 cycles for one iteration

Ogni iterazione del ciclo contiene una modifica dello stato e una chiamata di disegno. Sottraendo il risultato della sequenza di rendering DrawPrimitive :

3850 - 1100 = 2750 cycles for SetTexture

Questo è il numero medio di cicli da aggiungere SetTexture a questa sequenza di rendering. Questa stessa tecnica può essere applicata ad altre modifiche di stato.

Perché SetTexture viene chiamato modifica di stato semplice? Poiché lo stato impostato è vincolato in modo che la pipeline ese la stessa quantità di lavoro ogni volta che lo stato viene modificato. Vincolare entrambe le trame alla stessa dimensione e allo stesso formato garantisce la stessa quantità di lavoro per ogni chiamata SetTexture .

Profilatura di una modifica dello stato che deve essere attivata o disattivata

Esistono altre modifiche allo stato che causano la modifica della quantità di lavoro eseguita dalla pipeline grafica per ogni iterazione del ciclo di rendering. Ad esempio, se z-testing è abilitato, ogni colore pixel aggiorna una destinazione di rendering solo dopo che il valore z del nuovo pixel viene testato rispetto al valore z per il pixel esistente. Se z-testing è disabilitato, questo test per pixel non viene eseguito e l'output viene scritto molto più velocemente. L'abilitazione o la disabilitazione dello stato z-test modifica notevolmente la quantità di lavoro svolto (dalla CPU e dalla GPU) durante il rendering.

SetRenderState richiede uno stato di rendering specifico e un valore di stato per abilitare o disabilitare z-testing. Il valore di stato specifico viene valutato in fase di esecuzione per determinare la quantità di lavoro necessaria. È difficile misurare questa modifica dello stato in un ciclo di rendering e precondire comunque lo stato della pipeline in modo che cambi. L'unica soluzione consiste nell'attivare o disattivare la modifica dello stato durante la sequenza di rendering.

Ad esempio, la tecnica di profilatura deve essere ripetuta due volte come indicato di seguito:

  1. Iniziare profilando la sequenza di rendering DrawPrimitive . Chiamare questa linea di base.
  2. Profilare una seconda sequenza di rendering che attiva o disattiva la modifica dello stato. Il ciclo di sequenza di rendering contiene:
    • Modifica dello stato per impostare lo stato in una condizione "false".
    • DrawPrimitive esattamente come la sequenza originale.
    • Modifica dello stato per impostare lo stato in una condizione "true".
    • Secondo DrawPrimitive per forzare la modifica del secondo stato per essere realizzata.
  3. Trovare la differenza tra le due sequenze di rendering. A tale scopo, è necessario:
    • Moltiplicare la sequenza DrawPrimitive prevista per 2 perché nella nuova sequenza sono presenti due chiamate DrawPrimitive .
    • Sottrae il risultato della nuova sequenza dalla sequenza originale.
    • Dividere il risultato per 2 per ottenere il costo medio di "false" e la modifica dello stato "true".

Con la tecnica di ciclo usata nella sequenza di rendering, il costo della modifica dello stato della pipeline deve essere misurato attivando o disattivando lo stato da una condizione "true" a una condizione "false" e viceversa, per ogni iterazione nella sequenza di rendering. Il significato di "true" e "false" non sono letterali, significa semplicemente che lo stato deve essere impostato in condizioni opposte. In questo modo entrambe le modifiche dello stato vengono misurate durante la profilatura. Naturalmente tutto ciò che si è appreso sull'uso del meccanismo di query e l'inserimento della sequenza di rendering in un ciclo per negare il costo della transizione in modalità è ancora applicabile.

Ecco ad esempio la sequenza di codice per misurare il costo dell'attivazione o disattivazione del test z:

// Get the start counter value as shown in Example 4 

// Add a loop to the render sequence 
for(int i = 0; i < 1500; i++)
{
  // Precondition the pipeline state to the "false" condition
  SetRenderState(D3DRS_ZENABLE, FALSE);
  
  // Force the state change to propagate to the GPU
  DrawPrimitive(D3DPT_TRIANGLELIST, (2*i + 0)*3, 1);

  // Set the pipeline state to the "true" condition
  SetRenderState(D3DRS_ZENABLE, TRUE);

  // Force the state change to propagate to the GPU
  DrawPrimitive(D3DPT_TRIANGLELIST, (2*i + 1)*3, 1); 
}

// Get the stop counter value as shown in Example 4 

Esempio 5: Misurazione di una modifica dello stato di attivazione/disattivazione

Il ciclo attiva o disattiva lo stato eseguendo due chiamate SetRenderState . La prima chiamata SetRenderState disabilita z-testing e la seconda SetRenderState abilita z-testing. Ogni SetRenderState è seguito da DrawPrimitive in modo che il lavoro associato alla modifica dello stato venga elaborato dal driver anziché solo impostando un bit dirty nel driver.

Questi numeri sono ragionevoli per questa sequenza di rendering:

Variabile locale Numero di tick
Avvio 1792998845000
stop 1792998861740
Freq 3579545

 

La conversione dei segni di graduazione in cicli restituisce di nuovo:

# ticks  = (stop - start) = 1792998861740 - 1792998845000 = 15,120 ticks
# cycles    = machine speed * number of ticks / QPF
 9,300,000  = 2 GHz          * 16,740         / 3,579,545

Dividendo per il numero di iterazioni nel ciclo restituisce:

9,300,000 cycles / 1500 iterations = 6200 cycles for one iteration

Ogni iterazione del ciclo contiene due modifiche di stato e due chiamate di disegno. Sottraendo le chiamate di disegno (presupponendo 1100 cicli) lascia:

6200 - 1100 - 1100 = 4000 cycles for both state changes

Questo è il numero medio di cicli per entrambe le modifiche dello stato, quindi il tempo medio per ogni modifica dello stato è:

4000 / 2  = 2000 cycles for each state change

Di conseguenza, il numero medio di cicli da abilitare o disabilitare z-testing è di 2000 cicli. Vale la pena notare che QueryPerformanceCounter sta misurando z-enable metà del tempo e z-disable metà del tempo. Questa tecnica misura effettivamente la media di entrambe le variazioni di stato. In altre parole, si misura il tempo per attivare o disattivare uno stato. Usando questa tecnica, non è possibile sapere se i tempi di abilitazione e disabilitazione sono equivalenti perché è stata misurata la media di entrambi. Tuttavia, si tratta di un numero ragionevole da usare quando si effettua il budget di uno stato di attivazione/disattivazione come applicazione che causa questa modifica dello stato può eseguire questa operazione solo attivando o disattivando questo stato.

Ora è possibile applicare queste tecniche e profilarvi tutte le modifiche dello stato desiderate, giusto? Risposta errata. È comunque necessario prestare attenzione alle ottimizzazioni progettate per ridurre la quantità di lavoro che deve essere eseguita. Esistono due tipi di ottimizzazioni da tenere presenti durante la progettazione delle sequenze di rendering.

Controllare le ottimizzazioni delle modifiche di stato

La sezione precedente illustra come profilare entrambi i tipi di modifiche dello stato: una semplice modifica dello stato vincolata a generare la stessa quantità di lavoro per ogni iterazione e una modifica dello stato di attivazione/disattivazione che modifica notevolmente la quantità di lavoro svolto. Cosa accade se si accetta la sequenza di rendering precedente e si aggiunge un'altra modifica dello stato? Ad esempio, questo esempio accetta la sequenza di rendering z-enable> e aggiunge un confronto z-func:

// Add a loop to the render sequence 
for(int i = 0; i < 1500; i++)
{
  // Precondition the pipeline state to the opposite condition
  SetRenderState(D3DRS_ZFUNC, D3DCMP_NEVER);

  // Precondition the pipeline state to the opposite condition
  SetRenderState(D3DRS_ZENABLE, FALSE);
  
  // Force the state change to propagate to the GPU
  DrawPrimitive(D3DPT_TRIANGLELIST, (2*i + 0)*3, 1);

  // Now set the state change you want to measure
  SetRenderState(D3DRS_ZFUNC, D3DCMP_ALWAYS);

  // Now set the state change you want to measure
  SetRenderState(D3DRS_ZENABLE, TRUE);

  // Force the state change to propagate to the GPU
  DrawPrimitive(D3DPT_TRIANGLELIST, (2*i + 1)*3, 1); 
}

Lo stato z-func imposta il livello di confronto durante la scrittura nel buffer z (tra il valore z di un pixel corrente con il valore z di un pixel nel buffer di profondità). D3DCMP_NEVER disattiva il confronto z-testing mentre D3DCMP_ALWAYS imposta l'esecuzione del confronto ogni volta che viene eseguito z-testing.

La profilatura di una di queste modifiche di stato in una sequenza di rendering con DrawPrimitive genera risultati simili ai seguenti:

Modifica stato singolo Numero medio di cicli
solo D3DRS_ZENABLE 2000

 

oppure

Modifica stato singolo Numero medio di cicli
solo D3DRS_ZFUNC 600

 

Tuttavia, se si profila sia D3DRS_ZENABLE che D3DRS_ZFUNC nella stessa sequenza di rendering, è possibile visualizzare risultati simili ai seguenti:

Entrambe le modifiche dello stato Numero medio di cicli
D3DRS_ZENABLE + D3DRS_ZFUNC 2000

 

È possibile prevedere che il risultato sia la somma di 2000 e 600 cicli (o 2600) perché il driver esegue tutte le operazioni associate all'impostazione di entrambi gli stati di rendering. La media è invece di 2000 cicli.

Questo risultato riflette un'ottimizzazione della modifica dello stato implementata nel runtime, nel driver o nella GPU. In questo caso, il driver potrebbe visualizzare il primo SetRenderState e impostare uno stato dirty che posticiperebbe il lavoro fino a un secondo momento. Quando il driver vede il secondo SetRenderState, lo stesso stato dirty potrebbe essere impostato in modo ridondante e lo stesso lavoro verrebbe posticipato ancora una volta. Quando viene chiamato DrawPrimitive , il lavoro associato allo stato dirty viene infine elaborato. Il driver esegue il lavoro una sola volta, il che significa che le prime due modifiche dello stato vengono effettivamente consolidate dal driver. Analogamente, le modifiche di terzo e quarto stato vengono effettivamente consolidate dal driver in un'unica modifica di stato quando viene chiamato il secondo DrawPrimitive . Il risultato netto è che il driver e la GPU elaborano una singola modifica dello stato per ogni chiamata di disegno.

Questo è un buon esempio di ottimizzazione del driver dipendente dalla sequenza. Il driver ha posticipato il lavoro due volte impostando uno stato dirty e quindi eseguito il lavoro una volta per cancellare lo stato dirty. Questo è un buon esempio del tipo di miglioramento dell'efficienza che può avvenire quando il lavoro viene posticipato fino a quando non è assolutamente necessario.

Come è possibile sapere quali modifiche di stato impostano uno stato dirty internamente e quindi posticipare il lavoro fino a un secondo momento? Solo testando le sequenze di rendering (o parlando con i writer di driver). I driver vengono aggiornati e migliorati periodicamente in modo che l'elenco delle ottimizzazioni non sia statico. C'è solo un modo per sapere assolutamente quali costi di modifica dello stato in una determinata sequenza di rendering, su un particolare set di hardware; e questo è per misurarlo.

Attenzione per le ottimizzazioni DrawPrimitive

Oltre alle ottimizzazioni delle modifiche dello stato, il runtime tenterà di ottimizzare il numero di chiamate di disegno che il driver deve elaborare. Si consideri, ad esempio, di nuovo queste chiamate di disegno:

DrawPrimitive(D3DPT_TRIANGLELIST, 0, 3); // Draw 3 primitives, vertices 0 - 8
DrawPrimitive(D3DPT_TRIANGLELIST, 9, 4); // Draw 4 primitives, vertices 9 - 20

Esempio 5a: Due chiamate di disegno

Questa sequenza contiene due chiamate di disegno, che il runtime si consolida in una singola chiamata equivalente a:

DrawPrimitive(D3DPT_TRIANGLELIST, 0, 7); // Draw 7 primitives, vertices 0 - 20

Esempio 5b: una singola chiamata di disegno concatenata

Il runtime concatena entrambe queste chiamate di disegno specifiche in una singola chiamata, riducendo il lavoro del driver del 50% perché il driver dovrà ora elaborare solo una chiamata di disegno.

In generale, il runtime concatena due o più chiamate DrawPrimitive back-to-back quando:

  1. Il tipo primitivo è un elenco di triangoli (D3DPT_TRIANGLELIST).
  2. Ogni chiamata DrawPrimitive successiva deve fare riferimento a vertici consecutivi all'interno del vertex buffer.

Analogamente, le condizioni corrette per la concatenazione di due o più chiamate DrawIndexedPrimitive back-to-back sono:

  1. Il tipo primitivo è un elenco di triangoli (D3DPT_TRIANGLELIST).
  2. Ogni chiamata DrawIndexedPrimitive successiva deve fare riferimento a indici consecutivi consecutivi all'interno del buffer di indice.
  3. Ogni chiamata DrawIndexedPrimitive successiva deve usare lo stesso valore per BaseVertexIndex.

Per evitare la concatenazione durante la profilatura, modificare la sequenza di rendering in modo che il tipo primitivo non sia un elenco di triangoli o modificare la sequenza di rendering in modo che non siano presenti chiamate di disegno back-to-back che utilizzano vertici consecutivi (o indici). In particolare, il runtime concatena anche le chiamate di disegno che soddisfano entrambe le condizioni seguenti:

  • Quando la chiamata precedente è DrawPrimitive, se la chiamata di disegno successiva:
    • utilizza un elenco di triangoli, AND
    • specifica StartVertex = StartVertex precedente + PrimitiveCount precedente * 3
  • Quando si usa DrawIndexedPrimitive, se la chiamata di disegno successiva:
    • utilizza un elenco di triangoli, AND
    • specifica StartIndex = previous StartIndex + previous PrimitiveCount * 3, AND
    • specifica BaseVertexIndex = baseVertexIndex precedente

Ecco un esempio più sottile di concatenazione delle chiamate di disegno che è facile da ignorare quando si esegue la profilatura. Si supponga che la sequenza di rendering sia simile alla seguente:

  for(int i = 0; i < 1500; i++)
  {
    SetTexture(...);
    DrawPrimitive(D3DPT_TRIANGLELIST, i*3, 1);
  }

Esempio 5c: modifica di uno stato e una chiamata di disegno

Il ciclo scorre fino a 1500 triangoli, impostando una trama e disegnando ogni triangolo. Questo ciclo di rendering richiede circa 2750 cicli per SetTexture e 1100 cicli per DrawPrimitive , come illustrato nelle sezioni precedenti. Si potrebbe prevedere in modo intuitivo che lo spostamento di SetTexture all'esterno del ciclo di rendering debba ridurre la quantità di lavoro svolto dal driver di 1500 * 2750 cicli, ovvero la quantità di lavoro associata alla chiamata a SetTexture 1500 volte. Il frammento di codice sarà simile al seguente:

  SetTexture(...); // Set the state outside the loop
  for(int i = 0; i < 1500; i++)
  {
//    SetTexture(...);
    DrawPrimitive(D3DPT_TRIANGLELIST, i*3, 1);
  }

Esempio 5d: esempio 5c con la modifica dello stato all'esterno del Loop

Lo spostamento di SetTexture all'esterno del ciclo di rendering riduce la quantità di lavoro associata a SetTexture perché viene chiamata una sola volta anziché 1500 volte. Un effetto secondario meno ovvio è che il lavoro per DrawPrimitive è ridotto anche da 1500 chiamate a 1 chiamata perché tutte le condizioni per la concatenazione delle chiamate di disegno vengono soddisfatte. Quando viene elaborata la sequenza di rendering, il runtime elabora 1500 chiamate in una singola chiamata driver. Spostando questa riga di codice, la quantità di lavoro del driver è stata ridotta notevolmente:

total work done = runtime + driver work

Example 5c: with SetTexture in the loop:
runtime work = 1500 SetTextures + 1500 DrawPrimitives 
driver  work = 1500 SetTextures + 1500 DrawPrimitives 

Example 5d: with SetTexture outside of the loop:
runtime work = 1 SetTexture + 1 DrawPrimitive + 1499 Concatenated DrawPrimitives 
driver  work = 1 SetTexture + 1 DrawPrimitive 

Questi risultati sono completamente corretti, ma sono molto fuorvianti nel contesto della domanda originale. L'ottimizzazione delle chiamate di disegno ha causato la riduzione drastica della quantità di lavoro del driver. Si tratta di un problema comune durante l'esecuzione di profilatura personalizzata. Quando si eliminano le chiamate da una sequenza di rendering, prestare attenzione ad evitare di disegnare la concatenazione delle chiamate. Questo scenario è infatti un esempio potente della quantità di miglioramento delle prestazioni del driver possibili da questa ottimizzazione del runtime.

Ora si sa come misurare le modifiche dello stato. Iniziare profilando DrawPrimitive. Aggiungere quindi ogni modifica dello stato aggiuntivo alla sequenza (in alcuni casi aggiungendo una chiamata e in altri casi aggiungendo due chiamate) e misurare la differenza tra le due sequenze. È possibile convertire i risultati in tick o cicli o tempo. Analogamente alla misurazione delle sequenze di rendering con QueryPerformanceCounter, la misurazione delle singole modifiche dello stato si basa sul meccanismo di query per controllare il buffer dei comandi e inserire le modifiche dello stato in un ciclo per ridurre al minimo l'impatto delle transizioni in modalità. Questa tecnica misura il costo dell'attivazione di uno stato, poiché il profiler restituisce la media di abilitazione e disabilitazione dello stato.

Con questa funzionalità, è possibile iniziare a generare sequenze di rendering arbitrarie e misurare accuratamente il runtime e il lavoro del driver associati. I numeri possono quindi essere usati per rispondere alle domande di budgeting come "quante più di queste chiamate" possono essere effettuate nella sequenza di rendering mantenendo comunque una velocità di frame ragionevole, presupponendo scenari limitati dalla CPU.

Riepilogo

Questo documento illustra come controllare il buffer dei comandi in modo che le singole chiamate possano essere profilate in modo accurato. I numeri di profilatura possono essere generati in tick, cicli o tempo assoluto. Rappresentano la quantità di runtime e di lavoro del driver associati a ogni chiamata API.

Iniziare profilando una chiamata Draw*Primitive in una sequenza di rendering. Ricordare di:

  1. Usare QueryPerformanceCounter per misurare il numero di tick per chiamata API. Usare QueryPerformanceFrequency per convertire i risultati in cicli o tempo se si desidera.
  2. Usare il meccanismo di query per svuotare il buffer dei comandi prima dell'avvio.
  3. Includere la sequenza di rendering in un ciclo per ridurre al minimo l'impatto della transizione in modalità.
  4. Usare il meccanismo di query per misurare quando la GPU ha completato il suo lavoro.
  5. Attenzione alla concatenazione del runtime che avrà un impatto importante sulla quantità di lavoro eseguita.

Ciò offre prestazioni di base per DrawPrimitive che possono essere usate per la compilazione da. Per profilare una modifica dello stato, seguire questi suggerimenti aggiuntivi:

  1. Aggiungere la modifica dello stato a un profilo di sequenza di rendering noto della nuova sequenza. Poiché il test viene eseguito in un ciclo, è necessario impostare lo stato due volte in valori opposti, ad esempio abilitare e disabilitare per istanza.
  2. Confrontare la differenza nei tempi di ciclo tra le due sequenze.
  3. Per le modifiche dello stato che modificano significativamente la pipeline (ad esempio SetTexture), sottraggono la differenza tra le due sequenze per ottenere il tempo per la modifica dello stato.
  4. Per le modifiche dello stato che modificano significativamente la pipeline (e quindi richiedono l'attivazione di stati come SetRenderState), sottrae la differenza tra le sequenze di rendering e divide per 2. In questo modo verrà generato il numero medio di cicli per ogni modifica dello stato.

Ma prestare attenzione alle ottimizzazioni che causano risultati imprevisti durante la profilatura. Le ottimizzazioni delle modifiche dello stato possono impostare stati sporchi che causano il rinvio del lavoro. Ciò può causare risultati del profilo che non sono intuitivi come previsto. Disegnare chiamate concatenate ridurrà notevolmente il lavoro del driver che può portare a conclusioni fuorvianti. Le sequenze di rendering pianificate con attenzione vengono usate per impedire che si verifichino modifiche allo stato e disegnare le concatenazioni delle chiamate. Il trucco consiste nel impedire che le ottimizzazioni si verifichino durante la profilatura in modo che i numeri generati siano numeri di budget ragionevoli.

Nota

La duplicazione di questa strategia di profilatura in un'applicazione senza il meccanismo di query è più difficile. Prima di Direct3D 9 l'unico modo prevedibile per svuotare il buffer dei comandi consiste nel bloccare una superficie attiva (ad esempio una destinazione di rendering) per attendere fino a quando la GPU non è inattiva. Questo perché il blocco di una superficie forza il runtime a svuotare il buffer dei comandi nel caso in cui siano presenti comandi di rendering nel buffer che devono aggiornare la superficie prima che venga bloccata, oltre all'attesa del completamento della GPU. Questa tecnica è funzionale, anche se è più obtrusiva che usando il meccanismo di query introdotto in Direct3D 9.

 

Appendice

I numeri di questa tabella sono un intervallo di approssimazioni per la quantità di runtime e il lavoro del driver associati a ognuna di queste modifiche dello stato. Le approssimazioni si basano sulle misurazioni effettive effettuate sui driver usando le tecniche illustrate nel documento. Questi numeri sono stati generati usando il runtime Direct3D 9 e sono dipendenti dal driver.

Le tecniche di questo documento sono progettate per misurare il funzionamento del runtime e del driver. In generale, è impraticabile fornire risultati che corrispondono alle prestazioni della CPU e alla GPU in ogni applicazione, in quanto richiederebbe una matrice completa di sequenze di rendering. Inoltre, è particolarmente difficile eseguire il benchmark delle prestazioni della GPU perché dipende fortemente dalla configurazione dello stato nella pipeline prima della sequenza di rendering. Ad esempio, l'abilitazione della fusione alfa influisce poco sulla quantità di lavoro della CPU necessaria, ma può avere un grande impatto sulla quantità di lavoro eseguita dalla GPU. Pertanto, le tecniche di questo documento limitano il lavoro della GPU alla quantità minima possibile limitando la quantità di dati che devono essere sottoposti a rendering. Ciò significa che i numeri nella tabella corrispondono più strettamente ai risultati ottenuti dalle applicazioni limitate dalla CPU (anziché da un'applicazione limitata dalla GPU).

Si consiglia di usare le tecniche presentate per coprire gli scenari e le configurazioni più importanti per l'utente. I valori della tabella possono essere usati per confrontare con i numeri generati. Poiché ogni driver varia, l'unico modo per generare i numeri effettivi visualizzati consiste nel generare risultati di profilatura usando gli scenari.

Chiamata API Numero medio di cicli
SetVertexDeclaration 6500 - 11250
SetFVF 6400 - 11200
SetVertexShader 3000 - 12100
SetPixelShader 6300 - 7000
SPECULARENABLE 1900 - 11200
SetRenderTarget 6000 - 6250
SetPixelShaderConstant (1 costante) 1500 - 9000
NORMALNORMALS 2200 - 8100
LightEnable 1300 - 9000
SetStreamSource 3700 - 5800
ILLUMINAZIONE 1700 - 7500
DIFFUSEMATERIALSOURCE 900 - 8300
AMBIENTMATERIALSOURCE 900 - 8200
COLORVERTEX 800 - 7800
SetLight 2200 - 5100
SetTransform 3200 - 3750
SetIndices 900 - 5600
AMBIENTALE 1150 - 4800
SetTexture 2500 - 3100
SPECULARMATERIALSOURCE 900 - 4600
EMISSIVEMATERIALSOURCE 900 - 4500
SetMaterial 1000 - 3700
ZENABLE 700 - 3900
WRAPPING0 1600 - 2700
MINFILTER 1700 - 2500
MAGFILTER 1700 - 2400
SetVertexShaderConstant (1 costante) 1000 - 2700
COLOROP 1500 - 2100
COLORARG2 1300 - 2000
COLORARG1 1300 - 1980
CULLMODE 500 - 2570
RITAGLIO 500 - 2550
DrawIndexedPrimitive 1200 - 1400
ADDRESSV 1090 - 1500
ADDRESSU 1070 - 1500
DrawPrimitive 1050 - 1150
SRGBTEXTURE 150 - 1500
STENCILMASK 570 - 700
STENCILZFAIL 500 - 800
STENCILREF 550 - 700
ALFABLENDENABLE 550 - 700
STENCILFUNC 560 - 680
STENCILWRITEMASK 520 - 700
STENCILFAIL 500 - 750
ZFUNC 510 - 700
ZWRITEENABLE 520 - 680
STENCILENABLE 540 - 650
STENCILPASS 560 - 630
SRCBLEND 500 - 685
Two_Sided_StencilMODE 450 - 590
ALFATESTENABLE 470 - 525
ALFAREF 460 - 530
ALFAFUNC 450 - 540
DESTBLEND 475 - 510
COLORWRITEENABLE 465 - 515
CCW_STENCILFAIL 340 - 560
CCW_STENCILPASS 340 - 545
CCW_STENCILZFAIL 330 - 495
SCISSORTESTENABLE 375 - 440
CCW_STENCILFUNC 250 - 480
SetScissorRect 150 - 340

 

Argomenti avanzati