soobook
GPU

GPU Cluster Hardware Map

GPU 클러스터를 이해하기 전에 알아야 할 CPU, RAM, PCIe, DMA, driver, NUMA의 관계

GPU 클러스터를 이해하려면 먼저 서버 한 대 안에서 데이터가 어떻게 이동하는지 봐야 한다.
GPU, NIC, SSD는 모두 CPU 옆에 붙은 I/O 장치이고, OS와 driver가 그 장치들을 다룬다.

GPU 인프라 글을 읽다 보면 PCIe, DMA, NUMA, driver, kernel module 같은 단어가 계속 나온다.

이번 글은 컴퓨터 한 대 안에서 CPU, RAM, GPU, NIC가 어떤 관계인지 보는 데 집중한다.

서버 한 대의 구성

서버를 아주 거칠게 그리면 CPU, RAM, disk, NIC, GPU로 나뉜다.

flowchart LR
    CPU["CPU"] <--> RAM["RAM"]
    CPU <--> PCIE["PCIe Root Complex"]
    PCIE <--> GPU["GPU"]
    PCIE <--> NIC["NIC"]
    PCIE <--> SSD["NVMe SSD"]
    NIC <--> NET["Network"]

CPU, RAM, Disk, NIC, GPU 모두 익숙한 개념이다.

여기서 GPU, NIC, NVMe SSD는 보통 PCIe에 붙는다. 그래서 GPU 서버를 이해할 때 PCIe를 피할 수 없다.

GPU가 아무리 빠르더라도, CPU와 GPU 사이로 데이터가 오가는 길은 PCIe다.

PCIe(Peripheral Component Interconnect Express)

PCIe(PCI Express)는 CPU와 주변 장치를 연결하는 고속 직렬 인터커넥트다. 그래픽카드, NIC, NVMe SSD, HBA 같은 장치가 여기에 붙는다.

PCIe는 lane 단위로 폭이 정해진다. x16은 lane 16개를 묶은 링크이고, GPU는 보통 x16 링크를 쓴다.

NVMe SSD는 x4 링크를 쓰는 경우가 많다. 세대가 올라가면 lane 하나가 보내는 데이터율이 올라간다.

Lane

Lane은 쉽게 말하면 장치 사이에 놓인 독립적인 고속 통로다.

Lane 하나는 단방향 선 하나가 아니라, 보통 송신용 differential pair와 수신용 differential pair로 구성된다.

그래서 PCIe는 full-duplex다. 즉 CPU 쪽에서 GPU로 보내는 데이터와 GPU 쪽에서 CPU로 돌아오는 데이터가 동시에 흐를 수 있다.

Lane을 통해 PCIe 프로토콜의 packet이 흐른다.

  • CPU가 GPU의 MMIO register에 쓰는 command
  • CPU RAM에서 GPU VRAM으로 복사되는 데이터
  • GPU가 DMA로 host memory를 읽거나 쓰는 요청
  • NVMe SSD나 NIC가 RAM과 주고받는 DMA payload
  • configuration read/write
  • interrupt 메시지

GPU 관점에서는 “학습 데이터, tensor, command, completion, DMA payload” 같은 것들이 결국 PCIe packet으로 쪼개져 lane을 타고 이동한다고 보면 된다.

Lane이 많으면 좋은가? 대역폭 관점에서는 보통 좋다.

고속도로 비유로 말하면 lane이 많다는 건 차선이 많다는 뜻이고, 한 번에 더 많은 차량이 지나갈 수 있다.

하지만 항상 체감 성능이 좋아지는 건 아니다.

  • 대역폭은 늘지만 latency가 크게 줄지는 않는다. 차선이 많아지는 것이지 목적지가 가까워지는 건 아니다.
  • workload가 PCIe 병목이 아니면 차이가 작다. 데이터가 이미 GPU VRAM에 있고 GPU compute가 병목이면 x8과 x16 차이가 거의 안 날 수 있다.
  • 반대로 CPU memory와 GPU VRAM 사이로 데이터를 자주 왕복하거나, NVMe SSD와 NIC가 RAM으로 큰 데이터를 밀어 넣는 workload에서는 lane 수가 중요해진다.
flowchart TD
    CPU["CPU"]
    RC["PCIe Root Complex"]
    SW["PCIe Switch"]
    G0["GPU 0"]
    G1["GPU 1"]
    N0["NIC"]
    S0["NVMe SSD"]

    CPU --> RC
    RC --> SW
    SW --> G0
    SW --> G1
    SW --> N0
    RC --> S0

Root Complex는 CPU 쪽 PCIe의 시작점이다. 장치가 많아지면 PCIe switch가 중간에서 fan-out한다.

