Skip to content
Published on

cuDNN 완전 해부: 딥러닝 연산이 GPU에서 빛처럼 빠른 이유

Authors

들어가며: cuDNN이 없다면 무슨 일이 벌어지는가

PyTorch에서 torch.nn.Conv2d를 호출할 때 내부에서 무슨 일이 벌어지는지 아는가? 단순히 "GPU에서 컨볼루션을 실행한다"가 전부가 아니다. cuDNN이 알아서 알고리즘을 선택하고, 메모리 레이아웃을 조정하고, 하드웨어에 맞는 최적화 커널을 실행한다.

Raw CUDA 커널로 직접 컨볼루션을 구현하면 cuDNN 대비 10~100배 느리다. 그 이유를 정확히 이해하는 것이 이 글의 목표다.


1. cuDNN이 왜 필요한가: Raw CUDA vs 최적화 라이브러리

CUDA는 범용 병렬 프로그래밍 모델이다. 어떤 연산이든 병렬화할 수 있지만, 딥러닝의 특정 연산(컨볼루션, 배치 정규화, 어텐션)을 위한 전문적인 최적화는 담겨 있지 않다.

cuDNN(CUDA Deep Neural Network library)은 이런 연산들을 위한 수작업으로 튜닝된 커널 라이브러리다. 같은 연산을 수행해도:

Naive CUDA 컨볼루션:       ~5 TFLOPS 달성 (H100 이론값의 0.5%)
cuDNN 최적화 컨볼루션:     ~900 TFLOPS 달성 (H100 이론값의 90%)
차이: ~180

왜 이런 격차가 생기는가:

  1. 알고리즘 선택: 입력 크기에 따라 Direct, im2col+GEMM, Winograd, FFT 중 최적을 자동 선택
  2. 메모리 레이아웃 최적화: Tensor Core를 활성화하는 NHWC 레이아웃 사용
  3. 커널 퓨전: 여러 연산을 하나의 커널로 합쳐 메모리 왕복 횟수 감소
  4. 워프 수준 최적화: 메모리 코얼레싱, 레지스터 재사용 등 HW 특성 완전 활용

PyTorch와 TensorFlow는 cuDNN을 기반 라이브러리로 사용한다:

import torch

# 이 코드는 내부적으로 cuDNN을 호출
conv = torch.nn.Conv2d(64, 128, kernel_size=3, padding=1).cuda()
x = torch.randn(32, 64, 56, 56, device='cuda')
y = conv(x)  # → cudnnConvolutionForward() 호출

# cuDNN 사용 여부 확인
print(torch.backends.cudnn.enabled)   # True
print(torch.backends.cudnn.version())  # 예: 8906

2. 컨볼루션의 4가지 알고리즘: cuDNN의 핵심

컨볼루션은 딥러닝에서 가장 연산 집약적인 연산이다. cuDNN은 4가지 알고리즘을 상황에 맞게 선택한다.

알고리즘 A: Direct Convolution (Naive)

가장 직관적인 구현. 출력의 각 원소를 독립적으로 계산한다:

출력 픽셀 (i, j)에 대해:
  for c_out in range(C_out):
    for c_in in range(C_in):
      for kh in range(K):
        for kw in range(K):
          output[c_out, i, j] += input[c_in, i+kh, j+kw] * filter[c_out, c_in, kh, kw]

총 연산 수: N × C_out × C_in × K^2 × H_out × W_out

장점: 단순함. 단점: 메모리 접근 패턴이 비효율적. 대규모 입력에서 매우 느리다.

알고리즘 B: im2col + GEMM (cuDNN 기본값)

컨볼루션을 행렬 곱셈으로 변환하는 핵심 트릭. cuBLAS의 고도로 최적화된 GEMM을 활용할 수 있다.

im2col 변환:

입력 특징맵 [3×3 이미지, 3×3 커널]:

원본 입력:            im2col 변환 결과:
┌─────────┐           ┌────────────────────────────┐
1  2  3 │  im2col   │ 1  2  4  5  (패치 0, 위치 0,0)4  5  6 │ ────────→ │ 2  3  5  6  (패치 1, 위치 0,1)7  8  9 │           │ 4  5  7  8  (패치 2, 위치 1,0)└─────────┘           │ 5  6  8  9  (패치 3, 위치 1,1)                      └────────────────────────────┘
각 행 = 수용 영역(receptive field) 하나의 모든 픽셀값

그 다음: output = filter_matrix × im2col_matrix
          (표준 GEMMTensor Core 완전 활용 가능!)

im2col의 단점: 입력 데이터를 재배열하므로 추가 메모리 필요 (입력의 K^2배).

