development

[CUDA] GPU는 어떻게 빠른 연산이 가능할까?

moonull-ptr 2024. 9. 10. 22:05

Motivation & Goal

  • NVIDIA는 AI 시장에서 하드웨어와 그에 맞는 소프트웨어 스택이 압도적임
  • CUDA는 하나의 큰 생태계가 되었고, 유저들은 많은 기법들을 이용해서 NVIDIA GPU를 fully utilize하고 있음
  • Pytorch, vLLM과 같은 라이브러리들은 backend에 숨겨서 일반 유저들이 CUDA를 몰라도 연구를 할 수 있게 하였음
  • 그러나 GPU의 내부를 알게되는것은 충분한 강점이며, 보다 나은 성능을 위해서는 필수적임
  • 큰 틀에서의 CUDA 의 개념들을 훑어보기로 합시다

Memory Hierarchy

그렇다면 CUDA, NVIDIA GPU는 내부적으로 어떻게 동작하는지 알아보겠습니다. 우선 메모리 구조를 살펴보면 다음과 같습니다.

Memory Hierarchy

  • Global memory
    GPU의 메모리로, DRAM 영역에 해당함.
  • L2 cache
    모든 SM에서 공유되고, 각 CUDA 블록의 스레드들은 이 메모리에 접근이 가능. NVIDIA A100 GPU는 L2 캐시의 크기가 40MB로 매우 증가. 이전세대인 V100 GPU의 6MB와 확연히 차이남.
  • L1/Shared memory (SMEM)
    각 SM에 있는 L1 캐시와 공유 메모리로 사용할 수 있는 고속의 on-chip scratchpad 메모리. CUDA 블록 내의 모든 스레드는 공유 메모리를 공유할 수 있으며, 주어진 SM에서 실행되는 모든 CUDA 블록은 해당 SM이 제공하는 물리적 메모리 자원을 공유할 수 있음.
  • Read-only memory
    각 SM에는 명령어 캐시, 상수 메모리, 텍스처 메모리, 그리고 RO 캐시가 있으며, 이는 커널 코드에서 읽기 전용으로 사용됨.
  • Registers
    레지스터는 각 스레드에 대해 개인적으로 할당되며, 한 스레드에 할당된 레지스터는 다른 스레드에서 볼 수 없음. 레지스터의 활용은 컴파일러가 결정함.

Programming Model

스레드 그룹을 CUDA 블록이라고 합니다. CUDA 블록들은 그리드로 그룹화됩니다. 커널은 스레드 블록의 그리드로 실행됩니다. 각 CUDA 블록은 하나의 스트리밍 멀티프로세서(SM)에서 실행되며, 다른 SM으로 이동할 수 없습니다(except during preemption, debugging, or CUDA dynamic parallelism). 한 SM은 CUDA 블록이 필요로 하는 자원에 따라 여러 개의 CUDA 블록을 동시에 실행할 수 있습니다. 각 커널은 하나의 장치에서 실행되며, CUDA는 한 장치에서 여러 커널을 동시에 실행하는 것을 지원합니다.

RTX A6000 has 128KB L1 cache per SM and total 6MB L2 cache
RTX A6000 has 256KB Register per SM

Warp

워프는 SM에서 실행되는 기본 단위입니다. 스레드 블록 그리드를 실행하면, 그리드 내의 스레드 블록들이 SM에 분배됩니다. 스레드 블록이 SM에 스케줄링되면, 스레드 블록 내의 스레드들은 워프로 더 세분화됩니다. 워프는 32개의 연속된 스레드로 구성되며, 워프 내의 모든 스레드는 단일 명령 다중 스레드(SIMT) 방식으로 실행됩니다. 즉, 모든 스레드가 동일한 명령을 실행하고, 각 스레드는 자신의 개인 데이터에 대해 해당 연산을 수행합니다. 아래 그림은 스레드 블록의 논리적 관점과 하드웨어 관점 간의 관계를 보여줍니다.

Branch Divergence

워프를 처리할 때 두 분기문이 모두 실행될 수 있습니다. if 분기문이 처리될 때, if 조건을 충족하지 않는 모든 스레드는 일시적으로 비활성화됩니다(else 분기문도 마찬가지입니다). 이러한 branch divergence는 SM의 전체 활용도를 감소시킵니다. 스루풋을 최대화하기 위해 가능하다면 워프 내의 모든 스레드가 동일한 코드로 분기하도록 코드를 작성해야합니다.

Branch Divergence

Coalesced Memory Access

Memory operation을 수행할 때 일정 단위로 읽어오게 되는데, 캐시 효율을 높이기 위해서는 필요한 메모리가 인접해있는게 효율적입니다. 이를 `Memory Coalescing`이라고 하는데, 단순하게는 각 스레드가 필요한 메모리가 연속적인 경우에 효율적이라고 생각할 수 있습니다.

Best Case

