딥러닝을 원리부터 빠르게 구동하기

발행: (2026년 5월 23일 PM 08:50 GMT+9)
12 분 소요

출처: Hacker News

그래서 딥러닝 모델의 성능을 개선하고 싶으시군요. 어떻게 접근하면 좋을까요? 사람들은 종종 이전에 효과가 있었거나 트윗에서 본 여러 가지 트릭을 떠올립니다. “제자리 연산을 사용하라! 그래디언트를 None으로 설정하라! PyTorch 1.10.0은 설치하고 1.10.1은 안 된다!” 같은 식이죠.

현대 시스템(특히 딥러닝)에서 성능 최적화가 과학이라기보다 연금술처럼 느껴지는 이유는 이해할 수 있습니다. 그렇다고 해서 기본 원리부터 추론하면 많은 접근 방식을 배제할 수 있어 문제를 훨씬 더 다루기 쉬워집니다.

예를 들어, 딥러닝으로 좋은 성능을 얻는 일은 많은 추측을 동반합니다. 하지만 훈련 손실이 테스트 손실보다 훨씬 낮다면 “과적합” 상태이며, 모델 용량을 늘리려는 시도는 시간 낭비입니다. 반대로 훈련 손실과 검증 손실이 동일하다면 모델을 정규화하려는 시도 역시 시간 낭비가 됩니다.

마찬가지로 딥러닝 시스템의 효율성은 다음 세 가지 요소로 나눌 수 있습니다.

  • Compute(연산): GPU가 실제 부동소수점 연산(FLOPS)을 수행하는 데 소요되는 시간
  • Memory(메모리): GPU 내부에서 텐서를 이동하는 데 소요되는 시간
  • Overhead(오버헤드): 그 외 모든 것

머신러닝 모델을 훈련할 때와 마찬가지로, 현재 어느 영역에 속하는지 알면 실제로 중요한 최적화 포인트를 좁힐 수 있습니다. 예를 들어, 메모리 전송에 모든 시간을 쓰고 있다면(즉, 메모리 대역폭 제한 상태) GPU의 FLOPS를 높여도 도움이 되지 않습니다. 반대로 큰 행렬 곱 연산에만 시간을 쏟고 있다면(즉, 연산 제한 상태) 오버헤드를 줄이기 위해 모델 로직을 C++로 다시 작성해도 큰 효과를 보지 못합니다.

따라서 GPU가 계속 brrrr 소리를 내게 하려면 시스템이 시간을 소비하는 세 가지 요소—연산, 메모리 대역폭, 오버헤드—에 대해 이야기해 보겠습니다.

“쓴 교훈 뒤에는 GPU를 효율적으로 운영하는 수많은 엔지니어들이 있습니다.” 이미지 출처: Gwern

Note: 이 글의 대부분은 GPU와 PyTorch를 예시로 사용합니다(제가 PyTorch 팀에 있기 때문이죠). 하지만 원리는 대부분의 하드웨어와 프레임워크에 일반화됩니다.

딥러닝 시스템을 최적화하는 한 관점은 연산 제한 영역에 머무는 시간을 최대화하는 것입니다. 여러분은 312 테라플롭스라는 막대한 연산 능력을 구매했으며, 이상적으로는 그 312 테라플롭스를 모두 활용하고 싶을 겁니다. 하지만 비싼 행렬 곱 연산에서 가치를 얻으려면 다른 영역에 소비되는 시간을 줄여야 합니다.

그렇다면 왜 연산을 최대화하고 메모리 대역폭을 최대화하지 않나요? 이유는 간단합니다—오버헤드나 메모리 비용은 줄일 수 있지만, 실제 연산 자체를 바꾸지 않으면 연산량을 크게 줄일 수 없기 때문입니다.

연산 활용도를 높이는 것이 어려운 이유는 연산 성장 속도가 메모리 대역폭 성장 속도보다 훨씬 빠르기 때문입니다. 아래 표는 CPU FLOPS와 메모리 대역폭의 두 배 증가 시간 차이를 보여줍니다.

연산을 공장에 비유해 보겠습니다. 우리는 공장에 명령(오버헤드)을 보내고, 재료(메모리 대역폭)를 공급해 공장이 효율적으로 돌아가게 합니다(연산).

하지만 공장의 효율이 재료 공급 속도보다 빨리 향상된다면, 공장은 최고 효율에 도달하기가 점점 어려워집니다.

공장의 규모(FLOPS)는 두 배가 되었지만, 대역폭이 따라오지 못하면 성능도 두 배가 되지 않습니다.

