아래 코드는 CUDA 프로그래밍의 "Hello World"라고 불리는 벡터 덧셈(Vector Addition) 예제입니다.
이 코드는 CPU가 반복문(for)을 돌며 하나씩 계산하는 방식과 달리, GPU의 수많은 스레드(Thread)가 동시에 각자의 데이터를 계산하는 병렬 처리의 기초 원리를 보여줍니다.
이 문서에서는 CUDA의 기본 개념부터 벡터 덧셈 예제의 작동 원리, 그리고 Ray Tracing과의 연관성까지 단계적으로 설명합니다.
Part 1: CUDA 기본 개념
CUDA란?
CUDA (Compute Unified Device Architecture)
•
NVIDIA가 개발한 GPU 병렬 컴퓨팅 플랫폼
•
C/C++ 기반의 프로그래밍 언어 확장
•
GPU의 수천 개 코어를 활용한 고속 연산
•
딥러닝, 과학 계산, 그래픽스 등에 활용
Host vs Device
Host (호스트)
•
CPU와 그에 연결된 메인 메모리 (RAM)
•
순차적 처리에 최적화
•
프로그램의 제어 흐름 관리
Device (디바이스)
•
GPU와 그에 연결된 비디오 메모리 (VRAM)
•
병렬 처리에 최적화
•
수천 개의 연산을 동시 수행
메모리 분리
CUDA 프로그래밍 모델
스레드 계층 구조
용어 정리
•
Thread (스레드): 실제 연산을 수행하는 가장 작은 단위
•
Block (블록): 여러 스레드를 묶은 그룹
•
Grid (그리드): 여러 블록을 묶은 전체 작업 공간
•
Kernel (커널): GPU에서 실행되는 함수
Part 2: CUDA 벡터 덧셈 작동 원리
전체 실행 흐름
이 프로그램은 Host(CPU)와 Device(GPU)라는 두 개의 서로 다른 메모리 공간을 오가며 작업을 수행합니다. 전체 과정은 크게 [할당 → 복사 → 연산 → 회수]의 4단계로 이루어집니다.
실행 흐름도
왜 복사가 필요한가?
메모리 분리의 이유
•
GPU는 CPU의 RAM에 직접 접근 불가
•
CPU는 GPU의 VRAM에 직접 접근 불가
•
데이터 교환을 위해 명시적 복사 필요
•
PCIe 버스를 통한 데이터 전송
병목 현상
•
CPU-GPU 간 데이터 전송은 상대적으로 느림
•
계산량이 많을수록 전송 오버헤드 상쇄
•
작은 데이터는 CPU가 더 빠를 수 있음
Part 3: 커널 함수 상세 분석
addKernel 함수 - "일꾼들의 작업 지시서"
__global__ void addKernel(int* c, const int* a, const int* b)
{
int i = threadIdx.x; // "나는 몇 번째 스레드인가?"
c[i] = a[i] + b[i];
}
C++
복사
코드 분석
global 키워드
__global__ void addKernel(...)
C++
복사
•
이 함수는 GPU에서 실행됨
•
CPU에서 호출 가능
•
반환 타입은 반드시 void
•
Device 코드와 Host 코드의 경계
CUDA 함수 한정자
__global__ // GPU에서 실행, CPU에서 호출
__device__ // GPU에서 실행, GPU에서 호출
__host__ // CPU에서 실행, CPU에서 호출 (기본값)
C++
복사
Loop가 없다?
보통 C++라면 for(int i=0; i<5; i++)를 썼겠지만, 여기선 없습니다. 대신 수많은 스레드가 이 함수를 동시에 실행합니다.
CPU 방식 (순차)
for (int i = 0; i < 5; i++)
{
c[i] = a[i] + b[i];
}
// 총 5번의 반복, 순차 실행
C++
복사
GPU 방식 (병렬)
// 5개의 스레드가 동시에 실행
// Thread 0: c[0] = a[0] + b[0]
// Thread 1: c[1] = a[1] + b[1]
// Thread 2: c[2] = a[2] + b[2]
// Thread 3: c[3] = a[3] + b[3]
// Thread 4: c[4] = a[4] + b[4]
C++
복사
threadIdx.x
int i = threadIdx.x;
C++
복사
•
현재 작업을 수행 중인 스레드의 고유 번호(ID)
•
각 스레드는 자신의 ID를 알고 있음
•
이를 통해 담당할 데이터 결정
스레드 인덱스 상세
threadIdx.x // 블록 내 x축 스레드 인덱스 (0부터 시작)
threadIdx.y // 블록 내 y축 스레드 인덱스
threadIdx.z // 블록 내 z축 스레드 인덱스
blockIdx.x // 그리드 내 x축 블록 인덱스
blockIdx.y // 그리드 내 y축 블록 인덱스
blockIdx.z // 그리드 내 z축 블록 인덱스
blockDim.x // 블록의 x축 크기 (스레드 개수)
gridDim.x // 그리드의 x축 크기 (블록 개수)
C++
복사
전역 인덱스 계산
// 1D 예시
int i = blockIdx.x * blockDim.x + threadIdx.x;
// 2D 예시 (이미지 처리)
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
int idx = y * width + x;
C++
복사
Part 4: 메모리 관리 함수
addWithCuda 함수 - "데이터 물류 시스템"
1. GPU 메모리 할당 (cudaMalloc)
cudaMalloc((void**)&dev_a, size * sizeof(int));
C++
복사
동작 원리
•
GPU는 CPU의 RAM(메모리)을 직접 볼 수 없음
•
GPU 전용 메모리(VRAM)에 공간을 따로 확보
•
malloc과 동일하지만 GPU에 할당
함수 시그니처
cudaError_t cudaMalloc(void** devPtr, size_t size);
// devPtr: 할당된 GPU 메모리 주소를 받을 포인터
// size: 할당할 바이트 크기
// 반환값: 성공 시 cudaSuccess
C++
복사
사용 예시
int* dev_a; // GPU 메모리 주소를 저장할 포인터
cudaMalloc((void**)&dev_a, 100 * sizeof(int)); // 100개 int 공간
C++
복사
2. 데이터 복사 (cudaMemcpy HostToDevice)
cudaMemcpy(dev_a, a, size * sizeof(int), cudaMemcpyHostToDevice);
C++
복사
동작 원리
•
CPU에 있는 데이터({1,2,3,4,5})를 GPU 메모리로 복사
•
PCIe 버스를 통한 데이터 전송
•
동기 작업 (완료될 때까지 대기)
함수 시그니처
cudaError_t cudaMemcpy(
void* dst, // 목적지 주소
const void* src, // 소스 주소
size_t count, // 복사할 바이트 크기
enum cudaMemcpyKind kind // 복사 방향
);
C++
복사
복사 방향 (cudaMemcpyKind)
cudaMemcpyHostToHost // CPU → CPU
cudaMemcpyHostToDevice // CPU → GPU
cudaMemcpyDeviceToHost // GPU → CPU
cudaMemcpyDeviceToDevice // GPU → GPU
cudaMemcpyDefault // 자동 감지 (Unified Memory)
C++
복사
3. 커널 실행 (<<< >>>)
addKernel<<<1, size>>>(dev_c, dev_a, dev_b);
C++
복사
동작 원리
이 부분이 가장 중요합니다. GPU에게 일을 시키는 명령입니다.
실행 구성 문법
kernelFunction<<<gridDim, blockDim, sharedMem, stream>>>(args...);
// gridDim: 그리드 크기 (블록 개수)
// blockDim: 블록 크기 (블록당 스레드 개수)
// sharedMem: 공유 메모리 크기 (바이트, 선택)
// stream: CUDA 스트림 (선택)
C++
복사
예제 분석
addKernel<<<1, size>>>(dev_c, dev_a, dev_b);
// <<<1, size>>>의 의미:
// - 1개의 블록 생성
// - 각 블록에 size(5개)만큼의 스레드 생성
// - 총 1 × 5 = 5개의 스레드
C++
복사
다양한 실행 구성
// 1D 구성
kernel<<<numBlocks, threadsPerBlock>>>();
// 2D 구성 (이미지 처리)
dim3 threadsPerBlock(16, 16);
dim3 numBlocks(width/16, height/16);
kernel<<<numBlocks, threadsPerBlock>>>();
// 3D 구성 (볼륨 데이터)
dim3 threadsPerBlock(8, 8, 8);
dim3 numBlocks(width/8, height/8, depth/8);
kernel<<<numBlocks, threadsPerBlock>>>();
C++
복사
비동기 실행
•
커널 호출은 비동기
•
CPU는 커널 완료를 기다리지 않고 다음 코드 실행
•
cudaDeviceSynchronize()로 완료 대기
4. 결과 회수 (cudaMemcpy DeviceToHost)
cudaMemcpy(c, dev_c, size * sizeof(int), cudaMemcpyDeviceToHost);
C++
복사
동작 원리
•
GPU가 계산해 둔 결과(dev_c)는 GPU 메모리에만 존재
•
이를 확인하려면 CPU 메모리(c)로 복사 필요
•
동기 작업 (복사 완료 보장)
5. 메모리 해제 (cudaFree)
cudaFree(dev_a);
cudaFree(dev_b);
cudaFree(dev_c);
C++
복사
동작 원리
•
할당한 GPU 메모리 반납
•
free()와 동일하지만 GPU 메모리 대상
•
메모리 누수 방지
Part 5: 병렬 처리 시각화
스레드 동작 분석
코드에서 size가 5이므로, GPU 내부에서는 다음과 같은 일이 동시에 벌어집니다.
스레드 번호 (threadIdx.x) | 수행하는 작업 | 데이터 (a + b) | 결과 (c) |
Thread 0 | c[0] = a[0] + b[0] | 1 + 10 | 11 |
Thread 1 | c[1] = a[1] + b[1] | 2 + 20 | 22 |
Thread 2 | c[2] = a[2] + b[2] | 3 + 30 | 33 |
Thread 3 | c[3] = a[3] + b[3] | 4 + 40 | 44 |
Thread 4 | c[4] = a[4] + b[4] | 5 + 50 | 55 |
CPU였다면 위에서 아래로 순서대로 했겠지만, GPU는 이 5줄을 한 번에 처리합니다. 데이터가 5개가 아니라 500만 개라면 속도 차이는 엄청나게 벌어집니다.
성능 비교
CPU 순차 처리
for (int i = 0; i < N; i++) {
c[i] = a[i] + b[i];
}
// 시간 복잡도: O(N)
// 1백만 개: ~1초
C++
복사
GPU 병렬 처리
addKernel<<<blocks, threads>>>(dev_c, dev_a, dev_b);
// 시간 복잡도: O(1) (이론적)
// 1백만 개: ~0.001초
C++
복사
실제 성능
Part 6: Ray Tracing과의 연관성
왜 벡터 덧셈부터 시작하는가?
작성하신 코드 아래쪽에 주석 처리된 Ray Tracing 코드가 보입니다. 이 CUDA 예제를 먼저 수행하는 이유는 레이 트레이싱(Ray Tracing)이 GPU 병렬 처리에 가장 적합한 작업이기 때문입니다.
CPU vs GPU Ray Tracing
기존 CPU 방식
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
// 각 픽셀에 대해 광선 추적
Ray r = camera.getRay(x, y);
Color c = trace(r, scene);
image[y][x] = c;
}
}
// 1920×1080 = 2,073,600 픽셀을 순차 처리
// 매우 느림 (수 분 ~ 수 시간)
C++
복사
CUDA 방식
__global__ void render(Color* image, Camera camera, Scene scene, int width, int height)
{
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x >= width || y >= height) return;
int idx = y * width + x;
Ray r = camera.getRay(x, y);
image[idx] = trace(r, scene);
}
// 각 픽셀마다 스레드 하나 배정
// 수천, 수만 개의 스레드가 동시에 계산
// 매우 빠름 (수 초 ~ 수 분)
C++
복사
벡터 덧셈 → 픽셀 렌더링
지금 연습하신 Vector Add 예제의 i(인덱스)가 나중에는 Pixel(x, y) 좌표가 되어, 각 픽셀의 색상을 병렬로 계산하게 될 것입니다.
개념 매핑
// Vector Addition
threadIdx.x → 배열 인덱스 i
c[i] = a[i] + b[i]
// Ray Tracing
threadIdx.x, threadIdx.y → 픽셀 좌표 (x, y)
image[y][x] = trace(ray(x, y))
C++
복사
Ray Tracing이 병렬 처리에 적합한 이유
독립성
•
각 픽셀의 계산은 다른 픽셀과 독립적
•
데이터 경쟁(Race Condition) 없음
•
동기화 불필요
높은 계산 복잡도
•
각 픽셀당 수백~수천 번의 연산
•
광선 추적, 반사, 굴절, 그림자 계산
•
전송 오버헤드 << 계산 시간
규칙적인 데이터 패턴
•
2D 그리드 구조
•
연속적인 메모리 접근
•
캐시 효율 좋음
Part 7: 에러 처리 및 디버깅
CUDA 에러 처리
cudaError_t 활용
cudaError_t err;
err = cudaMalloc((void**)&dev_a, size * sizeof(int));
if (err != cudaSuccess)
{
fprintf(stderr, "cudaMalloc failed: %s\n", cudaGetErrorString(err));
return 1;
}
C++
복사
에러 체크 매크로
#define CHECK_CUDA(call) \
do { \
cudaError_t err = call; \
if (err != cudaSuccess) { \
fprintf(stderr, "CUDA Error: %s at %s:%d\n", \
cudaGetErrorString(err), __FILE__, __LINE__); \
exit(1); \
} \
} while(0)
// 사용
CHECK_CUDA(cudaMalloc((void**)&dev_a, size * sizeof(int)));
C++
복사
커널 에러 확인
비동기 에러
addKernel<<<1, size>>>(dev_c, dev_a, dev_b);
// 커널 실행 에러 확인
cudaError_t err = cudaGetLastError();
if (err != cudaSuccess)
{
fprintf(stderr, "Kernel launch failed: %s\n", cudaGetErrorString(err));
}
// 커널 완료 대기 및 런타임 에러 확인
err = cudaDeviceSynchronize();
if (err != cudaSuccess)
{
fprintf(stderr, "Kernel execution failed: %s\n", cudaGetErrorString(err));
}
C++
복사
디버깅 팁
printf 디버깅
__global__ void addKernel(int* c, const int* a, const int* b)
{
int i = threadIdx.x;
printf("Thread %d: %d + %d = %d\n", i, a[i], b[i], a[i] + b[i]);
c[i] = a[i] + b[i];
}
C++
복사
디바이스 정보 확인
int device;
cudaGetDevice(&device);
cudaDeviceProp prop;
cudaGetDeviceProperties(&prop, device);
printf("Device: %s\n", prop.name);
printf("Compute Capability: %d.%d\n", prop.major, prop.minor);
printf("Max Threads per Block: %d\n", prop.maxThreadsPerBlock);
printf("Max Grid Size: %d × %d × %d\n",
prop.maxGridSize[0], prop.maxGridSize[1], prop.maxGridSize[2]);
C++
복사
Part 8: 최적화 기법
메모리 전송 최소화
나쁜 예
for (int i = 0; i < iterations; i++)
{
cudaMemcpy(dev_data, host_data, size, cudaMemcpyHostToDevice);
kernel<<<blocks, threads>>>(dev_data);
cudaMemcpy(host_data, dev_data, size, cudaMemcpyDeviceToHost);
}
C++
복사
좋은 예
cudaMemcpy(dev_data, host_data, size, cudaMemcpyHostToDevice);
for (int i = 0; i < iterations; i++)
{
kernel<<<blocks, threads>>>(dev_data);
}
cudaMemcpy(host_data, dev_data, size, cudaMemcpyDeviceToHost);
C++
복사
적절한 블록/스레드 크기
권장 사항
// 블록당 스레드 수는 32의 배수 (Warp 크기)
// 일반적으로 128, 256, 512 사용
dim3 threadsPerBlock(256);
dim3 numBlocks((N + threadsPerBlock.x - 1) / threadsPerBlock.x);
kernel<<<numBlocks, threadsPerBlock>>>(data, N);
C++
복사
공유 메모리 활용
Global Memory vs Shared Memory
__global__ void slowKernel(int* data)
{
int idx = threadIdx.x;
// Global Memory 반복 접근 (느림)
data[idx] = data[idx] * 2 + data[idx] * 3;
}
__global__ void fastKernel(int* data)
{
__shared__ int temp[256];
int idx = threadIdx.x;
// Global Memory에서 Shared Memory로 복사
temp[idx] = data[idx];
__syncthreads();
// Shared Memory 접근 (빠름)
temp[idx] = temp[idx] * 2 + temp[idx] * 3;
__syncthreads();
// 결과를 Global Memory에 저장
data[idx] = temp[idx];
}
C++
복사
결론
핵심 요약
CUDA 벡터 덧셈
•
GPU 병렬 컴퓨팅의 기초 예제
•
Host와 Device 메모리 분리 이해
•
커널 함수와 스레드 개념 학습
•
데이터 전송과 동기화 패턴
실행 흐름
1.
GPU 메모리 할당 (cudaMalloc)
2.
CPU → GPU 데이터 전송 (cudaMemcpy)
3.
커널 실행 (<<<...>>>)
4.
GPU → CPU 결과 회수 (cudaMemcpy)
5.
GPU 메모리 해제 (cudaFree)
병렬 처리 핵심
•
각 스레드가 고유 ID를 가짐
•
Loop 대신 동시 실행
•
독립적인 데이터 처리에 최적
Ray Tracing으로 가는 길
벡터 덧셈 (현재)
•
1D 배열 처리
•
기본 메모리 관리
•
단순 연산
Ray Tracing (다음 단계)
•
2D 이미지 처리
•
복잡한 광선 추적 알고리즘
•
수백 배 빠른 렌더링
이 코드는 "데이터를 GPU로 옮기고 → 병렬로 계산하고 → 다시 가져오는" GPU 컴퓨팅의 가장 기초적이고 표준적인 절차를 구현한 것입니다. 이 패턴은 단순한 벡터 덧셈부터 복잡한 Ray Tracing까지 모든 CUDA 프로그램의 기본 골격이 됩니다.