워프의 모든 스레드가 요청한 주소가 128바이트의 캐시 라인 내에 있는 경우, 메모리 로드 작업을 완료하기 위해 단 하나의 128바이트 트랜잭션만 필요합니다. 버스 활용률은 100%이며, 이 트랜잭션에는 사용되지 않은 데이터가 없습니다.

Worst Case

최악의 경우 워프가 요청한 총 바이트 수가 128바이트에 불과하더라도, 그 주소가 N개의 캐시 라인에 걸쳐 있을 수 있습니다. 여기서 0 < N ≤ 32입니다. 메모리 로드 작업을 완료하려면 N개의 메모리 트랜잭션이 필요합니다.

Memory Coalescing

Bank Conflicts

공유 메모리 요청의 여러 주소가 동일한 메모리 뱅크에 속할 때 conflict가 발생하여 요청이 다시 실행됩니다. 하드웨어는 이 요청을 충돌이 없는 여러 개의 별도 트랜잭션으로 나누며, 이로 인해 필요한 별도 메모리 트랜잭션 수에 비례하여 유효 대역폭이 감소하게 됩니다.

Conflict-free Access
Conflict-free Access (Random Access)
Bank Conflict Access

Asynchronous Copy (Memory Prefetch)

암페어(Ampere) 아키텍처부터 CUDA는 글로벌 메모리에서 공유 메모리로의 비차단 비동기 복사를 지원합니다. 이를 통해 메모리 작업 중 GPU 연산을 동시에 수행할 수 있을 뿐만 아니라 메모리 로드 경로를 DRAM-L2(bypass)-SMEM으로 단축하여 더욱 효율적으로 만듭니다. 이 기능은 DRAM에서 공유 메모리(SRAM)로 데이터를 직접 복사하면서 L2 캐시를 우회할 수 있게 해주어 데이터 전송의 대기 시간을 줄이고 GPU의 처리 효율을 높입니다.

Async Copy DRAM to SRAM
Overlapping Copy and Computation


Buffer Caching

L2 Persistent Cache

ML 모델의 각 레이어의 output은 다음 레이어의 input으로 들어가는 경우가 많습니다. 기존에는 레이어를 통과할 때마다 DRAM 을 거쳐야했습니다. 이런 비효율적인 사항을 NVIDIA에서도 인식하고 있었고, 이를 개선하기 위해서 CUDA 11.0, compute capability 8.0 부터 `L2 Persistent Cache` 기능을 도입했습니다. 

  • L2 persistent cache 는 커널의 데이터 Load/Store 과정을 DRAM이 아닌 L2 캐시에서 수행할 수 있도록 해줍니다.
  • L2 대역폭은 약 3.8TB/s, DRAM의 대역폭은 1.94 TB/s 입니다 (A100).

CUTLASS (CUDA Templates for Linear Algebra Subroutines)

그렇다면 우리는 위의 조건들을 모두 고려하며 개발해야 할까요? 정말 대단하고 감사하게도, 많은 라이브러리들은 이미 저런 부분을 자동적으로, 혹은 어느정도 경험적으로, 유저가 크게 신경쓰지 않아도 되는 환경을 만들어두었습니다.

 

CUTLASS는 CUDA C++ 템플릿으로 이루어진, 행렬곱과 같은 선형대수 연산에 특화되어있는 라이브러리입니다. 기존에는 각 GPU에 맞게 커널을 작성해야했다면, CUTLASS 는 그러한 하드웨어적인 파라미터를 고려하여 자동으로 커널이 만들어질 수 있도록 C++ 템플릿을 활용한 것이 특징입니다. PyTorch, DeepSpeed, vLLM, TensorRT (TRT-LLM)  NVIDIA GPU를 지원하고, CUDA 라이브러리를 활용하는 메이저한 딥러닝 프레임워크죠. 내부 C/C++ backend 를 살펴보면, cuBLAS(Lt), cuTENSOR, cuSPARSE(Lt), cuDNN 와 같은 라이브러리들의 API 를 활용하는 경우가 많은데, 이보다 더 깊은 코드들은 공개되어있지 않는 부분도 많습니다. 그래도 Nsight 로 프로파일링을 해보면 내부적으로 CUTLASS가 커널을 제공하고 있음을 알 수 있습니다.

CUTLASS

CUTLASS가 기존의 cuBLAS, cuDNN 과 같은 라이브러리들과 무엇이 다른지에 대한 글을 참고해보면 다음과 같습니다.

  • 그냥 API가 필요하다면 깊게 생각하지말고 cudnn이 좋음
  • CUTLASS는 다음과 같은 상황일 때 활용하는게 좋음
  • 소스코드 cutsomization, 컴파일러가 관련이 있을 때
  • problem size가 cudnn에서 최적화되지 않았을 때
  • int4, int1과 같은, 라이브러리에서 지원하지 않는 커널들이 필요할 때
  • 필요한 커널만 사용하고 싶을 때
  • CPU 오버헤드를 최소화하고싶을 때