서비스
교육 철학

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 프로그램의 기본 골격이 됩니다.