
1. 들어가며: 왜 내 코드를 GPU에서 돌리고 싶을까?
"내 코드를 GPU에서 돌릴 수 없을까?" 딥러닝이나 대규모 데이터 처리가 일상화된 오늘날, 파이썬 사용자라면 누구나 한 번쯤 품어보는 질문입니다. 이 질문에 대한 해답의 중심에는 바로 NVIDIA의 CUDA 기술이 있습니다. 이 문서는 CUDA가 처음인 입문자를 위해, 복잡한 개념을 쉬운 비유로 풀어내고 그 핵심 원리를 명확하게 이해할 수 있도록 돕는 것을 목표로 합니다.
2. 핵심 비유: 똑똑한 총괄 셰프 vs. 수많은 보조 요리사
CPU와 GPU의 차이를 이해하는 가장 쉬운 방법은 이들을 식당 주방에 비유하는 것입니다.
- CPU는 소수의 똑똑한 총괄 셰프와 같습니다. 이 셰프는 복잡한 요리(작업)를 순서에 맞춰 하나씩 완벽하게 처리하는 데 특화되어 있습니다.
- GPU는 수천 명의 보조 요리사와 같습니다. 이들은 각자 복잡한 요리 과정을 처리하기보다는, '양파 1,000개 썰기'와 같은 단순하고 반복적인 작업을 동시에 처리하는 데 특화되어 있습니다.
| 특징 | CPU (총괄 셰프) | GPU (수천 명의 보조 요리사) |
| 코어 특징 | 몇 개의 아주 똑똑한 코어 | 수천 개의 작은 코어 |
| 처리 방식 | 복잡한 일을 순차적으로 처리 | 단순한 일을 동시에(병렬로) 처리 |
그렇다면 이 수많은 보조 요리사들에게 어떻게 일을 지시할 수 있을까요? 바로 여기서 CUDA가 등장합니다.
3. CUDA: GPU와 대화하는 방법
**CUDA(Compute Unified Device Architecture)**는 NVIDIA가 개발한 기술로, 프로그래머가 GPU의 수많은 병렬 처리 코어를 직접 제어할 수 있게 해주는 일종의 '소통 방법'입니다. C++ 같은 저수준 언어뿐만 아니라, 파이썬에서는 Numba와 같은 라이브러리를 통해 이 강력한 기능을 아주 쉽게 활용할 수 있습니다.
4. 반드시 알아야 할 CUDA 핵심 용어
CUDA 프로그래밍의 기본 구조를 이해하려면 다음 세 가지 용어를 반드시 알아야 합니다.
- Host: CPU와 시스템 메모리 (RAM)를 의미합니다.
- Device: GPU와 그에 속한 비디오 메모리 (VRAM)를 의미합니다.
- Kernel: GPU 위에서 병렬로 실행되도록 작성된 함수를 지칭하는 용어입니다.
간단히 말해, Host(CPU)가 데이터와 작업 지시서(Kernel)를 Device(GPU)에게 보내면, GPU가 그 일을 병렬로 처리하는 구조입니다. 이제 이 용어들이 실제 코드에서 어떻게 작동하는지 단계별로 살펴보겠습니다.
5. CUDA 작업의 5단계 흐름: 데이터의 여정
CUDA 작업의 흐름을 이해하는 가장 좋은 방법은 데이터의 여정을 따라가 보는 것입니다. 이는 마치 Host(CPU의 나라)에 있는 데이터를 Device(GPU의 나라)로 출장 보내 일을 시키고, 결과 보고서를 다시 받아오는 5단계 과정과 같습니다.
- 1단계: 데이터 준비 (Host에서) 모든 작업의 시작은 CPU입니다. 계산에 필요한 초기 데이터 배열을 CPU가 사용하는 메모리(RAM)에 생성합니다.
- 2단계: GPU로 데이터 전송 (Host → Device) CPU와 GPU는 물리적으로 분리된 메모리를 사용하므로, GPU가 작업할 데이터를 cuda.to_device와 같은 명령어로 GPU의 메모리(VRAM)로 복사해줘야 합니다.
- 3단계: GPU에서 커널 실행 (Device에서) 데이터가 준비되면, GPU에서 실행될 Kernel 함수를 호출합니다. 이때 GPU의 수천 개 코어(스레드)가 동시에 커널 함수를 실행하며, 각자 맡은 데이터 조각을 병렬로 처리합니다.
- 4단계: CPU로 결과 복사 (Device → Host) GPU에서의 계산이 모두 끝나면, 결과 데이터는 여전히 GPU 메모리에 남아있습니다. copy_to_host() 명령어를 통해 이 결과를 다시 CPU의 메모리로 가져와야 합니다.
- 5단계: 결과 확인 (Host에서) 이제 최종 결과가 CPU 메모리로 돌아왔으므로, 프로그램의 다른 부분에서 이 결과를 확인하거나 활용할 수 있습니다.
💡 핵심 인사이트: 가장 중요한 것은 데이터 이동 최소화 핵심은 GPU가 아무리 빨리 계산을 끝내도, 데이터를 주고받는 길(PCIe 버스)이 막히면 전체 작업 시간은 느려진다는 점입니다. 따라서 똑똑한 CUDA 프로그래머는 계산에 필요한 모든 데이터를 최대한 한 번에 GPU로 보내고, 여러 계산을 그곳에서 모두 마친 뒤 최종 결과만 가져오는 방식으로 데이터 이동 횟수 자체를 줄이는 데 집중합니다.
이처럼 데이터 이동이라는 추가 과정이 필요한데도 GPU를 사용하는 이유는 무엇일까요? 그 답은 바로 압도적인 속도에 있습니다.
6. CUDA가 강력한 이유: 행렬 곱셈과 성능 비교
CUDA의 진정한 힘은 행렬 곱셈과 같은 연산에서 드러납니다. 행렬 곱셈은 딥러닝의 핵심을 이루는 연산으로, 결과 행렬의 각 원소를 개별적으로, 서로에게 영향을 주지 않고 계산할 수 있어 병렬 처리에 완벽하게 부합합니다.
💡 성능을 한 단계 더: 공유 메모리 기본적인 CUDA 코드의 성능을 한 단계 더 끌어올리기 위해 **공유 메모리(Shared Memory)**라는 고급 기법을 사용하기도 합니다. 이는 GPU 코어들이 멀리 있는 메인 메모리에 매번 접근하는 대신, 근처에 있는 매우 빠른 '공용 사물함' 같은 공간에 데이터를 잠시 공유해두고 사용하는 방식입니다. 이를 통해 메모리 접근 시간을 획기적으로 줄여 상당한 속도 향상을 얻을 수 있습니다.
실제 성능 벤치마크에 따르면, 1024x1024 크기 이상의 대규모 행렬 곱셈에서 CUDA를 사용한 GPU는 고도로 최적화된 CPU 라이브러리(NumPy)보다 수십 배에서 수백 배까지 빠른 성능을 보여줍니다. 실제 성능 측정 시에는 '웜업(Warm-up)' 과정이 필요하다는 점도 기억해야 합니다. GPU는 첫 연산 시 초기화 작업이 필요해 시간이 더 걸릴 수 있으므로, 정확한 성능은 보통 두 번째 실행부터 측정합니다.
⚠️ 주의할 점: 데이터가 작으면 오히려 손해! 한 가지 중요한 사실은, 처리할 데이터의 크기가 너무 작으면 오히려 GPU가 CPU보다 느릴 수 있다는 점입니다. 데이터를 GPU로 보내고 다시 가져오는 데 걸리는 시간(오버헤드)이 실제 계산 시간보다 길어지기 때문입니다. 따라서 CUDA는 대량의 데이터를 한꺼번에 처리할 때 가장 큰 효과를 발휘합니다.
7. 결론: 원리 이해를 넘어 실전으로
지금까지 살펴본 것처럼, CUDA는 수많은 단순 작업을 동시에 처리해야 하는 문제에 대한 강력한 해결책입니다.
Numba를 이용해 직접 커널을 작성하는 것은 CUDA의 작동 원리를 깊이 이해하는 데 매우 훌륭한 방법입니다. 하지만 실제 현업 프로젝트에서는 PyTorch와 같은 고수준 딥러닝 프레임워크를 주로 사용합니다. PyTorch는 내부적으로 엔비디아가 직접 만든 고도로 최적화된 CUDA 라이브러리(예: 행렬 연산을 위한 cuBLAS, 딥러닝 연산을 위한 cuDNN)를 사용하므로, 개발자는 복잡한 커널 코드 없이 .to(device)와 같은 간단한 명령만으로 GPU의 엄청난 성능을 손쉽게 활용할 수 있습니다.
따라서 제가 추천하는 가장 효율적인 학습 경로는 다음과 같습니다.
Numba로 CUDA의 작동 원리를 먼저 이해하고, 실제 프로젝트에서는 파이토치와 같은 프레임워크를 활용하는 것이 가장 효율적인 학습 경로입니다.
파이썬 사용자라면 누구나 한 번쯤 "내 코드를 GPU에서 돌릴 수 없을까?"라는 고민을 하게 됩니다. 딥러닝이나 대규모 데이터 처리가 일상화된 요즘, 엔비디아의 CUDA 기술은 필수적인 요소가 되었습니다. 복잡한 C++ 없이 파이썬만으로 CUDA를 경험해보고 싶은 초보자를 위해 핵심 개념과 예제 코드를 정리해 드립니다.
컴퓨터의 뇌 역할을 하는 CPU가 몇 개의 아주 똑똑한 코어로 복잡한 일을 하나씩 처리한다면, GPU는 수천 개의 작은 코어로 단순한 일을 동시에 처리합니다. CUDA는 바로 이 GPU의 병렬 처리 능력을 우리가 사용하는 프로그래밍 언어로 제어할 수 있게 해주는 기술입니다. 파이썬에서는 Numba라는 라이브러리를 통해 이 기능을 아주 쉽게 빌려 쓸 수 있습니다.
파이썬으로 구현하는 CUDA 예제
일반적인 파이썬 리스트 계산과 GPU를 이용한 계산이 어떻게 다른지 보여주는 가장 기초적인 벡터 덧셈 예제입니다.
import numpy as np
from numba import cuda
# GPU에서 실행될 '커널' 함수 정의
@cuda.jit
def add_kernel(x, y, out):
# 각 스레드가 처리할 데이터의 인덱스를 계산합니다
idx = cuda.grid(1)
# 데이터 범위를 벗어나지 않는지 확인 후 연산 수행
if idx < x.size:
out[idx] = x[idx] + y[idx]
# 1. 데이터 준비 (100만 개의 요소)
n = 1000000
x = np.ones(n, dtype=np.float32)
y = np.ones(n, dtype=np.float32)
out = np.zeros(n, dtype=np.float32)
# 2. CPU 데이터를 GPU 메모리로 복사
device_x = cuda.to_device(x)
device_y = cuda.to_device(y)
device_out = cuda.device_array_like(out)
# 3. GPU 실행 설정 (블록 크기와 그리드 크기 결정)
threads_per_block = 256
blocks_per_grid = (n + (threads_per_block - 1)) // threads_per_block
# 4. 커널 실행
add_kernel[blocks_per_grid, threads_per_block](device_x, device_y, device_out)
# 5. 결과를 다시 CPU 메모리로 가져오기
result = device_out.copy_to_host()
print(f"계산 완료: {result[:5]}...")
코드의 핵심 원리
이 코드에서 가장 중요한 부분은 @cuda.jit이라는 데코레이터입니다. 이 명령은 일반 파이썬 함수를 GPU가 이해할 수 있는 머신 코드로 컴파일해 줍니다.
또한 cuda.grid(1)이라는 명령을 통해 수만 개의 스레드가 각자 자신이 맡은 번호(인덱스)를 찾아가 동시에 덧셈을 수행하게 됩니다. 식당으로 비유하자면, 요리사 한 명이 100그릇의 요리를 순서대로 하는 것이 아니라 100명의 요리사가 동시에 각자의 그릇을 하나씩 요리하는 것과 같습니다.
이 예제를 직접 실행해 보려면 PC에 NVIDIA 그래픽 카드가 설치되어 있어야 하며, pip install numba 명령어로 라이브러리를 설치해야 합니다.
파이썬으로 GPU의 성능을 제대로 끌어내기 위해 꼭 알아야 할 메모리 관리 개념을 정리해 드립니다.
GPU 프로그래밍의 핵심: 메모리 이동
위 예제 코드에서 데이터를 to_device로 옮기고 다시 copy_to_host로 가져오는 과정을 보셨을 겁니다. 이는 CPU가 사용하는 메모리(RAM)와 GPU가 사용하는 메모리(VRAM)가 물리적으로 떨어져 있기 때문입니다.
효율적인 쿠다 프로그래밍의 관건은 이 데이터 이동을 최소화하는 것입니다. 데이터를 옮기는 시간 자체가 계산 시간보다 길어질 수 있기 때문입니다.
주요 개념 정리
- Host: CPU와 시스템 메모리를 의미합니다.
- Device: GPU와 그에 딸린 비디오 메모리를 의미합니다.
- Kernel: GPU에서 병렬로 실행되는 함수를 부르는 명칭입니다.
- Grid, Block, Thread: GPU 내부에서 작업을 나누는 단위로, 스레드가 가장 작은 실행 단위입니다.
핵심 키워드
호스트와 디바이스, 메모리 할당, 데이터 전송 최적화, 병렬 처리 알고리즘, 엔비디아 드라이버, 넘파이 호환성
다음 단계로 나아가기
실제로 성능 차이를 느껴보고 싶다면 데이터의 크기를 1,000만 개 이상으로 늘려보세요. 일반적인 파이썬 for문과 비교했을 때 수백 배 이상의 속도 차이를 체감할 수 있습니다.
단순한 덧셈을 넘어 GPU가 가장 잘하는 분야인 **행렬 곱셈(Matrix Multiplication)**을 구현해 보겠습니다. 행렬 곱셈은 딥러닝의 핵심 연산으로, 수많은 곱셈과 덧셈을 동시에 처리해야 하기에 CUDA의 진가를 확인하기 가장 좋습니다.
행렬 곱셈의 원리
두 행렬을 곱할 때, 결과 행렬의 각 원소 $(i, j)$는 첫 번째 행렬의 $i$번째 행과 두 번째 행렬의 $j$번째 열을 각각 곱해서 더한 값입니다. 이 계산들은 서로 독립적이기 때문에, 수만 개의 GPU 스레드가 결과창의 칸 하나씩을 맡아 동시에 계산을 끝낼 수 있습니다.
핵심 키워드
행렬 곱셈, 2차원 그리드, 스레드 인덱싱, 공유 메모리, 딥러닝 가속, 성능 벤치마크
파이썬 CUDA 행렬 곱셈 코드
import numpy as np
from numba import cuda
import math
@cuda.jit
def matmul_kernel(A, B, C):
# 2차원 그리드에서 현재 스레드의 행(row)과 열(col) 위치를 찾습니다
row, col = cuda.grid(2)
# 결과 행렬의 범위 안에 있는지 확인
if row < C.shape[0] and col < C.shape[1]:
tmp = 0.
# 행과 열의 곱을 누적합으로 계산
for k in range(A.shape[1]):
tmp += A[row, k] * B[k, col]
C[row, col] = tmp
# 1. 데이터 생성 (512 x 512 행렬)
size = 512
a = np.random.rand(size, size).astype(np.float32)
b = np.random.rand(size, size).astype(np.float32)
c = np.zeros((size, size), dtype=np.float32)
# 2. GPU 메모리로 전송
d_a = cuda.to_device(a)
d_b = cuda.to_device(b)
d_c = cuda.to_device(c)
# 3. 블록 및 그리드 설정 (16x16 스레드 블록 사용)
threads_per_block = (16, 16)
blocks_per_grid_x = math.ceil(size / threads_per_block[0])
blocks_per_grid_y = math.ceil(size / threads_per_block[1])
blocks_per_grid = (blocks_per_grid_x, blocks_per_grid_y)
# 4. 커널 실행
matmul_kernel[blocks_per_grid, threads_per_block](d_a, d_b, d_c)
# 5. 결과 복사
result = d_c.copy_to_host()
print("행렬 곱셈 완료!")
성능을 더 높이는 팁: 공유 메모리
위의 코드는 가장 기본적인 방식입니다. 실제 현업에서는 **공유 메모리(Shared Memory)**라는 기법을 써서 성능을 더 끌어올립니다. GPU 코어들이 멀리 있는 비디오 메모리에 매번 접근하는 대신, 근처에 있는 아주 빠른 '공용 사물함'에 데이터를 잠시 넣어두고 쓰는 방식입니다. 이 방법을 쓰면 속도가 몇 배는 더 빨라집니다.
GPU와 CPU의 성능 차이를 직접 눈으로 확인해볼 차례입니다. 파이썬의 time 모듈을 사용하여 동일한 행렬 곱셈 연산을 수행할 때 걸리는 시간을 측정해 보겠습니다. 데이터의 크기가 커질수록 GPU가 왜 압도적인지 명확히 알 수 있습니다.
성능 측정의 핵심
단순히 실행 시간만 재는 것이 아니라, GPU 연산에서는 Warm-up이라는 과정이 필요합니다. GPU는 첫 호출 시 컴파일과 초기화 작업이 병행되므로, 두 번째 실행부터 진짜 실력이 나옵니다. 또한 CPU 연산은 넘파이(NumPy)를 사용해 비교하겠습니다.
핵심 키워드
성능 벤치마크, 실행 시간 측정, 넘파이 vs 쿠다, 데이터 스케일링, 연산 가속도, 웜업 타임
성능 비교 테스트 코드
import numpy as np
from numba import cuda
import math
import time
@cuda.jit
def matmul_kernel(A, B, C):
row, col = cuda.grid(2)
if row < C.shape[0] and col < C.shape[1]:
tmp = 0.
for k in range(A.shape[1]):
tmp += A[row, k] * B[k, col]
C[row, col] = tmp
# 데이터 설정 (1024 x 1024 행렬)
size = 1024
a = np.random.rand(size, size).astype(np.float32)
b = np.random.rand(size, size).astype(np.float32)
c = np.zeros((size, size), dtype=np.float32)
# 1. CPU(NumPy) 실행 시간 측정
start_cpu = time.time()
cpu_result = np.dot(a, b)
end_cpu = time.time()
print(f"CPU(NumPy) 소요 시간: {end_cpu - start_cpu:.4f}초")
# 2. GPU(CUDA) 실행 시간 측정
d_a = cuda.to_device(a)
d_b = cuda.to_device(b)
d_c = cuda.device_array((size, size), dtype=np.float32)
threads_per_block = (16, 16)
blocks_per_grid = (math.ceil(size/16), math.ceil(size/16))
# GPU 웜업 (첫 실행은 기록에서 제외하거나 짧게 실행)
matmul_kernel[blocks_per_grid, threads_per_block](d_a, d_b, d_c)
cuda.synchronize()
# 실제 측정 시작
start_gpu = time.time()
matmul_kernel[blocks_per_grid, threads_per_block](d_a, d_b, d_c)
cuda.synchronize() # GPU 작업이 끝날 때까지 대기
end_gpu = time.time()
print(f"GPU(CUDA) 소요 시간: {end_gpu - start_gpu:.4f}초")
print(f"가속 비율: {(end_cpu - start_cpu) / (end_gpu - start_gpu):.2f}배")
결과 분석 및 주의사항
일반적으로 1024급 행렬에서는 GPU가 수십 배에서 수백 배까지 빠른 결과를 보여줍니다. 하지만 만약 데이터 크기가 너무 작다면(예: 32x32), 오히려 데이터를 GPU로 보내는 시간이 더 걸려 CPU가 더 빠를 수도 있습니다.
이처럼 CUDA 프로그래밍은 대량의 데이터를 한꺼번에 던져줄 때 가장 효율적입니다.
지금까지는 직접 CUDA 커널을 작성하는 법을 배웠지만, 실제 현업이나 연구에서는 파이토치(PyTorch) 같은 프레임워크를 사용해 훨씬 간결하게 CUDA를 활용합니다. 파이토치는 우리가 짠 복잡한 CUDA 코드를 내부적으로 미리 다 구현해 놓은 도구 상자와 같습니다.
파이토치에서 CUDA를 쓰는 이유
파이토치를 사용하면 직접 @cuda.jit을 쓰거나 그리드 크기를 계산할 필요가 없습니다. 단순히 텐서(Tensor)라는 데이터 단위를 메모리에서 GPU로 옮기기만 하면, 그 뒤의 모든 행렬 연산은 자동으로 최적화된 CUDA 커널을 통해 실행됩니다.
핵심 키워드
파이토치, 텐서, 디바이스 할당, CUDA 가용성 확인, 자동 미분, 딥러닝 가속
파이토치 CUDA 예제 코드
import torch
import time
# 1. CUDA 사용 가능 여부 확인
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"현재 사용 중인 장치: {device}")
# 2. 데이터 생성 및 GPU로 이동
# .to(device) 한 줄로 모든 메모리 전송이 완료됩니다
size = 5000
a = torch.randn(size, size).to(device)
b = torch.randn(size, size).to(device)
# 3. GPU 연산 수행
# 내부적으로는 고도로 최적화된 CUDA 커널이 돌아갑니다
start = time.time()
c = torch.matmul(a, b)
# 4. 연산 완료 대기 및 시간 측정
# GPU는 비동기 방식이므로 정확한 측정을 위해 동기화가 필요합니다
torch.cuda.synchronize()
print(f"파이토치 GPU 행렬 곱셈 시간: {time.time() - start:.4f}초")
# 5. 결과를 다시 CPU로 가져오기
result_cpu = c.cpu().numpy()
직접 짠 코드 vs 파이토치
우리가 앞에서 작성한 Numba 코드는 CUDA의 작동 원리를 배우기에 아주 좋습니다. 반면, 파이토치는 엔비디아에서 직접 만든 연산 라이브러리(cuBLAS, cuDNN)를 사용하기 때문에 속도가 훨씬 빠르고 안정적입니다.
입문 단계에서는 Numba로 원리를 이해하고, 실제 프로젝트에서는 파이토치를 사용하는 것이 가장 효율적인 학습 경로입니다.











