Um shader parece um programinha que roda uma vez por pixel. A GPU não o roda assim. Ela roda 32 pixels de cada vez, com uma instrução para os 32, e mantém dezenas desses grupos em andamento para nunca ficar esperando a memória. Disso saem dois fatos que explicam quase tudo o que deixa um shader rápido ou lento: um if custa os dois caminhos quando pixels vizinhos discordam, e uma leitura de textura só é barata enquanto outros pixels têm trabalho a fazer.
Ao final você vai conseguir
- Dizer o que é um warp, e por que 32 pixels dividem uma instrução.
- Prever quando um if custa os seus dois caminhos e quando custa um só.
- Explicar por que um laço custa tanto quanto o seu pixel mais lento.
- Explicar como os registradores decidem o quanto as leituras de textura ficam escondidas.
- Separar operações baratas de caras, e medir em vez de adivinhar.
Placas de vídeo são vendidas pelo número de núcleos: uma NVIDIA RTX 4060 tem 3.072. Não são 3.072 processadores independentes. O chip tem 24 multiprocessadores de streaming (SMs), cada SM tem 4 escalonadores, e cada escalonador comanda 32 lanes de aritmética. A cada ciclo, um escalonador escolhe uma instrução e as 32 lanes a executam, cada uma com os seus próprios dados. 24 × 4 × 32 = 3.072.
As 32 invocações que dividem um fluxo de instruções formam um warp. Cada lane é uma invocação do seu shader: um vértice num vertex shader, um pixel num fragment shader. Um warp de fragmentos é preenchido com quads 2×2 inteiros, 8 deles, porque as derivadas precisam dos quatro pixels de um quad (veja Derivadas e antisserrilhado). Todo fabricante constrói GPUs desse jeito; só mudam os nomes e as larguras:
| GPU | Nome do grupo | Lanes | Roda em |
|---|---|---|---|
| NVIDIA | warp | 32 | multiprocessador de streaming (SM) |
| AMD RDNA | wave (wavefront) | 32 ou 64, escolhido pelo driver | compute unit (CU) |
| AMD GCN | wavefront | 64 | compute unit (CU) |
| Intel | SIMD width | 8, 16 ou 32, escolhido pelo compilador | execution unit (EU) / Xe core |
| Apple | SIMD-group | 32 | núcleo da GPU |
| Arm Mali (Valhall) | warp | 16 | shader core |
| Vulkan / GLSL | subgroup | gl_SubgroupSize | o nome portável para todos os acima |
Pegue if (c) { A } else { B }. Quando todas as lanes de um warp têm o mesmo c, o warp pula o caminho que ninguém precisa e roda só A ou só B, como faria uma CPU. Quando as lanes discordam, ainda existe um único fluxo de instruções. O warp roda A com as lanes de B desligadas, depois B com as lanes de A desligadas. Cada pixel recebe o resultado certo, mas o warp gasta o tempo dos dois caminhos. Isso se chama divergência.
■ quer A · ■ quer B · moldura vermelha = roda os dois
Pixels âmbar querem o caminho A, os azuis o caminho B; o número em cada warp é o que ele paga. Com um uniform todo warp concorda. Com o círculo só os warps da borda pagam os dois caminhos. Listras estreitas e ruído fazem quase todo warp pagar A + B, embora nenhum pixel precise de mais de um. Experimente listras de 4 px com o warp 4 × 8, depois com 8 × 8.
Três tipos de condição
| Condição | Exemplo | Quanto custa |
|---|---|---|
| uniforme: a mesma para toda invocação do draw | if (uQuality > 1) | só o caminho tomado, em todo warp |
| coerente: muda pela tela, mas vizinhos concordam | if (d < 0.0) // a large shape | um caminho na maioria dos warps; os dois só nos warps ao longo da borda |
| incoerente: muda de pixel para pixel | if (hash(p) > 0.5) | os dois caminhos em quase todo warp |
Isso não é só teoria. A próxima figura cronometra uma passada de verdade na sua GPU: 1024 × 1024 pixels, cada um rodando um if / else cujos dois caminhos custam exatamente o mesmo. Na RTX 4060 a passada leva 0,246 ms quando a condição é uniforme e 0,479 ms quando ela alterna a cada pixel: o dobro do tempo, embora nenhum pixel tenha rodado mais de um caminho. Listras tornam visível o tamanho de um warp. Listras verticais de 4 pixels de largura não custam nada a mais, as horizontais precisam ter 8 pixels de altura: lá, um warp cobre um bloco de 4 pixels de largura por 8 de altura.
● xadrez · ● listras verticais · ● listras horizontais
Os dois caminhos fazem os mesmos 200 senos, então um pixel nunca custa mais que um caminho. As linhas tracejadas são um caminho (verde) e os dois caminhos (vermelho). Células pequenas custam o dobro: todo warp contém as duas cores. O tamanho em que cada linha cai diz a largura e a altura de um warp: na RTX 4060, as listras verticais param de custar o dobro em 4 pixels e as horizontais em 8, então lá um warp é um bloco de 4 pixels de largura por 8 de altura, 32 lanes. Outras GPUs dão outros formatos; aperte o botão para ver o da sua.
Laços esperam a lane mais lenta
Um laço cujo número de iterações muda entre os pixels diverge do mesmo jeito. As lanes que saem do laço cedo são desligadas e esperam, e o warp continua no laço até a última lane terminar. Um warp custa, então, tanto quanto o seu pixel mais lento. O raymarching é o caso clássico: um raio que raspa uma silhueta ou o chão perto do horizonte dá muitos passos pequenos, e todo pixel do seu warp paga por eles. Mude a figura acima para "laço" para ver isso, e use o mapa de calor do capítulo Raymarching numa cena de verdade.
Um conselho comum é evitar if em shaders e escrever step e mix no lugar. Ele é meio verdade. Para um if curto, o compilador já faz isso: calcula os dois lados e escolhe o resultado com uma instrução de seleção, sem salto nenhum. E o mix sempre calcula os dois lados, então nunca pode sair mais barato que um desvio que pula um deles. O que importa é o quanto os caminhos custam e o quanto a condição é coerente:
1// 1. Both sides cheap: write what reads best. The compiler emits the same select either way.
2float a = x > 0.5 ? 1.0 : 0.2;
3float b = mix(0.2, 1.0, step(0.5, x));
4
5// 2. One side expensive, condition coherent: a real branch skips the work
6// in every warp that lies wholly outside the shape.
7if (d < 0.0) {
8 col = expensiveInterior(p); // 200 instructions, only inside the shape
9}
10
11// 3. The same work written with mix runs the expensive side everywhere.
12col = mix(col, expensiveInterior(p), step(d, 0.0));Uma leitura de textura leva algumas dezenas de ciclos quando os texels estão no cache e várias centenas quando vêm da memória de vídeo. O warp que pediu não pode continuar, já que a sua próxima instrução precisa do valor. Então o escalonador troca para outro warp que esteja pronto, e trocar não custa nada: todo warp no SM mantém os seus registradores no banco de registradores do SM o tempo todo. Com warps suficientes em andamento, sempre há um pronto, e a espera fica escondida.
Quantos warps cabem é decidido pelos registradores. Um SM da série RTX 40 tem 65.536 registradores de 32 bits, 16.384 por escalonador, e aceita no máximo 48 warps, 12 por escalonador. Cada lane de cada warp precisa da sua própria cópia de cada valor que o shader mantém vivo ao mesmo tempo, então um shader que precisa de muitos registradores deixa lugar para menos warps. A fração do máximo que cabe se chama ocupação (occupancy).
Cada linha é um warp: claro = emitindo uma instrução, cinza = esperando a sua leitura de textura. A linha de baixo é a unidade de aritmética; cada buraco nela é tempo jogado fora. Comece em 64 registradores: 8 warps, e a unidade fica quase sempre parada enquanto todos esperam. Baixe a latência, ou ponha mais contas entre as leituras, e os buracos se fecham. Depois suba os registradores para 128: só cabem 4 warps, e nenhuma quantidade de contas por leitura mantém a unidade ocupada com uma latência de 600 ciclos.
O que faz os registradores subirem: muitos valores vivos ao mesmo tempo, arrays locais grandes, laços longos desenrolados, e muitas leituras de textura emitidas antes de os resultados serem usados (o que é bom para a latência, então é uma troca). Quem escolhe a contagem é o compilador; não dá para defini-la em GLSL. Profilers como o Nsight Graphics ou o Radeon GPU Analyzer a mostram por shader. Só vale olhar quando um shader é o gargalo e é pesado.
Os números exatos mudam a cada geração, mas a ordem se mantém há anos. Contando em multiplicações:
| Operação | Custo | Por quê |
|---|---|---|
| + − * , fma, min, max, clamp, abs, mix, select | 1 | lanes de taxa cheia; mix(a, b, t) = a + t · (b − a) é uma subtração e uma multiplicação-soma fundida |
| dot(vec3), length², cross | 2 a 6 | uma cadeia curta de multiplicações-soma |
| sqrt, inversesqrt, exp2, log2, sin, cos, 1/x | 4 a 8 | rodam em unidades de funções especiais: 16 por SM na série RTX 40, contra 128 lanes de multiplicação |
| a / b, normalize, pow(x, y) | 5 a 10 | montadas a partir das de cima: a · (1/b); v · inversesqrt(dot(v, v)); exp2(y · log2(x)), por isso pow de um x negativo é indefinido |
| / e % de int | 20 ou mais | não há divisor em hardware: emulado com muitas instruções; por uma constante potência de dois vira um deslocamento |
| texture() | algumas, mais a latência | a filtragem é feita pelas unidades de textura; o custo é a espera, escondida por outros warps |
| uma leitura de textura cujo uv vem de outra leitura | as latências se somam | a segunda leitura não pode começar antes de a primeira voltar |
| double | 64 nas GeForce | placas de consumo rodam floats de 64 bits a 1/64 da taxa; o WebGL não tem double |
Compiladores reordenam, juntam e removem código, então a única resposta confiável é uma medição. No OpenGL, cronometre uma passada com uma timer query (veja Medindo o desempenho na trilha de OpenGL); no WebGL as mesmas queries vêm de EXT_disjoint_timer_query_webgl2, que a figura de tempo acima usou. Cinco regras deixam os números confiáveis:
| Regra | Por quê |
|---|---|
| Meça em milissegundos, na GPU | o FPS esconde mudanças pequenas e inclui a CPU; um relógio da CPU só mede quanto tempo os comandos levaram para ser enviados |
| Aqueça primeiro | uma GPU parada roda com clock baixo na primeira fração de segundo |
| Repita, e fique com a rodada mais rápida | interrupções e mudanças de clock só acrescentam tempo |
| Faça do shader o gargalo | tela cheia, resolução alta, nada mais desenhado; senão você está medindo outra coisa |
| Mude uma coisa por vez | duas mudanças podem se anular |
Para ver por que um shader é lento, e não só o quanto, use o profiler do fabricante: Nsight Graphics (NVIDIA), Radeon GPU Profiler e Radeon GPU Analyzer (AMD), Intel GPA, ou o depurador de Metal do Xcode. Eles mostram registradores, ocupação, com que frequência os warps divergem e o que cada warp estava esperando.
| Sintoma | Causa | Correção |
|---|---|---|
| um if de "saída antecipada" não deixa nada mais rápido | a condição é incoerente, então todo warp ainda roda o caminho caro | deixe o teste mais grosso (por bloco ou por objeto), ou aceite o custo |
| um raymarcher fica lento só onde os objetos encontram o céu | raios que raspam silhuetas dão muitos passos, e o warp inteiro espera | limite os passos, aumente o limiar de acerto com a distância, envolva a cena numa forma barata |
| reescrever todo if como mix deixou o shader mais lento | o mix calcula os dois lados, inclusive o caro | mantenha desvios de verdade em volta de trabalho caro e coerente |
| uma leitura de textura a mais cortou a velocidade pela metade | o shader já tinha ocupação baixa, então nada esconde a nova latência | menos valores vivos, arrays menores; leia antes e use depois |
| um % ou / por uma variável aparece no profile | a divisão inteira é emulada | use floats, ou & (n − 1) e deslocamentos para potências de dois |
| uma otimização ajuda numa GPU e atrapalha em outra | a largura do warp, os caches e os compiladores são diferentes | meça no hardware em que você vai lançar |
Ideias-chave
- Um warp de 32 lanes (64 em algumas GPUs AMD, 16 na Mali) roda uma instrução de cada vez; cada lane é um pixel ou um vértice.
- Um if custa todo caminho que pelo menos uma lane do warp toma; um laço custa as iterações da lane mais lenta.
- Condições uniformes e coerentes são baratas; condições que mudam de pixel para pixel rodam os dois caminhos quase em todo lugar.
- Ifs curtos viram seleções de qualquer jeito; mix e step nunca pulam trabalho, então mantenha desvios de verdade em volta de código caro e coerente.
- A latência da memória é escondida trocando entre warps residentes, e os registradores por lane decidem quantos cabem.
- Meça em milissegundos com timer queries, com a GPU aquecida, a mais rápida de muitas rodadas, uma mudança por vez.