[CS300 #097] DMA — 데이터 복사를 CPU 대신 장치가 하게 하기
컴퓨터공학 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 보호가 노트북 보안 설정에 들어 있는 이유다.
확인 문제
- PIO 와 DMA 의 차이를 CPU 부담 관점에서 설명하라.
- 디스크립터 링에서 “도어벨” 레지스터의 역할은.
- DMA 와 CPU 캐시 사이에 일관성 문제가 생기는 이유는.
- IOMMU 가 없을 때의 위험은.
풀이
- PIO 는 CPU 가 데이터를 직접 옮겨 전송 내내 바쁘고, DMA 는 CPU 가 설정과 완료 처리만 하고 데이터 이동은 장치가 한다.
- 드라이버가 새 디스크립터를 링에 넣었다는 사실을 장치에게 알리는 MMIO 레지스터다.
- CPU 는 캐시를 통해, 장치는 메모리를 직접 보므로 한쪽의 최신 값이 다른 쪽에 아직 보이지 않을 수 있기 때문이다.
- 장치가 아무 물리 메모리나 읽고 쓸 수 있어 버그나 악의적 장치가 커널 메모리를 훼손·유출할 수 있다.
더 읽을거리 (References)
- Linux Kernel Documentation, Dynamic DMA mapping Guide
- Linux Kernel Documentation, x86 IOMMU Support
- Linux Kernel Documentation, VFIO — Virtual Function I/O
- Python 공식 문서, os.sendfile