컴퓨터공학 300 주제 시리즈의 097번째 글이다. 전체 지도는 여기.

한 줄 요약

DMA(Direct Memory Access)는 장치가 CPU 를 거치지 않고 주기억 장치를 직접 읽고 쓰게 하는 방식이다. CPU 는 “어디서 어디로 몇 바이트” 만 알려 주고 다른 일을 하다가, 전송이 끝나면 인터럽트로 통보받는다. 대량 I/O 에서 CPU 를 복사 노동에서 해방시키는 장치이고, 그 대가로 캐시 일관성과 보안(IOMMU) 문제를 함께 다뤄야 한다.

왜 필요한가

앞 글(#096)에서 인터럽트로 “언제 끝났는지” 를 아는 문제를 풀었다. 그런데 데이터 자체를 옮기는 일은 남았다. 디스크에서 1GB 를 읽는데 CPU 가 장치 레지스터에서 몇 바이트씩 꺼내 메모리에 옮겨 담는다면(프로그램 I/O, PIO), CPU 는 그동안 복사기 노릇만 한다.

현대 NVMe SSD 와 고속 NIC 는 초당 수 GB 이상을 주고받는다. 이 속도를 CPU 복사로 감당하면 코어 여러 개가 복사만 하다 끝난다. DMA 는 이 일을 장치(또는 DMA 엔진)에게 넘긴다.

핵심 개념

PIO 와 DMA 비교

PIO:  장치 ─► CPU 레지스터 ─► 메모리     (바이트·워드마다 CPU 개입)

DMA:  CPU: "버퍼 주소 X, 길이 N, 시작" ─► 장치
      장치 ════════════════════════► 메모리    (CPU 는 다른 일)
      장치 ─인터럽트─► CPU: "끝났다"
  PIO DMA
데이터 이동 주체 CPU 장치(버스 마스터) 또는 DMA 엔진
CPU 부담 전송 내내 설정과 완료 처리만
적합한 곳 작은 제어 레지스터 접근 대량 블록·패킷 전송

버스 마스터링과 디스크립터 링

옛 PC 에는 메인보드에 공용 DMA 컨트롤러가 있었다. PCIe 시대의 장치는 대부분 스스로 메모리 트랜잭션을 일으키는 버스 마스터다. CPU 와 장치는 메모리에 놓인 디스크립터 링(ring) 으로 일을 주고받는다.

        드라이버(CPU)가 채움            장치가 처리
   ┌────────┬────────┬────────┬────────┬────────┐
   │ desc 0 │ desc 1 │ desc 2 │ desc 3 │ ...    │  각 디스크립터: 버퍼 물리 주소, 길이, 상태
   └────────┴────────┴────────┴────────┴────────┘
        ▲ tail(새 일 추가)          ▲ head(장치가 처리한 곳)

1. 드라이버가 버퍼를 준비하고 디스크립터에 주소·길이를 적는다.
2. 장치의 "도어벨" 레지스터(MMIO)에 tail 위치를 쓴다.
3. 장치가 디스크립터를 읽고 DMA 로 데이터를 옮긴다.
4. 완료 상태를 디스크립터에 쓰고 인터럽트(MSI-X)를 보낸다.

NVMe 의 제출 큐·완료 큐, 네트워크 카드의 송수신 링이 모두 이 구조다. CPU 와 장치가 공유 메모리의 큐로 통신하는 생산자-소비자 패턴이다.

스캐터-개더

가상 메모리에서 연속된 버퍼도 물리 메모리에서는 흩어진 페이지들일 수 있다(#095). 장치는 물리 주소로 일하므로, 여러 조각의 (주소, 길이) 목록을 한 번에 넘겨 처리하게 하는 스캐터-개더 DMA 를 쓴다.

캐시 일관성 문제

CPU 는 캐시를 통해 메모리를 보는데, 장치는 메모리를 직접 본다. 그러면 두 가지 문제가 생길 수 있다.

  • CPU 가 쓴 데이터가 아직 캐시에만 있고 메모리에 없으면, 장치가 낡은 값을 보낸다.
  • 장치가 메모리에 쓴 새 데이터를 CPU 가 낡은 캐시 라인으로 읽는다.

x86 서버처럼 하드웨어가 DMA 와 캐시의 일관성을 맞춰 주는 플랫폼도 있고, 일부 임베디드 ARM 처럼 소프트웨어가 캐시를 비우고 무효화해야 하는 플랫폼도 있다. 리눅스는 이 차이를 DMA API(dma_map_single, dma_sync_* 등)로 감춰서, 드라이버가 같은 코드로 양쪽에서 동작하게 한다.

IOMMU: 장치용 MMU

DMA 장치는 원칙적으로 아무 물리 주소나 읽고 쓸 수 있다. 버그 있는 드라이버나 악의적인 장치가 커널 메모리를 덮어쓸 수 있다는 뜻이다. IOMMU(인텔 VT-d, AMD-Vi, ARM SMMU)는 장치가 쓰는 주소를 페이지 테이블로 변환·검사해서 허락된 영역에만 접근하게 한다. CPU 의 MMU 가 프로세스를 격리하듯 IOMMU 는 장치를 격리한다. VM 에 GPU·NIC 를 직접 넘겨주는 장치 할당(VFIO)도 IOMMU 가 있어야 안전하다.

제로 카피

DMA 가 장치와 메모리 사이 복사를 없애도, 소프트웨어가 커널 버퍼와 사용자 버퍼 사이에서 또 복사하면 이득이 줄어든다. 파일을 소켓으로 보내는 전통적 방식은 이렇다.

read():  디스크 ─DMA─► 커널 페이지 캐시 ─CPU 복사─► 사용자 버퍼
send():  사용자 버퍼 ─CPU 복사─► 커널 소켓 버퍼 ─DMA─► NIC

sendfile() 은 사용자 공간을 건너뛰어 CPU 복사를 줄인다. NIC 가 스캐터-개더를 지원하면 페이지 캐시에서 바로 DMA 로 내보낼 수도 있다. 웹 서버가 정적 파일을 보낼 때 쓰는 기법이다.

직접 해 보기

DMA 자체는 사용자 프로그램에서 직접 관찰하기 어렵다. 대신 DMA 가 없애려는 것, 곧 CPU 의 복사 비용을 잰다. 128MB 파일을 소켓으로 보낼 때 read+send 와 sendfile 의 보내는 스레드 CPU 시간을 비교한다. 디스크 속도 영향을 없애려고 파일은 메모리 기반 파일시스템(/dev/shm)에 두었다. 리눅스에서 python3 로 확인했다.

import os, socket, threading, time, resource

PATH, SIZE = "/dev/shm/blob.bin", 128 << 20       # 메모리 기반 파일: 디스크 속도 영향 제거
with open(PATH, "wb") as f:
    f.write(os.urandom(1 << 20) * (SIZE >> 20))

def drain(sock):                                   # 받는 쪽: 버퍼 하나로 계속 비운다
    buf = bytearray(1 << 20)
    while sock.recv_into(buf):
        pass

def thread_cpu():                                  # 보내는 스레드만의 CPU 시간
    r = resource.getrusage(resource.RUSAGE_THREAD)
    return r.ru_utime + r.ru_stime

def run(name, send):
    a, b = socket.socketpair()
    t = threading.Thread(target=drain, args=(b,)); t.start()
    c0, w0 = thread_cpu(), time.perf_counter()
    with open(PATH, "rb", buffering=0) as f:
        send(f, a)
    c1, w1 = thread_cpu(), time.perf_counter()
    a.close(); t.join(); b.close()
    print(f"{name:10s} wall {w1-w0:.3f}s  sender cpu {c1-c0:.3f}s")

def read_send(f, s):                               # 파일 -> 사용자 버퍼 -> 소켓 (복사 2번)
    buf = bytearray(1 << 20)
    while n := f.readinto(buf):
        s.sendall(memoryview(buf)[:n])

def sendfile(f, s):                                # 파일 -> 소켓, 사용자 공간을 거치지 않음
    off = 0
    while off < SIZE:
        off += os.sendfile(s.fileno(), f.fileno(), off, SIZE - off)

for _ in range(3):
    run("read+send", read_send)
    run("sendfile", sendfile)
os.remove(PATH)

출력:

read+send  wall 0.058s  sender cpu 0.053s
sendfile   wall 0.027s  sender cpu 0.017s
read+send  wall 0.096s  sender cpu 0.058s
sendfile   wall 0.030s  sender cpu 0.017s
read+send  wall 0.064s  sender cpu 0.055s
sendfile   wall 0.029s  sender cpu 0.017s

같은 데이터를 보내는데 보내는 쪽 CPU 시간이 약 3분의 1 로 줄었다. 사용자 버퍼를 오가는 복사가 사라졌기 때문이다. 이 실험은 유닉스 도메인 소켓이라 실제 NIC 와 DMA 는 관여하지 않는다. 그래도 “CPU 가 바이트를 옮기는 비용” 이 얼마나 큰지, 그리고 그것을 장치와 커널에 넘기면 CPU 가 얼마나 가벼워지는지는 보여 준다.

현업에서는

  • 고속 스토리지와 네트워크: NVMe 는 코어마다 제출·완료 큐 쌍을 둘 수 있게 설계돼 큐 하나를 두고 코어끼리 락으로 다투지 않는다. 멀티 큐 NIC 도 같은 생각이다. “링 크기”, “큐 개수” 같은 튜닝 항목은 모두 이 DMA 디스크립터 링 이야기다.
  • GPU 와 데이터 전송: 머신러닝 학습에서 CPU 메모리의 배치 데이터를 GPU 로 보내는 것도 DMA 다. 페이지가 고정(pinned)된 메모리여야 DMA 가 바로 할 수 있어서, 프레임워크들이 “pinned memory” 옵션을 제공한다(#099).
  • 가상화와 장치 할당: VM 이나 컨테이너에 GPU·NIC 를 직접 넘겨주는(PCI 패스스루, SR-IOV) 구성은 IOMMU 가 켜져 있어야 한다. 홈랩 서버에서 패스스루가 안 될 때 BIOS 의 VT-d/AMD-Vi 설정과 커널 IOMMU 옵션을 먼저 확인한다.
  • 보안: 썬더볼트처럼 외부에서 꽂는 PCIe 장치는 DMA 로 메모리를 읽을 수 있다. IOMMU 기반 DMA 보호가 노트북 보안 설정에 들어 있는 이유다.

확인 문제

  1. PIO 와 DMA 의 차이를 CPU 부담 관점에서 설명하라.
  2. 디스크립터 링에서 “도어벨” 레지스터의 역할은.
  3. DMA 와 CPU 캐시 사이에 일관성 문제가 생기는 이유는.
  4. IOMMU 가 없을 때의 위험은.

풀이

  1. PIO 는 CPU 가 데이터를 직접 옮겨 전송 내내 바쁘고, DMA 는 CPU 가 설정과 완료 처리만 하고 데이터 이동은 장치가 한다.
  2. 드라이버가 새 디스크립터를 링에 넣었다는 사실을 장치에게 알리는 MMIO 레지스터다.
  3. CPU 는 캐시를 통해, 장치는 메모리를 직접 보므로 한쪽의 최신 값이 다른 쪽에 아직 보이지 않을 수 있기 때문이다.
  4. 장치가 아무 물리 메모리나 읽고 쓸 수 있어 버그나 악의적 장치가 커널 메모리를 훼손·유출할 수 있다.

더 읽을거리 (References)