CPU 서버 개발자를 위한 GPU 아키텍처
최근 회사에서 CPU 서버에서 돌던 모델 서빙을 GPU로 옮기는 일을 준비하고 있습니다. 저는 그동안 CPU 아키텍처에 대해서는 공부를 해보았지만, GPU는 해본적이 없으나 막연히 비슷할 거라고 생각했었습니다.
그런데 자료를 읽다보니 혼란스러웠습니다. CUDA 코어가 만 개가 넘는다는데, CPU와 달리 코어 수에 맞춰 스레드 1만 개를 띄우면 안됩니다. nvidia-smi는 GPU utilization을 나타내는데, 같은 utilization이더라도 throughput은 천차만별일 수 있습니다.
이 글에서는 CPU에 익숙한 서버 개발자의 눈으로 GPU 아키텍처를 정리합니다.
[요약]
- CPU는 메모리 지연을 캐시로 줄이고, GPU는 다른 warp로 전환해서 숨깁니다.
- CUDA 코어는 CPU 코어와 같은 개념이 아니라 SIMD lane에 해당합니다. CPU 코어에 대응하는 것은 SM입니다.
- CPU의 context switch와 달리 GPU의 warp 전환 비용은 거의 없습니다. 상주하고 있는 모든 warp의 레지스터가 물리적으로 미리 배정되기 때문입니다.
- HBM(GPU와 붙어있는 메모리)과 PCIe(GPU와 호스트 메모리를 연결)는 대역폭이 50배 이상 차이 납니다. GPU 서빙의 주요 병목은 여기서 발생합니다.
- 멀티소켓 서버에서 GPU는 특정 CPU 소켓에 물려 있습니다. 반대편 소켓의 프로세스가 GPU와 DMA를 주고받으면 UPI를 거치게 되므로
numactl로 같은 노드에 바인딩해야 합니다. nvidia-smi의 GPU-Util은 utilization과 다릅니다. GPU 포화 여부는 DCGM 지표로 확인해야 합니다.
CPU와 GPU의 메모리 병목 해결법
CPU와 GPU는 메모리 병목을 최대한 줄여야 성능을 100% 낼 수 있습니다. CPU의 경우 DRAM을 접근하는 비용은 수백 사이클이고, 그 긴 시간 동안 ALU는 놀게 됩니다. CPU와 GPU는 이 대기 시간을 처리하는 방법이 다릅니다.
| CPU | GPU | |
|---|---|---|
| 전략 | 메모리 지연 줄임 | 메모리 지연 숨김 |
| 수단 | 큰 캐시, out-of-order 실행, 분기 예측, prefetcher | 대기가 생기면 다른 warp(스레드 32개 묶음)로 즉시 전환 |
| 목표 | 스레드 하나를 최대한 빨리 | 전체 처리량 최대화 |
| 스레드 전환 비용 | 수천 사이클 | 0 사이클 |
| 코어당 동시 스레드 | 1~2 (SMT) | SM당 최대 2,048 |
CPU는 기다리는 시간을 최대한 줄이도록 아키텍처가 구성되어 있습니다. L1/L2/L3 캐시로 데이터를 미리 가까이 두고, 분기 예측과 out-of-order 실행으로 명령어 파이프라인에 stall이 생기는 것을 최대한 막습니다.
CPU 코어는 ALU를 중심에 두고, 명령과 데이터를 공급하는 장치들(fetch/decode, 분기 예측기, out-of-order 스케줄러, 레지스터 파일, L1/L2 캐시)로 겹겹이 감싸고 있습니다. 실리콘을 현미경으로 직접 찍은 사진(die shot)을 보면 이해하기 쉽습니다.

