|
| 1 | +# Data Race, Atomics e Throughput em GPU |
| 2 | + |
| 3 | +Quando começamos a ver sobre paralelismo em CPU com **OpenMP**, aprendemos que certas operações compartilhadas entre threads — como somas globais ou atualizações em vetores — exigem cuidados. |
| 4 | +Usar um `#pragma omp critical` ou um `#pragma omp atomic` garante correção, mas também gera um gargalo, pois apenas uma thread pode acessar aquele trecho de código por vez. |
| 5 | +Em muitos casos, podemos substituir o uso de `critical` por **estratégias mais inteligentes**, como reduções (`reduction`) ou vetores locais por thread, justamente para **evitar o custo da sincronização**. |
| 6 | + |
| 7 | +Em CUDA o raciocínio é o mesmo, mas em uma escala muito maior. |
| 8 | +Uma GPU não executa 8 ou 16 threads, e sim **milhares** às vezes **dezenas de milhares** de forma simultânea. |
| 9 | +Isso significa que qualquer ponto de contenção, como uma operação `atomicAdd()` sobre um mesmo endereço de memória, pode eliminar completamente o paralelismo e fazer com que o desempenho da GPU drasticamente. |
| 10 | + |
| 11 | +Por isso, **é importante evitar ao máximo o uso de operações atômicas** |
| 12 | + |
| 13 | + |
| 14 | +## Por que o atomic é tão custoso na GPU |
| 15 | + |
| 16 | +Uma operação atômica é uma forma de **bloquear temporariamente** um endereço de memória enquanto uma thread o atualiza, impedindo que outra thread interfira. |
| 17 | +Em CPU, o impacto é pequeno, porque há poucas threads competindo. |
| 18 | +Mas na GPU, a atomic vira um verdadeiro funil: centenas ou milhares de threads tentam acessar o mesmo dado ao mesmo tempo, e o hardware é obrigado a **serializar os acessos**, uma thread por vez. |
| 19 | + |
| 20 | +Em OpenMP, se você usar `#pragma omp atomic`, oito threads se alternam para atualizar uma variável o atraso é perceptível, mas suportável. |
| 21 | +Em CUDA, `atomicAdd()` pode ser disputado por **10.000 threads ao mesmo tempo**, e o tempo de espera se torna centenas de vezes maior. |
| 22 | +Na prática, o throughput (quantidade de operações concluídas por segundo) despenca. |
| 23 | +O programa perde completamente o sentido de ser paralelo. |
| 24 | + |
| 25 | + |
| 26 | +## Uma solução: reduzir a competição por memória |
| 27 | + |
| 28 | +A melhor forma de evitar atomics não é torcer para que elas fiquem baratas, mas **reorganizar o algoritmo** para que cada thread ou bloco trabalhe **em regiões de memória diferentes**. |
| 29 | +No exemplo do histograma, em vez de todas as threads atualizarem o mesmo vetor global, cada bloco de threads constrói **seu próprio histograma local**, em *shared memory* (memória compartilhada do bloco). |
| 30 | +Essa memória é muito mais rápida, e como é exclusiva daquele bloco, **não há conflito entre blocos** portanto, **nenhum atomic é necessário**. |
| 31 | + |
| 32 | +Cada thread do bloco incrementa contadores no seu histograma local de forma direta (sem precisar de `atomicAdd()` global), e no final o bloco escreve o resultado em uma região separada da memória global. |
| 33 | +Depois, uma etapa de fusão combina os histogramas locais para gerar o resultado final. |
| 34 | +Essa fusão pode ser feita no host (CPU) ou em um segundo kernel da GPU. |
| 35 | +Mesmo que use algumas atomics na fusão, o custo é muito menor, porque agora temos poucos blocos competindo, e não milhares de threads. |
| 36 | + |
| 37 | + |
| 38 | +## Exemplo: três abordagens de histograma |
| 39 | + |
| 40 | +Neste exemplo temos três implementações do mesmo histograma em CUDA: |
| 41 | + |
| 42 | +1. **Versão ingênua:** |
| 43 | + As threads incrementam diretamente o vetor `hist[bin]++`. |
| 44 | + É a mais rápida, mas incorreta pois acontece *data race*. |
| 45 | + |
| 46 | +2. **Versão com `atomicAdd`:** |
| 47 | + Corrige o problema, mas força o hardware a serializar as operações. |
| 48 | + Funciona, mas é como colocar `#pragma omp critical` dentro do loop cada incremento é seguro, porém caro. |
| 49 | + |
| 50 | +3. **Versão com memória compartilhada:** |
| 51 | + Cada bloco calcula seu histograma local em *shared memory* e depois os resultados são somados. |
| 52 | + Essa abordagem evita o conflito global e preserva o paralelismo. |
| 53 | + É o equivalente GPU da técnica de *reduction* do OpenMP — divide o trabalho, reduz localmente, combina no final. |
| 54 | + |
| 55 | +```cpp |
| 56 | +#include <iostream> |
| 57 | +#include <vector> |
| 58 | +#include <cuda_runtime.h> |
| 59 | + |
| 60 | +// |
| 61 | +// ===================================================== |
| 62 | +// Kernel ingênuo — demonstra condição de corrida (data race) |
| 63 | +// ===================================================== |
| 64 | +// |
| 65 | +// Cada thread lê um valor do vetor `dados` e incrementa o contador |
| 66 | +// do "chunk" correspondente no vetor global `histograma`. |
| 67 | +// |
| 68 | +// Problema: várias threads podem tentar incrementar o mesmo índice |
| 69 | +// ao mesmo tempo. Como a operação (ler → somar → escrever) não é atômica, |
| 70 | +// o resultado final se corrompe. |
| 71 | +// |
| 72 | +__global__ void histograma_ingenuo(const int *dados, int *histograma, int N, int numChunks) { |
| 73 | + int i = blockIdx.x * blockDim.x + threadIdx.x; |
| 74 | + if (i < N) { |
| 75 | + int chunk = dados[i]; |
| 76 | + histograma[chunk]++; // condição de corrida (data race) |
| 77 | + } |
| 78 | +} |
| 79 | + |
| 80 | +// |
| 81 | +// ===================================================== |
| 82 | +// Kernel com atomicAdd — correto, mas reduz throughput |
| 83 | +// ===================================================== |
| 84 | +// |
| 85 | +// A função `atomicAdd()` garante exclusividade de acesso a um endereço. |
| 86 | +// Assim, o incremento é seguro, mas o paralelismo efetivo diminui, |
| 87 | +// pois várias threads competem para acessar o mesmo chunk. |
| 88 | +// |
| 89 | +__global__ void histograma_atomico(const int *dados, int *histograma, int N, int numChunks) { |
| 90 | + int i = blockIdx.x * blockDim.x + threadIdx.x; |
| 91 | + if (i < N) { |
| 92 | + int chunk = dados[i]; |
| 93 | + atomicAdd(&histograma[chunk], 1); // funciona, porém destroi o paralelismo |
| 94 | + } |
| 95 | +} |
| 96 | + |
| 97 | +// |
| 98 | +// ===================================================== |
| 99 | +// Kernel otimizado — histograma local em memória compartilhada |
| 100 | +// ===================================================== |
| 101 | +// |
| 102 | +// Cada bloco cria um histograma local na memória compartilhada (`shared memory`), |
| 103 | +// que é muito mais rápida e exclusiva de cada bloco. |
| 104 | +// Assim, evitamos o uso de operações atômicas globais. |
| 105 | +// |
| 106 | +// Após o cálculo local, cada bloco copia seu histograma parcial |
| 107 | +// para a memória global, e a fusão final é feita na CPU. |
| 108 | +// |
| 109 | +__global__ void histograma_compartilhado(const int *dados, int *histogramas_blocos, int N, int numChunks) { |
| 110 | + extern __shared__ int hist_local[]; // memória compartilhada dinâmica |
| 111 | + int tid_global = blockIdx.x * blockDim.x + threadIdx.x; |
| 112 | + |
| 113 | + // --- Etapa 1: Inicializa o histograma local com zeros --- |
| 114 | + for (int i = threadIdx.x; i < numChunks; i += blockDim.x) |
| 115 | + hist_local[i] = 0; |
| 116 | + __syncthreads(); |
| 117 | + |
| 118 | + // --- Etapa 2: Atualiza o histograma local --- |
| 119 | + if (tid_global < N) { |
| 120 | + int chunk = dados[tid_global]; |
| 121 | + hist_local[chunk]++; |
| 122 | + } |
| 123 | + __syncthreads(); |
| 124 | + |
| 125 | + // --- Etapa 3: Copia o histograma local para a memória global --- |
| 126 | + for (int i = threadIdx.x; i < numChunks; i += blockDim.x) |
| 127 | + histogramas_blocos[blockIdx.x * numChunks + i] = hist_local[i]; |
| 128 | +} |
| 129 | + |
| 130 | +// |
| 131 | +// ===================================================== |
| 132 | +// Fusão dos histogramas locais na CPU |
| 133 | +// ===================================================== |
| 134 | +// |
| 135 | +// Após cada bloco gerar seu histograma local na GPU, |
| 136 | +// esta função soma todos os histogramas parciais |
| 137 | +// em um histograma final consolidado. |
| 138 | +// |
| 139 | +void fundir_histogramas_CPU(const std::vector<int> &histogramas_blocos, |
| 140 | + std::vector<int> &histograma_final, |
| 141 | + int numBlocos, int numChunks) { |
| 142 | + for (int b = 0; b < numBlocos; b++) |
| 143 | + for (int c = 0; c < numChunks; c++) |
| 144 | + histograma_final[c] += histogramas_blocos[b * numChunks + c]; |
| 145 | +} |
| 146 | + |
| 147 | +// |
| 148 | +// ===================================================== |
| 149 | +// Função principal |
| 150 | +// ===================================================== |
| 151 | +// |
| 152 | +// Mede o tempo de execução de cada abordagem (ingênua, atômica, compartilhada) |
| 153 | +// e compara os resultados. |
| 154 | +// |
| 155 | +int main() { |
| 156 | + const int N = 1 << 20; // 1 milhão de elementos |
| 157 | + const int numChunks = 256; // quantidade de "caixas" do histograma |
| 158 | + const int tamBloco = 256; // threads por bloco |
| 159 | + const int numBlocos = (N + tamBloco - 1) / tamBloco; |
| 160 | + |
| 161 | + std::cout << "=== HISTOGRAMA EM GPU ===\n"; |
| 162 | + std::cout << "Elementos: " << N |
| 163 | + << " | Chunks: " << numChunks |
| 164 | + << " | " << numBlocos << " blocos x " |
| 165 | + << tamBloco << " threads\n\n"; |
| 166 | + |
| 167 | + // ----------------------------- |
| 168 | + // Alocação e inicialização no host |
| 169 | + // ----------------------------- |
| 170 | + std::vector<int> h_dados(N); |
| 171 | + for (auto &v : h_dados) v = rand() % numChunks; |
| 172 | + |
| 173 | + std::vector<int> h_hist_ingenuo(numChunks, 0); |
| 174 | + std::vector<int> h_hist_atomico(numChunks, 0); |
| 175 | + std::vector<int> h_hist_compart(numChunks, 0); |
| 176 | + |
| 177 | + // ----------------------------- |
| 178 | + // Alocação na GPU |
| 179 | + // ----------------------------- |
| 180 | + int *d_dados = nullptr; |
| 181 | + int *d_hist = nullptr; |
| 182 | + int *d_hist_blocos = nullptr; |
| 183 | + cudaMalloc(&d_dados, N * sizeof(int)); |
| 184 | + cudaMalloc(&d_hist, numChunks * sizeof(int)); |
| 185 | + cudaMalloc(&d_hist_blocos, numBlocos * numChunks * sizeof(int)); |
| 186 | + |
| 187 | + cudaMemcpy(d_dados, h_dados.data(), N * sizeof(int), cudaMemcpyHostToDevice); |
| 188 | + |
| 189 | + size_t tamMemCompart = numChunks * sizeof(int); |
| 190 | + |
| 191 | + // Variáveis para medir tempo |
| 192 | + cudaEvent_t inicio, fim; |
| 193 | + cudaEventCreate(&inicio); |
| 194 | + cudaEventCreate(&fim); |
| 195 | + float tempo_ingenuo = 0.0f, tempo_atomico = 0.0f, tempo_compart = 0.0f; |
| 196 | + |
| 197 | + // ===================================================== |
| 198 | + // Versão ingênua |
| 199 | + // ===================================================== |
| 200 | + cudaMemset(d_hist, 0, numChunks * sizeof(int)); |
| 201 | + cudaEventRecord(inicio); |
| 202 | + histograma_ingenuo<<<numBlocos, tamBloco>>>(d_dados, d_hist, N, numChunks); |
| 203 | + cudaEventRecord(fim); |
| 204 | + cudaEventSynchronize(fim); |
| 205 | + cudaEventElapsedTime(&tempo_ingenuo, inicio, fim); |
| 206 | + cudaMemcpy(h_hist_ingenuo.data(), d_hist, numChunks * sizeof(int), cudaMemcpyDeviceToHost); |
| 207 | + |
| 208 | + // ===================================================== |
| 209 | + // Versão atômica |
| 210 | + // ===================================================== |
| 211 | + cudaMemset(d_hist, 0, numChunks * sizeof(int)); |
| 212 | + cudaEventRecord(inicio); |
| 213 | + histograma_atomico<<<numBlocos, tamBloco>>>(d_dados, d_hist, N, numChunks); |
| 214 | + cudaEventRecord(fim); |
| 215 | + cudaEventSynchronize(fim); |
| 216 | + cudaEventElapsedTime(&tempo_atomico, inicio, fim); |
| 217 | + cudaMemcpy(h_hist_atomico.data(), d_hist, numChunks * sizeof(int), cudaMemcpyDeviceToHost); |
| 218 | + |
| 219 | + // ===================================================== |
| 220 | + // Versão otimizada (memória compartilhada) |
| 221 | + // ===================================================== |
| 222 | + cudaEventRecord(inicio); |
| 223 | + histograma_compartilhado<<<numBlocos, tamBloco, tamMemCompart>>>(d_dados, d_hist_blocos, N, numChunks); |
| 224 | + cudaEventRecord(fim); |
| 225 | + cudaEventSynchronize(fim); |
| 226 | + cudaEventElapsedTime(&tempo_compart, inicio, fim); |
| 227 | + |
| 228 | + std::vector<int> h_hist_blocos(numBlocos * numChunks); |
| 229 | + cudaMemcpy(h_hist_blocos.data(), d_hist_blocos, numBlocos * numChunks * sizeof(int), cudaMemcpyDeviceToHost); |
| 230 | + fundir_histogramas_CPU(h_hist_blocos, h_hist_compart, numBlocos, numChunks); |
| 231 | + |
| 232 | + // ===================================================== |
| 233 | + // Cálculo do throughput (M ops/s) |
| 234 | + // ===================================================== |
| 235 | + auto throughput = [&](float ms) { |
| 236 | + return static_cast<double>(N) / (ms / 1000.0) / 1e6; |
| 237 | + }; |
| 238 | + |
| 239 | + double thr_ingenuo = throughput(tempo_ingenuo); |
| 240 | + double thr_atomico = throughput(tempo_atomico); |
| 241 | + double thr_compart = throughput(tempo_compart); |
| 242 | + |
| 243 | + std::cout << "──────────────────────────────────────────────────────────────\n"; |
| 244 | + std::cout << "Versão | Tempo (ms) | Throughput (M ops/s)\n"; |
| 245 | + std::cout << "──────────────────────────────────────────────────────────────\n"; |
| 246 | + std::cout << "Ingênua | " << tempo_ingenuo << " | " << thr_ingenuo << "\n"; |
| 247 | + std::cout << "Atômica | " << tempo_atomico << " | " << thr_atomico << "\n"; |
| 248 | + std::cout << "Otimizada | " << tempo_compart << " | " << thr_compart << "\n"; |
| 249 | + std::cout << "──────────────────────────────────────────────────────────────\n\n"; |
| 250 | + |
| 251 | + // ===================================================== |
| 252 | + // Liberação de recursos |
| 253 | + // ===================================================== |
| 254 | + cudaFree(d_dados); |
| 255 | + cudaFree(d_hist); |
| 256 | + cudaFree(d_hist_blocos); |
| 257 | + cudaEventDestroy(inicio); |
| 258 | + cudaEventDestroy(fim); |
| 259 | + |
| 260 | + return 0; |
| 261 | +} |
| 262 | + |
| 263 | +``` |
| 264 | +
|
| 265 | +Lembre-se de carregar o modulo cuda disponível, depois compile com: |
| 266 | +
|
| 267 | +``` |
| 268 | +nvcc -Ofast hist.cu -o hist |
| 269 | +``` |
| 270 | +
|
| 271 | +Execute usando o srun: |
| 272 | +
|
| 273 | +``` |
| 274 | +srun --partition=gpu --gres=gpu:1 ./hist |
| 275 | +``` |
| 276 | +
|
| 277 | +
|
| 278 | +Em programação paralela **sincronizar sempre tem custo**. Mas, na GPU, esse custo é multiplicado por milhares de threads, e o impacto pode ser catastrófico. |
| 279 | +Por isso, **usar funções atômicas deve ser o último recurso**, reservado apenas para casos em que não há outra forma de evitar uma condição de corrida. |
| 280 | +
|
| 281 | +A verdadeira otimização em CUDA não está em “usar mais threads”, e sim em **organizar o trabalho de modo que cada thread e cada bloco acessem dados diferentes**. |
| 282 | +Sempre que o acesso for independente, a GPU mostra toda sua força; quando há disputa, ela se comporta de forma lenta. |
| 283 | +
|
| 284 | +
|
| 285 | +
|
| 286 | +### O que é **Throughput** |
| 287 | +
|
| 288 | +O termo **throughput** mede **a quantidade de trabalho que um sistema realiza por unidade de tempo**. |
| 289 | +No nosso caso, ele indica **quantos incrementos (operações)** a GPU consegue fazer **por segundo**. |
| 290 | +
|
| 291 | +Em outras palavras: |
| 292 | +
|
| 293 | +> **Throughput = quantas operações o programa consegue realizar por segundo.** |
| 294 | +
|
| 295 | +Cada thread da GPU processa um elemento do vetor de entrada `dados[]` e incrementa o contador do “chunk” correspondente no vetor `histograma[]`. |
| 296 | +Logo, temos **N operações** (uma para cada elemento). |
| 297 | +
|
| 298 | +O tempo total de execução (`tempo_ms`) é medido com os eventos do CUDA (`cudaEventRecord`). |
| 299 | +
|
| 300 | +
|
| 301 | +## Fórmula utilizada no código |
| 302 | +
|
| 303 | +O throughput é calculado como: |
| 304 | +
|
| 305 | +$$ |
| 306 | +\text{Throughput (M ops/s)} = \frac{N}{t_s} \div 10^6 |
| 307 | +$$ |
| 308 | +
|
| 309 | +onde: |
| 310 | +
|
| 311 | +| Símbolo | Significado | |
| 312 | +| :------- | :----------------------------------------------------------- | |
| 313 | +| ( N ) | Número total de operações executadas (elementos processados) | |
| 314 | +| ( t_s ) | Tempo total do kernel em segundos | |
| 315 | +| ( 10^6 ) | Conversão para “milhões de operações por segundo” | |
| 316 | +
|
| 317 | +Como `cudaEventElapsedTime()` retorna o tempo em **milissegundos**, fazemos: |
| 318 | +
|
| 319 | +$$ |
| 320 | +t_s = \frac{t_{ms}}{1000} |
| 321 | +$$ |
| 322 | +
|
| 323 | +
|
0 commit comments