CUDA C na bancada: shared memory, medição honesta e o caminho até o Jetson
Quem chega em CUDA pelo tutorial de “hello GPU” costuma sair com a sensação de que o problema acabou: o kernel rodou, o printf apareceu e o cudaMemcpy devolveu o vetor. Em projeto real — especialmente em visão embarcada, fusão de sensores ou pré-processamento em SBC NVIDIA — o gargalo quase nunca é “saber lançar um kernel”. É saber o que a memória está fazendo, medir sem se enganar e escolher se o ganho cabe no envelope térmico e energético da placa.
O repositório c_cuda foi montado exatamente nessa lógica de bancada: oito exemplos progressivos (cuda_1 … cuda_8), Makefile que detecta a arquitetura da GPU e tabelas de tempo medidas em duas máquinas diferentes. Este artigo não reescreve o README. Usa os números publicados lá como âncora e discute as decisões de projeto por trás de shared memory, reduction e streams — e por que isso importa quando o destino não é só um desktop com GTX 1660, mas um caminho de edge que termina no Jetson.
Por que começar “vazio” e com erro explícito
cuda_1 (kernel vazio + printf) e cuda_2 (soma de dois números com error checking) parecem triviais. Em bancada, eles resolvem três falhas clássicas de setup:
- Toolchain e runtime:
nvcc, driver e capability batem? O Makefile do repo força essa conversa cedo:maketenta descobrir a arch; se não, você forçamake ARCH=sm_50(MX130) oumake ARCH=sm_75(GTX 1660). Compilar para a capability errada é uma forma silenciosa de “funciona no meu notebook e falha no outro”. - Modelo host/device: até o primeiro
<<<grid, block>>>e o primeirocudaDeviceSynchronize, muita gente ainda pensa em CUDA como “uma função C mais rápida”. O kernel vazio força a distinção entre código que roda na CPU e código que o driver despacha para a SMD. - Error checking como hábito:
cuda_2ecuda_3insistirem em checar retorno não é pedantismo. Em kernels de matriz e reduction, umcudaMemcpyfalho ou um launch com grid inválido se manifesta como resultado “quase certo” — o pior tipo de bug em validação de algoritmo.
cuda_3 (soma de vetores) fecha o ciclo de indexação 1D: threadIdx, blockIdx, stride e limites. É o ponto em que o engenheiro deixa de tratar a GPU como calculadora mágica e passa a tratar como arranjo de workers com memória própria.
Armadilhas de setup que o README não dramatiza
Na prática de bancada, três sintomas voltam com frequência:
| Sintoma | Causa típica | O que fazer antes de “otimizar” |
|---|---|---|
nvcc não acha, ou gera binário que não roda |
Driver ≠ toolkit ≠ capability | nvidia-smi, versão do CUDA, ARCH= explícito |
| Kernel “roda” e resultado errado sem crash | Bounds, grid curto, memcpy sem check | Assert de tamanho + cudaGetLastError após launch |
| Tempo “ótimo” num PC e péssimo no outro | Arch errada, WSL vs nativo, clocks | Tabelar GPU + ambiente + métrica juntos |
Sem essa higiene, shared memory e streams viram teatro: você mede ruído de setup, não ganho de algoritmo.
Matriz em global memory: o baseline honesto
cuda_4 multiplica matrizes só com global memory e já introduz timing com cudaEvent. Esse exemplo é o baseline de decisão, não o “código final”. Em multiplicação de matrizes densa, cada elemento de saída reutiliza linhas e colunas; se cada thread busca tudo direto da DRAM da GPU, você paga latência e largura de banda demais. Ainda assim, você precisa desse número: sem baseline, “shared memory ficou 2× mais rápido” vira marketing interno.
A lição de projeto é simples: otimize contra uma medição, não contra a intuição. Em visão embarcada, o equivalente é medir o pré-processamento (resize, conversão de cor, convolução) no caminho real de câmera antes de reescrever em TensorRT ou CUDA custom.
Como medir sem se enganar
cudaEventRecord / cudaEventElapsedTime medem o device com menos ruído do que clock() no host, mas ainda dá para mentir para si mesmo:
- Esquecer
cudaDeviceSynchronize(ou o sync implícito do evento) e cronometrar só o enqueue. - Incluir o primeiro launch “frio” (JIT, alocação, warmup de cache) na média de produção.
- Misturar H2D + kernel + D2H num único número e chamar isso de “tempo do algoritmo”.
- Comparar MX130 em WSL com 1660 nativa e concluir “shared memory não vale a pena”.
Protocolo mínimo alinhado ao espírito do c_cuda: warmup descartado, N corridas, GPU e ambiente nomeados, e — quando houver claim de overlap — decomposição transferência versus compute.
Shared memory tiled: o salto conceitual
cuda_5 traz a versão tiled com shared memory; cuda_6 compara lado a lado Global vs Shared. Aqui mora o ângulo do repositório.
Shared memory é memória on-chip, por bloco, com latência muito menor que a global, mas com tamanho limitado e regras de bank conflict. O padrão tiled carrega um tile de A e um tile de B para __shared__, sincroniza o bloco (__syncthreads), computa a contribuição parcial e avança o tile. O ganho vem de reuso de dados: o mesmo elemento lido da global alimenta várias contas dentro do bloco.
Nos benchmarks publicados no README do c_cuda (WSL):
| GPU | Global Memory | Shared Memory | Speedup Shared |
|---|---|---|---|
| MX130 (sm_50) | 51.1 ms | 20.4 ms | 2.51× |
| GTX 1660 (sm_75) | 8.4 ms | 4.1 ms | 2.06× |
Três leituras de engenharia:
- O speedup relativo não é constante entre GPUs. Na MX130 o ganho relativo foi maior; na 1660 o absoluto é muito menor (8.4 ms → 4.1 ms). Em decisão de produto, “2,5×” na GPU fraca pode ser a diferença entre caber ou não no ciclo de 30 fps; na GPU forte, o mesmo algoritmo pode já estar folgado e o próximo gargalo ser I/O de câmera.
- A GTX 1660 ficou ~4–6× mais rápida que a MX130 nos kernels testados no repo. Isso calibra expectativa: portar o mesmo
.cupara um notebook Maxwell de entrada não é “só recompilar e esquecer”. - Medição honesta inclui ambiente. O README também compara WSL vs Windows nativo na mesma GTX 1660: global memory 8.43 ms (WSL) vs 7.90 ms (nativo); shared 4.10 ms nos dois. A conclusão do autor — kernels de compute parecidos; nativo um pouco melhor em transferências e kernels curtos — é exatamente o tipo de nuance que some em posts que citam um único número “milagroso”.
Para quem vem de automação: pense em shared memory como um buffer local de bloco análogo a um cache de ciclo rápido no CLP — útil quando há reuso, inútil (ou prejudicial) se o padrão de acesso não justifica a sincronização e o footprint.
Quando shared memory não paga
Nem todo kernel mere tiled. Shared memory deixa de ser vitória fácil quando:
- O padrão de acesso tem pouco reuso (streaming puro de um passe).
- O tile força
__syncthreadsdemais e o bloco fica ocioso esperando o straggler. - Bank conflicts transformam “memória rápida” em fila serializada.
- O footprint
__shared__reduz occupancy a ponto de esconder o ganho de latência.
A decisão correta no cuda_6 não é “sempre Shared”. É medir Global, medir Shared, escolher com a tabela na mão. Em Jetson Nano, onde a margem de milissegundos é curta, esse hábito vale mais do que memorizar a API.
Parallel reduction: de “somar vetor” a padrão de biblioteca
cuda_7 faz parallel reduction com shared memory (1M elementos no benchmark do README: ~5–6 ms na MX130, 1.4 ms na GTX 1660 em WSL). Reduction é o esqueleto de soma, máximo, norma, histogramas parciais e várias etapas de redes neurais. O ponto de ensino não é o algoritmo em si — é reconhecer padrões de comunicação entre threads: árvore de redução, warps, e o fato de que a primeira implementação correta quase nunca é a mais rápida (bank conflicts, divergência, últimas etapas sequenciais).
O README lista próximos objetivos (bank conflicts, occupancy, __shfl, scan, histogramas, Nsight). Isso importa na narrativa do site: o repositório se posiciona como laboratório contínuo, não como checklist concluído. Em conteúdo técnico monetizável, honestidade de roadmap gera mais confiança do que fingir cobertura completa de CUDA.
Redução na bancada: o que quebra primeiro
| Problema | Sintoma | Direção de diagnóstico |
|---|---|---|
| Grid/bloco que não cobre N | Soma parcial “quase certa” | Checar resto N % blockDim |
__syncthreads dentro de ramo divergente |
Hang ou resultado fantasma | Sincronizar com todas as threads ativas no mesmo caminho |
| Usar shared sem padding / indexing errado | Bank conflict ou overwrite | Revisar layout do tile e stride |
Comparar só com std::accumulate single-thread sem warmup |
Speedup teatral | Baseline justo + mesmo tipo numérico |
Reduction bem medida ensina humildade: o algoritmo “certo” no quadro branco ainda pode ser lento no silício.
Streams: quando o kernel não é o único ator
cuda_8 introduz CUDA Streams e overlap CPU/GPU. Números do README (WSL):
| GPU | Sem overlap | Com streams | Speedup |
|---|---|---|---|
| MX130 | 21.3 ms | 17.8 ms | 1.20× |
| GTX 1660 | 6.8 ms | 4.4 ms | 1.53× |
Streams brilham quando há transferência + compute para pipelinar. Em desktop com PCIe razoável e workload balanceado, o overlap aparece. Em Jetson, a memória é unificada de forma diferente (e Unified Memory / zero-copy entram na conversa); o princípio continua: esconder latência de movimento de dados. O erro clássico é otimizar só o kernel e deixar o pipeline de câmera → CPU → H2D → kernel → D2H serializado.
Comparação WSL vs nativo no README ainda mostra nuance: na 1660, speedup de streams foi 1.53× no WSL e 1.10× no Windows nativo para aquele ensaio — outro lembrete de que “ganho de stream” depende do balanceamento do pipeline no ambiente medido.
Tabela de decisão: streams ou não?
| Situação | Streams ajudam? | Por quê |
|---|---|---|
| Kernel longo, transfers curtos | Pouco | Compute já domina; overlap pequeno |
| Transfers longos + kernels médios | Sim | Há o que esconder sob o PCIe |
| Pipeline câmera → pré → inferência | Sim (conceito) | Estágios paralelos importam mais que um único kernel |
| Medição só no WSL com I/O intenso | Cuidado | Valide no target nativo / Jetson |
| Memória unificada no Jetson | Depende | Reavalie copies explícitas vs zero-copy |
Streams não são “modo turbo”. São ferramenta de escalonamento de latência. Sem perfil de transferência, o cudaStreamCreate só adiciona complexidade.
Makefile com auto GPU arch: detalhe chato, decisão certa
Detectar arquitetura no make (Windows + Linux/WSL) parece infra. Em time misto notebook/desktop/CI, é o que evita binários incompatíveis e discussões intermináveis de “no meu PC compila”. Forçar ARCH=sm_50 ou sm_75 documenta a intenção. Para Jetson, o equivalente mental é fixar a compute capability da geração (e o JetPack) no script de build — não deixar o default do host “vazar” para o target.
Armadilhas de build multi-GPU
- Compilar no host sm_75 e copiar o binário para um ambiente que só tem Maxwell.
- Confiar no default do
nvccda máquina de CI sem pin de JetPack no target. - Misturar fatbin multi-arch “por via das dúvidas” sem medir tamanho e tempo de load no edge.
- Tratar warning de arch como ruído cosmético — às vezes é incompatibilidade adiada.
O Makefile do c_cuda não é cerimônia: é o contrato de reproducibilidade entre MX130, 1660 e, depois, Jetson.
O edge path até o Jetson
Por que este estudo de CUDA C no desktop alimenta a linha Jetson do site?
- Os mesmos conceitos de memória (global, shared, registers) aparecem em kernels custom, em plugins TensorRT e em pré-processamento CUDA de câmera CSI.
- A disciplina de medição (
cudaEvent, tabelas por GPU, WSL vs nativo) é o antídoto contra demos que “rodam 60 fps” sem dizer resolução, batch, precision nem temperatura. - A curva MX130 → 1660 ensina a ler hardware fraco vs forte. O Jetson Nano (Maxwell 128 CUDA cores, 4 GB) está mais próximo do regime “cada milissegundo conta” do que do regime desktop Turing folgado. Shared memory e redução bem feitas importam mais quando a margem é pequena.
- Streams e overlap antecipam pipelines reais: captura, pré-processamento, inferência e pós-processamento em estágios — o mesmo desenho mental do protótipo 4x4 / veículo de teste com Jetson na Wilson Tecnologia.
Ponte industrial: do .cu ao chão de fábrica / AMR
Em automação e robótica móvel, CUDA raramente é o produto. É o acelerador de um estágio. A ponte honesta:
Conceito no c_cuda |
Tradução em sistema industrial / AGV |
|---|---|
| Baseline global | Medir o ciclo atual antes de “otimizar visão” |
| Shared / tiled | Reuso de buffer on-chip ≈ reduzir tráfego de barramento |
| Reduction | Agregar score, energia, histograma de ROI |
| Streams / overlap | Pipeline captura→pré→inferência→comando |
| Arch pinada no Make | Imagem JetPack + power mode documentados |
| WSL vs nativo | Lab vs target — nunca misturar números |
O engenheiro de painel que já aprendeu a não confiar em “melhorou” sem trend de ciclo reconhece o método. CUDA muda o silício; não muda a ética de medição.
Decisões de projeto que o código sugere
- Comece pelo baseline global (
cuda_4/ lado Global decuda_6) antes de shared. Sem isso, você não sabe se o tiling pagou. - Publique GPU, ambiente e métrica juntos. “51.1 ms” só faz sentido com MX130 + WSL + Global Memory no
cuda_6. - Trate WSL como ambiente de desenvolvimento válido, mas valide números críticos em target nativo quando houver I/O intenso — o próprio README já mostra diferenças em reduction e em kernels curtos.
- Não confunda speedup algorítmico com capacidade absoluta. 2,5× na MX130 ainda pode ser lento demais para o ciclo; 2× na 1660 pode já ser irrelevante frente ao sensor.
- Planeje o port para Jetson como mudança de envelope, não como
scpdo binário. JetPack, power mode (nvpmodel), clocks (jetson_clocks/ jtop) e termal mudam o resultado tanto quanto o kernel.
O que deliberadamente não fazer com este material
- Não inventar timings. Os únicos números de CUDA deste artigo são os do README do
c_cuda(MX130 / GTX 1660, WSL e nativo onde citado). Sem novo benchmark, sem novo “X ms”. - Não vender TensorRT / DeepStream como se estivessem no repo. O caminho conceitual existe; o código progressivo é
cuda_1…cuda_8. - Não tratar speedup de streams do WSL como garantia no Jetson. Memória unificada e ISP/CSI mudam o balanço.
- Não usar o estudo como tutorial de SaaS, cloud GPU rental ou “como montar startup de visão”. É bancada e decisão de envelope.
- Não omitir temperatura e power mode em claims de FPS no Nano — o irmão editorial
jetson_nanoexiste exatamente para isso.
Checklist de bancada antes de declarar “shared memory ganhou”
- Arch correta (
sm_50/sm_75/ target Jetson) e mesmo tipo numérico nos dois lados. - Warmup + média; eventos CUDA — não só relógio de parede no host.
- Tabela Global vs Shared na mesma GPU e ambiente.
- Separar tempo de memcpy do tempo de kernel quando o claim for overlap.
- Anotar se a medição foi WSL ou nativo.
- Só então discutir port para Jetson (JetPack, nvpmodel, jtop).
Conclusão
O valor do c_cuda para a editorial técnica da Wilson não é “mais um tutorial de soma de vetores”. É um método: progressão host→device→global→shared→medição→reduction→streams, com Makefile consciente de arch e números reais em duas GPUs. Shared memory deixa de ser buzzword e vira decisão mensurável. Streams deixam de ser feature de slides e viram overlap de pipeline. E o caminho até o Jetson fica claro: quem aprendeu a medir memória e latência no desktop está preparado para decidir o que cabe — e o que não cabe — em percepção embarcada sob orçamento de watts.