다이 하나에 코어가 8개 들어 있습니다. 빨간 영역은 L3 캐시로 다이 한가운데를 통째로 차지하고 있습니다. 노란 박스가 코어인데, 코어 안을 들여다봐도 L3와 맞닿은 쪽 상당 부분이 L2·L1·µop 캐시입니다. 정작 계산을 하는 ALU·FPU는 코어 안에서도 좁은 구역이라 이 배율에서는 확인이 어렵습니다. 가운데 파란 띠는 제어·연결 로직(Infinity Fabric, 전력 관리)입니다.
그러면 노란 박스 안, 코어 하나의 내부는 어떨까요? 이번에는 인텔 서버 CPU(Skylake-SP Xeon)의 코어 한 개를 20배율로 확대한 사진입니다.

빨간 박스는 코어 전용 L2와 공유 L3 슬라이스입니다. 그 밖의 나머지 영역 전체가 코어의 로직입니다. 명령을 가져오고 해석하는 프런트엔드, out-of-order 스케줄러, 그리고 실행 유닛(정수 ALU와 FPU) 등이 있습니다. 다만 어느 것이 ALU인지 사진에서는 잘 구분되지 않습니다. 로직 사이사이의 작은 줄무늬 블록들은 대부분 L1·µop 캐시·TLB·레지스터 파일 같은 SRAM입니다.
즉 CPU의 경우 다이 대부분이 캐시이고, 한 CPU 코어 내에서도 ALU가 아니라 ALU의 지연을 줄이도록 돕는 장치(캐시 등)들이 더 큰 면적을 차지하고 있습니다.
GPU는 반대로 ALU가 기다려도 상관없게 만듭니다. 캐시와 제어 회로를 최소화하고 그 자리에 ALU를 빽빽하게 채웠습니다. 어떤 스레드가 메모리를 기다리면 바로 다른 스레드로 갈아탑니다.
같은 방식으로 GPU 다이샷을 보면 차이가 한눈에 들어옵니다.

