soobook
BACKEND

KVM and QEMU

KVM은 CPU와 메모리를 가상화하고, QEMU는 그 위에 디스크와 네트워크 카드 같은 장치를 흉내 낸다.

Intro

리눅스에서 VM을 띄울 때 KVM을 많이 쓴다. 특히 QEMU와 같이 쓰고는 한다.

VM 을 쓰는 이유는 컨테이너로는 안 되는 자리가 있기 때문이다.

컨테이너는 host 커널을 공유해서 게스트에 다른 커널이나 드라이버를 줄 수 없다.

커널까지 통째로 격리하려면 VM이 필요하고, 그 VM의 CPU를 번역 없이 물리 CPU에서 돌리는 일을 KVM이 맡는다.

이름이 붙어 다니니 비슷한 물건처럼 보이지만, KVM과 QEMU는 정반대의 일을 한다.

KVM은 커널 안에 있다. CPU를 가상화하고, 게스트를 실제 CPU에서 native 속도로 돌린다.

QEMU는 유저스페이스에 있다. 게스트가 볼 디스크, 네트워크 카드, 칩셋을 소프트웨어로 만들어 낸다.

한쪽은 하드웨어를 나눠 쓰게 하고, 다른 쪽은 없는 하드웨어를 흉내 낸다.

Division of Labor

게스트가 하는 일은 크게 두 갈래다. CPU와 메모리를 쓰는 일, 그리고 장치를 건드리는 일.

