soobook
GPU

GPU Cluster Hardware Map

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

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

서버를 아주 Rough하게 그리면 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

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

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

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

Lanes

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이 많다는 건 차선이 많다는 뜻이고, 한 번에 더 많은 차량이 지나갈 수 있다.

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 and BAR

CPU가 PCIe 장치를 다루려면 장치의 레지스터를 읽고 써야 한다. 그런데 GPU나 NIC 내부 레지스터 접근은 CPU가 RAM에 접근하는 것과 다르다.

이 레지스터는 system RAM에 있는 데이터가 아니라 GPU나 NIC 내부 회로에 있다.

이를 위해 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
    autonumber
    participant Driver
    participant RAM
    participant NIC
    participant CPU

    Note over Driver,NIC: Setup
    Driver->>RAM: Prepare buffer
    Driver->>NIC: Set DMA address
    Note over NIC,CPU: Transfer
    NIC->>RAM: DMA write
    NIC->>CPU: Signal completion
    CPU->>RAM: Process packet

장치가 보는 주소와 CPU가 보는 주소는 항상 같지 않다.

Linux DMA API 문서는 CPU 가상 주소, CPU 물리 주소, bus address를 구분한다.

bus address는 PCIe 장치가 바라보는 주소 공간의 주소

장치가 DMA에 쓰는 주소는 bus address이고, IOMMU가 있으면 이 주소가 다시 실제 물리 메모리로 변환된다.

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

Driver and 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를 사용한다.

Interrupts and 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를 쓰면 불필요한 원격 접근을 줄일 수 있다.

References