AWS를 필두로 한 퍼블릭 클라우드가 세상의 대세가 된 이후에 우리는 VM을 많이 잊어가고 있었다. 실제로는 VM인 EC2 같은 자원의 아랫단을 제어할 일이 없어지니, 이제는 VM이 진짜 호스트 머신처럼 보이기 시작했었다. 그런데 이제 값비싼 GPU 자원들을 운용해야할 일이 많이 일어나고, 그 자원을 클라우드 밖 IDC에서 운영하려다보니 이제 다시 VM을 바라보기 시작했다. 그리고 일반적으로 이런 Hypervisor나 KVM 같은 놈들은 CPU Instruction을 주로 가상화한다. 물론 GPU를 VM 수준에서 가상화하는 방법도 있기는 있다만.. 그래도 결국 이 비싸고 귀중하신 상전 GPU들을 가상화 오버헤드 없이 최대한의 성능을 이끌어 내야하는 것은 언제나 동일하다. 결국 우리는 VM에게 직접 GPU를 할당시켜줘야하고, 일반적으로 리눅스에서는 VFIO라는 커널 프레임워크를 사용해서 PCIe Passthrough를 수행한다. 물론 최근 더 고귀한 GPU들 께서는 NVLink 같은 자체 인터페이스를 달고나오시긴 하지만 PCIe는 아직도 가장 일반적인 인터페이스다. 이 글에서는 이 고귀한 GPU를 최대한 적은 오버헤드를 가지고 VM에서 운용하기 위한 가장 기본적이자 필수적인 VFIO를 사용한 PCIe passthrough의 메커니즘을 정리해보고자 한다.

VFIO Framework

일단 이 VFIO라는 놈은 단순한 리눅스 드라이버가 아니라 프레임워크라고 봐야한다. 리눅스 커널 내에서 유저스페이스에 있는 프로그램들이 PCI 디바이스에 직접 접근하도록 하고, 디바이스를 격리시키는 역할을 수행한다. 이놈은 크게 3가지 계층 구조로 구분할 수 있는데, 우선 가장 위에 애플리케이션에서 접근하기 위한 인터페이스를 담당하는 VFIO Core가 존재하고, 그 다음으로 IOMMU 그룹과 메모리 매핑을 관리하는 VFIO Container 계층이 있다. 그리고 또 그 아래에는 VFIO Device 계층이 있고 여기서는 각각의 PCIe 패스스루 장치들과 상호작용을 직접 담당하게 된다. 이런 계층 관계는 여타 리눅스 시스템 프로그래밍이 그렇듯 파일 디스크립터를 기반으로 구현된다. 특정한 디바이스에 접근하기 위해서는 /dev/vfio/vfio 를 열어서 컨테이너 파일 디스크립터를 얻은 뒤에 /dev/vfio/<GROUP_ID> 를 열어서 디바이스 그룹 디스크립터를 가져온다. 그리고 마지막으로 ioctl 명령을 VFIO_GROUP_GET_DEVICE_FD으로 실행하여 원하는 특정한 장치의 파일 디스크립터를 얻어온다.

IOMMU

