딥러닝을 더 빠르게: GPU 성능을 위한 제1원칙 가이드
딥러닝 모델의 성능을 최적화하는 것은 종종 연금술처럼 느껴집니다. 개발자들은 왜 이러한 변화가 효과가 있는지에 대한 명확한 이해 없이 PyTorch 버전을 변경하거나, 그래디언트를 None으로 설정하거나, 인플레이스(in-place) 연산을 사용하는 등 소위 """잡다한 기술(grab-bag of tricks)"""에 의존하곤 합니다. 하지만 제1원칙(first principles)으로부터 추론함으로써, 우리는 추측에서 벗어나 성능을 저해하는 실제 병목 현상을 체계적으로 식별할 수 있습니다.
시스템을 최적화하려면 먼저 자신이 어떤 """레짐(regime)"""에 있는지 알아야 합니다. 훈련 손실(training loss)과 검증 손실(validation loss)의 관계가 과적합(overfitting)인지 과소적합(underfitting)인지를 알려주는 것처럼, 시스템의 자원 활용도를 분석하면 시간이 실제로 어디에 소비되는지 알 수 있습니다. 딥러닝에서 효율성은 일반적으로 계산(Compute), 메모리(Memory), 그리고 **오버헤드(Overhead)**의 세 가지 구성 요소로 나뉩니다.
성능의 세 가지 기둥
1. 계산 (The Factory)
계산은 GPU가 실제 부동 소수점 연산(FLOPS)을 수행하는 데 소비하는 시간을 의미합니다. 대부분의 최적화 목표는 계산 중심(compute-bound) 레짐에서 보내는 시간을 최대화하는 것입니다. 현대적인 GPU(예: A100의 312 TeraFLOPS)의 막대한 TFLOPs를 위해 비용을 지불했으므로, 이를 실제로 활용하고 싶을 것입니다.
이러한 피크 수치는 보통 행렬 곱셈을 위해 특별히 설계된 Tensor Cores와 같은 특수 하드웨어의 성능을 의미한다는 점에 유의해야 합니다. 만약 연산이 행렬 곱셈이 아니라면, 피크 성능의 극히 일부만 활용하게 될 것입니다. 그러나 대부분의 딥러닝 모델(예: BERT)에서 비-행렬 곱셈(non-matmul) 연산(layer norm, activations)은 전체 FLOPS에서 아주 작은 비중을 차지합니다. 즉, 비-행렬 곱셈 연산의 비효율성이 계산 측면의 주요 병목은 아니지만, 메모리 측면에서는 병목이 될 수 있습니다.
2. 메모리 대역폭 (The Warehouse)
메모리 대역폭은 데이터를 한 곳에서 다른 곳으로 이동시키는 비용, 즉 GPU의 DRAM("""창고(warehouse)""""")")에서 계산 유닛/SRAM("""공장(factory)""""")")로 이동시키는 비용을 의미합니다.
많은 연산이 **메모리 중심(memory-bound)**입니다. 이는 GPU가 실제 계산을 하는 것보다 데이터를 전송하는 데 더 많은 시간을 소비한다는 것을 의미합니다. torch.cos와 같은 단순한 단항 연산(unary operation)이 대표적인 예입니다. GPU는 DRAM에서 데이터를 읽고, 아주 작은 계산을 수행한 뒤, 다시 기록합니다. 계산이 너무 빨라서 GPU는 거의 모든 시간을 메모리 전송을 기다리는 데 사용합니다.
연산자 융합(Operator Fusion)의 힘
메모리 병목 현상을 해결하기 위해 우리는 **연산자 융합(operator fusion)**을 사용합니다. 모든 개별 연산의 결과를 전역 메모리에 다시 쓴 다음 다음 단계에서 다시 읽는 대신, 융합은 여러 연산을 하나의 GPU 커널로 결합합니다.
예를 들어, x.cos().cos()는 보통 네 번의 전역 메모리 접근(읽기 두 번, 쓰기 두 번)이 필요합니다. 융합을 사용하면 두 번(읽기 한 번, 쓰기 한 번)만 필요합니다. 이것이 GELU와 같은 복잡한 활성화 함수가 ReLU와 같은 단순한 함수와 비용이 비슷한 경우가 많은 이유입니다. 병목은 수학적 연산의 수가 아니라 메모리 접근입니다.
3. 오버헤드 (The Manager)
오버헤드는 계산이나 메모리 전송이 아닌 모든 것을 의미합니다. 여기에는 Python 인터프리터, PyTorch 프레임워크의 디스패치 로직, 그리고 CUDA 커널을 실행하는 데 걸리는 시간이 포함됩니다.
현대적인 GPU는 매우 빠르기 때문에 Python이 거대한 병목이 됩니다. Python이 단일 FLOP을 수행하는 데 걸리는 시간 동안, A100은 수백만 개를 처리할 수 있습니다. PyTorch는 커널을 **비동기적(asynchronously)**으로 실행함으로써 이를 완화합니다. CPU가 GPU보다 """앞서 달려가며(run ahead)""""")")를 하여 GPU가 쉬지 않도록 작업을 큐에 쌓아둡니다.
하지만 텐서(tensor)가 너무 작으면, GPU가 CPU가 다음 작업을 큐에 넣기 전에 작업을 더 빨리 끝내버립니다. 이 경우 GPU는 """비싼 종이 무게추(expensive paperweight)""""")")가 됩니다.
병목 현상 식별하기
자신이 어떤 레짐에 있는지 아는 것이 해결책을 결정합니다. 배치 사이즈를 두 배로 늘렸는데 실행 시간이 거의 늘어나지 않는다면, 아마도 **오버헤드 중심(overhead-bound)**일 가능성이 높습니다. 배치 사이즈를 늘렸음에도 연산의 복잡도를 높였는데 실행 시간이 그대로라면, 당신은 **메모리 대역폭 중심(memory-bandwidth bound)**입니다.
| 성능 레짐 | 가능한 해결책 |
|---|---|
| Overhead-Bound | Tracing (jit.trace, FX), CUDA Graphs, 또는 TorchDynamo와 같은 JIT 컴파일러로 이동 |
| Bandwidth-Bound | Operator Fusion (Triton, NVFuser, XLA) |
| Compute-Bound | Tensor Cores 활용, 하드웨어 업그레이드 |
종합 및 비판적 관점
제1원칙 접근 방식은 명확한 사고 모델을 제공하지만, 실제 적용은 복잡할 수 있습니다. 커뮤니티 논의에서 언급되었듯이, 성능은 거의 이식성이 없습니다. ONNX로 내보낸 모델은 ONNX Runtime이나 TensorRT를 통해 실행할 때마다 다르게 동작할 수 있으며, 결과는 대상 하드웨어와 메모리 튜닝에 따라 달라질 수 있습니다.
나아가, 스케일링의 """쓴맛의 교훈(bitter lesson)""""")")는 연산자 융합에서의 인간의 지혜가 가치 있지만, 장기적인 추세는 막대한 TFLOPs와 대역폭의 증가를 지향한다는 것을 시사합니다. NVIDIA의 계산 능력과 상호 연결 기술(interconnects) 모두에서 지수적 성로를 유지하는 능력은 소프트웨어 계층이 따라잡기 위해 고과하는 동안에도 하드웨어가 가능성의 경계를 계속해서 넓히고 있음을 보장합니다.
궁극적으로 목표는 **계산 강도(compute intensity)**를 높이는 것입니다. 즉, 메모리 접근 대비 계산의 비율을 것입니다. 오버헤드를 줄이고 연산자를 융합함으로써, GPU가 가장 잘하는 일, 즉 피크 속도로 대규모 행렬 곱셈을 수행하는 데 집중할 수 있는 길을 열어줍니다.