이 두 갈래가 각각 다른 경로로 처리된다.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "At a glance: KVM does CPU, QEMU does devices",
  "width": 960,
  "height": 645,
  "mobileWidth": 900,
  "regions": [
    {
      "id": "kvm-side",
      "label": "KVM path\\nCPU, memory — 하드웨어 가속",
      "x": 36,
      "y": 146,
      "width": 430,
      "height": 443
    },
    {
      "id": "qemu-side",
      "label": "QEMU path\\ndevice I/O — 에뮬레이션",
      "x": 494,
      "y": 146,
      "width": 430,
      "height": 443
    }
  ],
  "nodes": [
    {
      "id": "guest",
      "kind": "host",
      "label": "Guest OS",
      "caption": "guest kernel + apps",
      "x": 480,
      "y": 86,
      "width": 300,
      "height": 60
    },
    {
      "id": "kcmd",
      "kind": "note",
      "label": "CPU / memory 명령",
      "x": 251,
      "y": 284,
      "width": 240,
      "height": 56
    },
    {
      "id": "kvm",
      "kind": "switch",
      "label": "KVM",
      "caption": "커널 모듈",
      "x": 251,
      "y": 398,
      "width": 240,
      "height": 66
    },
    {
      "id": "cpu",
      "kind": "gpu",
      "label": "CPU",
      "caption": "VT-x, guest 직접 실행",
      "x": 251,
      "y": 512,
      "width": 240,
      "height": 66
    },
    {
      "id": "dcmd",
      "kind": "note",
      "label": "device 접근 (I/O)",
      "x": 709,
      "y": 284,
      "width": 240,
      "height": 56
    },
    {
      "id": "qemu",
      "kind": "switch",
      "label": "QEMU",
      "caption": "유저스페이스 VMM",
      "x": 709,
      "y": 398,
      "width": 240,
      "height": 66
    },
    {
      "id": "edev",
      "kind": "memory",
      "label": "Emulated device",
      "caption": "가상 NIC, 디스크",
      "x": 709,
      "y": 512,
      "width": 240,
      "height": 66
    }
  ],
  "links": [
    {
      "id": "g-k",
      "points": [[420, 116], [251, 256]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "g-q",
      "points": [[540, 116], [709, 256]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "k1",
      "points": [[251, 312], [251, 365]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "k2",
      "points": [[251, 431], [251, 479]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "q1",
      "points": [[709, 312], [709, 365]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "q2",
      "points": [[709, 431], [709, 479]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    }
  ],
  "labels": [
    {
      "text": "native 속도",
      "x": 251,
      "y": 567
    },
    {
      "text": "VM exit → 유저스페이스",
      "x": 709,
      "y": 567
    }
  ]
}

왼쪽이 KVM의 몫이다. 게스트의 평범한 명령과 메모리 접근은 KVM이 CPU의 가상화 기능 위에서 그대로 실행시킨다. 소프트웨어가 끼어들지 않으니 native에 가깝다.

오른쪽이 QEMU의 몫이다. 게스트가 디스크나 네트워크 카드를 건드리면, 그건 진짜 장치가 아니라 QEMU가 만든 가짜다. 접근할 때마다 QEMU가 유저스페이스에서 대신 처리한다.

KVM은 있는 CPU를 나눠 쓰게 하고, QEMU는 없는 장치를 흉내 낸다. 하이퍼바이저 하나를 커널과 유저스페이스가 나눠 가진 구조다.

둘을 굳이 같이 쓰는 이유도 여기서 나온다.

QEMU 혼자 VM을 돌리면 CPU 명령까지 소프트웨어로 번역해야 해서 느리다.

KVM이 그 CPU 부분을 물리 CPU에서 native로 돌려 주므로, 둘을 붙이면 빠른 CPU와 유연한 장치를 한꺼번에 얻는다.

KVM

이제 KVM 을 더 자세히 알아보자.

KVM은 가상 머신 커널 모듈(Kernel-based Virtual Machine)이다.

리눅스 커널에 들어가는 모듈이고, kvm.ko와 CPU별 모듈(kvm-intel.ko 또는 kvm-amd.ko)로 나뉜다.

KVM이 켜지면 리눅스 커널 자체가 하이퍼바이저가 된다. 그 아래에 다른 OS가 없으므로 KVM은 Type 1로 분류된다.

커널이 하이퍼바이저가 된다고 해서 host OS가 사라지는 것은 아니다. host의 user space는 그대로 살아 있고, QEMU도 그중 하나다. VM은 host에서 보면 프로세스 하나이고, vCPU 하나가 스레드 하나다.

KVM이 유저스페이스에 내주는 창구는 /dev/kvm이라는 디바이스 파일이다.

VM을 만들고 돌리는 모든 조작이 이 파일에 대한 ioctl 호출로 이뤄진다.

ioctl (Input/Output Control)은 리눅스 시스템에서 사용자 프로그램이 하드웨어 장치나 커널 구성 요소와 직접 상호작용하기 위해 사용하는 시스템 콜이다.

int kvm = open("/dev/kvm", O_RDWR);
int vm  = ioctl(kvm, KVM_CREATE_VM, 0);        // VM 하나 생성
ioctl(vm, KVM_SET_USER_MEMORY_REGION, &region); // 게스트 메모리 등록
int vcpu = ioctl(vm, KVM_CREATE_VCPU, 0);      // vCPU 하나 생성
ioctl(vcpu, KVM_RUN, 0);                        // vCPU 실행

KVM 이 뭘 하냐? 세 개만 기억하자.

  • CPU 가상화
  • 메모리 격리
  • 커널 안에서 끝낼 수 있는 처리

CPU Virtualization

CPU 가상화부터 보자. Intel VT-x나 AMD-V를 켜서 게스트를 non-root 모드에 올린다.

게스트 커널은 그 안에서 진짜 ring 0을 쓰고, 대부분의 명령은 CPU에서 곧바로 실행된다.

특권 명령이 나올 때만 CPU가 제어를 KVM으로 넘긴다.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "CPU virtualization: where the guest runs",
  "width": 960,
  "height": 526,
  "mobileWidth": 900,
  "regions": [
    {
      "id": "nonroot",
      "label": "non-root mode\\n게스트 직접 실행",
      "x": 36,
      "y": 64,
      "width": 560,
      "height": 294
    },
    {
      "id": "root",
      "label": "root mode\\nKVM (hypervisor)",
      "x": 628,
      "y": 64,
      "width": 296,
      "height": 294
    }
  ],
  "nodes": [
    {
      "id": "gapp",
      "kind": "host",
      "label": "Guest apps",
      "caption": "ring 3",
      "x": 316,
      "y": 184,
      "width": 240,
      "height": 60
    },
    {
      "id": "gkernel",
      "kind": "host",
      "label": "Guest kernel",
      "caption": "ring 0 (non-root)",
      "x": 316,
      "y": 304,
      "width": 240,
      "height": 60
    },
    {
      "id": "kvm",
      "kind": "switch",
      "label": "KVM",
      "caption": "hypervisor (root)",
      "x": 776,
      "y": 244,
      "width": 220,
      "height": 70
    },
    {
      "id": "cpu",
      "kind": "gpu",
      "label": "Physical CPU",
      "caption": "VT-x / AMD-V",
      "x": 480,
      "y": 430,
      "width": 720,
      "height": 64
    }
  ],
  "links": [
    {
      "id": "a-k",
      "points": [[316, 214], [316, 274]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "k-cpu",
      "points": [[316, 334], [316, 398]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "exit",
      "points": [[436, 296], [666, 262]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "entry",
      "points": [[666, 238], [436, 286]],
      "tone": "muted",
      "flow": { "speed": "slow", "emphasis": "muted" }
    }
  ],
  "labels": [
    {
      "text": "명령 직접 실행 (native)",
      "x": 405,
      "y": 376
    },
    {
      "text": "VM exit (특권 명령)",
      "x": 551,
      "y": 306
    },
    {
      "text": "VM entry",
      "x": 551,
      "y": 224
    }
  ]
}

Memory Isolation

메모리는 격리해서 나눈다.

게스트가 믿는 물리 주소를 host의 진짜 물리 주소로 바꾸는 두 번째 변환 단계를 EPT/NPT로 관리한다.

VM마다 접근 가능한 메모리를 못박아 두므로, VM A가 VM B의 메모리를 넘볼 수 없다.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "EPT/NPT: from print(a) to DRAM",
  "width": 720,
  "height": 604,
  "mobileWidth": 700,
  "regions": [
    {
      "id": "translate",
      "label": "print(a) 한 줄이 DRAM에 닿기까지",
      "x": 40,
      "y": 44,
      "width": 640,
      "height": 516
    }
  ],
  "nodes": [
    {
      "id": "proc",
      "kind": "host",
      "label": "Guest process — print(a)",
      "caption": "변수 a 의 주소 = GVA 0x1000",
      "x": 360,
      "y": 140,
      "width": 360,
      "height": 60
    },
    {
      "id": "gpt",
      "kind": "switch",
      "label": "Guest page table",
      "caption": "1단계 변환 — 게스트 커널이 관리",
      "x": 360,
      "y": 232,
      "width": 360,
      "height": 60
    },
    {
      "id": "gpa",
      "kind": "note",
      "label": "GPA 0x5000",
      "caption": "게스트가 믿는 물리주소",
      "x": 360,
      "y": 324,
      "width": 360,
      "height": 60
    },
    {
      "id": "ept",
      "kind": "switch",
      "label": "EPT / NPT",
      "caption": "2단계 변환 — CPU 하드웨어, KVM은 세팅만",
      "x": 360,
      "y": 416,
      "width": 360,
      "height": 60
    },
    {
      "id": "dram",
      "kind": "memory",
      "label": "DRAM — HPA 0x9A000",
      "caption": "실제 메모리",
      "x": 360,
      "y": 508,
      "width": 360,
      "height": 60
    }
  ],
  "links": [
    {
      "id": "t1",
      "points": [[360, 170], [360, 202]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "t2",
      "points": [[360, 262], [360, 294]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "t3",
      "points": [[360, 354], [360, 386]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "t4",
      "points": [[360, 446], [360, 478]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    }
  ],
  "labels": []
}

게스트 안에서 파이썬 프로세스가 print(a) 한 줄을 실행한다고 하자.

변수 a를 읽으려면 a가 담긴 메모리 주소에 접근해야 하는데, 프로세스가 아는 주소는 가상 주소, 이를테면 GVA 0x1000이다.

먼저 게스트 커널의 페이지 테이블이 이 GVA 0x1000을 게스트 물리 주소, 이를테면 GPA 0x5000으로 바꾼다.

게스트 입장에서는 여기가 진짜 물리 메모리다. 하지만 GPA 0x5000은 host의 실제 DRAM 주소가 아니다.

이 주소를 CPU의 EPT/NPT가 다시 host 물리 주소, 이를테면 HPA 0x9A000으로 한 번 더 바꿔줘야 실제 DRAM에 닿는다.

변환 자체는 매 접근마다 CPU의 MMU가 두 단계를 한꺼번에 걷는다.

EPT/NPT: GPA를 HPA로 바꾸는 주소 변환 기능. Intel은 EPT(Extended Page Table), AMD는 NPT(Nested Page Table)라고 부른다.
MMU: CPU 안에서 주소 변환을 담당하는 하드웨어(Memory Management Unit). 프로그램이 쓰는 가상 주소를 페이지 테이블을 따라가며 물리 주소로 바꾼다.

KVM은 EPT/NPT 테이블을 세팅해 둘 뿐, 주소를 직접 바꾸지 않는다.

게스트마다 이 EPT를 따로 두기 때문에, A 게스트의 GPA 0x5000과 B 게스트의 GPA 0x5000은 서로 다른 HPA로 떨어진다.

In-Kernel Handling

마지막으로, 커널 안에서 끝낼 수 있는 처리는 커널에서 끝낸다. 인터럽트 컨트롤러(APIC)나 타이머를 커널 안에서 흉내 내(in-kernel irqchip) 유저스페이스까지 나가는 비용을 아낀다.

KVM은 CPU와 메모리만 안다. 디스크가 무엇인지, 네트워크 카드가 무엇인지, 게스트 부팅 이미지를 어떻게 올리는지는 모른다.

KVM은 “게스트를 CPU에서 안전하게 돌려 주는” 딱 그 일만 한다. VM을 하나의 완성된 기계로 조립하는 일은 KVM 밖의 누군가가 해야 한다.

QEMU

QEMU는 유저스페이스에서 도는 하나의 프로그램이다.

원래 정체성은 가상화가 아니라 emulation이다.

TCG(Tiny Code Generator)로 다른 ISA의 명령을 host 명령으로 번역해, host에 없는 CPU까지 흉내 낼 수 있다.

VM을 돌릴 때 QEMU가 맡는 역할은 VMM(virtual machine monitor), 즉 기계 전체를 조립하고 운영하는 쪽이다.

  • machine model: 칩셋, 버스, 펌웨어(BIOS/UEFI)를 구성해 게스트가 부팅할 “가상 메인보드”를 만든다.
  • device emulation: 네트워크 카드, 디스크 컨트롤러, VGA 같은 장치를 소프트웨어로 흉내 낸다. 게스트는 평범한 드라이버로 이 장치를 쓴다.
  • memory: 게스트 메모리를 자기 주소 공간에 할당한다. KVM과 같이 쓸 때는 이 영역을 KVM_SET_USER_MEMORY_REGION으로 KVM에 등록한다.
  • main loop: 이벤트와 I/O를 처리하는 루프를 돌리며 VM의 수명 전체를 관리한다.

칩셋은 메인보드 위에서 CPU와 나머지 부품 사이의 통신을 중개하는 칩 묶음이다(chip-set). CPU가 메모리, 디스크, USB와 대화할 때 그 사이의 교통정리를 맡는다.

버스는 부품들이 데이터를 주고받는 공용 통로다. GPU나 NIC가 꽂히는 PCIe가 대표적인 버스다.

펌웨어는 Hardware 와 OS 사이 계층에 있는 소프트웨어다. 보통 BIOS 라고 불리는 그것이 펌웨어에 해당한다. 전원이 들어오면 CPU가 가장 먼저 실행하는 코드가 펌웨어고, 펌웨어가 하드웨어를 점검한 뒤 디스크에서 부트로더를 찾아 OS에 바통을 넘긴다.

QEMU 혼자서도 VM을 돌릴 수 있다. 이 경우 CPU까지 TCG로 번역하므로 완전 에뮬레이션이고, 느리다.

그래서 보통 CPU 실행을 KVM에 위임한다.

qemu-system-x86_64 -accel kvm ...   # 또는 -enable-kvm

이 옵션을 주는 순간 QEMU는 게스트 명령을 스스로 번역하지 않는다.

대신 /dev/kvm을 열고 ioctl을 호출하는 쪽이 된다. CPU는 KVM이 하드웨어 가속으로 돌리고, QEMU는 장치만 담당한다.

즉 KVM과 QEMU의 관계는 이렇게 정리된다.

 

QEMU = VMM (오케스트레이터)KVM = CPU 가속기\begin{gathered} \textbf{\text{QEMU = VMM (오케스트레이터)}} \\[2pt] \textbf{\text{KVM = CPU 가속기}} \end{gathered}

 

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "The stack: where each piece lives",
  "width": 880,
  "height": 684,
  "mobileWidth": 860,
  "regions": [
    {
      "id": "vm",
      "label": "VM",
      "x": 160,
      "y": 44,
      "width": 560,
      "height": 236
    },
    {
      "id": "host",
      "label": "Host — hypervisor (QEMU + KVM)",
      "x": 160,
      "y": 308,
      "width": 560,
      "height": 236
    }
  ],
  "nodes": [
    {
      "id": "app",
      "kind": "note",
      "label": "Application",
      "caption": "software in VM, guest ring 3",
      "x": 440,
      "y": 136,
      "width": 360,
      "height": 60
    },
    {
      "id": "gos",
      "kind": "host",
      "label": "Guest OS",
      "caption": "guest kernel, ring 0 (non-root)",
      "x": 440,
      "y": 228,
      "width": 360,
      "height": 60
    },
    {
      "id": "qemu",
      "kind": "switch",
      "label": "QEMU",
      "caption": "user space, 가상 장치",
      "x": 440,
      "y": 400,
      "width": 360,
      "height": 60
    },
    {
      "id": "kvm",
      "kind": "switch",
      "label": "KVM",
      "caption": "kernel, root mode, VT-x/AMD-V",
      "x": 440,
      "y": 492,
      "width": 360,
      "height": 60
    },
    {
      "id": "hw",
      "kind": "gpu",
      "label": "Machine",
      "caption": "CPU, RAM, PCIe, NIC",
      "x": 440,
      "y": 608,
      "width": 560,
      "height": 64
    }
  ],
  "links": [
    {
      "id": "cpu-direct",
      "path": "M 260 228 L 128 228 L 128 608 L 152 608",
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "dev-exit",
      "path": "M 620 228 L 752 228 L 752 400 L 636 400",
      "tone": "muted",
      "flow": { "speed": "slow", "emphasis": "muted" }
    }
  ],
  "labels": [
    {
      "text": "CPU 명령",
      "x": 64,
      "y": 352
    },
    {
      "text": "직접 실행",
      "x": 64,
      "y": 368
    },
    {
      "text": "device I/O",
      "x": 816,
      "y": 300
    },
    {
      "text": "VM exit",
      "x": 816,
      "y": 316
    }
  ]
}

Application과 Guest OS는 VM 안에 살고, QEMU는 host의 user space, KVM은 host의 kernel에 산다.

그리고 그 둘을 합친 것이 하이퍼바이저다.

CPU 명령은 이 스택을 순서대로 내려가지 않는다는 것을 기억하자.

게스트 명령은 QEMU를 건너뛰고 Machine의 CPU에서 직접 돌고, device I/O만 VM exit으로 QEMU에 내려온다.

The Run Loop

둘이 실제로 맞물려 도는 모습은 하나의 반복 루프로 나타난다.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "The KVM_RUN loop",
  "width": 960,
  "height": 376,
  "mobileWidth": 900,
  "regions": [
    {
      "id": "loop",
      "label": "vCPU 실행 루프",
      "x": 40,
      "y": 60,
      "width": 880,
      "height": 256
    }
  ],
  "nodes": [
    {
      "id": "qemu",
      "kind": "switch",
      "label": "QEMU thread",
      "caption": "device 에뮬레이션",
      "x": 170,
      "y": 210,
      "width": 200,
      "height": 84
    },
    {
      "id": "kvm",
      "kind": "switch",
      "label": "KVM",
      "caption": "kernel",
      "x": 480,
      "y": 210,
      "width": 180,
      "height": 84
    },
    {
      "id": "guest",
      "kind": "host",
      "label": "Guest vCPU",
      "caption": "non-root 실행",
      "x": 790,
      "y": 210,
      "width": 200,
      "height": 84
    }
  ],
  "links": [
    {
      "id": "f1",
      "points": [[270, 192], [390, 192]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "f2",
      "points": [[570, 192], [690, 192]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "r1",
      "points": [[690, 228], [570, 228]],
      "tone": "muted",
      "flow": { "speed": "slow", "emphasis": "muted" }
    },
    {
      "id": "r2",
      "points": [[390, 228], [270, 228]],
      "tone": "muted",
      "flow": { "speed": "slow", "emphasis": "muted" }
    }
  ],
  "labels": [
    {
      "text": "일부 exit은 커널이 바로 처리",
      "x": 480,
      "y": 130
    },
    {
      "text": "① ioctl(KVM_RUN)",
      "x": 330,
      "y": 174
    },
    {
      "text": "② VM entry",
      "x": 630,
      "y": 174
    },
    {
      "text": "④ device I/O 리턴",
      "x": 330,
      "y": 250
    },
    {
      "text": "③ VM exit",
      "x": 630,
      "y": 250
    },
    {
      "text": "guest는 CPU에서 직접 돈다. 유저스페이스로 나오는 건 I/O일 때뿐.",
      "x": 480,
      "y": 290
    }
  ]
}
  1. QEMU는 vCPU마다 스레드를 하나씩 만들고, 그 스레드가 ioctl(vcpu, KVM_RUN)을 호출한다.

  2. KVM이 VM entry로 게스트를 CPU에 올린다.

게스트는 non-root 모드에서 native 속도로 명령을 실행한다. 이 구간에는 KVM도 QEMU도 끼어들지 않는다.

  1. 게스트가 특권 명령이나 장치 접근을 만나면 CPU가 제어를 KVM에 넘긴다. 이것이 VM exit이다.

  2. 여기서 갈림길이 생긴다.

    1. 커널이 처리할 수 있는 exit이면 KVM이 바로 처리하고 다시 VM entry로 돌아가고 유저스페이스로 나오지 않는다.
    2. 하지만 QEMU가 에뮬레이트하는 장치에 대한 접근이면, KVM_RUN이 리턴한다.

이때 KVM과 QEMU가 공유하는 struct kvm_run에 exit 이유(KVM_EXIT_IO, KVM_EXIT_MMIO 등)가 담겨 있다.

QEMU는 그 장치를 흉내 내 처리한 뒤 결과를 kvm_run에 써넣고, 다시 KVM_RUN을 호출하면서 루프가 이어진다.

커널과 유저스페이스를 오가는 context switch가 매번 들기 떄문에 VM 성능 튜닝의 상당 부분은 “VM exit 횟수를 어떻게 줄이느냐”로 귀결된다.

Device Passthrough

가장 빠른 답은 물리 장치를 게스트에 통째로 넘기는 passthrough다. 리눅스에서 이를 가능하게 하는 것이 VFIO와 IOMMU다.

{
  "diagram": "html-diagram",
  "variant": "explainer",
  "title": "GPU passthrough: control path vs data path",
  "width": 960,
  "height": 690,
  "mobileWidth": 900,
  "regions": [
    {
      "id": "ctrl",
      "label": "제어 경로 — setup 한 번",
      "x": 36,
      "y": 44,
      "width": 430,
      "height": 602
    },
    {
      "id": "data",
      "label": "데이터 경로 — 매 접근",
      "x": 494,
      "y": 44,
      "width": 430,
      "height": 602
    }
  ],
  "nodes": [
    {
      "id": "vm-l",
      "kind": "host",
      "label": "VM",
      "caption": "guest GPU driver",
      "x": 251,
      "y": 140,
      "width": 300,
      "height": 60
    },
    {
      "id": "qemu-l",
      "kind": "switch",
      "label": "QEMU",
      "caption": "/dev/vfio 열고 장치 매핑",
      "x": 251,
      "y": 232,
      "width": 300,
      "height": 60
    },
    {
      "id": "vfio-l",
      "kind": "switch",
      "label": "VFIO",
      "caption": "host driver 분리",
      "x": 251,
      "y": 324,
      "width": 300,
      "height": 60
    },
    {
      "id": "iommu-l",
      "kind": "memory",
      "label": "IOMMU table",
      "caption": "GPA → HPA 매핑 세팅",
      "x": 251,
      "y": 416,
      "width": 300,
      "height": 60
    },
    {
      "id": "pcie-l",
      "kind": "note",
      "label": "PCIe bus",
      "x": 251,
      "y": 504,
      "width": 300,
      "height": 56
    },
    {
      "id": "gpu-l",
      "kind": "gpu",
      "label": "GPU",
      "x": 251,
      "y": 594,
      "width": 300,
      "height": 60
    },
    {
      "id": "ram-r",
      "kind": "host",
      "label": "VM — guest RAM",
      "caption": "게스트 메모리",
      "x": 709,
      "y": 140,
      "width": 300,
      "height": 60
    },
    {
      "id": "iommu-r",
      "kind": "memory",
      "label": "IOMMU",
      "caption": "주소 변환 (하드웨어)",
      "x": 709,
      "y": 416,
      "width": 300,
      "height": 60
    },
    {
      "id": "gpu-r",
      "kind": "gpu",
      "label": "GPU",
      "caption": "DMA engine",
      "x": 709,
      "y": 594,
      "width": 300,
      "height": 60
    }
  ],
  "links": [
    {
      "id": "l1",
      "points": [[251, 170], [251, 202]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "l2",
      "points": [[251, 262], [251, 294]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "l3",
      "points": [[251, 354], [251, 386]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "l4",
      "points": [[251, 446], [251, 474]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "l5",
      "points": [[251, 532], [251, 564]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "r1-down",
      "points": [[699, 170], [699, 386]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "r1-up",
      "points": [[719, 386], [719, 170]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    },
    {
      "id": "r2-down",
      "points": [[699, 446], [699, 564]],
      "tone": "primary",
      "flow": { "speed": "normal", "count": 2, "emphasis": "strong" }
    },
    {
      "id": "r2-up",
      "points": [[719, 564], [719, 446]],
      "tone": "primary",
      "flow": { "speed": "normal", "emphasis": "soft" }
    }
  ],
  "labels": [
    {
      "text": "DMA — QEMU 없음",
      "x": 600,
      "y": 278
    },
    {
      "text": "테이블만 보고 통과",
      "x": 598,
      "y": 505
    }
  ]
}

VFIO는 커널 서브시스템이다. GPU를 host의 드라이버(예: nvidia)에서 떼어 내 vfio-pci 드라이버에 바인딩한다.

이제 host OS는 그 GPU를 건드리지 않고, /dev/vfio를 통해 유저스페이스가 장치를 안전하게 잡을 수 있다.

QEMU는 이 /dev/vfio 장치를 열어 게스트에 매핑한다. 게스트 드라이버는 GPU를 실물처럼 직접 제어한다.

문제는 DMA다. GPU는 CPU를 거치지 않고 메모리에 직접 접근한다. 게스트에 붙은 GPU가 임의의 host 메모리나 다른 VM의 메모리를 읽으면 격리가 무너진다.

여기서 IOMMU가 개입한다. 장치가 DMA로 접근하는 주소를 게스트 물리 주소에서 host 물리 주소로 변환하고, 허용된 범위 밖은 막는다. VFIO가 이 IOMMU를 프로그래밍해 각 장치가 자기 VM의 메모리에만 닿게 한다.

경로는 둘로 갈린다. 장치 설정은 QEMU를 거쳐 이뤄지지만, 일단 붙고 나면 데이터는 GPU와 게스트 메모리 사이를 DMA로 직접 오간다. QEMU도 KVM도 데이터 경로에 끼지 않는다. 그래서 native에 가까운 성능이 나온다.

GPU 워크로드는 GPU만 쓰는 게 아니라 CPU, PCIe, NIC, 디스크 I/O를 함께 쓴다.
VM은 이 하드웨어 스택 전체를 게스트 단위로 격리하고, passthrough로 성능 손실 없이 장치를 내줄 수 있다.

Summary

KVM은 커널 모듈로, CPU의 VT-x/AMD-V 위에서 게스트를 native 속도로 돌린다.

QEMU는 유저스페이스 VMM으로, 게스트가 볼 기계를 조립하고 장치를 흉내 내며 VM의 수명을 관리한다.

둘은 KVM_RUN 루프로 맞물린다. 게스트는 CPU에서 직접 돌고, QEMU가 흉내 내는 장치에 접근할 때만 VM exit으로 유저스페이스에 나온다.

이 왕복 비용을 줄이는 방법으로 아예 물리 장치를 게스트에 직접 붙이는 VFIO와 IOMMU가 있다.

References