# im2col이 어떻게 동작하는지 Python으로 보는 예시
import numpy as np

def im2col(input, kernel_h, kernel_w, stride=1, pad=0):
    N, C, H, W = input.shape
    out_h = (H + 2*pad - kernel_h) // stride + 1
    out_w = (W + 2*pad - kernel_w) // stride + 1

    # 패딩 적용
    img = np.pad(input, [(0,0),(0,0),(pad,pad),(pad,pad)], mode='constant')

    # im2col 행렬 생성: (C*kernel_h*kernel_w) × (N*out_h*out_w)
    col = np.zeros((N, C, kernel_h, kernel_w, out_h, out_w))
    for j in range(kernel_h):
        jj = j + stride * np.arange(out_h)
        for i in range(kernel_w):
            ii = i + stride * np.arange(out_w)
            col[:, :, j, i, :, :] = img[:, :, jj[:, None], ii[None, :]]

    col = col.transpose(0, 4, 5, 1, 2, 3).reshape(N*out_h*out_w, -1)
    return col
# cuDNN의 실제 im2col은 이보다 훨씬 최적화되어 있음

알고리즘 C: Winograd 알고리즘 (소형 커널 최강자)

3×3 컨볼루션처럼 소형 커널에서 cuDNN이 기본으로 선택하는 알고리즘. Winograd의 최소 필터링 알고리즘(1980)을 딥러닝에 적용한 것이다.

핵심 아이디어: 선형대수 변환으로 곱셈 횟수를 대폭 줄인다.

일반 3×3 컨볼루션 (2×2 출력):
- 입력 패치: 4×4 = 16개 원소
- 커널: 3×3 = 9개 원소
- 출력: 2×2 = 4개 원소
- 필요한 곱셈  (naive): 4 × 9 = 36
Winograd F(2×2, 3×3) 변환 후:
- 변환된 입력: 4×4 = 16원소 (선형 변환, 곱셈 없음)
- 원소별 곱셈: 16 (4×4)
- 역변환: 4×42×2 (선형 변환, 곱셈 없음)
- 필요한 곱셈 수: 16- 절감: 3616 = 2.25배 감소

수식으로 표현하면:

Y = A^T [(G × g × G^T)  (B^T × d × B)] A

여기서:
d = 입력 타일 (4×4)
g = 3×3 커널
B, G, A = 고정된 변환 행렬 (사전 계산됨)
= 원소별 곱셈 (하다마르 곱)

핵심: G × g × G^T는 커널에 대해 한 번만 계산 (추론 시 미리 계산 가능)
      B^T × d × B는 각 입력 타일에 대해 계산
      둘을 원소별로 곱하면 36번 대신 16번의 곱셈으로 동일한 결과

cuDNN은 3×3, stride=1 컨볼루션에서 자동으로 Winograd를 선택한다. ResNet, VGG 등 대부분의 CNN이 3×3 컨볼루션을 주로 사용하기 때문에 실전에서 매우 중요하다.

알고리즘 D: FFT 기반 컨볼루션 (대형 커널)

7×7, 11×11처럼 대형 커널에는 주파수 영역 컨볼루션이 효율적이다.

시간 영역 컨볼루션:   O(N × K^2)  (N = 출력 크기, K = 커널 크기)
주파수 영역 컨볼루션: O(N × log N) (FFT 후 원소별 곱셈)

K가 클수록 FFT 방식이 유리:
K=3:  9 vs log(N)7  → 큰 차이 없음
K=11: 121 vs log(N)7FFT17배 적은 곱셈

3. cuDNN Auto-Tuner: benchmark 모드의 진실

# 이 한 줄이 실제로 하는 일은?
torch.backends.cudnn.benchmark = True

benchmark=False (기본값):

  • cuDNN이 입력 크기 기반으로 알고리즘을 휴리스틱으로 선택
  • 첫 실행부터 빠르지만 최적이 아닐 수 있음

benchmark=True:

  • 첫 번째 forward pass에서 cudnnFindConvolutionForwardAlgorithm()을 호출
  • 현재 입력 크기에 대해 사용 가능한 모든 알고리즘을 실제로 실행하고 벤치마킹
  • 가장 빠른 알고리즘을 선택해 캐시에 저장
  • 이후 같은 입력 크기에서는 캐시된 알고리즘 사용
# benchmark=True의 효과를 직접 확인
import torch
import time

torch.backends.cudnn.benchmark = False
model = resnet50().cuda()
x = torch.randn(32, 3, 224, 224).cuda()