IOMMU가 가장 핵심적인 요소가 아닐까 싶다. 이놈은 Input-Output Memory Management Unit의 약자인데, CPU에서 MMU가 프로세스상의 가상 주소를 물리적 메모리 주소로 변환하는 역할을 수행하는 것 처럼, IOMMU는 디바이스가 수행하는 DMA 작업에 대한 주소 변환을 수행한다. 이를 통해서 VM에 할당된 장치가 허가되지 않은 메모리 영역에 접근하는 것을 방지하는 역할을 한다. IOMMU도 결국 메모리를 다루는 하드웨어 요소라서 페이지 테이블을 통해서 메모리 매핑을 관리하는데, 이 구조는 CPU와 꽤나 유사하기는 하지만 메모리 영역 중심으로 구성된 테이블이 아닌 장치들의 매핑을 기반하는 구조라는게 조금은 다르다. PCI 디바이스를 식별하기 위해서 PCI BDF(Bus/Device/Function) 번호를 매겨서 사용하는데, 가장 취상위에는 루트 테이블이 존재하고 루트 테이블 아래에 장치별로 할당된 컨텍스트 테이블이 존재한다. 각 컨텍스트 테이블의 값들은 디바이스별 페이지 테이블의 루트 테이블을 가리킨다. 참고로 이 구조는 다단계 레벨 페이지 테이블로 구성되어있기에 점진적인 주소 매핑을 수행한다. 실제로 DMA 변환을 수행할 때는 GPU가 메모리 접근을 요청할 때 Device AddressBDF 형태의 Device Id를 함께 포함해서 요청하게 된다. 이 떄 IOMMUDevice ID를 해당 디바이스의 컨텍스트 테이블을 조회해서 해당 디바이스 전용 페이지 테이블의 루트를 찾은 뒤, 아까 말했던 점진적으로 주소 범위를 줄여가는 방식으로 이 주소를 호스트의 물리 주소로 변환하여 반환한다. 즉, PCIe passthrough를 통해서 디바이스에 접근할 때는 이 IOMMU의 주소 변환 과정을 항상 거쳐야하므로 이놈의 성능이 상당히 큰 영향을 주게 된다. 즉, IOMMU를 얼마나 최적화하느냐에 따라서 성능 오버헤드를 최대한 줄일 수 있을 것이다. 몇가지 찾아본 방식은 역시나 캐싱을 주로 하는데, 디바이스 컨텍스트 테이블 엔트리를 캐시하는 Context Cache와 각 프로세스들과 VM의 주소 공간을 구분하기 위한 PASID 캐시를 주로 쓴다고 한다.

Interrupt Remapping

GPU 같은 PCI 장치들은 보통 인터럽트를 수행하는 기법으로 MSIMSI-X를 사용한다. 일단 MSI부터 간단히 설명하자면 하드웨어 장치가 CPU 인터럽터를 요청하는 여러 방법중 하나인데, 해당 기술들 이전에는 물리적인 하드웨어 인터럽트 라인을 사용했지만 MSI는 memory write를 통해서 인터럽트를 트리거하는 방식이다. CPU 입장에서는 MSI 메모리 인터럽트 영역으로 예약된 위치에 memory write가 발생할 시 이를 인지하고 인터럽트를 발싱시키는 방식이다. 아무튼 이런 방식을 통해서 인터럽트를 수행하는데, 우리가 해야할 것은 호스트 머신에서 발생하는 인터럽트를 VM으로도 전달해줘야한다. 이 과정을 인터럽트 리매핑이라고 부른다. 이런 인터럽트는 먼저 VM 내부에서 사용되는 GPU 제어를 담당하는 게스트 드라이버가 인터럽트 벡터라고 불리는 장치 인터럽트 식별자를 GPU에 등록한다. 그 다음에 istcol 명령의 VFIO_DEVICE_SET_IRQS 액션을 통해서 GPU에서 발생하는 인터럽트를 VM으로 라우팅시킨다.

Memory Interface, VM <> Host

또한 VM상에서 패스스루를 하기 위해서는 VM 메모리를 페이지 피닝을 통해서 물리 RAM의 특정 위치들에 고정시켜야한다. 이 과정은 ioctl VFIO_IOMMU_MAP_DMA 를 통해 게스트 물리 주소를 호스트 물리 주소로 매핑시킨다. 참고로 이 때 생성되는 DMA 매핑은 VM의 메모리 레이아웃이 변경될 때 마다 업데이트 되어야하고, 이 과정은 게스트 메모리가 swap out 되지 않도록 해서 GPU DMA 작업이 항상 유효한 메모리를 참조할 수 있도록 보장하는 역할이다. 특히 NUMA 시스템이라면 이런 피닝이 더 영향을 많이 끼치는데, GPU가 연결된 NUMA 노드에 VM 메모리를 할당하고 VM vCPU 같은 NUMA 노드를 물리적인 CPU 코어에 고정해서 여러 NUMA 노드에 걸친 메모리 인터리빙을 방지해서 지역성을 보장시켜야 성능을 최대한 끌어올릴 수 있을 것이다. 특히 GPU는 대용량의 연속 메모리를 접근하는 워크로드가 많아서 Memory Page를 Huge Pages를 켜서 사용하면 TLB 미스 효율을 충분히 줄일 수 있고, 이렇게되면 IOMMU 페이지 테이블이 더 작아져서 주소 변환의 오버헤드도 줄어들게 할 수도 있다.