CPU에서 가장 큰 영역을 차지하던 캐시가 크게 줄어든 것을 확인할 수 있습니다. 또한 이 칩의 L2 캐시는 2 MB가 전부이고, H100도 50 MB로 서버 CPU의 L3보다 작습니다. 그 빈자리를 채운 것이 ALU들을 묶어놓은 SM 20개이고, 이들의 가장자리는 메모리 컨트롤러와 io 장치들이 감싸고 있습니다.
"CUDA 코어"는 코어가 아니다
GPU를 처음 접하셨다면 용어가 생소하여 제가 하는 말이 무슨 말인지 이해를 잘 못했을 것입니다.
제가 처음에 가장 햇갈렸던 용어는 바로 CUDA "코어"입니다. 이 "코어"라는 용어를 CPU 코어와 비슷한 개념으로 받아들이면 안됩니다.
명령을 해석하고 발행하는 주체는 그림에서 한 단계 위에 있는 processing block입니다. SM 하나는 processing block 4개로 나뉘고, 각 block은 자기 warp 스케줄러와 64 KB 레지스터 파일, 그리고 FP32 ALU(CUDA 코어) 32개를 가집니다.
스케줄러가 warp(H100 기준 block당 최대 16개) 중 하나를 골라 명령을 발행하면 32개 ALU가 그 명령 하나를 서로 다른 데이터에 대해 함께 수행합니다.
그래서 CPU 코어에 대응하는 것을 굳이 찾자면 processing block이 가장 가깝고, CUDA 코어는 CPU로 치면 SIMD lane에 해당합니다. AVX-512 lane 16개를 "코어 16개"라고 부르지 않듯이, CUDA 코어 수를 CPU 코어와 동일하다고 오해하면 안됩니다.
Tensor Core는 작은 행렬 곱을 통째로 한 명령에 처리하는 전용 유닛으로, FP32 ALU(CUDA 코어)와는 별개의 하드웨어입니다. 별개라고 해도 내부는 결국 전용 MAC(multiply-accumulate) 유닛을 행렬 곱 구조로 배열한 것이고, H100 기준 processing block마다 1개씩(SM당 4개) 들어 있습니다. Tensor Core 하나가 FP16 기준 사이클당 약 512 FMA(fused multiply-add)를 처리하므로 같은 block의 CUDA 코어 32개를 전부 합친 것(사이클당 32 FMA)보다 16배 큽니다. 딥러닝의 연산 대부분이 행렬 곱이므로, 모델 서빙에서 FLOPs의 대부분을 담당하는 유닛입니다.
실행 모델: 프로세스에서 스레드까지
하드웨어와 달리 소프트웨어 실행 모델은 CPU 개념에 하나씩 대응시킬 수 있습니다. GPU에서 실행되는 함수 하나를 커널(kernel)이라 부르고, 이 커널을 스레드 수만 정해서 실행합니다.
| GPU | 정체 | CPU 대응 |
|---|---|---|
| CUDA context | 주소 공간, 프로세스당 1개 | 프로세스 |
| Stream | 순서가 보장되는 작업 큐 | 작업 큐 |
| Grid | 커널 실행 한 번 | 병렬 for 루프의 전체 index 범위 |
| Block (thread block) | SM 하나에 통째로 배정되는 스레드 묶음 | 루프를 나눈 chunk |
| Warp | 명령 발행 단위 (32 스레드) | SIMD 벡터 명령 하나 |
| Thread | lane 하나 | iteration 하나 |
이름이 겹쳐서 헷갈리기 쉬운데, 여기서의 블록은 스레드 묶음이고 앞 절의 processing block은 하드웨어입니다. 이 글에서 그냥 "블록"이라고 하면 항상 thread block을 가리킵니다.
하드웨어는 스레드 블록을 32개씩 잘라 warp로 만들고, 그 warp들을 SM 안의 processing block에 나눠 배정합니다. warp 크기 32와 processing block의 FP32 ALU 개수 32가 딱 맞는 것은 우연이 아닙니다. warp 32가 먼저 정해진 아키텍처 상수이고, FP32 폭이 거기에 맞춰진 것입니다. warp 스케줄러는 warp 단위로 명령을 발행하고, 32개 lane이 서로 다른 데이터에 같은 연산을 수행합니다. 이 실행 모델이 SIMT(Single Instruction, Multiple Threads)입니다.
이 추상화의 비용이 warp divergence입니다. if문에서 warp 안의 스레드가 갈리면 하드웨어는 양쪽 분기를 순차로 모두 실행하고, 해당 없는 lane은 놉니다. 노는 lane의 자리를 다른 warp가 채우지도 못합니다. 발행 단위가 warp 전체이기 때문입니다. GPU가 분기 많은 코드에 약한 이유, 그리고 블록 크기를 32의 배수로 잡으라는 이유가 바로 이것입니다.
GPU에서 커널과 블록의 관계는 프로그램과 프로세스의 관계입니다. 커널은 "무엇을 할지"(코드)이고, 블록은 "누가 할지"(스레드 묶음)입니다. 커널 코드는 context에 로드되어 계속 존재하고, 실행(grid)만 생성과 소멸을 반복합니다.
블록 스케줄링에는 CPU와 다른 성질이 둘 있습니다.
- migration 불가: 한 번 SM에 배정된 블록은 다른 SM으로 옮겨가지 못합니다. shared memory와 레지스터가 그 SM에 묶여 있기 때문입니다.
- preemption 불가: 블록은 끝날 때까지 하드웨어가 뺏지 않습니다.
이 두 성질 때문에 블록 간 동기화는 불가능합니다. 블록 10,000개를 띄웠는데 동시 상주 가능한 것이 2000개라면, 5000번 블록은 0번 블록이 끝나기 전까지 시작조차 못합니다. cooperative launch라는 예외가 있지만, "모든 블록이 동시 상주 가능"함을 하드웨어에 보증받아야만 쓸 수 있습니다.
CPU는 왜 지연을 숨기지 못할까?
x86-64의 아키텍처 레지스터는 이름이 고정된 16개입니다(rax, rbx, ...). 모든 스레드가 같은 이름의 레지스터를 씁니다. 스레드 A의 rax와 스레드 B의 rax는 논리적으로 같은 저장소이므로, 스레드를 전환하려면 반드시 현재 값을 메모리로 옮기고 새 값을 가져와야 합니다.
여기에 OS 커널 모드로 진입, TLB와 캐시 오염까지 더하면 context switch 한 번에 수천 사이클이 듭니다. 메모리 지연은 수백 사이클인데, 그걸 위해 수천 사이클짜리 context switch을 할 수는 없습니다.
CPU도 이 문제를 아예 안 푼 것은 아닙니다. Hyper-Threading(SMT)이 바로 두 스레드 상태를 하드웨어에 동시에 상주시켜 전환 비용을 없애는 기법입니다. 레지스터들을 두 벌 만든 것은 아니고, 코어 안에 존재하는 아키텍처 레지스터(16개) 외 훨씬 많은 물리 레지스터(200개)를 사용합니다. SMT는 "rax가 물리 레지스터 몇 번인지"를 적어 두는 매핑 테이블(RAT)과 PC, 플래그 등을 스레드별로 복제합니다. 같은 이름의 rax가 스레드마다 다른 물리 레지스터를 가리키니 저장/복원 오버헤드가 사라집니다. 다만 아직까지는 2개의 스레드가 한계입니다.
warp 전환은 왜 저렴한가
앞에서 본 대로 GPU의 실행 단위는 warp(스레드 32개)이고, SM 하나에 warp가 최대 64개 상주합니다.
블록이 SM에 배정되는 순간, 그 블록에 속한 모든 스레드의 레지스터가 SM의 레지스터 파일에 물리적으로 배정되고 블록이 끝날 때까지 보존됩니다. Warp 3의 R5와 Warp 7의 R5는 이름만 같을 뿐 레지스터 파일의 다른 주소입니다. 겹치는 것이 없으니 저장/복원할 것도 없고, warp 전환은 레지스터 파일을 읽는 base offset을 바꾸는 것이 전부입니다.
warp 스케줄러는 매 사이클, 상주 warp 중 의존성 대기가 없는(eligible) warp를 골라 명령을 발행합니다. 어떤 warp가 메모리 접근으로 수백 사이클을 기다리는 동안 스케줄러는 준비된 다른 warp를 계속 발행합니다. 이것이 GPU가 메모리 지연을 가리는 원리입니다.
trade-off: 레지스터가 occupancy를 정한다
이러한 warp 전환은 trade off가 있습니다. 상주하는 모든 warp들의 레지스터를 미리 배정해야 하므로, 스레드 하나가 레지스터를 많이 쓰면 상주할 수 있는 warp 수가 줄어듭니다.
H100 기준 SM당 32비트 레지스터는 65,536개입니다. 최대 64 warp × 32 스레드로 나누면 스레드당 32개가 나옵니다. 이보다 많이 쓰면 그만큼 상주 warp가 줄어듭니다.
| 스레드당 레지스터 | 상주 warp | Occupancy |
|---|---|---|
| 32 | 64 | 100% |
| 64 | 32 | 50% |
| 128 | 16 | 25% |
| 255 (최대) | 8 | 12.5% |
occupancy(상주 warp / 최대 warp)의 저하는 곧 성능 손실입니다. CPU에서 레지스터 압박은 성능을 조금 갉아먹는 수준이지만, GPU에서는 메모리 지연이 그대로 노출됩니다.
여기까지 오면 도입부의 첫 번째 질문에 답할 수 있습니다. GPU에서는 lane 수만큼 스레드를 띄우는 것이 아니라, 지연을 숨길 수 있도록 SM마다 warp를 가득 상주시켜야 합니다. H100이라면 132 SM × 64 warp × 32 스레드 = 약 27만 개의 스레드입니다. CPU의 감각으로 "코어 수에 맞춰" 만 개만 띄우면 SM당 warp가 두어 개뿐이라 메모리 지연이 전부 노출되고, ALU는 대부분의 시간을 놀게 됩니다.
더군다나 애초에 여기서 말하는 스레드가 CPU 스레드와 다릅니다. CPU 스레드는 OS가 스택과 커널 자료구조를 붙여 관리하지만 GPU 스레드는 그런 역할도 없고, 혼자 스케줄되지도 않습니다. 스케줄 단위는 warp이고, 스레드는 그 안에서 데이터만 다른 lane 하나입니다. CUDA 코어가 코어가 아니었듯, CUDA 스레드도 CPU가 말하는 그 스레드가 아닙니다.
실제로 커널을 띄울 때 27만이라는 수를 적어 내는 것은 아닙니다. 지정하는 값은 <<<블록 수, 블록당 스레드 수>>> 둘뿐이고, 그 곱이 총 스레드 수입니다.
27만은 여기에 적는 값이 아니라 하드웨어에 동시에 올라갈 수 있는 상한입니다. 100만 개를 띄워도 상주하는 것은 그중 일부입니다. 그래서 occupancy를 올리기 위해서는 스레드를 더 많이 띄우는 것이 아니라, 레지스터를 아껴 스레드가 더 많이 상주하게 만들어야 합니다.
register spill
register 압박이 occupancy만 깎고 끝난다면 그나마 다행입니다. 진짜 문제는 register spill입니다. 커널이 한 시점에 동시에 들고 있어야 하는 값(지역 변수, 중간 계산 결과)이 스레드당 register 수보다 많아지면 컴파일러는 넘치는 값을 register 밖으로 밀어내는데, 저장되는 곳이 local memory입니다. local memory의 실제 위치는 device DRAM이라 접근 시간이 매우 늘어납니다.
spill을 감수하고 occupancy를 지키면 warp마다 메모리 지연이 늘고, spill을 피하려고 register를 늘리면 occupancy가 떨어집니다. 어느 쪽을 골라도 지연을 숨기기 어렵기 때문에, GPU에서 register를 얼마나 쓰느냐는 CPU처럼 컴파일러에게 맡기는 최적화가 아니라 개발자가 커널 코드를 어떻게 짜느냐의 문제입니다.
메모리 계층과 shared memory
| 계층 | 범위 | 크기 (H100) | 지연 | CPU 대응 |
|---|---|---|---|---|
| 레지스터 | 스레드 전용 | 256 KB/SM | ~1 cycle | 레지스터 |
| Shared memory | 블록 내 공유 | ~228 KB/SM | ~30 cycle | 대응 없음 |
| L1 | SM 내부 (하드웨어 관리) | shared와 합쳐 256 KB/SM | ~30 cycle | L1 |
| L2 | 칩 전체 | 50 MB | ~200 cycle | L3 |
| HBM (VRAM) | 칩 전체 | 80 GB | ~500 cycle | DRAM |
| 호스트 메모리 | PCIe 통해 DMA로 복사 | - | 수 µs | - |
shared memory는 CPU 개발자에게 익숙한 L3 캐시가 아닌 별도의 다른 계층입니다. CPU 캐시와 달리 개발자가 직접 코드로 배치를 명시합니다. shared memory와 L1은 물리적으로 같은 SRAM을 나눠 쓰는 것이고, 분할 비율도 커널이 지정할 수 있습니다. 같은 메모리를 하드웨어가 채우면 L1, 개발자가 채우면 shared memory인 셈입니다.
참고로 많이 햇갈리는 부분이 VRAM과 HBM인데, VRAM은 역할 이름이고 HBM/GDDR은 그 역할을 구현하는 기술 이름입니다.
HBM은 왜 빠른가
HBM3의 대역폭은 3.35 TB/s로 서버 CPU의 DDR5(8채널 ~300 GB/s)의 10배가 넘습니다.
| HBM3 | PCIe Gen5 x16 | |
|---|---|---|
| 배선 수 | 1024비트 × 스택 수 | 16 lane (배선 64개) |
| 핀당 속도 | ~6.4 Gbps | 32 Gbps |
| 물리 거리 | 수 mm (silicon interposer) | 수십 cm (PCB + 커넥터) |
핀당 속도는 PCIe가 5배 빠르지만 HBM의 배선 수가 많아 대역폭이 훨씬 큽니다. DRAM die를 8~12층 쌓아 TSV(through-silicon via)로 연결하고, GPU die 바로 옆 silicon interposer 위에 앉히는 2.5D 패키징 덕분에 신호 거리가 수 mm로 줄었습니다. 배선은 길수록 신호가 약해지고 이웃 배선과 서로 간섭해 0과 1을 구분하기 어려워지는데, 거리가 수 mm면 수천 개를 깔 수 있습니다.