# 첫 번째 실행 (알고리즘 휴리스틱 선택)
t0 = time.time()
for _ in range(10): y = model(x)
torch.cuda.synchronize()
print(f"benchmark=False: {(time.time()-t0)*1000:.0f}ms")

torch.backends.cudnn.benchmark = True
# 첫 번째 실행에서 벤치마킹 (느림!)
t0 = time.time()
y = model(x)  # 이 한 번이 오래 걸림 (~수 초)
torch.cuda.synchronize()
print(f"첫 번째 실행 (벤치마킹 포함): {(time.time()-t0)*1000:.0f}ms")

# 이후 실행 (캐시된 최적 알고리즘 사용)
t0 = time.time()
for _ in range(10): y = model(x)
torch.cuda.synchronize()
print(f"benchmark=True (워밍업 후): {(time.time()-t0)*1000:.0f}ms")
# 보통 20-40% 빠름

주의: 입력 크기가 매 배치마다 달라지면 benchmark=True가 오히려 느릴 수 있다. 매번 새로운 벤치마킹이 필요하기 때문. 학습 데이터의 입력 크기가 고정된 경우에만 사용하라.


4. 배치 정규화와 커널 퓨전: 메모리 트래픽 감소의 마법

배치 정규화(Batch Normalization)는 겉보기에 단순하다:

y = gamma * (x - mean(x)) / sqrt(var(x) + eps) + beta

하지만 이것을 별도의 커널로 구현하면 치명적으로 비효율적이다:

Naive 구현의 메모리 왕복:
1. x 로드 (HBM에서 읽기)
2. mean 계산 → HBM에 저장
3. x 로드 + mean 로드 (2HBM 읽기)
4. variance 계산 → HBM에 저장
5. x 로드 + mean 로드 + var 로드 (3HBM 읽기)
6. 정규화 계산 → HBM에 저장
7. ReLU: HBM 읽기 → 계산 → HBM 쓰기

: HBM 접근 약 10

cuDNN은 BN + ReLU를 단일 커널로 퓨전한다:

Fused BN+ReLU 커널:
1. x를 레지스터/공유 메모리에 로드 (HBM 읽기 1)
2. 레지스터에서 mean, variance 계산 (HBM 접근 없음)
3. 레지스터에서 정규화 (HBM 접근 없음)
4. 레지스터에서 ReLU (HBM 접근 없음)
5. 결과를 HBM저장 (HBM 쓰기 1)

: HBM 접근 2 (5배 감소!)
# PyTorch에서 fused BN+ReLU 사용
import torch.nn as nn

# 기본 방식: Conv → BN → ReLU (3개 별도 커널)
class NaiveBlock(nn.Module):
    def __init__(self, c):
        super().__init__()
        self.conv = nn.Conv2d(c, c, 3, padding=1)
        self.bn = nn.BatchNorm2d(c)
        self.relu = nn.ReLU()

    def forward(self, x):
        x = self.conv(x)
        x = self.bn(x)    # 별도 cuDNN 커널
        x = self.relu(x)  # 별도 elementwise 커널
        return x

# cuDNN fused: torch.backends.cudnn.benchmark=True 환경에서
# cuDNN이 자동으로 conv+bn+relu를 하나의 커널로 최적화

최신 cuDNN (v8 이상)은 그래프 API를 통해 더 복잡한 퓨전도 지원한다:

# cuDNN Graph API (PyTorch 2.0+ torch.compile과 연계)
# Conv + BN + ReLU + Residual Add를 단일 커널로 처리
# torch.compile()이 자동으로 이런 퓨전을 수행
model = torch.compile(model)  # 내부적으로 cuDNN Graph API 활용

5. Attention 메커니즘과 FlashAttention: cuDNN의 한계를 넘어서

Transformer의 어텐션 연산을 표준적으로 구현하면:

# Standard Attention — 메모리 비효율적
def standard_attention(Q, K, V, scale):
    # S = Q × K^T : (N, N) 크기의 어텐션 점수 행렬 생성
    S = torch.matmul(Q, K.transpose(-2, -1)) * scale  # N^2 메모리!
    # 소프트맥스
    P = torch.softmax(S, dim=-1)
    # V와 곱하기
    O = torch.matmul(P, V)
    return O

# 문제: S는 O(N^2) 메모리를 차지함
# N=8192 (문서 길이), FP16 기준:
# S 크기 = 8192 * 8192 * 2 bytes = 128MB (헤드 1개당!)

FlashAttention은 cuDNN을 우회하는 완전히 새로운 CUDA 커널로 이 문제를 해결한다.

FlashAttention의 핵심 아이디어: HBM에 N^2 행렬을 쓰지 않는다