GPU 8장이 들어간 서버에서는 이런 switch와 topology가 중요해진다.

여기서 topology란 “어떤 장치가 어느 경로로 연결되어 있는가”다.

GPU 0과 GPU 1이 같은 PCIe switch 아래에 있는지, GPU와 NVMe SSD, NIC가 어느 Root Complex에 가까이 붙어 있는지에 따라 데이터 이동 비용이 달라진다.

MMIO와 BAR

CPU가 PCIe 장치를 다루려면 장치의 레지스터를 읽고 써야 한다. 그런데 GPU나 NIC 내부 레지스터는 CPU의 일반 RAM이 아니다.

이를 위해 PCIe 장치는 BAR(Base Address Register)를 노출한다. OS는 부팅 과정에서 장치의 BAR를 물리 주소 공간 어딘가에 매핑하고, driver는 그 영역을 통해 장치 레지스터에 접근한다.

flowchart LR
    CPU["CPU instruction"] --> MMIO["MMIO address"]
    MMIO --> BAR["PCIe BAR"]
    BAR --> DEV["Device registers"]

이 방식을 MMIO(Memory-Mapped I/O)라고 부른다. 이름처럼 메모리 주소를 읽고 쓰는 것처럼 보이지만, 실제 대상은 RAM이 아니라 장치 레지스터다.

예를 들어 driver가 NIC의 송신 큐에 “이 버퍼를 보내라”고 알려줄 때, CPU는 NIC 레지스터에 명령을 쓴다. GPU driver도 같은 식으로 GPU에 작업 큐와 메모리 정보를 전달한다.

DMA

CPU가 네트워크 패킷이나 디스크 블록을 매번 직접 복사하면 너무 비싸다. 그래서 고속 I/O 장치는 DMA(Direct Memory Access)를 쓴다.

DMA는 장치가 시스템 메모리를 직접 읽거나 쓰는 방식이다. NIC가 패킷을 받으면 CPU가 바이트를 하나씩 옮기는 게 아니라, NIC가 미리 지정된 RAM 버퍼에 패킷을 써 넣는다.

sequenceDiagram
    participant Driver
    participant RAM
    participant NIC
    participant CPU

    Driver->>RAM: receive buffer 준비
    Driver->>NIC: DMA 주소 전달
    NIC->>RAM: 패킷을 직접 기록
    NIC->>CPU: interrupt 또는 polling으로 알림
    CPU->>RAM: 완성된 패킷 처리

장치가 보는 주소와 CPU가 보는 주소는 항상 같지 않다. Linux DMA API 문서는 CPU 가상 주소, CPU 물리 주소, bus address를 구분한다. 장치가 DMA에 쓰는 주소는 bus address이고, IOMMU가 있으면 이 주소가 다시 실제 물리 메모리로 변환된다.

그래서 driver는 아무 포인터나 장치에 던지지 않는다. dma_map_single() 같은 API로 버퍼를 DMA용으로 매핑하고, 그 결과로 받은 DMA address를 장치에 알려준다.

Driver와 kernel

사용자 프로그램은 보통 GPU나 NIC 레지스터를 직접 만지지 않는다. 중간에 kernel과 driver가 있다.

flowchart TD
    APP["User process"]
    LIB["CUDA runtime / network library"]
    SYSCALL["System call / ioctl"]
    KERNEL["Linux kernel"]
    DRIVER["Device driver"]
    DEVICE["GPU / NIC"]

    APP --> LIB
    LIB --> SYSCALL
    SYSCALL --> KERNEL
    KERNEL --> DRIVER
    DRIVER --> DEVICE

Kernel은 하드웨어 자원을 관리한다. 어떤 프로세스가 어떤 장치에 접근 가능한지, 어떤 메모리 페이지가 어디에 있는지, interrupt를 누가 처리할지 같은 일을 맡는다.

Driver는 특정 장치를 다루는 kernel 코드다. NVIDIA GPU driver는 GPU 초기화, 메모리 관리, command submission, interrupt 처리, 에러 보고를 맡는다. NIC driver는 큐 설정, 패킷 송수신, DMA 버퍼 관리, link 상태 처리를 맡는다.

사용자 공간에서는 보통 라이브러리를 통해 driver를 간접 호출한다.

CUDA 프로그램은 CUDA runtime과 NVIDIA driver를 거쳐 GPU에 작업을 보낸다. 네트워크 프로그램은 socket API를 거쳐 NIC driver를 사용한다.

Interrupt와 polling

장치가 일을 끝냈을 때 CPU에 알려야 한다. 가장 직관적인 방식은 interrupt다. 장치가 CPU에 신호를 보내면 kernel이 해당 driver의 interrupt handler를 실행한다.

