Skip to content

Commit 94c1be2

Browse files
committed
aula 14
1 parent d6d262e commit 94c1be2

13 files changed

Lines changed: 796 additions & 260 deletions

File tree

material/aulas/aula14/image-1.png

61.1 KB
Loading

material/aulas/aula14/image-2.png

45.2 KB
Loading

material/aulas/aula14/image-3.png

58.1 KB
Loading

material/aulas/aula14/image-4.png

36.4 KB
Loading

material/aulas/aula14/index.md

Lines changed: 469 additions & 258 deletions
Large diffs are not rendered by default.

material/aulas/aula14/index_old.md

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
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+

material/aulas/aula15/kernel-execution-on-gpu-1-625x438.png renamed to material/aulas/aula14/kernel-execution-on-gpu-1-625x438.png

File renamed without changes.

0 commit comments

Comments
 (0)