표준 어텐션 메모리 흐름:
Q, K, VHBM 읽기
S = QK^THBM 쓰기 (N^2 크기!)
softmax(S)HBM 쓰기
P × VHBM 쓰기
HBM 접근: O(N^2)

FlashAttention 메모리 흐름:
Q, K, V를 타일 단위로 로드 → 공유 메모리
공유 메모리에서 어텐션 부분합 계산 (N^2 행렬 생성 없음)
최종 출력만 HBM에 저장
HBM 접근: O(N)N^2에서 N으로!

타일링 트릭의 핵심:

FlashAttention 타일링:

Q를 블록 Q_1, Q_2, ... Q_Tc로 분할
KV를 블록 K_1, V_1, ... K_Tr, V_Tr로 분할

for i in range(Tc):
  Q_i를 SRAM에 로드
  O_i = 0, l_i = 0, m_i = -inf  (소프트맥스 통계 초기화)

  for j in range(Tr):
    K_j, V_j를 SRAM에 로드

    # SRAM에서만 계산 (HBM 접근 없음)
    S_ij = Q_i × K_j^T  (타일 크기의 어텐션 점수)
    m_ij = max(m_i, rowmax(S_ij))  (수치 안정성)
    P_ij = exp(S_ij - m_ij)

    # 온라인 소프트맥스 업데이트 (Numerically stable!)
    O_i = diag(exp(m_i - m_ij)) × O_i + P_ij × V_j
    l_i = exp(m_i - m_ij) × l_i + rowsum(P_ij)
    m_i = m_ij

  # 최종 정규화 후 HBM에 저장
  O_i = diag(l_i)^(-1) × O_iHBM 쓰기 1

결과:

  • 메모리 사용량: O(N^2) → O(N) (N=8192 기준 수백 MB 절약)
  • 속도: 2~4배 빠름 (HBM 트래픽 감소 덕분)

6. 메모리 레이아웃: NCHW vs NHWC

딥러닝 텐서의 메모리 레이아웃은 성능에 결정적인 영향을 준다.

NCHW 레이아웃 (batch × channel × height × width):
배치=1, 채널=3(RGB), 4×4 이미지:

메모리 배치:
[R00 R01 R02 R03 | R10 R11 ... | R30 R31 R32 R33 |
 G00 G01 G02 G03 | G10 G11 ... | G30 G31 G32 G33 |
 B00 B01 B02 B03 | B10 B11 ... | B30 B31 B32 B33]

→ 같은 채널의 픽셀들이 연속적으로 배치

NHWC 레이아웃 (batch × height × width × channel):
메모리 배치:
[R00 G00 B00 | R01 G01 B01 | R02 G02 B02 | R03 G03 B03 |
 R10 G10 B10 | R11 G11 B11 | ...
 R30 G30 B30 | R31 G31 B31 | R32 G32 B32 | R33 G33 B33]

→ 같은 공간 위치의 모든 채널이 연속적으로 배치

왜 Tensor Core는 NHWC를 선호하는가:

Tensor Core는 16×16 행렬 타일을 처리한다. NCHW에서 컨볼루션을 위한 16×16 타일을 구성하면 채널 방향으로 불연속적인 메모리 접근이 발생한다. NHWC에서는 채널이 연속적이라 타일 로딩이 연속 메모리 읽기가 된다.

# PyTorch에서 NHWC 사용
x_nchw = torch.randn(32, 64, 56, 56, device='cuda')

# NHWC로 변환 (channels_last 포맷)
x_nhwc = x_nchw.to(memory_format=torch.channels_last)

# channels_last 형식으로 모델 변환
model = model.to(memory_format=torch.channels_last)

# 이 상태에서 forward pass는 cuDNN이 NHWC 커널을 자동 선택
output = model(x_nhwc)

# 벤치마크 비교
import time
model_nchw = resnet50().cuda()
model_nhwc = resnet50().cuda().to(memory_format=torch.channels_last)
x = torch.randn(64, 3, 224, 224).cuda()
x_nhwc = x.to(memory_format=torch.channels_last)

# 실측 결과 (H100 기준):
# NCHW: ~12ms/batch
# NHWC: ~9ms/batch  (약 25% 빠름)

7. TensorRT: cuDNN의 다음 단계

TensorRT는 cuDNN 위에 구축된 추론 최적화 엔진이다. 학습 완료된 모델을 배포 환경에서 최대 성능으로 실행한다.

TensorRT의 최적화 파이프라인:

원본 모델 (ONNX/PyTorch)
  그래프 분석
  레이어 퓨전
  ┌─────────────────────────────────────┐