GPU 다이와 HBM 스택 사이 간격이 눈으로 보기에도 몇 mm뿐입니다.
반면 서버 메모리인 DIMM은 같은 DRAM 칩을 평면에 늘어놓고 커넥터를 거쳐 CPU까지 수 cm가 걸립니다.
trade off는 유연성입니다. HBM은 패키지에 영구 본딩되어 교체할 수 없습니다. 반면 PCIe는 슬롯을 거치고 카드를 꽂았다 뺄 수 있어야 하므로, 배선을 줄이고 배선당 속도를 늘렸습니다.
CPU와 GPU는 어떻게 연결되어 있는가
개인적으로 GPU를 공부하면서 가장 좋았던 부분입니다. CPU-메모리-GPU가 하나의 시스템으로 어떻게 구성되어있는지 이해하면 서버 전체의 성능 그림이 머리속으로 들어옵니다.
말로만 들으면 추상적이니 실제 2소켓 서버 보드부터 보겠습니다.

슬롯이 길수록 카드와 맞닿는 금속 접점이 많고, 접점이 많다는 것은 그만큼 배선이 많다는 뜻입니다. lane 하나는 배선 4개로 이루어지는데, 송신용 2개와 수신용 2개입니다. 이렇게 2개가 한 세트로 묶인 것을 differential pair라고 부릅니다.
송신 쌍과 수신 쌍이 따로 있으니 양방향이 동시에 최고 속도를 낼 수 있습니다. x16이면 배선 64개로, 사진 위쪽의 짧은 슬롯(x1)과 그 아래 긴 슬롯(x16)의 길이 차이가 그대로 배선 수 차이로 나타납니다. 슬롯 하나에 묶인 배선은 그 슬롯 전용으로 root complex나 스위치까지 올라갑니다.
대역폭은 lane 수 × 세대로 정해집니다. Gen5 x16이 단방향 ~63 GB/s입니다.
HBM과 PCIe는 직렬로 연결되어 있습니다. 그렇기에 메모리 병목이 발생했을 때 대부분의 원인은 대역폭이 훨씬 작은 PCIe입니다. 모델 서빙이 가중치를 HBM에 상주시키는 구조인 이유, host와 device 사이를 오가는 io 복사가 병목이 되는 이유입니다.
참고로 GPU와 GPU 사이, 그리고 NVIDIA Grace 계열의 CPU-GPU 사이에는 NVLink(방향당 수백 GB/s)가 따로 있습니다. 다만 이 글이 다루는 일반적인 x86 서버 + PCIe 카드 구성에서 호스트와 GPU를 잇는 통로는 PCIe뿐입니다.
이 병목은 실물로 보면 더 체감이 됩니다.