이러한 연산 활용도 감소는 ML 시스템 엔지니어에게 영구적인 직업 안정성을 제공하는 동시에, 병목 현상을 이해하는 것이 더욱 중요해짐을 의미합니다.

FLOPS에 대한 한 가지 부연

현대 머신러닝 가속기에는 행렬 곱 전용 하드웨어가 탑재되어 있습니다. 예를 들어 Nvidia의 “Tensor Cores”가 그것입니다.

따라서 행렬 곱을 수행하지 않으면 명시된 312 테라플롭스가 아니라 19.5 테라플롭스 수준만 달성할 수 있습니다. 이는 GPU에만 해당되는 것이 아니라, TPU조차도 GPU보다 일반적인 특성을 가지고 있습니다.

GPU가 행렬 곱이 아닌 연산에 대해 현저히 느린 것은 처음엔 문제가 될 것처럼 보일 수 있습니다—예를 들어 레이어 정규화나 활성화 함수는 어떨까요? 실제로는 이러한 연산이 FLOPS 관점에서는 거의 무시할 수 있는 수준입니다. 아래 표는 BERT 모델에서 연산 종류별 FLOP 수를 보여줍니다(“Tensor Contraction” = 행렬 곱).

전체 FLOPS 중 비행렬 곱 연산은 0.2%에 불과하므로, GPU가 비행렬 곱 연산을 15배 느리게 수행한다는 사실은 크게 영향을 미치지 않습니다. 다만 정규화와 포인트와이즈 연산은 각각 250배700배 낮은 FLOPS를 보입니다.

그렇다면 비행렬 곱 연산이 기대보다 훨씬 오래 걸리는 이유는 무엇일까요?

우리 비유를 되돌아보면, 핵심 원인은 재료를 공장으로, 그리고 다시 창고로 옮기는 데 걸리는 시간—즉, 메모리 대역폭입니다.

Bandwidth

대역폭 비용은 데이터를 한 장소에서 다른 장소로 옮기는 데 드는 비용을 의미합니다. 이는 CPU에서 GPU로, 노드 간, 혹은 CUDA 전역 메모리에서 CUDA 공유 메모리로 데이터를 이동하는 경우를 포함합니다. 여기서는 특히 후자를 중점적으로 다루며, 흔히 “대역폭 비용” 혹은 “메모리 대역폭 비용”이라고 부릅니다.

다른 두 비용(일반적으로 “데이터 전송 비용”과 “네트워크 비용”이라고 함)도 중요하지만, 분산 성능까지 파고들면 이 글을 마무리하기가 어려워집니다.

메모리 대역폭 비용이 무엇인지 이해하려면 다시 공장 비유로 돌아가 보겠습니다.

우리 공장은 실제 작업을 수행하는 곳이지만, 대량 저장소 역할을 하지는 못합니다. 이는 작업을 수행하는 동안 저장소가 빠르게 사용될 수 있도록 최적화(SRAM)되어 있기 때문이며, 용량이 많지는 않습니다.

그렇다면 실제 결과와 재료는 어디에 보관할까요? 일반적인 접근 방식은 저렴한 토지와 넓은 공간이 있는 **창고(드램)**에 보관하는 것입니다. 그리고 공장과 창고 사이를 메모리 대역폭으로 연결해 재료를 운반합니다.

이처럼 연산 유닛과 창고 사이를 오가는 비용을 “메모리 대역폭 비용”이라고 부릅니다. 참고로, GPU의 DRAM 용량은 nvidia-smi 명령어로 확인할 수 있으며, “CUDA Out of Memory” 오류의 주요 원인입니다.

GPU 커널을 실행할 때마다 데이터를 GPU DRAM(창고)으로 옮기고 다시 되돌려야 한다는 점을 기억하세요.

예를 들어 torch.cos와 같은 단항 연산을 수행하면, 데이터를 창고에서 가져와서 각 데이터에 대해 아주 작은 계산을 수행한 뒤 다시 창고에 돌려보냅니다. 데이터를 옮기는 비용이 매우 크기 때문에, 실제 연산에 쓰이는 시간은 거의 없고 대부분이 데이터 이동에 소비됩니다.

이러한 상황을 메모리 바운드 연산이라고 하며, 연산에 할당되는 시간이 거의 없다는 뜻입니다.

그렇다면 어떻게 개선할 수 있을까요? 연산 시퀀스가 어떻게 구성되는지 살펴보겠습니다.

포인트와이즈 연산들의 연속적인 흐름 예시

0 조회
Back to Blog

관련 글

더 보기 »