Nota
O acesso a esta página requer autorização. Podes tentar iniciar sessão ou mudar de diretório.
O acesso a esta página requer autorização. Podes tentar mudar de diretório.
- É difícil criar o perfil preciso do Direct3D
- Como criar um perfil preciso de uma sequência de renderização Direct3D
- Perfilação de mudanças de estado do Direct3D
- Resumo
- Apêndice
Depois de ter um aplicativo Microsoft Direct3D funcional e desejar melhorar seu desempenho, você geralmente usa uma ferramenta de criação de perfil pronta para uso ou alguma técnica de medição personalizada para medir o tempo necessário para executar uma ou mais chamadas de interface de programação de aplicativo (API). Se você fez isso, mas está obtendo resultados de tempo que variam de uma sequência de renderização para a próxima, ou está fazendo hipóteses que não se sustentam aos resultados reais do experimento, as informações a seguir podem ajudá-lo a entender o porquê.
As informações fornecidas aqui baseiam-se no pressuposto de que você tem conhecimento e experiência com o seguinte:
- Programação C/C++
- Programação da API Direct3D
- Medindo o tempo da API
- A placa de vídeo e seu driver de software
- Possíveis resultados inexplicáveis de experiências anteriores de criação de perfis
Criar perfis precisos do Direct3D é difícil
Um analisador de desempenho relata a quantidade de tempo gasto em cada chamada de API. Isto é feito para melhorar o desempenho ao identificar e ajustar áreas problemáticas. Existem diferentes tipos de profilers e técnicas de criação de perfis.
- Um perfilador de amostragem fica inativo a maior parte do tempo, acordando em intervalos específicos para amostrar ou registar as funções em execução. Ele retorna a porcentagem de tempo gasto em cada chamada. Geralmente, um perfilador de amostragem não é muito invasivo para a aplicação e tem um impacto mínimo na sobrecarga da aplicação.
- Um perfilador de instrumentação mede o tempo real que uma chamada leva a retornar. Requer a compilação de delimitadores de início e parada em um aplicativo. Um perfilador de instrumentação é comparativamente mais invasivo a uma aplicação do que um perfilador de amostragem.
- Também é possível usar uma técnica de criação de perfil personalizada com um temporizador de alto desempenho. Isso produz resultados muito parecidos com um perfilador de instrumentação.
O tipo de perfilador ou técnica de criação de perfil usada é apenas parte do desafio de gerar medições precisas.
O perfilamento dá-lhe respostas que o ajudam a gerir o desempenho. Por exemplo, suponha que você saiba que uma chamada de API tem em média mil ciclos de relógio para ser executada. Você pode afirmar algumas conclusões sobre o desempenho, como as seguintes:
- Uma CPU de 2 GHz (que gasta 50% do seu tempo renderizando) está limitada a chamar essa API 1 milhão de vezes por segundo.
- Para atingir 30 quadros por segundo, não é possível chamar essa API mais de 33.000 vezes por quadro.
- Você só pode renderizar 3,3 mil objetos por quadro (supondo 10 chamadas de API para a sequência de renderização de cada objeto).
Em outras palavras, se você tivesse tempo suficiente por chamada de API, poderia responder a uma pergunta de orçamento, como o número de primitivos que podem ser renderizados interativamente. Mas os números brutos retornados por um profiler de instrumentação não responderão de forma precisa às perguntas de orçamento. Isso ocorre porque o pipeline gráfico tem problemas de design complexos, como o número de componentes que precisam executar tarefas, o número de processadores que controlam como o trabalho flui entre os componentes e as estratégias de otimização implementadas no tempo de execução e em um controlador que são projetadas para tornar o pipeline mais eficiente.
Cada chamada de API passa por vários componentes
Cada chamada é processada por vários componentes no seu caminho do aplicativo para a placa de vídeo. Por exemplo, considere a seguinte sequência de renderização contendo duas chamadas para desenhar um único triângulo:
SetTexture(...);
DrawPrimitive(D3DPT_TRIANGLELIST, 0, 1);
O diagrama conceitual a seguir mostra os diferentes componentes pelos quais as chamadas devem passar.
O aplicativo invoca o Direct3D que controla a cena, manipula as interações do usuário e determina como a renderização é feita. Todo esse trabalho é especificado na sequência de renderização, que é enviada para o tempo de execução usando chamadas de API do Direct3D. A sequência de renderização é praticamente independente de hardware (ou seja, as chamadas de API são independentes de hardware, mas um aplicativo tem conhecimento de quais recursos uma placa de vídeo suporta).
O tempo de execução converte essas chamadas em um formato independente do dispositivo. O tempo de execução lida com toda a comunicação entre o aplicativo e o driver, de modo que um aplicativo será executado em mais de uma peça compatível de hardware (dependendo dos recursos necessários). Ao medir uma chamada de função, um profiler de instrumentação mede o tempo gasto em uma função, bem como o tempo que a função leva para retornar. Uma limitação de um profiler de instrumentação é que ele pode não incluir o tempo que um driver leva para enviar o trabalho resultante à placa de vídeo, nem o tempo que a placa de vídeo leva para processar o trabalho. Em outras palavras, um profiler de instrumentação pronto a usar não consegue atribuir todo o trabalho associado a cada chamada de função.
O driver de software usa conhecimento específico de hardware sobre a placa de vídeo para converter os comandos independentes do dispositivo em uma sequência de comandos da placa de vídeo. Os drivers também podem otimizar a sequência de comandos que são enviados para a placa de vídeo, para que a renderização na placa de vídeo seja feita de forma eficiente. Essas otimizações podem causar problemas de criação de perfil porque a quantidade de trabalho feito não é o que parece ser (você pode precisar entender as otimizações para contabilizá-las). O driver normalmente retorna o controle para o tempo de execução antes que a placa de vídeo tenha terminado de processar todos os comandos.
A placa de vídeo executa a maior parte da renderização combinando dados dos buffers de vértice e índice, texturas, informações de estado de renderização e os comandos gráficos. Quando a placa de vídeo termina a renderização, o trabalho criado a partir da sequência de renderização é concluído.
Cada chamada de API do Direct3D deve ser processada por cada componente (o tempo de execução, o driver e a placa de vídeo) para renderizar qualquer coisa.
Há mais de um processador controlando os componentes
A relação entre esses componentes é ainda mais complexa, porque o aplicativo, o tempo de execução e o driver são controlados por um processador e a placa de vídeo é controlada por um processador separado. O diagrama a seguir mostra dois tipos de processadores: uma unidade central de processamento (CPU) e uma unidade de processamento gráfico (GPU).
Os sistemas de PC têm pelo menos uma CPU e uma GPU, mas podem ter mais de uma ou ambas. As CPUs estão localizadas na placa-mãe e as GPUs estão localizadas na placa-mãe ou na placa de vídeo. A velocidade da CPU é determinada por um chip de clock na placa-mãe, e a velocidade da GPU é determinada por um chip de clock separado. O relógio da CPU controla a velocidade do trabalho feito pelo aplicativo, o tempo de execução e o driver. O aplicativo envia trabalho para a GPU através do tempo de execução e do driver.
A CPU e a GPU geralmente funcionam a velocidades diferentes, independentes uma da outra. A GPU pode responder ao trabalho assim que o trabalho estiver disponível (supondo que a GPU tenha terminado de processar o trabalho anterior). O trabalho da GPU é feito em paralelo com o trabalho da CPU, conforme destacado pela linha curva na figura acima. Uma ferramenta de perfilagem geralmente mede o desempenho da CPU, não da GPU. Isso torna a criação de perfil desafiadora, porque as medições feitas por um criador de perfil de instrumentação incluem o tempo da CPU, mas podem não incluir o tempo da GPU.
O objetivo da GPU é descarregar o processamento da CPU para um processador projetado especificamente para o trabalho gráfico. Nas placas de vídeo modernas, a GPU substitui grande parte do trabalho de transformação e iluminação no pipeline da CPU para a GPU. Isso reduz muito a carga de trabalho da CPU, deixando mais ciclos de CPU disponíveis para outros processamentos. Para ajustar um aplicativo gráfico para obter o máximo desempenho, você precisa medir o desempenho da CPU e da GPU e equilibrar o trabalho entre os dois tipos de processadores.
Este documento não aborda tópicos relacionados à medição do desempenho da GPU ou ao equilíbrio do trabalho entre a CPU e a GPU. Se você quiser entender melhor o desempenho de uma GPU (ou de uma placa de vídeo específica), visite o site do fornecedor para procurar mais informações sobre o desempenho da GPU. Em vez disso, este documento se concentra no trabalho feito pelo tempo de execução e pelo driver, reduzindo o trabalho da GPU a uma quantidade insignificante. Isso é, em parte, baseado na experiência de que os aplicativos com problemas de desempenho geralmente são limitados pela CPU.
Otimizações de tempo de execução e driver podem mascarar medições de API
O tempo de execução tem uma otimização de desempenho incorporada que pode sobrecarregar a medição de uma chamada individual. Aqui está um cenário de exemplo que demonstra esse problema. Considere a seguinte sequência de renderização:
BeginScene();
...
SetTexture(...);
DrawPrimitive(D3DPT_TRIANGLELIST, 0, 1);
...
EndScene();
Present();
Exemplo 1: Sequência de renderização simples
Observando os resultados das duas chamadas na sequência de renderização, um instrumentador de perfil pode retornar resultados semelhantes aos seguintes:
Number of cycles for SetTexture : 100
Number of cycles for DrawPrimitive : 950,500
O criador de perfil retorna o número de ciclos de CPU necessários para processar o trabalho associado a cada chamada (lembre-se de que a GPU não está incluída nesses números porque a GPU ainda não começou a trabalhar nesses comandos). Como IDirect3DDevice9::DrawPrimitive exigiu quase um milhão de ciclos para processar, pode-se concluir que não é muito eficiente. No entanto, você logo verá por que essa conclusão está incorreta e como você pode gerar resultados que podem ser usados para o orçamento.
A medição de alterações de estado requer sequências de renderização cuidadosas
Todas as chamadas, exceto IDirect3DDevice9::DrawPrimitive, DrawIndexedPrimitiveou Clear (como SetTexture, SetVertexDeclaratione SetRenderState) produzem uma alteração de estado. Cada alteração de estado define o estado do pipeline que controla como a renderização será feita.
As otimizações no tempo de execução e/ou no driver são projetadas para acelerar a renderização, reduzindo a quantidade de trabalho necessário. A seguir estão algumas otimizações de alteração de estado que podem poluir as médias de perfil:
- Um driver (ou o tempo de execução) pode salvar uma alteração de estado como um estado local. Como o motorista pode operar em um algoritmo "preguiçoso" (adiando o trabalho até que seja absolutamente necessário), o trabalho associado a algumas mudanças de estado pode atrasar.
- O tempo de execução (ou um driver) pode remover alterações de estado por otimização. Um exemplo disso pode ser remover uma alteração de estado redundante que desativa a iluminação porque a iluminação foi desativada anteriormente.
Não há nenhuma maneira infalível de olhar para uma sequência de renderização e concluir quais alterações de estado definirão um bit sujo e adiarão o trabalho, ou simplesmente serão removidas pela otimização. Mesmo que você possa identificar alterações de estado otimizadas no tempo de execução ou driver de hoje, o tempo de execução ou driver de amanhã provavelmente será atualizado. Você também não sabe prontamente qual era o estado anterior, por isso é difícil identificar alterações de estado redundantes. A única maneira de verificar o custo de uma alteração de estado é medir a sequência de renderização que inclui as alterações de estado.
Como você pode ver, as complicações causadas por ter vários processadores, comandos sendo processados por mais de um componente e otimizações incorporadas nos componentes tornam a criação de perfil difícil de prever. Na próxima seção, cada um desses desafios de criação de perfil será abordado. Sequências de renderização Direct3D de exemplo serão mostradas, com as técnicas de medição que as acompanham. Com esse conhecimento, você será capaz de gerar medições precisas e repetíveis em chamadas individuais.
Como criar um perfil preciso de uma sequência de renderização Direct3D
Agora que alguns dos desafios de criação de perfil foram destacados, esta seção mostrará técnicas que ajudarão você a gerar medidas de perfil que podem ser usadas para orçamento. Medições de perfil precisas e repetíveis são possíveis se você entender a relação entre os componentes controlados pela CPU e como evitar otimizações de desempenho implementadas pelo tempo de execução e pelo driver.
Para começar, você precisa ser capaz de medir com precisão o tempo de execução de uma única chamada de API.
Escolha uma ferramenta de medição precisa como QueryPerformanceCounter
O sistema operativo Microsoft Windows inclui um temporizador de alta resolução que pode ser usado para medir tempos de alta resolução decorridos. O valor atual de um desses temporizadores pode ser retornado usando QueryPerformanceCounter. Depois de invocar QueryPerformanceCounter para retornar valores de início e parada, a diferença entre os dois valores pode ser convertida para o tempo real decorrido (em segundos) usando QueryPerformanceCounter.
As vantagens de usar QueryPerformanceCounter são que ele está disponível no Windows e é fácil de usar. Basta cercar as chamadas com uma chamada QueryPerformanceCounter e salvar os valores de início e parada. Portanto, este artigo demonstrará como usar QueryPerformanceCounter para analisar os tempos de execução, semelhante à maneira como um profiler de instrumentação o mediria. Aqui está um exemplo que mostra como incorporar QueryPerformanceCounter em seu código-fonte:
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();
Exemplo 2: Implementação de criação de perfil personalizada com QPC
O início e a paragem são dois inteiros grandes que manterão os valores de início e paragem retornados pelo temporizador de alto desempenho. Observe que QueryPerformanceCounter(&start) é chamado pouco antes de SetTexture e QueryPerformanceCounter(&stop) é chamado logo após DrawPrimitive. Depois de obter o valor stop, QueryPerformanceFrequency é chamado para retornar freq, que é a frequência do temporizador de alta resolução. Neste exemplo hipotético, suponha que você obtenha os seguintes resultados para iniciar, parar e freqüência:
| Variável local | Número de tiques |
|---|---|
| Início | 1792998845094 |
| parar | 1792998845102 |
| freqüência | 3579545 |
Você pode converter esses valores para o número de ciclos necessários para executar as chamadas de API da seguinte forma:
# ticks = (stop - start) = 1792998845102 - 1792998845094 = 8 ticks
# cycles = CPU speed * number of ticks / QPF
# 4568 = 2 GHz * 8 / 3,579,545
Em outras palavras, são necessários cerca de 4568 ciclos de clock para processar SetTexture e DrawPrimitive nesta máquina de 2 GHz. Você pode converter esses valores para o tempo real que levou para executar todas as chamadas da seguinte forma:
(stop - start)/ freq = elapsed time
8 ticks / 3,579,545 = 2.2E-6 seconds or between 2 and 3 microseconds.
Usar o QueryPerformanceCounter exige que adiciones medições de início e fim à sequência de renderização e utilizes o QueryPerformanceFrequency para converter a diferença (número de intervalos) no número de ciclos de CPU ou no tempo real. Identificar a técnica de medição é um bom começo para desenvolver uma implementação de perfil personalizada. Mas antes de entrar e começar a fazer medições, você precisa saber como lidar com a placa de vídeo.
Foco nas medições da CPU
Como dito anteriormente, a CPU e a GPU trabalham em paralelo para processar o trabalho gerado pelas chamadas de API. Um aplicativo do mundo real requer o perfil de ambos os tipos de processadores para descobrir se seu aplicativo é limitado por CPU ou GPU. Como o desempenho da GPU é específico do fornecedor, seria muito desafiador produzir resultados neste documento que abranjam a variedade de placas de vídeo disponíveis.
Em vez disso, este documento se concentrará apenas na criação de perfil do trabalho executado pela CPU usando uma técnica personalizada para medir o tempo de execução e o trabalho do driver. O trabalho da GPU será reduzido a uma quantidade insignificante, para que os resultados da CPU sejam mais visíveis. Um benefício dessa abordagem é que essa técnica produz resultados no Apêndice que você deve ser capaz de correlacionar com suas medições. Para reduzir o trabalho exigido pela placa de vídeo a um nível insignificante, basta reduzir o trabalho de renderização para a menor quantidade possível. Isso pode ser feito limitando as chamadas de desenho para renderizar um único triângulo e pode ser ainda mais restrito para que cada triângulo contenha apenas um pixel.
A unidade de medida usada neste artigo para medir o trabalho da CPU será o número de ciclos de clock da CPU em vez do tempo real. Os ciclos de relógio da CPU têm a vantagem de serem mais portáteis, especialmente em aplicações limitadas pela CPU, do que o tempo decorrido real em máquinas com diferentes velocidades de CPU. Isso pode ser facilmente convertido em tempo exato, se desejado.
Este documento não aborda tópicos relacionados ao balanceamento da carga de trabalho entre a CPU e a GPU. Lembre-se, o objetivo deste documento não é medir o desempenho geral de um aplicativo, mas mostrar como medir com precisão o tempo que o tempo de execução e o driver levam para processar chamadas de API. Com essas medições precisas, você pode assumir a tarefa de orçar a CPU para entender determinados cenários de desempenho.
Controlar otimizações de tempo de execução e drivers
Com uma técnica de medição identificada e uma estratégia para reduzir o trabalho da GPU, o próximo passo é entender o ambiente de execução e as otimizações de driver que atrapalham na criação do perfil.
O trabalho da CPU pode ser dividido em três buckets: o trabalho do aplicativo, o trabalho do tempo de execução e o trabalho do driver. Ignore o trabalho do aplicativo, pois ele está sob controle do programador. Do ponto de vista do aplicativo, o tempo de execução e o driver são como caixas pretas, pois o aplicativo não tem controle sobre o que é implementado neles. A chave é entender as técnicas de otimização que podem ser implementadas no tempo de execução e no driver. Se você não entender essas otimizações, é muito fácil tirar a conclusão errada sobre a quantidade de trabalho que a CPU está fazendo com base nas medições de perfil. Em particular, há dois tópicos relacionados a um elemento denominado 'command buffer' e o que ele pode fazer para ofuscar o perfil de desempenho. Esses tópicos são:
- Otimização do tempo de execução com o Command Buffer. O buffer de comandos é uma otimização de tempo de execução que reduz o impacto de uma transição de modo. Para controlar o tempo da transição de modo, consulte Controlando o buffer de comandos.
- Neutralizar os efeitos temporais do Command Buffer. O tempo decorrido de uma transição de modo pode ter um grande impacto nas medições de perfil. A estratégia para isso é tornar a sequência de renderização grande em comparação com o modo de transição.
Controlando o buffer de comandos
Quando um aplicativo faz uma chamada de API, o tempo de execução converte a chamada de API em um formato independente do dispositivo (que chamaremos de comando) e a armazena no buffer de comandos. O buffer de comandos é adicionado ao diagrama a seguir.
Cada vez que o aplicativo faz outra chamada de API, o tempo de execução repete essa sequência e adiciona outro comando ao buffer de comandos. Em algum momento, o tempo de execução esvazia o buffer (enviando os comandos para o driver). No Windows XP, esvaziar o buffer de comandos causa uma transição de modo à medida que o sistema operacional alterna do tempo de execução (em execução no modo de usuário) para o driver (em execução no modo kernel), conforme mostrado no diagrama a seguir.
- modo de usuário - O modo de processador não privilegiado que executa o código do aplicativo. Os aplicativos de modo de usuário não podem obter acesso aos dados do sistema, exceto por meio de serviços do sistema.
- modo kernel - O modo de processador privilegiado no qual o código executivo baseado no Windows é executado. Um driver ou thread em execução no modo kernel tem acesso a toda a memória do sistema, acesso direto ao hardware e às instruções da CPU para executar E/S com o hardware.
A transição acontece cada vez que a CPU muda do modo de usuário para o modo kernel (e vice-versa) e o número de ciclos que requer é grande em comparação com uma chamada de API individual. Se o tempo de execução enviasse cada chamada de API para o driver quando ela fosse invocada, cada chamada de API incorreria no custo de uma transição de modo.
Em vez disso, o buffer de comandos é uma otimização de tempo de execução projetada para reduzir o custo efetivo da transição de modo. O buffer de comandos enfileira muitos comandos de driver em preparação para uma única transição de modo. Quando o tempo de execução adiciona um comando ao buffer de comandos, o controle é retornado ao aplicativo. Um analisador de perfil não tem como saber que os comandos do driver provavelmente ainda nem foram enviados. Como resultado, os números retornados por uma ferramenta de perfilamento de instrumentação disponível comercialmente são enganosos, pois medem o trabalho em tempo de execução, mas não o trabalho associado do driver.
Resultados do perfil sem uma transição de modo
Usando a sequência de renderização do exemplo 2, aqui estão algumas medições típicas de temporização que ilustram a magnitude de uma transição de modo. Supondo que a chamada SetTexture e DrawPrimitive não cause uma transição de modo, um perfilador de instrumentação pronto para uso poderia retornar resultados semelhantes a estes:
Number of cycles for SetTexture : 100
Number of cycles for DrawPrimitive : 900
Cada um dos números representa a quantidade de tempo que demora para o tempo de execução adicionar essas chamadas ao buffer de comando. Como não há transição de modo, o motorista ainda não fez nenhum trabalho. Os resultados do analisador de desempenho são precisos, mas não medem todo o trabalho que a sequência de renderização eventualmente obrigará a CPU a executar.
Resultados de perfis com uma transição de modo
Agora, veja o que acontece para o mesmo exemplo quando ocorre uma transição de modo. Desta vez, suponha que SetTexture e DrawPrimitive causarem uma transição de modo. Mais uma vez, uma ferramenta de profiling de instrumentação pronta a usar pode retornar resultados semelhantes a estes.
Number of cycles for SetTexture : 98
Number of cycles for DrawPrimitive : 946,900
O tempo medido para SetTexture é aproximadamente o mesmo, no entanto, o aumento dramático na quantidade de tempo gasto em DrawPrimitive é devido à transição de modo. Aqui está o que está acontecendo:
- Suponha que o buffer de comandos tenha espaço para um comando antes que nossa sequência de renderização seja iniciada.
- SetTexture é convertido em um formato independente do dispositivo e adicionado ao buffer de comandos. Nesse cenário, essa chamada preenche o buffer de comando.
- A execução tenta adicionar DrawPrimitive ao buffer de comandos, mas não consegue, porque está cheio. Em vez disso, o tempo de execução esvazia o buffer de comandos. Isso provoca a transição para o modo núcleo. Suponha que a transição leva cerca de 5000 ciclos. Este tempo contribui para o tempo gasto em DrawPrimitive.
- Em seguida, o driver processa o trabalho associado a todos os comandos que foram esvaziados do buffer de comandos. Suponha que o tempo do driver para processar os comandos que quase preencheram o buffer de comandos é de cerca de 935.000 ciclos. Suponha que o trabalho do driver associado a SetTexture é de cerca de 2750 ciclos. O tempo despendido nesta tarefa é acumulado no tempo total em DrawPrimitive.
- Quando o driver termina o seu trabalho, a transição de modo utilizador retorna o controlo para o runtime. O buffer de comandos agora está vazio. Suponha que a transição leva cerca de 5000 ciclos.
- A sequência de renderização termina convertendo DrawPrimitive e adicionando-o ao buffer de comandos. Suponha que isso leva cerca de 900 ciclos. Este tempo contribui para o tempo gasto em DrawPrimitive.
Resumindo os resultados, veja:
DrawPrimitive = kernel-transition + driver work + user-transition + runtime work
DrawPrimitive = 5000 + 935,000 + 2750 + 5000 + 900
DrawPrimitive = 947,950
Assim como a medição para DrawPrimitive sem transição de modo (900 ciclos), a medição para DrawPrimitive com transição de modo (947,950 ciclos) é precisa, mas inútil em termos de planeamento do trabalho da CPU. O resultado contém o trabalho em tempo de execução correto, o trabalho do driver para SetTexture, o trabalho do driver para quaisquer comandos que precederam SetTexture, bem como duas transições de modo. No entanto, falta na medição as operações do driver DrawPrimitive.
Uma transição de modo pode acontecer em resposta a qualquer chamada. Depende do que estava anteriormente no buffer de comando. Você precisa controlar a transição de modo para entender quanto trabalho de CPU (tempo de execução e driver) está associado a cada chamada. Para fazer isso, você precisa de um mecanismo para controlar o buffer de comando e o tempo da transição de modo.
O mecanismo de consulta
O mecanismo de consulta no Microsoft Direct3D 9 foi projetado para permitir que o tempo de execução consulte a GPU sobre o progresso e retorne determinados dados da GPU. Durante a criação de perfil, se o trabalho da GPU for minimizado para que tenha um impacto insignificante no desempenho, você poderá retornar o status da GPU para ajudar a medir o trabalho do driver. Afinal, o trabalho do driver é concluído quando a GPU vê os comandos do driver. Além disso, o mecanismo de consulta pode ser ajustado para controlar duas características de buffer de comando que são importantes para a análise de desempenho: quando o buffer de comando esvazia e a quantidade de trabalho no buffer.
Aqui está a mesma sequência de renderização usando o mecanismo de consulta:
// 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);
Exemplo 3: Usando uma consulta para controlar o buffer de comandos
Aqui está uma explicação mais detalhada de cada uma dessas linhas de código:
- Crie uma consulta de evento criando um objeto de consulta com D3DQUERYTYPE_EVENT.
- Adicione um marcador de evento de consulta ao buffer de comandos chamando Issue(D3DISSUE_END). Esse marcador diz ao driver para rastrear quando a GPU termina de executar quaisquer comandos que precederam o marcador.
- A primeira chamada esvazia o buffer de comando porque chamar GetData com D3DGETDATA_FLUSH força o buffer de comando a ser esvaziado. Cada chamada subsequente está verificando a GPU para ver quando ela termina de processar todo o trabalho do buffer de comandos. Esse loop não retorna S_OK até que a GPU esteja ociosa.
- Amostrar a hora de início.
- Invoque as chamadas da API que estão a ser analisadas.
- Adicione um segundo marcador de evento de consulta ao buffer de comandos. Este marcador será utilizado para acompanhar a conclusão das chamadas.
- A primeira chamada esvazia o buffer de comando porque chamar GetData com D3DGETDATA_FLUSH força o buffer de comando a ser esvaziado. Quando a GPU termina de processar todo o trabalho do buffer de comandos, GetData retorna S_OK e o loop é encerrado porque a GPU está ociosa.
- Amostrar o tempo de parada.
Aqui estão os resultados medidos com QueryPerformanceCounter e QueryPerformanceFrequency:
| Variável local | Número de carrapatos |
|---|---|
| Início | 1792998845060 |
| parar | 1792998845090 |
| frequência | 3579545 |
Convertendo ticks em ciclos mais uma vez (em uma máquina de 2 GHz):
# ticks = (stop - start) = 1792998845090 - 1792998845060 = 30 ticks
# cycles = CPU speed * number of ticks / QPF
# 16,450 = 2 GHz * 30 / 3,579,545
Aqui está o detalhamento do número de ciclos por chamada:
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
O mecanismo de consulta permitiu-nos controlar o tempo de execução e o trabalho do condutor que está a ser medido. Para entender cada um desses números, aqui está o que está acontecendo em resposta a cada uma das chamadas de API, juntamente com os tempos estimados:
A primeira chamada esvazia o buffer de comandos chamando GetData com D3DGETDATA_FLUSH. Quando a GPU termina de processar todo o trabalho do buffer de comandos, GetData retorna S_OK e o loop é encerrado porque a GPU está ociosa.
A sequência de renderização começa convertendo SetTexture em um formato independente do dispositivo e adicionando-o ao buffer de comandos. Suponha que isso leve cerca de 100 ciclos.
DrawPrimitive é convertido e adicionado ao buffer de comandos. Suponha que isso leva cerca de 900 ciclos.
Problema adiciona um marcador de consulta ao buffer de comandos. Suponha que isso leve cerca de 200 ciclos.
GetData faz com que o buffer de comandos seja esvaziado, o que força a transição do modo kernel. Suponha que isso leva cerca de 5000 ciclos.
Em seguida, o motorista processa o trabalho associado a todas as quatro chamadas. Suponha que o tempo do driver para processar SetTexture é de cerca de 2964 ciclos, DrawPrimitive é de cerca de 3600 ciclos, Issue é de cerca de 200 ciclos. Assim, o tempo total de operação para todos os quatro comandos é de cerca de 6450 ciclos.
Observação
O driver também leva um pouco de tempo para ver qual é o status da GPU. Como o trabalho da GPU é trivial, a GPU já deve estar concluída. GetData retornará S_OK com base na probabilidade de conclusão da GPU.
Quando o driver termina o seu trabalho, a transição de modo de usuário retorna o controlo para o runtime. O buffer de comandos agora está vazio. Suponha que isso leva cerca de 5000 ciclos.
Os números para GetData incluem:
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
O mecanismo de consulta usado em combinação com QueryPerformanceCounter mede todo o trabalho da CPU. Isso é feito com uma combinação de marcadores de consulta e comparações de estado da consulta. Os marcadores de consulta Start e Stop adicionados ao buffer de comandos são usados para controlar quanto trabalho há no buffer. Aguardando até que o código de retorno correto seja retornado, a medição de início é feita pouco antes de uma sequência de renderização limpa ser iniciada e a medição de parada é feita logo após o driver ter concluído o trabalho associado ao conteúdo do buffer de comandos. Isso captura efetivamente o trabalho da CPU feito pelo tempo de execução do sistema, assim como pelo driver.
Agora que você sabe sobre o buffer de comandos e o efeito que ele pode ter na criação de perfil, você deve saber que existem algumas outras condições que podem fazer com que o tempo de execução esvazie o buffer de comando. Você precisa ficar atento a isso em suas sequências de renderização. Algumas dessas condições são em resposta a chamadas de API, outras são em resposta a alterações de recursos no tempo de execução. Qualquer uma das seguintes condições causará uma transição de modo:
- Quando um dos métodos de bloqueio (Lock) é chamado no buffer de vértices, no buffer de índices ou na textura (sob certas condições com certos sinalizadores).
- Quando um dispositivo ou buffer de vértice, buffer de índice ou textura é criado.
- Quando um dispositivo, buffer de vértice, buffer de índice ou textura é destruído pela última libertação.
- Quando invocar ValidateDevice.
- Quando Present é chamado.
- Quando o buffer de comandos é preenchido.
- Quando GetData é chamado com D3DGETDATA_FLUSH.
Tenha cuidado para observar essas condições em suas sequências de renderização. Sempre que uma transição de modo é adicionada, 10.000 ciclos de trabalho do motorista serão adicionados às suas medições de perfil. Além disso, o buffer de comando não é dimensionado estaticamente. O tempo de execução pode alterar o tamanho do buffer em resposta à quantidade de trabalho que está sendo gerada pelo aplicativo. Esta é mais uma otimização que depende de uma sequência de renderização.
Portanto, tenha cuidado para controlar as transições de modo durante a criação de perfil. O mecanismo de consulta oferece um método robusto para esvaziar o buffer de comando para que você possa controlar o tempo da transição de modo, bem como a quantidade de trabalho que o buffer contém. No entanto, mesmo esta técnica pode ser melhorada reduzindo o tempo de transição do modo para torná-lo insignificante em relação ao resultado medido.
Tornar a sequência de renderização grande em comparação com a transição de modo
No exemplo anterior, o switch de modo kernel e o switch de modo de usuário consomem cerca de 10.000 ciclos que não têm nada a ver com tempo de execução e trabalho de driver. Como a transição de modo é incorporada ao sistema operacional, ela não pode ser reduzida a zero. Para tornar a transição de modo insignificante, a sequência de renderização precisa ser ajustada de forma que o trabalho do driver e do tempo de execução seja uma ordem de magnitude maior do que as mudanças de modo. Poderia tentar fazer uma subtração para eliminar as transições, mas distribuir o custo ao longo de uma sequência de renderização significativamente maior é mais confiável.
A estratégia para reduzir a transição de modo até que ela se torne insignificante é adicionar um loop à sequência de renderização. Por exemplo, veja os resultados da criação de perfil se for adicionado um loop que repetirá a sequência de renderização 1500 vezes:
// 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);
Exemplo 4: Adicionar um loop à sequência de renderização
Aqui estão os resultados medidos com QueryPerformanceCounter e QueryPerformanceFrequency:
| Variável local | Número de tiques |
|---|---|
| Início | 1792998845000 |
| parar | 1792998847084 |
| frequência | 3579545 |
Usando o QueryPerformanceCounter mede 2 840 ticks agora. A conversão de ticks em ciclos é a mesma que já mostramos:
# ticks = (stop - start) = 1792998847084 - 1792998845000 = 2840 ticks
# cycles = machine speed * number of ticks / QPF
# 6,900,000 = 2 GHz * 2840 / 3,579,545
Em outras palavras, são necessários cerca de 6,9 milhões de ciclos nesta máquina de 2 GHz para processar as 1500 chamadas no loop de renderização. Dos 6,9 milhões de ciclos, a quantidade de tempo nas transições de modo é de aproximadamente 10k, então agora os resultados do perfil medem quase inteiramente o trabalho associado ao SetTexture e DrawPrimitive .
Observe que o exemplo de código requer uma matriz de duas texturas. Para evitar uma otimização de tempo de execução que removeria SetTexture se ele definir o mesmo ponteiro de textura toda vez que for chamado, basta usar uma matriz de duas texturas. Dessa forma, a cada passagem pelo loop, o ponteiro de textura muda, e o trabalho completo associado a SetTexture é executado. Certifique-se de que ambas as texturas têm o mesmo tamanho e formato, para que nenhum outro estado mude quando a textura for alterada.
E agora você tem uma técnica para criar o perfil do Direct3D. Depende do contador de alto desempenho (QueryPerformanceCounter) para registar o número de ciclos de relógio que a CPU leva para processar o trabalho. O trabalho é cuidadosamente controlado para ser o tempo de execução e o trabalho de driver associado a chamadas de API usando o mecanismo de consulta. Uma consulta fornece dois meios de controle: primeiro para esvaziar o buffer de comandos antes que a sequência de renderização seja iniciada e, em segundo lugar, para retornar quando o trabalho da GPU for concluído.
Até agora, este artigo mostrou como criar o perfil de uma sequência de renderização. Cada sequência de renderização tem sido bastante simples, contendo uma única chamada de DrawPrimitive e uma chamada SetTexture. Isso foi feito para se concentrar no buffer de comando e no uso do mecanismo de consulta para controlá-lo. Aqui está um breve resumo de como criar o perfil de uma sequência de renderização arbitrária:
- Use um contador de alto desempenho como QueryPerformanceCounter para medir o tempo necessário para processar cada chamada de API. Use QueryPerformanceFrequency e a taxa de clock da CPU para convertê-la para o número de ciclos de CPU por chamada de API.
- Minimize a quantidade de trabalho da GPU renderizando listas de triângulos, onde cada triângulo contém um pixel.
- Use o mecanismo de consulta para esvaziar o buffer de comandos antes da sequência de renderização. Isso garante que a profilagem irá capturar a quantidade correta de tempo e trabalho do driver associado à sequência de renderização.
- Controle a quantidade de trabalho adicionado ao buffer de comandos com marcadores de eventos de consulta. Essa mesma consulta deteta quando a GPU termina seu trabalho. Como o trabalho da GPU é trivial, isso é praticamente equivalente a medir quando o trabalho do driver é concluído.
Todas essas técnicas são usadas para traçar o perfil das alterações de estado. Supondo que você tenha lido e entendido como controlar o buffer de comandos e tenha concluído com êxito as medições da linha de base em DrawPrimitive , você está pronto para adicionar alterações de estado às suas sequências de renderização. Há alguns desafios adicionais de criação de perfil ao adicionar alterações de estado a uma sequência de renderização. Se você pretende adicionar alterações de estado às suas sequências de renderização, continue na próxima seção.
Criação de perfil de alterações de estado do Direct3D
O Direct3D usa muitos estados de renderização para controlar quase todos os aspetos do pipeline. As APIs que causam alterações de estado incluem qualquer função ou método diferente das chamadas Draw*Primitive .
As alterações de estado são complicadas porque você pode não conseguir ver o custo de uma alteração de estado sem renderizar. Isso é resultado do algoritmo preguiçoso que o driver e a GPU usam para adiar o trabalho até que ele absolutamente tenha que ser feito. Em geral, você deve seguir estas etapas para medir uma única alteração de estado:
- Primeiro, o perfil DrawPrimitive.
- Adicione uma alteração de estado à sequência de renderização e crie o perfil da nova sequência.
- Subtraia a diferença entre as duas sequências para obter o custo da mudança de estado.
Naturalmente, tudo o que você aprendeu sobre como usar o mecanismo de consulta e colocar a sequência de renderização em um loop para negar o custo da transição de modo ainda se aplica.
Definindo o perfil de uma alteração de estado simples
Começando com uma sequência de renderização que contém DrawPrimitive , aqui está a sequência de código para medir o custo de adicionar 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
Exemplo 5: Medindo uma chamada de API de alteração de estado
Observe que o loop contém duas chamadas, SetTexture e DrawPrimitive. A sequência de renderização faz um loop de 1500 vezes e gera resultados semelhantes a estes:
| Variável local | Número de tiques |
|---|---|
| Início | 1792998860000 |
| parar | 1792998870260 |
| frequência | 3579545 |
A conversão de ticks em ciclos mais uma vez obtém:
# 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
Dividindo pelo número de iterações no loop obtém-se:
5,775,000 cycles / 1500 iterations = 3850 cycles for one iteration
Cada iteração do loop contém uma alteração de estado e uma chamada de desenho. Subtraindo o resultado da sequência de renderização DrawPrimitive deixa:
3850 - 1100 = 2750 cycles for SetTexture
Este é o número médio de ciclos para adicionar SetTexture a essa sequência de renderização. Esta mesma técnica pode ser aplicada a outras alterações de estado.
Por que SetTexture chamado de simples alteração de estado? Porque o estado que está a ser definido é restrito para que o pipeline faça a mesma quantidade de trabalho cada vez que o estado é alterado. Restringir ambas as texturas ao mesmo tamanho e formato garante a mesma quantidade de trabalho para cada chamada de SetTexture.
Analisar uma Alteração de Estado que Precisa Ser Ativada ou Desativada
Há outras alterações de estado que fazem com que a quantidade de trabalho executado pelo pipeline gráfico seja alterada para cada iteração do loop de renderização. Por exemplo, se o teste z estiver habilitado, cada cor de pixel atualizará um destino de renderização somente depois que o valor z do novo pixel for testado em relação ao valor z do pixel existente. Se o teste z estiver desativado, esse teste por pixel não será feito e a saída será gravada muito mais rapidamente. Ativar ou desativar o estado z-test altera drasticamente a quantidade de trabalho realizado (pela CPU e pela GPU) durante a renderização.
SetRenderState requer um estado de renderização específico e um valor de estado para habilitar ou desabilitar o teste z. O valor de estado específico é avaliado em tempo de execução para determinar quanto trabalho é necessário. É difícil medir essa mudança de estado em um loop de renderização e ainda pré-condicionar o estado do pipeline para que ele seja alternado. A única solução é alternar a alteração de estado durante a sequência de renderização.
Por exemplo, a técnica de criação de perfil precisa ser repetida duas vezes:
- Comece por criar o perfil da sequência de renderização DrawPrimitive. Chame isso de linha de base.
- Crie o perfil de uma segunda sequência de renderização que alterna a alteração de estado. O loop de sequência de renderização contém:
- Uma alteração de estado para definir o estado em uma condição "falsa".
- DrawPrimitive exatamente como a sequência original.
- Uma alteração de estado para definir o estado em uma condição "verdadeira".
- Um segundo DrawPrimitive para forçar a realização da segunda mudança de estado.
- Encontre a diferença entre as duas sequências de renderização. Isto é feito por:
Com a técnica de looping utilizada na sequência de renderização, o custo de alterar o estado do pipeline precisa ser medido alternando o estado de verdadeiro para falso e vice-versa, em cada iteração do processo de renderização. O significado de "verdadeiro" e "falso" aqui não é literal, isso simplesmente significa que o Estado precisa ser colocado em condições opostas. Isso faz com que ambas as alterações de estado sejam medidas durante a criação de perfil. É claro que tudo o que você aprendeu sobre como usar o mecanismo de consulta e colocar a sequência de renderização em um loop para negar o custo da transição de modo ainda se aplica.
Por exemplo, aqui está a sequência de código para medir o custo de ativar ou desativar o teste 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
Exemplo 5: Medindo uma alteração de estado de alternância
O loop alterna o estado ao executar duas chamadas SetRenderState . A primeira chamada de SetRenderState desabilita o teste z e a segunda SetRenderState habilita o teste z. Cada SetRenderState é seguido por DrawPrimitive para que o trabalho associado à alteração de estado seja processado pelo controlador em vez de apenas definir um bit sujo no controlador.
Estes números são razoáveis para esta sequência de renderização:
| Variável local | Número de tiques |
|---|---|
| Início | 1792998845000 |
| parar | 1792998861740 |
| frequência | 3579545 |
A conversão de ticks em ciclos mais uma vez obtém:
# 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
Dividindo pelo número de iterações no loop obtém-se:
9,300,000 cycles / 1500 iterations = 6200 cycles for one iteration
Cada iteração do loop contém duas alterações de estado e duas chamadas de renderização. Subtraindo as chamadas de sorteio (assumindo 1100 ciclos) fica:
6200 - 1100 - 1100 = 4000 cycles for both state changes
Este é o número médio de ciclos para ambas as alterações de estado, portanto, o tempo médio para cada alteração de estado é:
4000 / 2 = 2000 cycles for each state change
Portanto, o número médio de ciclos para habilitar ou desabilitar o teste z é de 2000 ciclos. Vale a pena notar que QueryPerformanceCounter está medindo z-enable metade do tempo e z-disable metade do tempo. Esta técnica, na verdade, mede a média de ambas as mudanças de estado. Em outras palavras, você está medindo o tempo para alternar um estado. Usando essa técnica, você não tem como saber se os tempos de ativação e desativação são equivalentes, uma vez que você mediu a média de ambos. No entanto, esse é um número razoável para usar ao orçar um estado de alternância, pois um aplicativo que causa essa alteração de estado só pode fazê-lo alternando esse estado.
Então agora você pode aplicar essas técnicas e traçar o perfil de todas as mudanças de estado que quiser, certo? Não exatamente. Você ainda precisa ter cuidado com as otimizações que são projetadas para reduzir a quantidade de trabalho que precisa ser feito. Há dois tipos de otimizações que você deve estar ciente ao projetar suas sequências de renderização.
Fique atento às otimizações na mudança de estado
A seção anterior mostra como analisar ambos os tipos de alterações de estado: uma simples alteração de estado limitada a gerar a mesma quantidade de trabalho para cada iteração e uma alteração de estado intermitente que altera drasticamente a quantidade de trabalho realizado. O que acontece se você pegar a sequência de renderização anterior e adicionar outra alteração de estado a ela? Por exemplo, este exemplo usa a sequência de renderização z>-enable e adiciona uma comparação z-func a ela:
// 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);
}
O estado z-func define o nível de comparação ao gravar no z-buffer (entre o valor z de um pixel atual com o valor z de um pixel no buffer de profundidade). D3DCMP_NEVER desativa a comparação do teste z enquanto D3DCMP_ALWAYS define a comparação para acontecer toda vez que o teste z é feito.
A criação de perfil de qualquer uma dessas alterações de estado em uma sequência de renderização com DrawPrimitive gera resultados semelhantes a estes:
| Alteração de Estado único | Número médio de ciclos |
|---|---|
| D3DRS_ZENABLE só | 2000 |
ou
| Alteração de Estado único | Número médio de ciclos |
|---|---|
| D3DRS_ZFUNC apenas | 600 |
Mas, se você criar o perfil de D3DRS_ZENABLE e D3DRS_ZFUNC na mesma sequência de renderização, poderá ver resultados como estes:
| Ambas as mudanças de estado | Número médio de ciclos |
|---|---|
| D3DRS_ZENABLE + D3DRS_ZFUNC | 2000 |
Você pode esperar que o resultado seja a soma de 2000 e 600 (ou 2600) ciclos, porque o driver está fazendo todo o trabalho associado à configuração de ambos os estados de renderização. Em vez disso, a média é de 2000 ciclos.
Esse resultado reflete uma otimização de alteração de estado implementada no tempo de execução, no driver ou na GPU. Neste caso, o driver poderia ver o primeiro SetRenderState e definir um estado sujo que adiaria o trabalho para mais tarde. Quando o driver vê o segundo SetRenderState, o mesmo estado sujo pode ser definido de forma redundante e o mesmo trabalho seria adiado mais uma vez. Quando DrawPrimitive é chamado, o trabalho associado ao estado sujo é finalmente processado. O driver executa o trabalho uma vez, o que significa que as duas primeiras alterações de estado são efetivamente consolidadas pelo driver. Da mesma forma, a terceira e quarta alterações de estado são efetivamente consolidadas pelo driver em uma única alteração de estado quando o segundo DrawPrimitive é chamado. O resultado final é que o driver e a GPU processam uma única alteração de estado para cada chamada de sorteio.
Este é um bom exemplo de uma otimização de driver dependente de sequência. O motorista adiou o trabalho duas vezes, definindo um estado sujo, e então executou o trabalho uma vez para limpar o estado sujo. Este é um bom exemplo do tipo de melhoria da eficiência que pode ocorrer quando o trabalho é adiado até ser absolutamente necessário.
Como saber quais mudanças de estado configuram um estado sujo internamente e, portanto, adiam o trabalho para mais tarde? Somente testando sequências de renderização (ou conversando com desenvolvedores de drivers). Os drivers são atualizados e melhorados periodicamente para que a lista de otimizações não seja estática. Há apenas uma maneira de saber absolutamente quanto custa uma alteração de estado em uma determinada sequência de renderização, em um determinado conjunto de hardware; e isso é medi-lo.
Atenção às otimizações do DrawPrimitive
Além das otimizações de alteração de estado, o tempo de execução tentará otimizar o número de chamadas de desenho que o driver tem que processar. Por exemplo, considere estas chamadas de sorteio de volta para trás:
DrawPrimitive(D3DPT_TRIANGLELIST, 0, 3); // Draw 3 primitives, vertices 0 - 8
DrawPrimitive(D3DPT_TRIANGLELIST, 9, 4); // Draw 4 primitives, vertices 9 - 20
Exemplo 5a: Duas chamadas de desenho
Essa sequência contém duas instruções de desenho, que o tempo de execução consolidará em uma só chamada equivalente a:
DrawPrimitive(D3DPT_TRIANGLELIST, 0, 7); // Draw 7 primitives, vertices 0 - 20
Exemplo 5b: Uma única chamada de desenho concatenada
O tempo de execução concatenará ambas as chamadas de sorteio específicas em uma única chamada, o que reduz o trabalho do driver em 50%, porque o driver agora só precisará processar uma chamada de sorteio.
Em geral, o tempo de execução concatenará duas ou mais chamadas back-to-back DrawPrimitive quando:
- O tipo primitivo é uma lista de triângulos (D3DPT_TRIANGLELIST).
- Cada chamada sucessiva de DrawPrimitive deve referir-se a vértices consecutivos dentro do buffer de vértice.
Da mesma forma, as condições certas para concatenar duas ou mais chamadas consecutivas de DrawIndexedPrimitive são:
- O tipo primitivo é uma lista de triângulos (D3DPT_TRIANGLELIST).
- Cada chamada desucessivaDrawIndexedPrimitive deve referenciar índices consecutivos sequenciais dentro do buffer de índice.
- Cada chamada sucessiva de deve usar o mesmo valor para BaseVertexIndex.
Para evitar a concatenação durante a criação de perfil, modifique a sequência de renderização para que o tipo primitivo não seja uma lista de triângulos, ou ajuste a sequência de renderização para que não existam chamadas de desenho consecutivas que utilizem vértices (ou índices) consecutivos. Mais especificamente, o tempo de execução também concatenará chamadas de desenho que atendam às duas condições a seguir:
- Quando a chamada anterior for DrawPrimitive, se a próxima chamada de sorteio:
- usa uma lista de triângulos, e
- especifica que o StartVertex = StartVertex anterior + o PrimitiveCount anterior * 3
- Ao usar DrawIndexedPrimitive , se a próxima chamada de sorteio:
- usa uma lista de triângulos, e
- especifica o StartIndex = anterior StartIndex + anterior PrimitiveCount * 3, E
- especifica que o BaseVertexIndex é igual ao BaseVertexIndex anterior
Aqui está um exemplo mais sutil de concatenação de chamada de desenho que é fácil de ignorar quando você está criando o perfil. Suponha que a sequência de renderização tenha esta aparência:
for(int i = 0; i < 1500; i++)
{
SetTexture(...);
DrawPrimitive(D3DPT_TRIANGLELIST, i*3, 1);
}
Exemplo 5c: Uma alteração de estado e uma chamada de renderização
O loop itera através de 1500 triângulos, definindo uma textura e desenhando cada triângulo. Esse loop de renderização leva aproximadamente 2750 ciclos para SetTexture e 1100 ciclos para DrawPrimitive como mostrado nas seções anteriores. Você pode esperar intuitivamente que mover SetTexture fora do loop de renderização deve reduzir a quantidade de trabalho feito pelo driver em 1500 * 2750 ciclos, que é a quantidade de trabalho associada à chamada SetTexture 1500 vezes. O trecho de código teria esta aparência:
SetTexture(...); // Set the state outside the loop
for(int i = 0; i < 1500; i++)
{
// SetTexture(...);
DrawPrimitive(D3DPT_TRIANGLELIST, i*3, 1);
}
Exemplo 5d: Exemplo 5c com a alteração de estado fora do loop
Mover SetTexture fora do loop de renderização reduz a quantidade de trabalho associado a SetTexture uma vez que ele é chamado uma vez em vez de 1500 vezes. Um efeito secundário menos óbvio é que o trabalho para DrawPrimitive também é reduzido de 1500 chamadas para 1 chamada porque todas as condições para concatenar chamadas de desenho estão satisfeitas. Quando a sequência de renderização é processada, o tempo de execução processa 1500 chamadas em uma única chamada de driver. Ao mover esta linha de código, a quantidade de trabalho do driver foi reduzida drasticamente:
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
Estes resultados estão inteiramente corretos, mas são muito enganadores no contexto da pergunta original. A otimização da chamada de sorteio fez com que a quantidade de trabalho do motorista fosse drasticamente reduzida. Este é um problema comum ao fazer criação de perfil personalizada. Ao eliminar chamadas de uma sequência de renderização, tenha cuidado para evitar a concatenação de chamadas de desenho. Na verdade, esse cenário é um exemplo poderoso da quantidade de melhoria no desempenho do driver possível por essa otimização de tempo de execução.
Então, agora você sabe como medir as mudanças de estado. Comece a perfilar DrawPrimitive. Em seguida, adicione cada alteração de estado adicional à sequência (em alguns casos adicionando uma chamada e em outros casos adicionando duas chamadas) e meça a diferença entre as duas sequências. Você pode converter os resultados em ticks, ciclos ou tempo. Assim como medir sequências de renderização com QueryPerformanceCounter, medir alterações de estado individuais depende do mecanismo de consulta para controlar o buffer de comandos e colocar as alterações de estado em um loop para minimizar o impacto das transições de modo. Esta técnica mede o custo de alternar um estado, uma vez que o profiler retorna a média de ativação e desativação do estado.
Com esse recurso, você pode começar a gerar sequências de renderização arbitrárias e medir com precisão o tempo de execução associado e o trabalho do driver. Os números podem ser usados para responder a perguntas de orçamento como "quantas chamadas adicionais" podem ser feitas na sequência de renderização, mantendo uma taxa de quadros razoável, assumindo cenários limitados pela CPU.
Resumo
Este documento demonstra como controlar o buffer de comandos para que chamadas individuais possam ser perfiladas com precisão. Os números de criação de perfil podem ser gerados em ticks, ciclos ou tempo absoluto. Eles representam a quantidade de tempo de execução e trabalho de driver associado a cada chamada de API.
Comece pelo perfilamento de uma chamada Draw*Primitive numa sequência de renderização. Lembre-se de:
- Para medir o número de ticks por chamada de API, utilize o QueryPerformanceCounter. Use QueryPerformanceFrequency para converter os resultados em ciclos ou tempo, se desejar.
- Use o mecanismo de consulta para esvaziar o buffer de comandos antes de iniciar.
- Inclua a sequência de renderização em um loop para minimizar o impacto da transição de modo.
- Use o mecanismo de consulta para medir quando a GPU concluiu seu trabalho.
- Cuidado com a concatenação de tempo de execução que terá um grande impacto na quantidade de trabalho realizado.
Isso fornece um desempenho de linha de base para DrawPrimitive que pode ser usado para compilar. Para analisar uma alteração de estado, siga estes conselhos adicionais:
- Adicione a alteração de estado a um perfil de sequência de renderização conhecido da nova sequência. Como o teste é feito em um loop, isso requer definir o estado duas vezes em valores opostos (como ativar e desabilitar, por exemplo).
- Compare a diferença de tempos de ciclo entre as duas sequências.
- Para alterações de estado que alteram significativamente o pipeline (como SetTexture), subtraia a diferença entre as duas sequências para obter o tempo para a alteração de estado.
- Para alterações de estado que alteram significativamente o pipeline (e, portanto, exigem trocar estados como SetRenderState), subtraia a diferença entre as sequências de renderização e divida o resultado por 2. Isso gerará o número médio de ciclos para cada mudança de estado.
Mas tenha cuidado com otimizações que causam resultados inesperados ao criar o perfil. As otimizações de alteração de estado podem definir estados sujos, o que faz com que o trabalho seja adiado. Isso pode causar resultados de perfil que não são tão intuitivos quanto o esperado. Desenhar chamadas que são concatenadas reduzirá drasticamente o trabalho do motorista, o que pode levar a conclusões enganosas. Sequências de renderização cuidadosamente planejadas são usadas para evitar que ocorram alterações de estado e concatenações de chamadas de desenho. O truque é evitar que as otimizações aconteçam durante a criação de perfil para que os números gerados sejam números de orçamento razoáveis.
Observação
Duplicar essa estratégia de criação de perfil em um aplicativo sem o mecanismo de consulta é mais difícil. Antes do Direct3D 9, a única maneira previsível de esvaziar o buffer de comandos é bloquear uma superfície ativa (como um destino de renderização) para aguardar até que a GPU esteja ociosa. Isso ocorre porque o bloqueio de uma superfície força o tempo de execução a esvaziar o buffer de comandos, caso haja comandos de renderização no buffer que devem atualizar a superfície antes que ela seja bloqueada, além de esperar que a GPU termine. Esta técnica é funcional, embora seja mais intrusiva do que usar o mecanismo de consulta introduzido no Direct3D 9.
Apêndice
Os números nesta tabela são um intervalo de aproximações para a quantidade de tempo de execução e trabalho de driver associado a cada uma dessas alterações de estado. As aproximações são baseadas em medições reais feitas em motoristas usando as técnicas mostradas no artigo. Esses números foram gerados usando o tempo de execução do Direct3D 9 e dependem do driver.
As técnicas neste artigo são projetadas para medir o tempo de execução e o trabalho do driver. Em geral, é impraticável fornecer resultados que correspondam ao desempenho da CPU e da GPU em cada aplicativo, pois isso exigiria uma matriz exaustiva de sequências de renderização. Além disso, é particularmente difícil comparar o desempenho da GPU porque ela é altamente dependente da configuração de estado no pipeline antes da sequência de renderização. Por exemplo, habilitar a mistura alfa faz pouco para afetar a quantidade de trabalho da CPU necessária, mas pode ter um grande impacto na quantidade de trabalho feito pela GPU. Portanto, as técnicas neste artigo restringem o trabalho da GPU ao mínimo possível, limitando a quantidade de dados que precisam ser renderizados. Isso significa que os números na tabela corresponderão mais aos resultados obtidos de aplicativos que são limitados pela CPU (em oposição a um aplicativo que é limitado pela GPU).
Você é encorajado a usar as técnicas apresentadas para cobrir os cenários e configurações mais importantes para você. Os valores na tabela podem ser usados para comparar com os números gerados. Como cada condutor varia, a única maneira de gerar os números reais que verá é obter resultados de perfilagem usando os seus cenários.
| Chamada de API | Número médio de ciclos |
|---|---|
| DefinirDeclaraçãoDeVértice | 6500 - 11250 |
| SetFVF | 6400 - 11200 |
| SetVertexShader | 3000 - 12100 |
| SetPixelShader | 6300 - 7000 |
| ATIVAR ESPECULAR | 1900 - 11200 |
| SetRenderTarget | 6000 - 6250 |
| SetPixelShaderConstant (1 constante) | 1500 - 9000 |
| NORMALIZENORMALS | 2200 - 8100 |
| LightEnable | 1300 - 9000 |
| SetStreamSource | 3700 - 5800 |
| ILUMINAÇÃO | 1700 - 7500 |
| FonteDeMaterialDifuso | 900 - 8300 |
| AMBIENTEMATERIALFONTE | 900 - 8200 |
| COLORVERTEX | 800 - 7800 |
| SetLight | 2200 - 5100 |
| SetTransform | 3200 - 3750 |
| DefinirÍndices | 900 - 5600 |
| AMBIENTE | 1150 - 4800 |
| DefinirTextura | 2500 - 3100 |
| Fonte de Material Especular | 900 - 4600 |
| FONTEDEMATERIALEMISSIVO | 900 - 4500 |
| DefinirMaterial | 1000 - 3700 |
| ZENABLE | 700 - 3900 |
| WRAP0 | 1600 - 2700 |
| MINFILTER | 1700 - 2500 |
| FILTRO MAGNÉTICO | 1700 - 2400 |
| SetVertexShaderConstant (1 constante) | 1000 - 2700 |
| COLOROP | 1500 - 2100 |
| COLORARG2 | 1300 - 2000 |
| COLORARG1 | 1300 - 1980 |
| CULLMODE | 500 - 2570 |
| Recortes de Imprensa | 500 - 2550 |
| DesenharPrimitivaIndexada | 1200 - 1400 |
| ENDEREÇO | 1090 - 1500 |
| ENDEREÇOU | 1070 - 1500 |
| DrawPrimitive | 1050 - 1150 |
| SRGBTEXTURE | 150 - 1500 |
| STENCILMASK | 570 - 700 |
| STENCILZFAIL | 500 - 800 |
| STENCILREF | 550 - 700 |
| ALPHABLENDENABLE | 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 |
| ALPHATESTENABLE | 470 - 525 |
| ALFAREF | 460 - 530 |
| ALPHAFUNC | 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 |
Tópicos relacionados