ConvBNReLUConvBNReLU│
  │       ↓ 퓨전 후                     │
  │     단일 최적화 커널                 │
  └─────────────────────────────────────┘
  정밀도 선택 (FP32FP16INT8)
  커널 자동선택 (입력 크기별 벤치마킹)
  최적화된 실행 엔진

정밀도 단계별 성능 비교 (ResNet-50, batch=32, H100 기준):

정밀도레이턴시처리량정확도 손실
FP324.2ms7,600 img/s기준점
FP161.8ms17,800 img/s무시할 수준
INT80.9ms35,500 img/s~0.1% Top-1

INT8 양자화 캘리브레이션:

import tensorrt as trt

# INT8 캘리브레이터 설정
class MyCalibrator(trt.IInt8EntropyCalibrator2):
    def __init__(self, data_loader, cache_file):
        super().__init__()
        self.data_loader = iter(data_loader)
        self.cache_file = cache_file
        self.batch_allocation = None

    def get_batch_size(self):
        return 32

    def get_batch(self, names):
        try:
            batch = next(self.data_loader)[0].numpy()
            # 대표 데이터셋으로 activation 범위 캘리브레이션
            # TensorRT가 각 레이어의 FP32 범위를 INT8로 매핑
            if self.batch_allocation is None:
                self.batch_allocation = cuda.mem_alloc(batch.nbytes)
            cuda.memcpy_htod(self.batch_allocation, batch)
            return [int(self.batch_allocation)]
        except StopIteration:
            return None

# 빌더 설정
builder = trt.Builder(logger)
config = builder.create_builder_config()
config.set_flag(trt.BuilderFlag.INT8)  # INT8 활성화
config.int8_calibrator = MyCalibrator(calibration_loader, 'cache.bin')

8. 실제 LLM에서 cuDNN 호출 추적

GPT-2 / LLaMA를 실행할 때 내부에서 어떤 커널들이 호출되는지 추적해보자:

# PyTorch Profiler로 LLM 연산 추적
import torch
from torch.profiler import profile, ProfilerActivity

model = GPT2Model.from_pretrained('gpt2').cuda()
input_ids = torch.randint(0, 50257, (1, 512)).cuda()

with profile(
    activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
    with_stack=True
) as prof:
    output = model(input_ids)

# 상위 CUDA 커널 출력
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=10))

실제 실행되는 커널들:

GPT-2 Forward Pass 내부 CUDA 커널 (512 토큰, batch=1):

연산              커널                          시간   비율
─────────────────────────────────────────────────────────
Linear (QKV)     cublasSgemm (Tensor Core)      2.1ms  35%
Attention        flash_attn_fwd_kernel           1.4ms  23%
Linear (output)  cublasSgemm (Tensor Core)      0.9ms  15%
LayerNorm        layer_norm_kernel               0.3ms   5%
GELU             vectorized_elementwise_kernel   0.2ms   3%
Residual Add     vectorized_elementwise_kernel   0.1ms   2%
Embedding        Embedding_cuda                  0.1ms   2%
기타                                            0.9ms  15%

총 시간: ~6ms (H100 기준)

핵심 관찰:

  • Linear 레이어 = cuBLAS GEMM: LLM 연산의 약 50%가 행렬 곱셈
  • Attention = FlashAttention: cuDNN이 아닌 커스텀 CUDA 커널
  • LayerNorm = 커스텀 퓨전 커널: 평균 + 분산 + 정규화가 하나의 커널
  • 활성화 함수 = elementwise 커널: 매우 빠름 (메모리 대역폭 제한)

9. 정리: cuDNN이 만드는 성능 차이의 원천

cuDNN이 raw CUDA 대비 10~100배 빠른 이유를 정리하면:

  1. 알고리즘 최적화: Winograd, im2col+GEMM 등 수학적으로 더 효율적인 알고리즘 선택
  2. 커널 퓨전: 여러 연산을 하나의 커널로 합쳐 HBM 왕복 횟수 최소화
  3. 메모리 레이아웃 최적화: NHWC + Tensor Core 활성화로 하드웨어 효율 극대화
  4. Auto-Tuner: 실제 하드웨어에서 벤치마킹으로 입력 크기별 최적 구현 선택
  5. FlashAttention 같은 혁신: O(N^2) → O(N) 메모리 접근으로 어텐션 연산 혁신

PyTorch 한 줄 뒤에는 이런 수십 년의 최적화 연구가 숨어 있다. 이 내부 동작을 이해하는 것이 LLM 서빙 최적화, 커스텀 커널 작성, 하드웨어 선택의 출발점이다.