CUDA 첫걸음: 요리사 비유로 배우는 GPU 병렬 프로그래밍
들어가며: 왜 내 코드는 느릴까? GPU의 힘을 빌리는 방법
딥러닝과 대규모 데이터 처리가 일상화된 요즘, 파이썬 사용자라면 누구나 "내 코드를 더 빠르게 만들 수 없을까?"라는 고민에 빠지게 됩니다. 이 문제의 핵심 열쇠는 바로 GPU에 있습니다.
컴퓨터의 두뇌는 CPU와 GPU로 나뉩니다. CPU는 '몇 개의 아주 똑똑한 코어'로 복잡하고 순차적인 일을 처리하는 전문가와 같습니다. 반면, GPU는 '수천 개의 작은 코어'로 단순한 일을 동시에 처리하는 대규모 작업반과 같습니다.
CUDA는 바로 이 GPU의 엄청난 병렬 처리 능력을 프로그래밍 언어로 제어할 수 있게 해주는 엔비디아(NVIDIA)의 기술입니다. 이 글의 목표는 파이썬의 Numba 라이브러리를 통해 CUDA의 기본 원리를 쉽고 명확하게 이해하는 것입니다.
1. 세상을 바꾸는 아이디어: 혼자가 아닌 '함께' 일하기
CPU와 GPU의 작동 방식은 요리사에 비유하면 명확하게 이해할 수 있습니다.
- CPU (총괄 셰프)
- GPU (수백 명의 보조 요리사)
이 비유를 통해 알 수 있듯, CUDA의 핵심은 **'동시에 처리할 수 있는 단순 반복 작업을 GPU에 맡겨 폭발적인 속도 향상을 얻는 것'**입니다.
이제 이 수많은 요리사(GPU 코어)들에게 어떻게 일을 시키고 재료를 전달하는지 알아보겠습니다.
2. CUDA 프로그래밍의 첫 번째 규칙: 분리된 주방과 재료 창고
GPU 프로그래밍에서 가장 먼저 이해해야 할 개념은 메모리 이동입니다. 그 이유는 CPU가 사용하는 메모리(RAM)와 GPU가 사용하는 메모리(VRAM)가 물리적으로 떨어져 있기 때문입니다.
이 개념을 '총괄 셰프의 메인 창고(RAM)'와 '보조 요리사들의 개별 주방(VRAM)'이라는 비유로 확장해 봅시다. 보조 요리사들이 요리를 시작하려면, 총괄 셰프는 먼저 메인 창고에서 필요한 모든 재료를 각 주방으로 옮겨주어야 합니다. 요리가 끝나면, 완성된 음식들을 다시 메인 창고로 가져와야 합니다.
이 데이터 이동(to_device, copy_to_host)에 걸리는 시간은 전체 작업 시간에 큰 영향을 미칩니다. 따라서 효율적인 쿠다 프로그래밍의 관건은 이 데이터 이동을 최소화하는 것입니다.
이 과정에서 사용되는 4가지 핵심 용어는 다음과 같습니다.
| 용어 (Term) | 설명 (Description) |
| Host | CPU와 시스템 메모리(RAM)를 의미합니다. (총괄 셰프의 공간) |
| Device | GPU와 비디오 메모리(VRAM)를 의미합니다. (보조 요리사들의 주방) |
| Kernel | GPU에서 수많은 스레드가 병렬로 실행하는 함수입니다. (보조 요리사들에게 내리는 레시피) |
| Thread | GPU 내부의 가장 작은 실행 단위입니다. (개별 보조 요리사 한 명) |
그럼 이제 간단한 레시피(커널)를 통해 이 과정을 직접 코드로 확인해 보겠습니다.
3. 우리의 첫 CUDA 레시피: 100만 개 덧셈 순식간에 끝내기
100만 개의 숫자를 더하는 간단한 벡터 덧셈 예제로 CUDA 코드의 기본 구조를 살펴보겠습니다. 이 코드는 앞서 설명한 개념들이 실제로 어떻게 작동하는지 보여줍니다.
import numpy as np
from numba import cuda
# 4단계: 요리 시작! - 이 함수가 GPU에서 실행될 레시피(커널)입니다.
# @cuda.jit 데코레이터가 일반 파이썬 함수를 GPU가 이해하는 코드로 바꿔줍니다.
@cuda.jit
def add_kernel(x, y, out):
# 각 요리사(스레드)에게 고유 번호를 부여하여 자신이 담당할 재료를 찾게 합니다.
idx = cuda.grid(1)
if idx < x.size: # 할당된 재료가 전체 재료 범위를 벗어나지 않는지 확인
out[idx] = x[idx] + y[idx]
# 1단계: 재료 준비 (CPU)
# 총괄 셰프가 메인 창고에서 100만 개의 재료를 준비합니다.
n = 1000000
x = np.ones(n, dtype=np.float32)
y = np.ones(n, dtype=np.float32)
out = np.zeros(n, dtype=np.float32)
# 2단계: 재료 옮기기 (CPU → GPU)
# cuda.to_device()를 사용해 메인 창고(RAM)의 재료를 개별 주방(VRAM)으로 보냅니다.
device_x = cuda.to_device(x)
device_y = cuda.to_device(y)
device_out = cuda.device_array_like(out)
# 3단계: 요리사 배치하기 (실행 설정)
# 수많은 요리사들을 효율적으로 관리하기 위한 2단계 관리 시스템을 설정합니다.
# threads_per_block: 한 팀(Block)에 몇 명의 요리사(Thread)를 둘 것인가?
threads_per_block = 256
# blocks_per_grid: 전체 작업을 위해 총 몇 개의 팀(Block)이 필요한가?
blocks_per_grid = (n + (threads_per_block - 1)) // threads_per_block
# 커널을 실행합니다. 대괄호 안의 설정에 따라 요리사들이 배치됩니다.
add_kernel[blocks_per_grid, threads_per_block](device_x, device_y, device_out)
# 5단계: 완성된 요리 가져오기 (GPU → CPU)
# copy_to_host()를 사용해 개별 주방(VRAM)의 결과물을 다시 메인 창고(RAM)로 가져옵니다.
result = device_out.copy_to_host()
print(f"계산 완료: {result[:5]}...")
이 과정에서 @cuda.jit과 cuda.grid(1)의 역할이 핵심입니다. 이것이 바로 파이썬 함수를 GPU용 레시피로 변환하고, 각 요리사에게 고유 번호를 부여해 자신의 일을 찾아가게 하는 마법입니다. 또한 Grid(전체 작업)를 여러 Block(팀)으로, Block을 여러 Thread(요리사)로 나누는 계층적 구조를 통해 수천 개의 코어를 체계적으로 관리합니다.
단순한 덧셈을 넘어, 딥러닝의 심장이라 불리는 행렬 곱셈에서는 CUDA가 어떻게 진가를 발휘하는지 살펴보겠습니다.
4. 실전! 딥러닝의 핵심 연산, 행렬 곱셈 가속하기
행렬 곱셈은 GPU 병렬 처리에 가장 이상적인 작업 중 하나입니다. 결과 행렬의 각 원소를 계산하는 과정이 서로 독립적이기 때문에 수만 개의 스레드가 동시에 계산을 끝낼 수 있기 때문입니다.
2차원 행렬을 다루려면 각 스레드에 1차원 번호가 아닌 2차원 좌표를 할당해야 합니다. 다음 커널 코드는 cuda.grid(2)를 사용해 이 문제를 해결합니다.
@cuda.jit
def matmul_kernel(A, B, C):
# 2차원 그리드에서 현재 스레드의 행(row)과 열(col) 위치를 찾습니다
row, col = cuda.grid(2)
# 결과 행렬의 범위 안에 있는지 확인하여 메모리 오류를 방지합니다
if row < C.shape[0] and col < C.shape[1]:
tmp = 0.
# 행과 열의 곱을 누적합으로 계산
for k in range(A.shape[1]):
tmp += A[row, k] * B[k, col]
C[row, col] = tmp
cuda.grid(2)의 역할은 벡터 덧셈의 cuda.grid(1)과 비교하면 명확해집니다. 벡터 덧셈에서는 cuda.grid(1)이 각 '요리사'에게 줄을 선 순서대로 번호표 하나를 주었습니다. 하지만 2차원 행렬에서는 각자 맡을 자리의 좌표가 필요합니다. cuda.grid(2)는 바로 이 역할을 수행하여, 각 스레드에게 (row, col) 형태의 좌표 쌍을 부여해 결과 행렬 C의 정확한 위치를 찾아가게 합니다.
성능을 더 높이는 팁: 공유 메모리 (Shared Memory)
기본적인 커널보다 성능을 몇 배 더 끌어올리는 비법이 있습니다. 바로 공유 메모리를 사용하는 것입니다. 이는 GPU 코어들이 멀리 있는 비디오 메모리(VRAM)에 매번 접근하는 대신, 근처에 있는 아주 빠른 공용 사물함에 데이터를 잠시 넣어두고 쓰는 방식과 같습니다. 이것이 더 빠른 이유는 공유 메모리가 GPU 코어와 동일한 칩 위에 위치한 작은 온칩(on-chip) 메모리이기 때문입니다. 물리적으로 가까워 접근 속도가 훨씬 큰 오프칩(off-chip) VRAM보다 월등히 빠르므로 전체 연산 시간이 극적으로 단축됩니다.
그렇다면 실제로 GPU는 CPU보다 얼마나 빠를까요? 직접 성능을 측정하여 눈으로 확인해 봅시다.
5. CPU vs GPU: 속도 대결의 승자는?
성능을 측정할 때 한 가지 기억해야 할 점은 'Warm-up' 과정입니다. GPU는 첫 번째 함수 호출 시 컴파일과 초기화 작업을 함께 수행하므로 시간이 더 걸립니다. 따라서 두 번째 실행부터가 GPU의 진짜 실력을 보여주는 시간입니다.
1024x1024 크기 행렬 곱셈의 성능을 CPU(NumPy)와 GPU(CUDA)로 비교하면 다음과 같은 결과를 얻을 수 있습니다.
| 연산 주체 | 소요 시간 (예시) | 가속 비율 |
| CPU (NumPy) | ~2.5초 | 1x |
| GPU (CUDA) | ~0.05초 | ~50x |
1024x1024 행렬을 사용한 저희 테스트에서는 약 50배의 속도 향상을 보였지만, 사용하는 GPU와 데이터 크기에 따라 이 수치는 수십 배에서 수백 배까지도 달라질 수 있습니다.
이 결과에서 얻을 수 있는 가장 중요한 통찰은 다음과 같습니다. CUDA 프로그래밍은 대량의 데이터를 한꺼번에 처리할 때 가장 효율적입니다. 데이터 크기가 너무 작으면, 오히려 데이터를 GPU로 보내고 다시 가져오는 시간 때문에 CPU보다 느릴 수 있습니다.
지금까지 우리는 CUDA의 작동 원리를 직접 코드를 짜며 배웠습니다. 하지만 실제 현업에서는 더 편리한 도구를 사용합니다.
6. 더 스마트하게 일하기: PyTorch로 CUDA 활용하기
PyTorch와 같은 딥러닝 프레임워크는 우리가 짠 복잡한 CUDA 코드를 내부적으로 미리 다 구현해 놓은 도구 상자와 같습니다.
Numba를 이용해 직접 CUDA 코드를 짤 때는 @cuda.jit, 그리드/블록 계산 등 신경 쓸 것이 많았습니다. 하지만 PyTorch에서는 이 모든 과정이 자동화되어 있습니다. 사용자는 단지 텐서 데이터 뒤에 .to(device) 한 줄만 붙이면 됩니다.
# PyTorch에서는 단 한 줄로 데이터가 GPU로 이동합니다.
a = torch.randn(size, size).to(device)
b = torch.randn(size, size).to(device)
# 이 연산은 내부적으로 최적화된 CUDA 커널을 통해 실행됩니다.
c = torch.matmul(a, b)
심지어 PyTorch는 우리가 직접 짠 Numba 코드보다 더 빠릅니다. 그 이유는 엔비디아에서 직접 만든 고도로 최적화된 연산 라이브러리(NVIDIA가 직접 C++과 어셈블리 수준에서 최적화한 행렬 및 딥러닝 연산 모음인 cuBLAS, cuDNN)를 사용하기 때문입니다.
따라서 초보자를 위한 가장 효율적인 학습 경로는 다음과 같습니다. "입문 단계에서는 Numba로 원리를 이해하고, 실제 프로젝트에서는 PyTorch를 사용하는 것이 가장 좋다."
맺음말: 이제 당신도 GPU 프로그래머입니다
이 글을 통해 우리는 CUDA의 핵심 개념들을 배웠습니다. 마지막으로 정리해 보겠습니다.
- 병렬 처리: CPU(총괄 셰프)와 달리 GPU(수많은 요리사)는 단순 작업을 동시에 처리해 빠릅니다.
- 메모리 분리: CPU(Host)와 GPU(Device)는 별도의 메모리를 사용하므로 데이터 이동이 필수적이며, 이 비용을 최소화해야 합니다.
- 활용 분야: 행렬 곱셈과 같이 대규모의 독립적인 연산이 많은 작업에서 CUDA는 수십, 수백 배의 성능 향상을 보여줍니다.
- 현실적인 사용법: 원리 이해는 Numba로, 실제 개발은 PyTorch와 같은 프레임워크를 사용하는 것이 효율적입니다.
이제 당신은 CPU의 한계를 넘어 GPU의 강력한 성능을 활용할 수 있는 첫걸음을 떼었습니다. 이 지식을 바탕으로 더 빠르고 강력한 프로그램을 만들어 나가시길 응원합니다.
'Python을 배워보자' 카테고리의 다른 글
| 📂 한국어 AI 모델 허깅페이스 활용 가이드 (2) | 2026.04.13 |
|---|---|
| 파이썬으로 웹 콘텐츠 자동 추출하고 이메일 보내기: 간단한 스크립트 튜토리얼 (0) | 2025.10.11 |
| Playwright를 활용한 네이버 증권 크롤링 예제 (3) | 2025.07.26 |
| Playwright: 현대적인 웹 테스트 자동화의 강자 (3) | 2025.07.26 |
| 국가별 월별 기온 데이터를 FastAPI로 백엔드 구축 및 Dash로 테이블 출력하기 (3) | 2025.07.24 |