Company
교육 철학

CUDA Vector Addition - GPU 병렬 처리 기초

5696ff5d5a4c7ce8c80803127788e6c13f1c5df4
commit
아래 코드는 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 프로그램의 기본 골격이 됩니다.