하지만 이벤트가 매우 자주 발생하는 장치에서는 매번 interrupt를 걸면 CPU가 interrupt 처리에만 묶인다. 그래서 polling도 많이 쓴다. CPU가 일정 시간 동안 장치 큐를 직접 확인하는 방식이다.

네트워크 장치에서는 Linux NAPI가 대표적이다. 트래픽이 적을 때는 interrupt로 깨우고, 트래픽이 많을 때는 polling으로 묶어서 처리한다.

GPU 쪽에서도 비슷한 문제가 있다. 작업 완료, 에러, 페이지 fault 같은 이벤트를 kernel과 driver가 처리해야 한다.

그래서 GPU 성능을 볼 때는 GPU 자체만 보지 않고 CPU interrupt, driver, kernel 상태까지 같이 봐야 한다.

NUMA

서버 CPU가 하나만 있으면 단순하다. CPU 하나가 RAM과 PCIe 장치들을 관리한다.

하지만 GPU 서버는 CPU socket이 2개인 경우가 많다. 이때 각 CPU socket은 자기 쪽 RAM과 PCIe Root Complex를 가진다. Linux는 이런 물리 구역을 NUMA node로 본다.

flowchart LR
    subgraph N0["NUMA node 0"]
        CPU0["CPU socket 0"]
        RAM0["Local RAM 0"]
        GPU0["GPU 0-3"]
        NIC0["NIC 0"]
        CPU0 <--> RAM0
        CPU0 <--> GPU0
        CPU0 <--> NIC0
    end

    subgraph N1["NUMA node 1"]
        CPU1["CPU socket 1"]
        RAM1["Local RAM 1"]
        GPU1["GPU 4-7"]
        NIC1["NIC 1"]
        CPU1 <--> RAM1
        CPU1 <--> GPU1
        CPU1 <--> NIC1
    end

    CPU0 <--> CPU1

NUMA는 Non-Uniform Memory Access의 약자다. 모든 CPU가 모든 메모리를 볼 수 있지만, 접근 비용이 균일하지 않다는 뜻이다. CPU socket 0에서 RAM 0을 읽는 것은 가깝고, RAM 1을 읽는 것은 상대적으로 멀다.

PCIe 장치도 마찬가지다. 어떤 GPU가 어느 CPU socket에 가까운지, 어떤 NVMe SSD나 NIC가 어느 NUMA node에 붙어 있는지가 중요하다. GPU가 node 0에 있고 NIC가 node 1에 있으면, NIC가 받은 데이터를 GPU에 넘길 때 더 먼 경로를 타야 할 수 있다.

이런 배치를 확인할 때 numactl --hardware, lspci, nvidia-smi topo -m 같은 명령을 쓴다.

Locality

NUMA에서 핵심은 locality다. 계산하는 CPU, 접근하는 메모리, 사용하는 PCIe 장치가 가까울수록 좋다.

예를 들어 데이터 로더 프로세스가 CPU socket 1에서 돌고, 그 데이터가 RAM 1에 있고, 학습 GPU가 socket 0에 붙어 있다면 데이터가 socket 간 링크를 건너야 한다. 대역폭과 지연 시간 모두 손해를 본다.

반대로 GPU와 가까운 CPU core에 프로세스를 pinning하고, 가까운 메모리에서 버퍼를 할당하고, 같은 쪽 NVMe SSD나 NIC를 쓰면 불필요한 원격 접근을 줄일 수 있다.

데이터가 어디에서 어디로 이동하는가. 그 경로에 CPU, RAM, PCIe switch, NIC, GPU 중 무엇이 끼어 있는가.

서버 안의 경로

GPU 클러스터를 이해하는 첫 단계는 서버 밖의 네트워크가 아니라 서버 한 대 안의 경로를 읽는 것이다.

GPU가 빠르다는 말은 GPU 안의 연산이 빠르다는 뜻일 때가 많다. 하지만 GPU가 일을 하려면 데이터가 GPU까지 도착해야 하고, 작업을 시작하라는 제어 신호도 장치에 전달되어야 한다.

서버 안에서는 크게 두 종류의 경로를 구분하면 된다.

  • 제어 경로: CPU가 MMIO register를 읽고 쓰면서 장치에 명령을 전달한다.
  • 데이터 경로: NVMe SSD, NIC, GPU가 DMA로 RAM을 읽거나 쓴다.

NIC는 네트워크 장치지만, 이번 글의 범위에서는 PCIe에 붙은 I/O 장치로 보면 충분하다. NIC도 NVMe SSD처럼 RAM과 DMA를 주고받고, CPU와 driver가 그 큐와 버퍼를 관리한다.

References