VFIO
물리 장치를 유저스페이스와 VM에 안전하게 내주는 커널 프레임워크
passthrough의 실체는 두 가지다. 커널이 장치를 놓아주는 것, 그리고 IOMMU가 그 장치를 감시하는 것. VFIO는 이 둘을 묶어 유저스페이스에 안전하게 내주는 커널 프레임워크다.
Intro
KVM과 QEMU 글 마지막에서 passthrough를 봤다. GPU를 host 드라이버에서 떼어 vfio-pci에 바인딩하면, QEMU가 그 장치를 VM에 그대로 넘길 수 있다고 했다.
KubeVirt 글에서는 그렇게 준비된 GPU를 VM spec에 적는 방법을 봤다.
이번 글은 그 두 글 사이에 깔려 있는 층이다. vfio-pci에 바인딩한다는 게 실제로 무슨 일인지, 왜 그게 안전한지, 그리고 GPU 클러스터에서 이 경로가 어떻게 조립되는지를 본다.
Why VFIO(Virtual Function I/O)
문제를 먼저 정의하자. QEMU는 유저스페이스 프로세스다. 그런데 passthrough를 하려면 이 프로세스가 물리 GPU를 통째로 다뤄야 한다.
원래 장치는 커널의 것이다. 드라이버는 커널 안에서 돌고, 유저스페이스는 시스템 콜로 커널에 부탁만 한다. 이 경계를 넘어 장치를 유저스페이스에 직접 주려던 오래된 방법이 UIO다.
UIO (Userspace I/O)는 장치의 레지스터 영역을 유저스페이스에 mmap해 주는 커널 프레임워크다. 드라이버 로직을 유저스페이스에서 짤 수 있게 해 준다.
UIO는 세 가지가 부족했다. IOMMU라는 개념이 없고, 인터럽트 지원이 제한적이고, PCI config space 접근에 root 권한이 필요하다.
이 중 치명적인 것이 첫 번째다. 장치는 DMA를 한다. GPU Cluster Hardware Map에서 봤듯, DMA는 CPU를 거치지 않고 장치가 메모리에 직접 접근하는 경로다.
보호 장치가 없다면, 장치를 받은 프로세스가 장치에게 “저 주소를 읽어 와”라고 시킬 수 있다. 그 주소가 커널 메모리든 다른 프로세스의 메모리든 장치는 그냥 읽는다. 커널 문서도 DMA를 시스템 무결성에 가장 큰 위험이라고 못박는다.
VFIO는 이 구멍을 IOMMU로 막는다.
VFIO (Virtual Function I/O)는 IOMMU 보호 아래에서 장치를 유저스페이스에 직접 노출하는 커널 프레임워크다. 덕분에 root가 아닌 프로세스도 안전하게 장치 드라이버가 될 수 있다.
원래 KVM 안에 있던 PCI device assignment 코드가 VFIO로 대체됐고, 지금 QEMU의 passthrough는 전부 이 문으로 들어간다. QEMU만의 것도 아니다. DPDK 같은 유저스페이스 네트워크 드라이버도 같은 문을 쓴다.
IOMMU
VFIO의 안전을 담보하는 하드웨어가 IOMMU다.
IOMMU (I/O Memory Management Unit)는 장치의 메모리 접근에 끼어드는 주소 변환 하드웨어다. Intel은 VT-d, AMD는 AMD-Vi라고 부른다.
CPU 쪽 MMU가 프로세스의 가상 주소를 물리 주소로 바꾸듯, IOMMU는 장치가 DMA에 쓰는 주소를 물리 주소로 바꾼다. 장치가 내는 주소를 IOVA(I/O Virtual Address)라고 한다.
HPA(Host Physical Address)는 host의 실제 물리 메모리 주소다. 아래 다이어그램의 IOVA → HPA 변환이 곧 IOMMU가 하는 일이다.
프로세스마다 가상 주소 공간을 주듯, 장치에게도 장치용 가상 주소 공간을 주는 셈이다.
{
"diagram": "html-diagram",
"variant": "explainer",
"title": "IOMMU: a page table for device DMA",
"width": 960,
"height": 584,
"mobileWidth": 900,
"regions": [
{
"id": "no-iommu",
"label": "IOMMU 없음\\n장치의 주소가 곧 물리 주소",
"x": 36,
"y": 64,
"width": 430,
"height": 456
},
{
"id": "with-iommu",
"label": "IOMMU 있음\\nDMA가 테이블을 통과",
"x": 494,
"y": 64,
"width": 430,
"height": 456
}
],
"nodes": [
{
"id": "gpu-l",
"kind": "gpu",
"label": "GPU",
"caption": "DMA engine",
"x": 251,
"y": 190,
"width": 240,
"height": 66
},
{
"id": "note-l",
"kind": "note",
"label": "주소 검사 없음",
"x": 251,
"y": 320,
"width": 240,
"height": 56
},
{
"id": "ram-l",
"kind": "memory",
"label": "RAM 전체",
"caption": "커널, 다른 VM 메모리에도 닿는다",
"x": 251,
"y": 460,
"width": 300,
"height": 66
},
{
"id": "gpu-r",
"kind": "gpu",
"label": "GPU",
"caption": "DMA engine",
"x": 709,
"y": 190,
"width": 240,
"height": 66
},
{
"id": "iommu-r",
"kind": "switch",
"label": "IOMMU",
"caption": "IOVA → HPA 변환, 범위 검사",
"x": 709,
"y": 320,
"width": 280,
"height": 66
},
{
"id": "ram-r",
"kind": "memory",
"label": "자기 VM 메모리만",
"caption": "범위 밖 주소는 차단된다",
"x": 709,
"y": 460,
"width": 300,
"height": 66
}
],
"links": [
{
"id": "l1",
"points": [[251, 223], [251, 292]],
"tone": "primary",
"flow": { "speed": "fast", "count": 2, "emphasis": "strong" }
},
{
"id": "l2",
"points": [[251, 348], [251, 427]],
"tone": "primary",
"flow": { "speed": "fast", "count": 2, "emphasis": "strong" }
},
{
"id": "r1",
"points": [[709, 223], [709, 287]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
},
{
"id": "r2",
"points": [[709, 353], [709, 427]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
}
],
"labels": []
}
VM에서 이 변환이 결정적인 이유가 있다. VFIO는 IOMMU 테이블을 “IOVA = 게스트 물리 주소(GPA)“가 되도록 세팅한다.
그러면 게스트 드라이버가 장치에 GPA를 그대로 프로그래밍해도 DMA가 어긋나지 않는다.
장치가 GPA로 접근하면 IOMMU가 진짜 물리 주소(HPA)로 바꿔 주기 때문이다.
게스트는 자기가 실물 하드웨어 위에 있다고 믿은 채로 동작한다.
KVM과 QEMU에서 본 EPT와 정확히 대구를 이룬다.
CPU가 내는 주소는 EPT가 GPA → HPA로 바꾸고, 장치가 내는 주소는 IOMMU가 GPA → HPA로 바꾼다.
2단계 주소 변환이 CPU 쪽과 I/O 쪽에 하나씩 있다.
IOMMU는 하드웨어와 펌웨어 양쪽에서 켜야 동작한다. BIOS에서 VT-d(AMD-Vi)를 켜고, 커널 커맨드라인에
intel_iommu=on(AMD는amd_iommu=on)을 넣는다.
IOMMU Group
IOMMU가 장치마다 다른 테이블을 적용하려면 먼저 “이 DMA가 누구 것인지”를 알아야 한다.
근거는 PCIe transaction에 붙는 requester ID, 즉 장치의 BDF(bus/device/function) 번호다.
그런데 장치 단위 격리가 항상 성립하지는 않는다. 두 곳에서 깨진다.
하나는 requester ID 자체가 겹치는 경우다. PCIe-to-PCI 브리지 뒤의 장치들은 transaction이 브리지의 ID로 보이기 때문에 IOMMU가 서로 구분할 수 없다.
PCIe-to-PCI bridge는 옛 세대의 conventional PCI 장치를 PCIe 트리에 이어 주는 변환 칩이다. conventional PCI는 여러 장치가 배선을 공유하는 병렬 버스라 transaction에 발신자를 적는 필드 자체가 없다. 그래서 브리지가 아래에서 온 transaction을 위로 올릴 때 자기 BDF를 requester ID로 대신 찍는다.
다른 하나는 peer-to-peer 재라우팅이다. PCIe switch는 주소 기반 라우팅을 한다. transaction의 목적지 주소가 옆 포트 장치의 MMIO 범위(BAR)에 속하면, 위로 올릴 필요 없이 그 포트로 바로 넘긴다.
GPU끼리 P2P로 데이터를 주고받을 때는 이 방식이 좋지만 격리 관점에서는 구멍이다.
IOMMU는 트리 꼭대기의 Root Complex 안에 있으므로, 옆으로 직송된 DMA는 변환도 검사도 받지 않은 채 옆 장치에 꽂힌다.
VM이 조종하는 GPU가 host 소유 NIC의 레지스터 주소로 write를 쏘는 상황을 생각해 보면, IOMMU가 막을 기회 자체가 없다.
이 재라우팅을 막거나 강제로 위로 올려 보내는 PCIe 기능이 ACS(Access Control Services)다.
장치에서 IOMMU까지의 경로 어딘가에 ACS가 없으면, 커널은 그 지점 아래 장치들의 격리를 신뢰하지 않는다.
NVLink and NVSwitch에서 GPU P2P가 “ACS 설정에 따라 달라진다”고 했던 그 ACS다. P2P 성능을 위해 끄고 싶은 기능이, 격리를 위해서는 켜져 있어야 하는 기능이기도 하다.
{
"diagram": "html-diagram",
"variant": "explainer",
"title": "IOMMU groups: where isolation actually holds",
"width": 960,
"height": 589,
"mobileWidth": 900,
"regions": [
{
"id": "g0",
"label": "IOMMU group 0\\n혼자 격리 가능",
"x": 36,
"y": 361,
"width": 430,
"height": 164
},
{
"id": "g1",
"label": "IOMMU group 1\\nP2P 재라우팅 가능 — 통째로 묶인다",
"x": 494,
"y": 361,
"width": 430,
"height": 164
}
],
"nodes": [
{
"id": "rc",
"kind": "switch",
"label": "Root Complex + IOMMU",
"x": 480,
"y": 95,
"width": 360,
"height": 62
},
{
"id": "rp",
"kind": "note",
"label": "Root Port",
"caption": "ACS 지원",
"x": 251,
"y": 243,
"width": 240,
"height": 60
},
{
"id": "sw",
"kind": "note",
"label": "Switch",
"caption": "ACS 없음",
"x": 709,
"y": 243,
"width": 240,
"height": 60
},
{
"id": "gpu0",
"kind": "gpu",
"label": "GPU 0",
"x": 251,
"y": 476,
"width": 200,
"height": 60
},
{
"id": "gpu1",
"kind": "gpu",
"label": "GPU 1",
"x": 599,
"y": 476,
"width": 180,
"height": 60
},
{
"id": "nic",
"kind": "nic",
"label": "NIC",
"x": 819,
"y": 476,
"width": 180,
"height": 60
}
],
"links": [
{
"id": "rc-rp",
"points": [[390, 126], [251, 213]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "rc-sw",
"points": [[570, 126], [709, 213]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "rp-g0",
"points": [[251, 273], [251, 349]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
},
{
"id": "sw-g1",
"points": [[709, 273], [709, 349]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "p2p",
"points": [[689, 476], [729, 476]],
"tone": "muted",
"dashed": true,
"flow": { "speed": "slow", "emphasis": "muted" }
}
],
"labels": []
}
Root port 는 CPU 쪽 PCIe 계층의 출입구 포트이다. Root Complex 가 건물이라면 Root port 는 출입문이라고 생각하자. IOMMU 바로 앞의 마지막 관문이라서, root port가 ACS를 지원하면 그 아래에서 올라온 transaction은 옆으로 새지 않고 반드시 IOMMU를 통과한다는 보장이 생긴다.
다이어그램의 왼쪽과 오른쪽은 같은 시스템 안의 두 배치다.
GPU 0은 ACS 있는 root port에 직결되어 격리가 증명되므로 혼자 group 0이 된다.
GPU 1과 NIC는 ACS 없는 switch 아래라 서로에게 IOMMU 몰래 닿을 수 있으므로, 커널은 둘을 group 1로 함께 묶는다.
같은 GPU라도 꽂힌 슬롯이 다르면 group의 운명이 달라진다.
그래서 커널은 “다른 모든 장치로부터 확실히 격리할 수 있는 최소 장치 집합”을 IOMMU group으로 묶는다.
그리고 VFIO는 장치가 아니라 group을 소유권의 단위로 삼는다.
이 Grouping 은 선택이 아니라 판정 결과이다.
관리자가 구성하는 게 아님에 유의하자.
부팅 시 커널이 PCIe topology 를 훑으면서 “이 장치를 다른 장치들과 확실히 격리할 수 있는가” 를 장치마다 판정하고, 격리를 증명할 수 없는 장치들끼리 자동으로 한 group 에 넣는다.
여기서 group viability 규칙이 나온다. 어떤 group을 유저스페이스에 열려면, 그 group에 속한 장치 전부가 host 드라이버에서 분리되어 vfio-pci나 pci-stub 같은 안전한 드라이버에 붙어 있어야 한다. 브리지나 스위치 같은 연결 장치 자체는 예외다.
즉 GPU와 NIC이 같은 group에 묶여 있으면 GPU만 넘길 수 없다. GPU는 VM A에, NIC는 host나 VM B에 주는 조합도 불가능하다. 한 group은 한 주인이다.
GPU만 쓰고 싶다면 NIC도 host 드라이버에서 떼어내 vfio-pci나 pci-stub으로 봉인해 두거나, GPU를 ACS가 지원되는 다른 슬롯으로 옮겨야 한다.
서버 보드가 root port에 ACS를 제대로 달아 놨는지가 group의 모양을 결정하고, group의 모양이 passthrough 설계를 결정한다.
Container, Group, Device
이제 QEMU가 장치를 잡는 순서를 보자. VFIO의 유저스페이스 API는 세 계층의 파일 디스크립터로 되어 있다.
{
"diagram": "html-diagram",
"variant": "explainer",
"title": "The VFIO model: container, group, device",
"width": 720,
"height": 604,
"mobileWidth": 700,
"regions": [
{
"id": "flow",
"label": "QEMU가 GPU를 잡기까지",
"x": 40,
"y": 44,
"width": 640,
"height": 516
}
],
"nodes": [
{
"id": "qemu",
"kind": "host",
"label": "QEMU",
"caption": "유저스페이스 프로세스",
"x": 360,
"y": 140,
"width": 360,
"height": 60
},
{
"id": "container",
"kind": "switch",
"label": "/dev/vfio/vfio",
"caption": "container — DMA 매핑의 단위",
"x": 360,
"y": 232,
"width": 360,
"height": 60
},
{
"id": "group",
"kind": "switch",
"label": "/dev/vfio/26",
"caption": "group — 소유권 검사",
"x": 360,
"y": 324,
"width": 360,
"height": 60
},
{
"id": "device",
"kind": "gpu",
"label": "device fd",
"caption": "BAR 매핑, config, interrupt",
"x": 360,
"y": 416,
"width": 360,
"height": 60
},
{
"id": "guest",
"kind": "memory",
"label": "VM",
"caption": "게스트에게는 실물 GPU로 보인다",
"x": 360,
"y": 508,
"width": 360,
"height": 60
}
],
"links": [
{
"id": "s1",
"points": [[360, 170], [360, 202]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "s2",
"points": [[360, 262], [360, 294]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "s3",
"points": [[360, 354], [360, 386]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "s4",
"points": [[360, 446], [360, 478]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
}
],
"labels": [],
"badges": [
{
"text": "1",
"x": 150,
"y": 232,
"tone": "primary"
},
{
"text": "2",
"x": 150,
"y": 324,
"tone": "primary"
},
{
"text": "3",
"x": 150,
"y": 416,
"tone": "primary"
}
]
}
순서를 코드로 펼치면 이렇다.
container = open("/dev/vfio/vfio", O_RDWR); // 1. container 생성
group = open("/dev/vfio/26", O_RDWR); // 2. group 열기 (26 = group 번호)
ioctl(group, VFIO_GROUP_SET_CONTAINER, &container); // group을 container에 연결
ioctl(container, VFIO_SET_IOMMU, VFIO_TYPE1_IOMMU); // IOMMU 방식 선택
ioctl(container, VFIO_IOMMU_MAP_DMA, &map); // 게스트 RAM 전체를 IOVA=GPA로 매핑
device = ioctl(group, VFIO_GROUP_GET_DEVICE_FD, // 3. 장치 fd 획득
"0000:65:00.0");
container는 IOMMU 매핑을 공유하는 단위다. group 여러 개를 한 container에 붙이면 페이지 테이블을 공유해서 중복을 줄인다.
group을 열 때 소유권 검사가 이뤄진다. 앞서 본 viability 규칙, 즉 group 내 모든 장치가 host 드라이버에서 분리됐는지를 여기서 확인한다.
핵심은 VFIO_IOMMU_MAP_DMA다. QEMU는 게스트 RAM 전체를 “IOVA = GPA”로 등록하는데, 커널은 이때 해당 메모리 페이지를 전부 pin한다.
pin은 페이지를 물리 메모리의 그 자리에 고정한다. swap out도, 다른 위치로의 이동도 금지된다.
DMA는 page fault를 기다려 주지 않기 때문이다. CPU가 없는 페이지에 접근하면 커널이 fault를 처리하고 다시 실행하면 되지만, 장치의 DMA가 향하는 메모리는 항상 그 자리에 있어야 한다.
이 pinning 하나가 passthrough VM의 제약 대부분을 설명한다.
- 게스트 메모리 전체가 물리 메모리에 상주한다. swap도 memory overcommit도 불가능하다.
- 어차피 전부 상주할 메모리라면 1G hugepage로 미리 예약하는 편이 낫다. TLB 부담도 줄어든다.
- 커널의 automatic NUMA balancing은 페이지를 옮겨 locality를 맞추는 기능인데, pin된 페이지는 못 옮기므로 순수 오버헤드만 남는다. passthrough 노드에서
numa_balancing=0으로 끄는 이유다. - KubeVirt 글에서 passthrough VM은 live migration이 안 된다고 했다. 장치가 게스트 메모리에 직접 쓰는 중이라 dirty page 추적이 안 되는 것이 근본 원인이다.
최신 커널은 container/group fd 모델을 iommufd라는 새 인터페이스로 이행하는 중이다.
/dev/vfio/devices/vfioX를 바로 여는 cdev 방식이 추가됐지만, group이 소유권 단위라는 의미는 그대로 유지된다.
Binding to vfio-pci
노드 쪽 절차를 보자. 장치를 VM에 넘기려면 먼저 host 드라이버에서 떼어 vfio-pci에 붙여야 한다.
# 이 장치가 어느 IOMMU group인지 확인
readlink /sys/bus/pci/devices/0000:65:00.0/iommu_group
# → .../kernel/iommu_groups/26
modprobe vfio-pci
# host 드라이버(nvidia)에서 분리
echo 0000:65:00.0 > /sys/bus/pci/devices/0000:65:00.0/driver/unbind
# 다음 probe에서 vfio-pci가 잡도록 지정
echo vfio-pci > /sys/bus/pci/devices/0000:65:00.0/driver_override
echo 0000:65:00.0 > /sys/bus/pci/drivers_probe
부팅 시점에 vendor:device ID로 묶어 버리는 방법도 있다. /etc/modprobe.d/에 options vfio-pci ids=10de:2330을 넣으면 해당 ID의 장치는 처음부터 vfio-pci가 잡는다. 이때 오픈소스 드라이버 nouveau가 먼저 잡지 않도록 blacklist에 올리는 것이 관례다.
순서에는 함정이 있다. 드라이버가 장치를 사용 중이면 unbind가 끝나지 않는다. GPU라면 nvidia-persistenced나 NVSwitch 시스템의 fabric manager 같은 데몬이 /dev/nvidia*를 잡고 있는 동안 unbind가 hang에 걸릴 수 있고, 어중간하게 멈추면 재부팅 외에 복구 방법이 없는 상태가 되기도 한다.
그래서 passthrough 전용 노드는 host에 GPU 드라이버를 아예 설치하지 않는 쪽이 깔끔하다. host가 GPU를 만진 적이 없으면 뺏을 일도 없다.
Whole Device, VF, mdev
지금까지는 장치 하나를 통째로 넘기는 그림이었다. 장치를 내주는 단위에는 세 가지 경로가 있다.
{
"diagram": "html-diagram",
"variant": "explainer",
"title": "Three ways to hand a device to a VM",
"width": 960,
"height": 560,
"mobileWidth": 900,
"regions": [
{
"id": "whole",
"label": "Whole device",
"x": 36,
"y": 64,
"width": 280,
"height": 432
},
{
"id": "sriov",
"label": "SR-IOV VF",
"x": 340,
"y": 64,
"width": 280,
"height": 432
},
{
"id": "mdev",
"label": "mdev",
"x": 644,
"y": 64,
"width": 280,
"height": 432
}
],
"nodes": [
{
"id": "gpu-w",
"kind": "gpu",
"label": "GPU 전체",
"x": 176,
"y": 190,
"width": 200,
"height": 60
},
{
"id": "vfio-w",
"kind": "note",
"label": "vfio-pci",
"x": 176,
"y": 305,
"width": 160,
"height": 56
},
{
"id": "vm-w",
"kind": "host",
"label": "VM 하나",
"x": 176,
"y": 420,
"width": 200,
"height": 60
},
{
"id": "pf",
"kind": "gpu",
"label": "PF",
"caption": "하드웨어가 쪼갠다",
"x": 480,
"y": 190,
"width": 200,
"height": 60
},
{
"id": "vf1",
"kind": "note",
"label": "VF 1",
"x": 425,
"y": 305,
"width": 90,
"height": 56
},
{
"id": "vf2",
"kind": "note",
"label": "VF 2",
"x": 535,
"y": 305,
"width": 90,
"height": 56
},
{
"id": "vm-a",
"kind": "host",
"label": "VM A",
"x": 425,
"y": 420,
"width": 90,
"height": 60
},
{
"id": "vm-b",
"kind": "host",
"label": "VM B",
"x": 535,
"y": 420,
"width": 90,
"height": 60
},
{
"id": "gpu-m",
"kind": "gpu",
"label": "GPU + vendor driver",
"caption": "소프트웨어가 중재한다",
"x": 784,
"y": 190,
"width": 240,
"height": 60
},
{
"id": "md1",
"kind": "note",
"label": "mdev 1",
"x": 729,
"y": 305,
"width": 90,
"height": 56
},
{
"id": "md2",
"kind": "note",
"label": "mdev 2",
"x": 839,
"y": 305,
"width": 90,
"height": 56
},
{
"id": "vm-c",
"kind": "host",
"label": "VM C",
"x": 729,
"y": 420,
"width": 90,
"height": 60
},
{
"id": "vm-d",
"kind": "host",
"label": "VM D",
"x": 839,
"y": 420,
"width": 90,
"height": 60
}
],
"links": [
{
"id": "w1",
"points": [[176, 220], [176, 277]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
},
{
"id": "w2",
"points": [[176, 333], [176, 390]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
},
{
"id": "sv1",
"points": [[440, 220], [425, 277]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "sv2",
"points": [[520, 220], [535, 277]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "sv3",
"points": [[425, 333], [425, 390]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "sv4",
"points": [[535, 333], [535, 390]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "md-1",
"points": [[744, 220], [729, 277]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "md-2",
"points": [[824, 220], [839, 277]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "md-3",
"points": [[729, 333], [729, 390]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
},
{
"id": "md-4",
"points": [[839, 333], [839, 390]],
"tone": "primary",
"flow": { "speed": "normal", "emphasis": "soft" }
}
],
"labels": [
{
"text": "성능도 기능도 그대로",
"x": 176,
"y": 470
},
{
"text": "VF마다 진짜 PCIe function",
"x": 480,
"y": 470
},
{
"text": "하드웨어 분할 기능이 없을 때",
"x": 784,
"y": 470
}
]
}
Whole device passthrough는 지금까지의 그림이다. GPU 전체가 VM 하나에 붙는다. 성능도 기능도 bare metal과 같고, host에 GPU 드라이버도 추가 소프트웨어도 필요 없다. MIG를 지원하는 GPU라면 게스트 안에서 MIG를 bare metal처럼 쓸 수도 있다. 학습용 GPU 클러스터의 기본 선택지다.
SR-IOV는 하드웨어가 스스로를 쪼개는 방식이다. 물리 장치(PF, Physical Function)가 가상 장치(VF, Virtual Function) 여러 개를 만들어 내고, VF 하나하나가 자기 BDF와 requester ID를 가진 진짜 PCIe function이 된다. 그래서 VF를 각각 vfio-pci에 바인딩해 서로 다른 VM에 넘길 수 있다. Emulation and Virtualization에서 한 문장으로 지나간 그 개념이다. NIC에서는 표준처럼 쓰이고, NVIDIA vGPU도 Ampere 세대부터는 SR-IOV VF 위에 만들어진다.
mdev(mediated device)는 SR-IOV 같은 하드웨어 분할 기능이 없는 장치를 위한 소프트웨어 답이다. vendor driver가 물리 장치 하나를 여러 mdev 인스턴스로 쪼개고, 각 인스턴스는 VFIO 장치로 노출된다. 게스트 입장에서는 passthrough 장치와 구분되지 않지만, 실제 하드웨어 접근은 vendor driver가 중간에서 중재한다. Ampere 이전 세대의 NVIDIA vGPU와 Intel GVT-g가 대표 사용자다.
GPU Virtualization에서 본 vGPU를 VFIO 관점에서 다시 보면 이렇게 정리된다. host에 vGPU Manager라는 vendor driver가 깔리고, VM에 보이는 vGPU는 구세대에서는 mdev, Ampere 이후에는 SR-IOV VF다. 어느 쪽이든 라이선스가 필요하다. whole device passthrough는 이 스택 전부를 우회한다.
같은 선택이 장치마다 다르게 떨어지기도 한다. InfiniBand HCA는 SR-IOV를 일찍부터 지원했지만, VF는 subnet 관리 트래픽을 host의 PF 드라이버에 기대는 구조다. host에서 PF를 유지할 수 없거나 테넌트에게 fabric 제어까지 줘야 하는 환경이라면 PF를 통째로 passthrough하는 선택이 나온다. KubeVirt 글에서 범위 밖으로 미뤘던 부분이 이 지점이다.
VFIO in Kubernetes
이 전체가 KubeVirt에서 어떻게 조립되는지 보자. 경로는 네 단계다.
{
"diagram": "html-diagram",
"variant": "explainer",
"title": "From vfio-pci to VM spec",
"width": 960,
"height": 376,
"mobileWidth": 900,
"regions": [
{
"id": "chain",
"label": "GPU가 VM에 닿기까지",
"x": 40,
"y": 60,
"width": 880,
"height": 256
}
],
"nodes": [
{
"id": "hostgpu",
"kind": "gpu",
"label": "Host GPU",
"caption": "vfio-pci 바인딩",
"x": 159,
"y": 210,
"width": 190,
"height": 84
},
{
"id": "plugin",
"kind": "switch",
"label": "Device plugin",
"caption": "kubelet에 리소스 광고",
"x": 373,
"y": 210,
"width": 190,
"height": 84
},
{
"id": "kvcr",
"kind": "switch",
"label": "KubeVirt CR",
"caption": "permittedHostDevices",
"x": 587,
"y": 210,
"width": 190,
"height": 84
},
{
"id": "vmspec",
"kind": "host",
"label": "VM spec",
"caption": "gpus, hostDevices",
"x": 801,
"y": 210,
"width": 190,
"height": 84
}
],
"links": [
{
"id": "c1",
"points": [[254, 210], [278, 210]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
},
{
"id": "c2",
"points": [[468, 210], [492, 210]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
},
{
"id": "c3",
"points": [[682, 210], [706, 210]],
"tone": "primary",
"flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
}
],
"labels": [
{
"text": "노드 준비",
"x": 159,
"y": 130
},
{
"text": "광고",
"x": 373,
"y": 130
},
{
"text": "허용 목록",
"x": 587,
"y": 130
},
{
"text": "요청",
"x": 801,
"y": 130
}
],
"badges": [
{
"text": "1",
"x": 159,
"y": 156,
"tone": "primary"
},
{
"text": "2",
"x": 373,
"y": 156,
"tone": "primary"
},
{
"text": "3",
"x": 587,
"y": 156,
"tone": "primary"
},
{
"text": "4",
"x": 801,
"y": 156,
"tone": "primary"
}
]
}
먼저 노드를 준비한다. IOMMU를 켜고 장치를 vfio-pci에 바인딩하는 앞의 절차 그대로다.
device plugin이 vfio-pci에 바인딩된 장치를 발견해 kubelet에 extended resource로 광고한다. GPU라면 NVIDIA의 KubeVirt 전용 device plugin이 nvidia.com/TU104GL_Tesla_T4 같은 이름을 만들어 낸다.
KubeVirt CR의 permittedHostDevices에 장치를 허용 목록으로 올린다. PCI vendor:device ID를 Kubernetes 리소스 이름에 매핑한다.
configuration:
permittedHostDevices:
pciHostDevices:
- pciVendorSelector: "10DE:2330" # H100 SXM5
resourceName: nvidia.com/GH100_H100_SXM5_80GB
externalResourceProvider: true # 광고는 외부 device plugin에 위임
VM spec에서 그 리소스 이름을 요청한다.
spec:
domain:
devices:
gpus:
- deviceName: nvidia.com/GH100_H100_SXM5_80GB
name: gpu0
hostDevices:
- deviceName: mellanox.com/cx7_hca # GPU 외 PCI 장치는 hostDevices
name: ib0
스케줄러가 리소스 요청으로 노드를 고르고, virt-handler가 장치를 virt-launcher pod에 붙이면, 그 안의 QEMU가 /dev/vfio를 열어 앞 섹션의 ioctl 순서를 밟는다. 결국 KubeVirt의 GPU passthrough는 VFIO의 fd 모델을 Kubernetes 리소스로 포장한 것이다.
노드 준비까지 자동화하려면 GPU Operator를 쓴다. sandboxWorkloads.enabled=true로 켜고 노드에 nvidia.com/gpu.workload.config=vm-passthrough 라벨을 달면, VFIO Manager가 그 노드의 GPU를 vfio-pci에 바인딩하고 sandbox device plugin이 광고까지 맡는다. 라벨 값에 따라 한 노드는 container, vm-passthrough, vm-vgpu 중 한 가지 모드로만 동작한다.
What to Check
GPU 노드를 passthrough용으로 준비할 때 확인할 것들이다.
- BIOS에서 VT-x와 VT-d(AMD는 AMD-V, AMD-Vi)를 켠다. 출고 기본값이 꺼짐인 서버가 많다.
- 커널 커맨드라인에
intel_iommu=on iommu=pt(AMD는amd_iommu=on)를 넣고,dmesg | grep -e DMAR -e IOMMU로 IOMMU가 실제로 올라왔는지 확인한다. - 넘길 장치의 IOMMU group을 확인한다. 같은 group에 다른 장치가 묶여 있으면 함께 떼어내야 한다.
- host 쪽에서 장치를 잡을 드라이버를 정리한다. nouveau blacklist, GPU 점유 데몬 중지, 가능하면 host에 GPU 드라이버 미설치.
- 게스트 메모리는 pin된다.
default_hugepagesz=1G로 hugepage를 선예약하고,numa_balancing=0을 검토한다. - KubeVirt라면 featureGates에
GPU,HostDevices를 켜고permittedHostDevices를 채운다.
iommu=pt는 host가 쓰는 장치에는 identity mapping을 적용해 IOMMU 변환 오버헤드를 없애고, assign된 장치에만 변환을 적용하는 옵션이다. passthrough 노드의 관례적인 설정이다.
Summary
VFIO는 IOMMU 보호 아래에서 물리 장치를 유저스페이스에 직접 내주는 커널 프레임워크다. passthrough의 안전은 IOMMU가 담보하고, 절차는 VFIO가 제공한다.
소유권의 단위는 장치가 아니라 IOMMU group이다. requester ID와 ACS가 group의 모양을 결정하고, group의 모양이 어떤 장치를 넘길 수 있는지를 결정한다.
QEMU는 container, group, device 세 계층의 fd로 장치를 잡고, 게스트 RAM 전체를 IOVA=GPA로 매핑한다. 이때의 page pinning이 hugepage 선예약, NUMA balancing 비활성화, live migration 불가라는 passthrough VM의 제약을 만든다.
장치를 내주는 단위는 whole device, SR-IOV VF, mdev 세 가지다. 학습용 GPU 클러스터는 whole device passthrough가 기본이고, KubeVirt는 이 경로를 device plugin과 permittedHostDevices, VM spec으로 포장한다.
References
- Linux Kernel Documentation — VFIO
https://docs.kernel.org/driver-api/vfio.html - Linux Kernel Documentation — VFIO Mediated devices
https://docs.kernel.org/driver-api/vfio-mediated-device.html - Alex Williamson — IOMMU Groups, inside and out
https://vfio.blogspot.com/2014/08/iommu-groups-inside-and-out.html - KubeVirt User Guide — Host Devices Assignment
https://kubevirt.io/user-guide/compute/host-devices/ - NVIDIA — KubeVirt GPU Device Plugin
https://github.com/NVIDIA/kubevirt-gpu-device-plugin - NVIDIA — GPU Operator with KubeVirt
https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/gpu-operator-kubevirt.html - NVIDIA — Virtual GPU Software User Guide
https://docs.nvidia.com/vgpu/latest/grid-vgpu-user-guide/index.html - NVIDIA — MIG User Guide, Virtualization
https://docs.nvidia.com/datacenter/tesla/mig-user-guide/latest/virtualization.html