GPU 다이와 HBM은 카드 기판에 영구 납땜되어 있고, 서버와의 유일한 연결은 카드 아래쪽의 edge connector 한 줄입니다. GPU 연결은 단순히 GPU 칩을 소켓에 끼우는 것이 아니라, 이미 완성된 GPU 서브시스템(다이 + HBM + 전원 회로)을 PCIe 링크 하나로 서버에 연결시키는 것입니다. 카드 안쪽에서는 초당 수 TB가 지나는데, 저 커넥터 하나로 서버와 통신하는 것입니다.
멀티소켓 서버라면: GPU는 어느 소켓에 물려 있는가
2소켓 서버에서는 한 가지가 더 등장합니다. PCIe 장치는 특정 소켓의 root complex에 물리적으로 물려 있습니다.
앞서 본 서버 보드 사진에서 소켓마다 DIMM 슬롯이 그룹을 지어 붙어 있던 것이 이 구조입니다. AMD EPYC 9004 계열이라면 CPU 하나가 DDR5 12채널을 지원하므로 채널마다 한 장씩 꽂아 소켓당 12장, 2소켓 보드에는 DIMM 24장이 CPU 양옆으로 늘어섭니다. 이때 CPU 하나와 그 옆에 연결된 메모리들이 NUMA 노드 하나입니다.
여담으로 이렇게 DIMM을 24장이나 부어서 얻는 서버 전체 메모리 대역폭(~900 GB/s)보다 H100 한 장의 HBM(3.35 TB/s)이 3배 이상 빠릅니다.
소켓 사이는 UPI(인텔) 또는 xGMI(AMD)라는 캐시 일관성 interconnect로 연결됩니다. 소켓 0의 코어가 소켓 1에 물린 DRAM을 일반 load/store로 읽을 수 있는 것은 이 링크 덕분인데, 대역폭이 링크당 ~20~32 GB/s로 PCIe Gen4 x16 한 장 수준에 불과합니다.
그래서 GPU가 소켓 0에 물려 있는데 서빙 프로세스가 소켓 1에서 돌면, 모든 H2D/D2H 전송이 UPI를 한 번 더 지나갑니다. I/O 성능 저하의 주 원인입니다.
그래서 GPU, 그 GPU를 쓰는 프로세스의 코어, 그 프로세스가 쓰는 메모리를 같은 NUMA 노드에 모아 둬야합니다. GPU가 어느 소켓에 물려 있는지는 하드웨어로 이미 정해져 있으므로, 프로세스가 이에 맞추어야 합니다.
GPU-Util 100%를 믿으면 안 되는 이유
아키텍처를 이해했으니 측정 지표를 재해석할 수 있습니다. 가장 흔한 오해가 nvidia-smi의 GPU-Util입니다.
이 값은 utilization이 아닙니다. 샘플링 구간 중 "커널이 하나라도 GPU에 올라가 있던 시간의 비율"입니다. FLOPs의 5%만 쓰는 작은 커널이 쉬지 않고 돌아도 100%로 표시됩니다. occupancy가 10%든 100%든 똑같이 100%입니다. CPU 사용률 100%와는 의미가 완전히 다릅니다.
제대로 보려면 DCGM 지표를 써야합니다.
| 지표 | 의미 |
|---|---|
SM_ACTIVE |
warp가 올라간 SM의 비율 |
SM_OCCUPANCY |
상주 warp / 최대 warp |
PIPE_TENSOR_ACTIVE |
Tensor Core 실사용률 |
DRAM_ACTIVE |
HBM 대역폭 사용률 |
DRAM_ACTIVE나PIPE_TENSOR_ACTIVE가 이미 높다면 하드웨어 한계에 도달한 것입니다. worker를 늘려도 latency만 나빠집니다.SM_ACTIVE는 높은데SM_OCCUPANCY와 Tensor 사용률이 낮다면 커널이 작고 실행 오버헤드가 큰 상태입니다. 배칭이나 worker 추가를 해야합니다.
마무리
CPU는 스레드 하나의 지연을 줄이는데 초점을 맞췄고, GPU는 수천개의 warp를 상주하게 해 지연이 발생해도 다른 warp가 일하게 만듭니다. 그리고 이 둘은 PCIe로 연결되어 있습니다.
부족한 글이지만 저처럼 처음 GPU를 접하는 사람들에게 도움이 되길 바랍니다.
Reference
- CUDA C++ Programming Guide
- NVIDIA H100 Tensor Core GPU Architecture Whitepaper
- NVIDIA DCGM Documentation
- 본문 사진: Wikimedia Commons의 자유 이용 이미지 (저작자와 라이선스는 각 사진 캡션에 표기)