82 min read

virtio

Linux virtio I/O path와 abstraction 설계

upstream Linux source와 Virtio 1.3 specification 기반 심층 보고서

대상 독자: CS/OS PhD 수준의 systems researcher

작성 기준일: 2026-05-26 / 기준 source: torvalds/linux master mirror, docs.kernel.org, OASIS Virtio 1.3

핵심 질문: “virtio에서 I/O는 어떤 abstraction boundary를 지나며, Linux는 왜 이렇게 구현했는가?”

0. 읽는 방법과 scope

이 보고서는 virtio를 “가상 device를 위한 device-driver protocol”로만 요약하지 않고, Linux source tree 안에서 I/O request가 실제로 어떤 abstraction을 통과하는지 따라간다. 중심축은 세 가지다. 첫째, virtio core가 Linux driver model에 어떤 bus와 device/driver abstraction을 추가하는지. 둘째, virtqueue/vring이 shared-memory data path로 어떻게 구현되는지. 셋째, virtio-blk와 virtio-net이 그 abstraction을 block layer와 net stack에 어떻게 연결하는지다.

기준 source는 upstream Linux master mirror이며, master는 이동하는 target이므로 연구 논문이나 artifact에는 특정 commit hash를 pinning하는 것이 맞다. 여기서는 함수명과 source path를 중심으로 적고, line number는 “읽기 시작점” 정도로만 다룬다. 핵심 path는 drivers/virtio/virtio.c, drivers/virtio/virtio_ring.c, drivers/virtio/virtio_pci_modern.c, include/linux/virtio.h, include/linux/virtio_config.h, drivers/block/virtio_blk.c, drivers/net/virtio_net.c다.

문서의 설명은 guest Linux driver의 관점이다. host 쪽 QEMU device model, vhost-net, vhost-vdpa, device-side firmware, DPU implementation은 필요한 지점에서만 언급한다. virtio의 장점은 host implementation을 바꿔도 guest ABI를 유지하는 데 있으므로, guest source를 정확히 읽는 것만으로도 protocol boundary의 상당 부분이 드러난다.

요약 mental model

virtio I/O는 “control plane은 transport/config/status/feature negotiation, data plane은 shared-memory virtqueue”라는 구조다.
Device-specific driver는 “무엇을 전송할지”를 SG list로 만든다. virtio_ring은 “그 SG list를 어떻게 descriptor chain으로 publish할지”를 담당한다. transport는 “queue address와 notify mechanism을 device에 어떻게 알려줄지”를 담당한다.

Kernel documentation은 virtio가 PCI/MMIO/CCW 같은 transport를 통해 노출되며, driver-device communication은 shared memory virtqueue를 사용한다고 설명한다. OASIS Virtio spec은 이 virtqueue를 split 또는 packed format으로 정의하고, Linux는 이 두 layout을 virtio_ring.c 내부 구현으로 감춘다. 이 concealment가 중요하다. virtio-blk, virtio-net 같은 frontend driver는 대부분 virtqueue_add_*, virtqueue_kick, virtqueue_get_buf* 수준에서만 ring을 본다.

읽을 때는 “누가 memory를 소유하는가?”를 계속 추적하면 이해가 빠르다. Driver가 descriptor와 available information을 publish하기 전에는 device가 그 descriptor chain을 읽으면 안 된다. Device가 used information을 publish하기 전에는 driver가 completion으로 간주하면 안 된다. 이 두 ownership transition의 양쪽에 memory barrier, endian conversion, DMA mapping, notification suppression이 붙는다.

0.1 Static table of contents

내용
1 왜 virtio인가: device emulation, paravirtual I/O, standard ABI
2 Spec object와 Linux object의 대응
3 virtio bus, driver binding, probe sequence
4 feature negotiation과 device status state machine
5 virtqueue abstraction과 public API
6 split virtqueue layout: descriptor/available/used
7 submission hot path: SG list에서 avail->idx까지
8 completion hot path: interrupt에서 upper-layer completion까지
9 notification suppression, batching, event index
10 DMA mapping, IOMMU, memory ordering
11 transport abstraction과 virtio-pci modern
12 virtio-blk case study
13 virtio-net case study
14 packed ring, indirect descriptor, in-order feature
15 reset, teardown, admin queue, live migration 관점
16 왜 이런 구현인가: design rationale
17 invariants, pitfalls, debugging strategy
18 research 관점의 open questions
A-C source map, pseudo-code, glossary/reference

1. 왜 virtio인가: “가상 device”를 normal device처럼 보이게 하기

전통적인 full device emulation은 guest가 실제 hardware register를 access하는 것처럼 보이게 하면서 host가 그 access를 trap하고 emulation한다. 이 방식은 compatibility에는 강하지만, I/O datapath가 register-level protocol에 묶인다. 특히 packet 하나, block request 하나를 처리할 때마다 여러 MMIO/PIO access와 interrupt path가 발생하면, VM exit/entry와 emulation overhead가 hot path에 섞인다. virtio의 출발점은 “guest driver와 hypervisor/device가 서로 합의한 더 단순한 I/O ABI를 쓰자”이다.

OASIS Virtio spec은 virtio family의 목적을 straight-forward, efficient, standard, extensible mechanism으로 둔다. 이 네 단어는 Linux implementation을 읽는 데 좋은 guide다. Straight-forward는 driver author가 “normal bus, interrupt, DMA” 문법으로 접근한다는 뜻이고, efficient는 descriptor ring으로 bulk metadata를 전달한다는 뜻이며, standard는 hypervisor별 one-off driver를 줄인다는 뜻이고, extensible은 feature bits로 evolution을 관리한다는 뜻이다.

Linux에서는 virtio가 “하나의 bus”로 model된다. 실제 discovery는 PCI, MMIO, CCW 등 transport가 맡을 수 있지만, device-specific driver가 붙는 곳은 virtio_bus다. 이 구조 덕분에 virtio-blk driver는 PCI-specific BAR layout을 몰라도 되고, virtio-net driver는 CCW transport의 details를 몰라도 된다. 반대로 transport driver는 block request format이나 network header format을 몰라도 된다.

이 분리는 OS 연구자에게 익숙한 “mechanism/policy” 분리와 비슷하지만 정확히 같지는 않다. transport는 discovery, config access, queue provisioning, notification delivery라는 control mechanism을 제공한다. device-specific driver는 block/net/GPU 같은 semantics를 제공한다. virtio_ring은 data movement metadata를 ring에 publish하는 common mechanism을 제공한다. 결국 Linux의 virtio stack은 protocol boundary를 세 layer로 분해한다.

흥미로운 점은 virtio가 “가상화 전용”으로 시작했지만, 구현 형태는 점점 real device, smart NIC, DPU, vDPA, VDUSE 쪽으로 확장되었다는 것이다. 따라서 virtio를 단지 QEMU/KVM optimization으로 보면 abstraction의 힘을 과소평가하게 된다. Linux source에서 virtio_devicestruct device를 embed하고, transport를 virtio_config_ops로 추상화하는 것은 virtio를 일반 device model의 일부로 편입시키려는 선택이다.

Linux kernel documentation도 virtio devices가 hypervisor abstraction이지만 guest에는 physical device처럼 노출되고, PCI/MMIO/CCW transport가 device 자체와 orthogonal하다고 설명한다. 이 “orthogonal”이라는 단어가 핵심이다. ring data path를 표준화하면, transport와 backend가 바뀌어도 frontend driver의 core logic은 대체로 유지된다.

Layer view (guest 중심)

application / filesystem / TCP stack
        |
Linux block layer / net stack
        |
virtio-blk / virtio-net          <- device-specific semantics
        |
virtqueue API                    <- common queue abstraction
        |
virtio_ring.c                    <- split/packed vring implementation
        |
virtio_config_ops transport      <- PCI/MMIO/CCW control plane
        |
shared memory + notification     <- device/backend/vhost consumes
왜 “ring of descriptors”인가?

Data payload 자체를 ring 안에 copy하지 않고, buffer address/length/flags만 ring에 올린다. 따라서 data movement는 DMA/read/write path로 남고, ring은 ownership transfer metadata가 된다. 이 선택이 copy cost를 줄이고, host/device가 large buffer를 직접 접근할 수 있게 한다.

이 장의 결론은 단순하다. virtio는 “가상 I/O를 빠르게 하기 위한 trick”이라기보다, Linux driver model 안에 들어온 standardized I/O contract다. 이 contract의 성능은 ring descriptor와 notification batching에서 나오고, 유지보수성은 bus/transport/device-specific driver의 분리에서 나온다.

2. Spec object와 Linux object의 대응

Spec/개념 Linux object 역할
Virtio device struct virtio_device Device id, feature bitmap, struct device, config ops pointer, virtqueue list를 보유
Virtio driver struct virtio_driver id table, feature table, probe/remove/config_changed callback 등
Virtqueue struct virtqueue Frontend driver가 보는 public queue handle; callback, vdev, index, num_free
Vring implementation struct vring_virtqueue virtio_ring.c 내부의 private state; split/packed union, DMA map policy, free list
Transport ops struct virtio_config_ops status, reset, get/set config, find_vqs, del_vqs, finalize_features
Split ring vring_desc, avail, used Descriptor table + driver area + device area
Packed ring packed descriptor ring Descriptor/avail/used state를 하나의 ring entry flag protocol로 통합

include/linux/virtio.h를 보면 Linux가 virtio를 거의 “mini bus framework”처럼 다룬다는 점이 드러난다. struct virtio_device는 Linux generic struct device를 포함하고, struct virtio_driverstruct device_driver를 포함한다. 즉 virtio는 subsystem 내부 private registry가 아니라 driver core의 matching/probing/removal lifecycle에 참여한다.

struct virtqueue는 intentionally small public object다. callback, name, vdev, index, free count, reset flag, private pointer 정도만 driver에게 노출된다. 왜 ring descriptor pointer를 노출하지 않을까? 첫째, split ring과 packed ring을 switch할 수 있어야 한다. 둘째, DMA API와 legacy physical address mode 같은 transport/architecture policy를 driver마다 반복시키지 않아야 한다. 셋째, notification suppression과 memory barrier를 common code에서 enforce해야 한다.

struct vring_virtqueuevirtio_ring.c 내부 구현 object로, public struct virtqueue를 embed한다. 이 pattern은 Linux kernel에서 흔한 “public base + container private state”다. Frontend driver가 struct virtqueue *를 받지만 실제로는 container_of로 struct vring_virtqueue가 복원된다. 이렇게 하면 API surface는 작게 유지하면서 implementation은 복잡한 state를 가진다.

include/linux/virtio_config.hstruct virtio_config_ops는 transport abstraction이다. get, set, get_status, set_status, reset, find_vqs, del_vqs, get_features, finalize_features가 핵심이다. device-specific driver는 virtio_find_single_vqvirtio_find_vqs 같은 helper를 호출하지만, 실제 queue allocation과 MMIO/PCI programming은 transport가 수행한다.

Source reading 관점에서 중요한 boundary는 “device-specific driver는 request shape를 만들고, virtio_ring은 queue metadata를 publish하며, transport는 queue memory address와 notification path를 설정한다”는 것이다. 이 세 boundary가 무너지면 virtio-blk가 PCI BAR를 알아야 하거나, virtio-pci가 block request header를 알아야 하는 이상한 구조가 된다.

Spec의 language와 Linux object naming은 일부 다르다. Spec은 descriptor area/driver area/device area 같은 용어를 쓰며, 과거에는 descriptor table/available ring/used ring이라는 이름이 널리 쓰였다. Linux code와 많은 설명은 여전히 desc/avail/used라는 이름을 사용한다. 연구자가 source를 읽을 때 이 alias를 알고 있어야 spec과 code를 cross-reference하기 쉽다.

이 대응표는 이후 모든 chapter의 index다. 예를 들어 virtio_dev_probe()를 읽을 때는 virtio_devicevirtio_driver의 feature bitmap이 만나는 지점에 집중한다. virtqueue_add_split()을 읽을 때는 virtqueue public call이 vring_virtqueue private state의 descriptor free list를 어떻게 소비하는지 보면 된다. vp_modern_find_vqs()를 읽을 때는 virtio_config_ops.find_vqs가 PCI common config와 notify capability를 어떻게 사용해 queue를 active하게 만드는지 본다.

abstraction boundary test

어떤 field/function이 어느 layer에 있어야 하는지 헷갈리면 다음 질문을 하자. “이 정보가 block/net/GPU semantics인가, ring layout인가, transport discovery/notification인가?” Linux virtio source의 구조는 이 질문에 따라 꽤 일관되게 나뉜다.

이 구조는 later optimization에도 유리하다. Packed ring support가 추가되었을 때 virtio-blk의 request format을 대폭 바꾸지 않아도 된다. vDPA처럼 device/backend가 하드웨어로 이동해도 virtqueue contract는 유지된다. 반대로 transport-specific bug나 DMA mapping rule 변화가 device-specific driver 전체로 확산되는 것을 막을 수 있다.

3. virtio bus, driver binding, probe sequence

drivers/virtio/virtio.c의 central role은 virtio bus를 Linux driver core에 등록하고, virtio device가 register될 때 적절한 virtio_driver를 match/probe하는 것이다. virtio_bus.match, .probe, .remove, .shutdown 등을 가진 bus_type으로 정의된다. device-specific driver는 register_virtio_driver() macro/helper를 통해 이 bus에 등록된다.

Transport driver, 예컨대 virtio-pci는 PCI enumeration에서 vendor/device id를 보고 virtio PCI device를 발견한다. 그 뒤 legacy/modern probing을 수행하고, 성공하면 내부 virtio_device를 virtio bus에 등록한다. 그러면 generic virtio core가 device id와 driver id table을 비교하여 device-specific driver의 probe를 호출한다. docs.kernel.org의 virtio documentation도 이 discovery/probing flow를 같은 방식으로 설명한다.

이 design은 Linux의 normal device lifecycle을 유지한다. hotplug, module loading, sysfs, devres-like cleanup, power management, freeze/restore callback 등이 bus/driver model 위에서 자연스럽게 돌아간다. virtio가 hypervisor-specific pseudo bus로만 남았다면 이런 integration이 더 어려웠을 것이다.

virtio_dev_probe() 안에서 core는 먼저 status bit에 DRIVER를 더하고, device feature를 읽고, driver가 선언한 feature set과 교집합을 만든다. 특히 transport feature는 device-specific driver feature와 다르게 다뤄진다. 예컨대 ring layout, indirect descriptor, event index, packed ring 같은 capability는 device type에 독립적인 transport/ring feature다.

Feature negotiation 뒤에는 driver의 optional validate()가 호출될 수 있고, finalize_features()를 통해 transport가 실제 selected feature를 device에 write한다. 그 다음 virtio_features_ok()가 FEATURES_OK status를 set하고 다시 읽어서 device가 그 feature set을 받아들였는지 확인한다. 이 “write then verify”는 guest/device가 incompatible feature set으로 진행하는 것을 막는 fail-closed mechanism이다.

마지막으로 device-specific driver의 probe()가 호출된다. 보통 이 안에서 virtio_find_vqs()를 통해 virtqueue를 만든다. probe()가 return한 뒤 core는 driver가 아직 DRIVER_OK를 set하지 않았다면 virtio_device_ready()로 device를 ready 상태로 만든다. 이 sequence는 spec의 initialization order와 잘 맞물린다. Queue를 설정하기 전에 device가 data path를 시작하면 안 되고, driver가 callbacks/state를 준비하기 전에 interrupt가 들어와도 안 된다.

Simplified probe sequence (Linux 관점, 의사코드)

transport detects device
    -> register_virtio_device(vdev)
        -> virtio_bus match(id_table)
        -> virtio_dev_probe(vdev)
            add_status(DRIVER)
            device_features = config->get_features(vdev)
            negotiated = device_features & driver_features & transport_features
            driver->validate(vdev) optional
            config->finalize_features(vdev)
            set_status(FEATURES_OK); verify device kept it
            driver->probe(vdev)
                virtio_find_vqs(...)
                set up upper-layer state
            virtio_device_ready(vdev) if needed
            enable config notifications / scan
왜 core가 negotiation을 중앙화하는가?

Device-specific driver마다 feature protocol을 직접 구현하게 두면 subtle bug가 생긴다. 예를 들어 transport feature를 잘못 masking하거나 FEATURES_OK verification을 빼먹을 수 있다. Core가 공통 initialization을 소유하면 spec compliance와 error path가 한 곳에 모인다.

이 sequence의 failure path도 중요하다. Feature set이 reject되거나 driver probe가 실패하면 core는 FAILED status를 set한다. Reset path는 device가 더 이상 interrupt를 만들거나 memory를 access하지 않도록 하는 의미를 가진다. Linux source의 comment는 reset이 interrupt 및 memory access quiescence와 관련됨을 강조한다. 즉 reset은 “상태 bit 0으로 초기화” 이상의 synchronization point다.

CS/OS 연구 관점에서는 이 bus design이 virtio를 “ABI + lifecycle + data structure”의 조합으로 만든다는 점을 주목할 만하다. 단순히 SG list를 ring에 넣는 protocol이라면 lifecycle race가 남는다. Linux implementation은 driver core와 status state machine을 결합해, data path가 열리는 시점과 닫히는 시점을 비교적 명확하게 만든다.

4. feature negotiation과 device status state machine

Virtio initialization은 status bit state machine이다. Spec은 reset 후 ACKNOWLEDGE, DRIVER, feature negotiation, FEATURES_OK, virtqueue setup, DRIVER_OK의 sequence를 요구한다. Linux source는 이 sequence를 virtio_dev_probe()와 transport-specific finalize_features()find_vqs()의 조합으로 구현한다. 단일 함수가 spec을 문자 그대로 한 줄씩 실행하는 것이 아니라, core와 transport와 driver probe가 분담한다.

Feature negotiation은 virtio의 extensibility를 가능하게 한다. Device가 capability bitmap을 제공하면, driver는 이해하고 사용하려는 subset만 accept한다. Device-specific feature와 transport/ring feature가 같은 bit namespace에 존재할 수 있기 때문에 Linux core는 transport feature를 특별 취급한다. vring_transport_features() 같은 path가 ring implementation이 지원할 feature를 걸러낸다.

FEATURES_OK는 단순한 acknowledgement가 아니라 agreement checkpoint다. Driver가 selected features를 device에 write하고 FEATURES_OK를 set한 뒤, 다시 status를 읽어 device가 그 bit를 유지하는지 본다. Device가 feature set을 받아들일 수 없으면 FEATURES_OK를 clear할 수 있고, Linux core는 probe를 중단한다. 이 check가 없다면 incompatible layout이나 semantics로 queue를 시작할 수 있다.

PCI modern transport의 vp_finalize_features()는 VERSION_1 feature requirement 등 transport-level constraints를 적용한다. Modern virtio는 legacy quirks를 줄이기 위해 common config capability와 standard layout을 사용한다. Linux source에서 transport가 feature finalization에 개입하는 이유는, ring/device driver만으로는 PCI transport가 요구하는 condition을 알 수 없기 때문이다.

DRIVER_OK는 data path의 시작 signal이다. Spec은 driver가 DRIVER_OK를 set하기 전 device가 notification을 보내면 안 된다고 한다. Linux도 queue setup과 driver state initialization이 끝난 뒤 ready를 set한다. 특히 virtio-net처럼 callback이 NAPI scheduling, queue wakeup, packet completion과 연결되는 driver에서는 premature interrupt가 subtle race로 이어질 수 있다.

Status bit는 failure reporting에도 쓰인다. Driver가 unrecoverable error를 만나면 FAILED status를 set할 수 있다. Reset은 모든 status bit를 clear하고, transport-specific reset implementation은 device가 status zero를 observe할 때까지 기다릴 수 있다. 이 기다림은 “host/device가 이전 queue state를 더 이상 사용하지 않는다”는 synchronize point로 해석해야 한다.

Status sequence (spec 개념)

0 / reset
  -> ACKNOWLEDGE        driver saw device
  -> DRIVER             driver knows how to drive this device type
  -> FEATURES_OK        selected feature set accepted
  -> queue setup        addresses, sizes, callbacks, vectors
  -> DRIVER_OK          data path may run
  -> FAILED             error terminal state, if needed
feature negotiation의 research 의미

Feature bits는 ABI evolution의 explicit contract다. Kernel/hypervisor co-evolution에서 중요한 문제는 “새 optimization을 넣되 오래된 guest/device와 안전하게 interoperate하는 것”이다. virtio는 이 문제를 feature bit와 status checkpoint로 풀었다.

이 장을 source로 확인할 때는 virtio_dev_probe(), virtio_features_ok(), transport의 finalize_features(), 그리고 driver-specific probe에서 virtio_find_vqs()가 호출되는 순서를 따라가면 된다. virtio_ring.c의 hot path는 이 state machine이 완료된 뒤에야 의미가 있다.

5. virtqueue abstraction과 public API

Virtqueue는 virtio의 가장 중요한 programming model이다. Device-specific driver는 request/packet/control command를 “하나 이상의 scatterlist segment”로 표현하고, virtqueue API를 통해 queue에 등록한다. Public API는 virtqueue_add_outbuf, virtqueue_add_inbuf, virtqueue_add_sgs, virtqueue_kick, virtqueue_get_buf_ctx 같은 함수로 구성된다.

이 API에서 outin이라는 naming은 guest driver 관점이다. Out buffer는 driver가 data를 써서 device가 읽는 buffer다. In buffer는 device가 data를 써서 driver가 나중에 읽는 buffer다. Virtio spec은 descriptor chain에서 device-readable element가 device-writable element보다 앞에 와야 한다고 요구한다. Linux source의 add path도 out SG를 먼저 mapping하고, 그 다음 in SG를 mapping한다.

Public struct virtqueue는 callback pointer를 가진다. Device가 buffer를 consume하면 transport interrupt path가 vring_interrupt()를 거쳐 이 callback을 부른다. 그러나 callback 안에서 반드시 모든 completion을 처리해야 하는 것은 아니다. virtio-net은 callback에서 NAPI를 schedule하고 실제 packet draining은 poll context에서 수행할 수 있다. virtio-blk는 request completion을 block layer로 propagate한다.

virtqueue_kick_prepare()virtqueue_notify()가 분리된 점도 API design상 중요하다. Add path를 lock으로 보호한 뒤, notification이 필요한지 결정하고, lock을 release한 다음 expensive notify/MMIO write를 수행할 수 있다. 이 split은 batching과 lock hold time optimization에 직접 연결된다.

virtqueue_enable_cb_prepare(), virtqueue_poll(), virtqueue_disable_cb() 같은 callback control API는 interrupt race를 해결하는 핵심이다. Consumer가 ring을 drain하는 동안 callback을 disable하고, 다시 enable하려는 순간 device가 새 used entry를 publish하면 lost wakeup이 생길 수 있다. Linux API는 enable 후 poll/check pattern을 제공해 이 race를 닫는다.

이 API는 ring layout을 숨기지만, 완전히 abstract queue는 아니다. Driver는 queue가 bounded resource라는 점을 알아야 하고, num_free 부족 시 retry/stop queue를 해야 한다. 또한 SG list의 out/in ordering과 token(data pointer)의 lifetime을 지켜야 한다. Virtio abstraction은 “descriptor protocol을 숨긴다”이지 “backpressure와 lifetime 문제를 없앤다”가 아니다.

API 역할 typical use
virtqueue_add_sgs 여러 SG array를 하나의 descriptor chain으로 등록 virtio-blk request처럼 header/data/status가 섞일 때
virtqueue_add_outbuf device-readable buffer chain 등록 TX packet, command header
virtqueue_add_inbuf device-writable buffer chain 등록 RX buffer, status/result buffer
virtqueue_kick_prepare device notification 필요 여부 계산 lock 안에서 publish 후 decision
virtqueue_notify transport-specific notify 수행 lock 밖에서 MMIO/doorbell
virtqueue_get_buf_ctx used ring에서 completed token 회수 interrupt/NAPI/poll completion
token/data pointer

virtqueue add API는 void *data token을 함께 저장한다. Completion path에서 device가 used entry를 publish하면 Linux는 그 descriptor head에 대응하는 token을 돌려준다. 이 token은 upper-layer request object 포인터인 경우가 많다. Ring은 payload ownership뿐 아니라 software continuation도 저장한다.

OS abstraction 관점에서 virtqueue는 “bounded asynchronous command queue”다. 그러나 일반 lock-free queue와 달리 producer/consumer가 서로 다른 agent(driver/device)이고, memory visibility가 DMA/coherent memory/weak ordering에 걸쳐 있다. 그래서 enqueue/dequeue algorithm을 이해하려면 data structure뿐 아니라 barrier와 notification도 함께 읽어야 한다.

6. split virtqueue layout: descriptor table, available ring, used ring

Split virtqueue는 세 영역으로 나뉜다. Descriptor table은 buffer address, length, flags, next index를 담는다. Available ring은 driver가 device에게 “이 descriptor head들을 처리해도 된다”고 publish하는 영역이다. Used ring은 device가 driver에게 “이 descriptor chain을 처리했고 결과 length는 이렇다”고 publish하는 영역이다.

이 layout은 cache ownership을 분리하려는 의도를 가진다. Driver는 descriptor table과 available ring을 주로 쓴다. Device는 used ring을 주로 쓴다. 양쪽이 같은 cache line을 동시에 write하면 false sharing과 coherence traffic이 커진다. Spec의 efficient 설명도 driver/device가 같은 cache line에 write하는 효과를 피하도록 descriptor ring이 배치되었다는 점을 언급한다.

Descriptor table entry는 address/length/flags/next로 구성된다. Flags에는 NEXT, WRITE(device writable), INDIRECT 등이 있다. Chain은 linked list처럼 연결될 수 있다. WRITE flag가 없는 descriptor는 device-readable, WRITE flag가 있는 descriptor는 device-writable이다. Linux add path는 SG list를 순회하며 각 segment를 descriptor로 바꾸고, chain의 마지막에서 NEXT를 clear한다.

Available ring에는 idx와 ring array가 있다. Driver는 ring[avail_idx % size] = head로 descriptor chain의 head index를 써 넣고, memory barrier 뒤에 idx를 증가시킨다. Device는 idx 변화를 보고 새로운 available entry가 있다는 것을 안다. 여기서 barrier가 중요한 이유는 device가 증가된 idx를 먼저 보고 descriptor fields가 아직 보이지 않는 상태를 막아야 하기 때문이다.

Used ring은 device가 completion을 publish하는 영역이다. Device는 used ring entry에 descriptor head id와 written length를 넣고, used idx를 증가시킨다. Driver는 last_used_idx와 device의 used idx를 비교해 새 completion을 찾는다. Driver가 used entry를 읽기 전에도 barrier가 필요하다. idx 관찰 이후 entry payload가 visible해야 completion token을 안전하게 detach할 수 있다.

Split layout은 비교적 이해하기 쉽고 legacy support가 넓다. 하지만 descriptor table, available ring, used ring이 분리되어 있어 hot path에서 memory touch가 여러 location에 흩어진다. Packed ring은 이런 overhead를 줄이려는 방향이지만, flag/wrap counter protocol이 복잡해진다. Linux가 두 layout을 모두 virtio_ring.c에 넣고 public API를 유지하는 이유가 여기에 있다.

Split virtqueue conceptual layout

Descriptor table (driver writes descriptors)
  desc[0] = {addr, len, flags, next}
  desc[1] = {addr, len, flags, next}
  ...

Available ring / driver area (driver publishes work)
  avail.flags
  avail.idx                 // monotonically increments modulo 2^16
  avail.ring[i % qsz] = head_descriptor_index

Used ring / device area (device publishes completion)
  used.flags
  used.idx
  used.ring[i % qsz] = {id=head_descriptor_index, len=bytes_written}
“idx publish”의 의미

idx는 queue state의 commit point처럼 동작한다. Descriptor fields와 avail ring slot을 먼저 채우고, barrier로 ordering을 보장한 뒤 idx를 증가시켜야 한다. Completion도 used entry를 먼저 쓰고 idx를 publish하는 동일한 패턴을 가진다.

Source reading에서는 vring_virtqueue_split state를 확인하면 split ring implementation의 bookkeeping이 보인다. free_head, num_free, avail_idx_shadow, last_used_idx 같은 변수는 ring memory의 protocol state와 software allocator state 사이를 잇는다. Descriptor table 자체는 device-facing structure이지만, free list는 driver-only allocator다.

7. submission hot path: SG list에서 avail->idx까지

Submission hot path는 virtio에서 가장 자주 실행되는 code path다. Device-specific driver가 SG list와 token을 준비해 virtqueue_add_sgs() 혹은 wrapper를 호출하면, virtio_ring.c가 split 또는 packed implementation으로 dispatch한다. 여기서는 split path를 중심으로 설명한다. Packed path도 ownership publish라는 큰 구조는 같지만 flag protocol이 다르다.

virtqueue_add_split()의 첫 단계는 capacity check다. Queue에 충분한 free descriptor가 없으면 -ENOSPC류 error를 반환하고, upper layer는 queue stop/retry/batch drain 같은 policy를 적용한다. Indirect descriptor feature가 negotiated된 경우 여러 SG segment를 하나의 ring descriptor로 가리키는 indirect table을 사용할 수 있어 queue pressure를 줄일 수 있다.

다음 단계는 SG segment를 DMA address로 mapping하는 것이다. Linux는 vring_map_one_sg() 계열 helper를 통해 DMA_TO_DEVICE 또는 DMA_FROM_DEVICE direction을 적용한다. Device-readable out SG는 TO_DEVICE, device-writable in SG는 FROM_DEVICE다. VIRTIO_F_ACCESS_PLATFORM, Xen, legacy quirk 등의 이유로 DMA API를 쓸지 physical address를 쓸지가 달라질 수 있다.

Mapping 후 descriptor fields를 채운다. Out descriptors가 먼저 나오고, in descriptors가 뒤에 온다. 이 ordering은 spec requirement이기도 하고, many device implementation이 request header/readable payload 다음에 writable status/result를 기대하는 이유이기도 하다. 마지막 descriptor에서는 NEXT를 clear하고, chain head를 available ring에 publish할 준비를 한다.

Linux는 descriptor head에 대응하는 data token을 저장한다. Completion path는 device가 used entry의 id로 head descriptor index를 돌려주면, 그 index에 묶인 token을 찾아 upper layer에 반환한다. 이 token은 보통 block request, sk_buff, receive buffer metadata 같은 object다. 따라서 token lifetime은 “submit 후 completion 전까지 valid”해야 한다.

Commit 단계에서는 avail ring slot에 head를 쓰고, virtio_wmb()로 descriptor/avail slot visibility를 보장한 뒤, shadow idx를 증가시키고 avail->idx를 write한다. num_added는 notification batching과 wrap/overflow warning에 쓰인다. 이 단계가 끝나면 device가 descriptor chain을 consume할 수 있다.

마지막 단계는 kick decision이다. 항상 notify하면 hypervisor exit/MMIO doorbell overhead가 커진다. 하지만 notify를 빼먹으면 device가 work를 보지 못할 수 있다. Linux는 notification suppression state와 event idx feature를 보고 virtqueue_kick_prepare_split()에서 필요한지를 계산한다. 보통 driver는 lock 안에서 prepare까지 하고, lock 밖에서 virtqueue_notify()를 호출한다.

Submission path (split ring, 의사코드)

virtqueue_add_sgs(vq, out_sgs, in_sgs, token)
    lock held by caller or driver-specific lock
    if not enough free descriptors:
        return -ENOSPC
    if indirect is beneficial and available:
        allocate/build indirect descriptor table
    for sg in out_sgs:
        dma = map(sg, DMA_TO_DEVICE)
        fill desc[free_i] as device-readable
    for sg in in_sgs:
        dma = map(sg, DMA_FROM_DEVICE)
        fill desc[free_i] as device-writable
    data[head] = token
    avail.ring[avail_idx % qsz] = head
    virtio_wmb()
    avail.idx = ++avail_idx_shadow
    num_added++
    return 0

if virtqueue_kick_prepare(vq):
    unlock
    virtqueue_notify(vq)
왜 add와 notify를 분리하는가?

Descriptor publish는 shared memory write이고 notify는 transport-specific doorbell/MMIO/interrupt-like event다. 둘을 분리하면 여러 request를 publish한 뒤 한 번만 notify할 수 있고, lock을 잡은 상태에서 expensive I/O write를 하지 않아도 된다.

성능 측면에서 submission path의 비용은 descriptor allocation, SG DMA mapping, cache line write, barrier, optional notify로 나눌 수 있다. Virtio가 효율적인 이유는 payload copy를 하지 않는 것뿐 아니라, 여러 payload segment를 하나의 descriptor chain으로 batch하고, notification을 suppress/batch할 수 있기 때문이다. 그러나 SG mapping과 barrier는 사라지지 않는다. IOMMU가 켜진 환경에서는 DMA mapping 비용이 중요한 bottleneck이 될 수 있다.

8. completion hot path: used ring에서 upper-layer completion까지

Completion path는 device가 used ring에 entry를 publish하면서 시작된다. Transport는 interrupt 또는 polling signal을 통해 driver에게 “새 used entry가 있을 수 있다”고 알려준다. Linux의 common interrupt handler는 vring_interrupt()에서 virtqueue callback을 호출한다. 실제 completion draining은 driver callback 안에서 즉시 하거나, NAPI처럼 deferred context에서 할 수 있다.

virtqueue_get_buf_ctx_split()는 split used ring을 읽는 core 함수다. Driver의 last_used_idx와 device가 publish한 used->idx를 비교해 새 entry가 있는지 확인한다. 새 completion이 있으면 virtio_rmb()로 used entry visibility를 보장한 뒤, used ring slot에서 id와 len을 읽는다. 이 id는 descriptor head index다.

그 다음 Linux는 descriptor chain을 detach한다. Detach는 세 가지 일을 포함한다. 첫째, descriptor free list에 chain을 되돌린다. 둘째, DMA mapping을 unmap한다. 셋째, head index에 저장했던 token을 회수하고 해당 slot을 clear한다. 마지막으로 token과 len을 driver에게 반환한다. 이 len은 device가 writable buffer에 실제로 쓴 byte 수로 해석된다.

Completion path에서 callback enable/disable이 subtle하다. Driver가 ring을 drain하는 동안 callback을 disable하면 interrupt storm을 줄일 수 있다. 하지만 drain이 끝나고 callback을 다시 enable하는 순간 device가 새 completion을 publish할 수 있다. Linux는 enable 후 virtqueue_poll()로 idx 변화 여부를 확인하는 pattern을 제공해 lost wakeup을 피한다.

Virtio-net의 NAPI path는 이 pattern을 잘 보여준다. Callback은 callback을 disable하고 NAPI를 schedule한다. Poll function은 여러 packet/buffer를 drain하고, budget이 남아 completion을 모두 처리한 것 같으면 callback enable을 시도한다. Enable 직후 새 entry가 있으면 다시 poll을 계속하거나 schedule한다. 이 structure는 network driver의 standard interrupt mitigation pattern과 virtqueue protocol을 결합한다.

Virtio-blk는 block layer request completion으로 이어진다. Used entry가 돌아오면 status byte를 확인하고 request를 complete한다. Block driver에서는 queue depth와 request batching이 중요하고, network driver에서는 packet burst와 NAPI budget이 중요하지만, 양쪽 모두 used ring에서 token을 회수한다는 common mechanism을 공유한다.

Completion path (split ring, 의사코드)

interrupt or poll indicates possible completion
    callback(vq)
        while token = virtqueue_get_buf_ctx(vq, &len, &ctx):
            id = used.ring[last_used_idx % qsz].id
            virtio_rmb()
            token = data[id]
            detach descriptor chain
            unmap DMA segments
            recycle descriptors to free list
            return token, len
        maybe re-enable callbacks with race check
completion은 “buffer가 끝났다”보다 넓다

Used entry는 payload buffer뿐 아니라 software continuation을 반환한다. id는 descriptor head이고, Linux는 그 head에 저장한 token으로 request object를 찾는다. 따라서 virtqueue completion은 hardware descriptor completion과 upper-layer async operation completion 사이의 bridge다.

Memory ordering 관점에서 completion path는 submission path의 mirror image다. Device가 used entry를 쓰고 idx를 publish했다는 것을 driver가 observe하면, driver는 used entry payload와 DMA-written data가 visible하다는 ordering guarantee를 필요로 한다. Linux의 barrier와 DMA sync/unmap path는 이 guarantee를 architecture/transport/DMA API 조합에 맞춰 제공한다.

9. notification suppression, batching, event index

Virtio performance의 큰 부분은 notification을 줄이는 데서 나온다. Driver가 descriptor 하나를 publish할 때마다 device에 notify하면, 가상화 환경에서는 VM exit/doorbell/MMIO write가 hot path에 자주 등장한다. 반대로 notification을 너무 줄이면 device가 work를 늦게 보거나 latency가 커진다. 따라서 virtio는 notification suppression을 explicit protocol feature로 제공한다.

Split ring에는 available ring의 flags와 used ring의 flags, 그리고 event idx extension이 있다. Without event idx에서는 flags로 broad enable/disable을 표현한다. Event idx가 negotiated되면 producer가 어느 index까지 notification을 원하지 않는지 더 정확히 표현할 수 있다. Linux는 vring_need_event() 같은 helper logic으로 “이번 idx 변화가 notify boundary를 넘었는가?”를 계산한다.

num_added는 batching 관점에서 중요하다. 여러 descriptor를 add한 뒤 한 번의 kick decision으로 device notification을 보낼 수 있다. Block layer의 commit_rqs 같은 path는 여러 request를 queue에 등록하고 나중에 kick한다. Network TX도 burst 성격이 강하므로 notify suppression과 batching 효과가 크다.

Notification suppression은 양방향이다. Driver가 device notification을 줄이는 것뿐 아니라, device도 driver interrupt를 줄일 수 있다. Driver는 callback disable/enable API를 통해 “지금은 interrupt를 보내지 않아도 된다”는 상태를 ring에 반영한다. Device는 used entry를 publish하되 interrupt를 suppress할 수 있다. Driver는 polling/NAPI로 drain한다.

이 design은 lock-free queue와는 다르게 “queue state + notification state”가 분리되어 있다는 점에서 흥미롭다. Work가 ring에 있어도 notification이 suppress될 수 있고, notification이 와도 이미 다른 CPU/context가 work를 drain했을 수 있다. 따라서 interrupt는 level-triggered truth가 아니라 hint로 보는 것이 안전하다. Linux virtqueue callback도 “새 used entry가 있을 수 있다”는 hint로 생각해야 한다.

왜 notification decision을 common virtio_ring.c에 넣었을까? Device-specific driver마다 event idx arithmetic을 구현하면 off-by-one이나 wraparound bug가 발생하기 쉽다. idx는 16-bit modulo counter이고 wrap이 자연스럽다. Common code가 publish/notify ordering과 event decision을 함께 관리하면 correctness가 좋아진다.

Notification decision (conceptual)

publish descriptors:
    old_idx = avail_idx_shadow
    avail_idx_shadow += n
    avail.idx = avail_idx_shadow

kick_prepare:
    ensure idx write is visible before reading device's event/flags
    if event_idx:
        notify = index_crossed(device_requested_event, old_idx, new_idx)
    else:
        notify = device did not suppress notifications

if notify:
    transport_notify(queue_index)
latency-throughput trade-off

Notification suppression은 throughput을 올리지만 latency를 희생할 수 있다. Virtio의 API는 driver가 workload별 policy를 얹을 수 있게 한다. Block batching, network NAPI, polling mode는 같은 primitive 위에서 다른 latency/throughput point를 선택한다.

Research 관점에서는 virtqueue notification protocol을 “distributed condition variable”처럼 볼 수 있다. Condition state는 shared memory ring에 있고, wakeup은 transport notify/interrupt에 있다. Lost wakeup을 막기 위해 enable 후 poll, publish 후 barrier, event idx arithmetic 같은 장치가 필요하다.

10. DMA mapping, IOMMU, memory ordering

Virtio를 배울 때 흔한 오해는 “가상 device니까 DMA가 그냥 guest virtual address를 쓰겠지”라는 생각이다. 실제 Linux implementation은 훨씬 조심스럽다. Descriptor addr는 device가 해석할 address이고, modern virtio와 VIRTIO_F_ACCESS_PLATFORM 환경에서는 DMA API/IOMMU translation이 필요할 수 있다. 반면 legacy/quirk path에서는 physical address를 직접 쓸 수 있다.

virtio_ring.c의 DMA mapping logic은 호환성 때문에 복잡하다. vring_use_map_api()는 device feature, platform requirement, Xen 등 환경에 따라 DMA API를 사용할지 결정한다. Source comment도 이 area가 messy하다는 취지의 역사적 배경을 드러낸다. 이 복잡성은 virtio가 hypervisor-only ABI에서 platform device/DMA/IOMMU aware ABI로 확장되면서 생긴 비용이다.

DMA direction은 correctness와 performance 모두에 중요하다. Device-readable buffer는 driver가 data를 준비하고 device가 읽으므로 DMA_TO_DEVICE다. Device-writable buffer는 device가 data를 쓰고 driver가 completion 후 읽으므로 DMA_FROM_DEVICE다. 잘못된 direction은 cache maintenance와 IOMMU permission에서 bug를 만들 수 있다.

Memory ordering은 두 layer로 생각해야 한다. 첫째, CPU가 shared ring memory에 descriptor/idx를 쓰는 ordering이다. 여기에 virtio_wmb, virtio_rmb, virtio_mb류 barrier가 관여한다. 둘째, DMA buffer payload에 대한 cache visibility다. DMA API의 map/unmap/sync가 관여한다. Ring metadata와 payload data는 서로 연관되지만 같은 mechanism으로만 해결되지 않는다.

Weakly ordered architecture에서는 barrier가 특히 중요하다. Driver가 descriptor fields를 쓰기 전에 avail idx가 device에게 보이면 device는 partially initialized descriptor를 읽을 수 있다. Device가 used idx를 publish했는데 used entry나 payload write가 아직 visible하지 않으면 driver는 stale data를 completion으로 처리할 수 있다. Virtio barrier는 이런 ordering hole을 막기 위한 protocol-level fence다.

Endian 문제도 있다. Virtio는 little-endian field를 사용하며, Linux는 cpu_to_virtio*, virtio_to_cpu* macro/helpers를 통해 conversion한다. x86에서는 무시하기 쉽지만, portable driver와 cross-architecture correctness에서는 중요하다. Source에서 raw integer assignment처럼 보이지 않는 conversion path를 따라가야 한다.

IOMMU 관점에서는 virtio가 “guest physical memory를 host가 자유롭게 읽는다”는 단순 모델에서 벗어난다. vDPA/real device에서는 device가 실제 DMA master일 수 있고, platform access rules가 존재한다. Linux가 DMA API를 통과시키는 이유는 guest/hypervisor virtualization뿐 아니라 real hardware semantics와도 맞추기 위해서다.

문제 위험 Linux mechanism
Ring metadata ordering descriptor fields -> avail slot -> avail idx virtio_wmb/mb
Completion metadata ordering used entry -> used idx -> driver read virtio_rmb/mb
Payload visibility CPU cache vs device DMA dma_map/unmap/sync
Address translation CPU physical/IOVA/device address DMA API/IOMMU
Endian conversion CPU native vs virtio little-endian cpu_to_virtio*, virtio_to_cpu*
barrier를 source에서 읽는 법

Barrier call은 “성능 방해물”이 아니라 ownership transfer의 proof obligation이다. Driver가 device에게 descriptor chain을 넘기는 commit point, device가 driver에게 completion을 넘기는 commit point 주변에 barrier가 있어야 한다.

이 장은 virtio를 systems research에서 좋은 case study로 만든다. 단순한 ring buffer 알고리즘처럼 보이지만, 실제 correctness는 CPU memory model, DMA semantics, IOMMU policy, endian ABI, transport notification order가 함께 만족되어야 한다. Linux source의 abstraction은 이 cross-product를 device-specific driver에게 숨기는 데 많은 노력을 쓴다.

11. transport abstraction과 virtio-pci modern

Transport abstraction은 struct virtio_config_ops로 표현된다. Device-specific driver는 config field를 읽거나 virtqueue를 찾고 싶을 때 vdev->config의 op를 사용한다. PCI, MMIO, CCW는 각각 자신의 discovery/config/notify mechanism을 제공하지만, frontend driver는 같은 virtio API를 호출한다.

Modern virtio-pci는 PCI capability로 common config, notify config, ISR/status, device config 등을 노출한다. Linux의 drivers/virtio/virtio_pci_modern.c는 이 capability를 parsing하고, common config를 통해 queue size, descriptor address, avail address, used address, queue enable, queue vector 등을 설정한다.

vp_modern_find_vqs()와 관련 setup path를 보면, queue를 만들 때 vring_create_virtqueue()가 호출되어 ring memory와 vring_virtqueue object를 준비한다. 그 뒤 transport는 queue address를 PCI common config register에 write하고, MSI-X vector와 notify offset을 설정한다. 이 흐름은 “ring implementation은 memory layout을 만들고, transport는 device에게 그 memory layout의 address를 알려준다”로 요약된다.

Queue enable ordering도 중요하다. Modern PCI source에는 queue를 enable한 뒤에는 reset 없이는 되돌리기 어렵다는 comment가 있다. 그래서 setup이 완전히 준비된 뒤 enable한다. Data path는 DRIVER_OK 이전에 시작되면 안 되고, queue address/vector/callback이 준비되지 않은 상태에서 interrupt가 오면 안 된다.

Notify path는 transport-specific이다. PCI modern에서는 notify capability의 BAR/offset에 write하는 doorbell 방식이다. 그러나 virtqueue_notify() public API는 transport detail을 숨긴다. vring_virtqueue는 notify function pointer를 통해 transport op를 호출한다. 즉 ring implementation은 “notify를 해야 한다”는 decision을 내리고, transport는 “어디에 어떤 방식으로 notify할지”를 안다.

Config space access도 transport를 통해 이뤄진다. Device-specific driver는 capacity, MAC address, queue count, feature-specific config를 읽을 수 있다. Config change interrupt가 오면 driver의 config_changed callback이 호출된다. 여기서도 transport interrupt delivery와 device-specific reaction이 분리된다.

virtio-pci modern을 읽을 때 주의할 점은 legacy path와 modern path가 모두 Linux에 존재한다는 것이다. Legacy device support는 compatibility를 위해 남아 있으며, memory layout/feature/endian/IO access semantics가 일부 다르다. 이 보고서는 modern path 중심이지만, virtio_ring.c의 DMA quirk와 barrier naming은 legacy support의 흔적을 담고 있다.

Transport/ring interaction (PCI modern conceptual)

frontend driver:
    virtio_find_vqs(vdev, names, callbacks)
        -> vdev->config->find_vqs(...)

virtio-pci modern transport:
    parse common/notify/device config capabilities
    for each queue:
        qsz = read queue size from common config
        vq = vring_create_virtqueue(qsz, ...)
        write desc/avail/used DMA addresses to common config
        assign MSI-X vector if available
        map notify BAR offset
        enable queue
    return struct virtqueue* handles to frontend
transport는 data payload를 해석하지 않는다

virtio-pci는 block request header나 network packet layout을 모른다. Transport가 아는 것은 queue index, queue memory address, vector, notify offset, feature/status/config access뿐이다. 이 강한 분리가 virtio device family를 확장 가능하게 만든다.

Transport abstraction은 virtio의 portability를 만든다. 같은 virtio-net driver가 PCI VM device, MMIO embedded setup, s390 CCW environment에서 conceptual하게 같은 virtqueue API를 쓸 수 있다. Source의 virtio_config_ops는 이 portability의 vtable이다.

12. virtio-blk case study: block request가 descriptor chain이 되는 과정

Virtio-blk는 virtqueue abstraction을 이해하기 좋은 driver다. Block layer는 request를 만들고, virtio-blk는 그 request를 virtio block protocol의 header, data buffer, status byte로 바꾼다. Source의 virtblk_add_req()는 이 shape를 SG list로 구성하고 virtqueue_add_sgs()를 호출한다.

전형적인 read request를 생각해 보자. Driver는 out header에 “read” command와 sector 정보를 쓴다. Data buffer는 device가 disk data를 채워야 하므로 in descriptor다. Status byte도 device가 결과를 써야 하므로 in descriptor다. Write request에서는 data buffer가 device-readable out descriptor가 되고, status는 여전히 in descriptor다. 따라서 request type에 따라 SG direction이 달라진다.

virtio-blk의 descriptor chain은 보통 세 conceptual part를 가진다. [out header] [optional data descriptors] [in status]다. 이 simple format은 device parser를 단순하게 만든다. Header는 command metadata, data는 payload, status는 completion result다. Driver는 status byte를 completion path에서 확인해 block layer error로 변환한다.

Block layer integration에서는 blk-mq가 중요하다. 여러 hardware queue와 CPU affinity를 활용할 수 있고, virtio-blk는 virtqueue per queue 또는 multi-queue feature를 통해 parallelism을 얻는다. 각 virtqueue는 independent ring이므로 lock contention과 cache contention을 줄일 수 있다. Device/backend가 multi-queue를 제대로 처리하면 throughput이 좋아진다.

virtio_commit_rqs()류 path는 batching의 예다. 여러 request가 queue에 올라간 뒤 kick decision을 하고 notify를 보낸다. Block workload에서는 request merge와 scheduler/bio batching 때문에 여러 operation이 burst로 들어오기 쉽다. 매 request마다 notify하지 않는 것이 중요하다.

Completion path에서는 used entry가 돌아오고 token으로 request object를 찾은 뒤, status byte를 확인하고 blk-mq completion을 호출한다. Device가 data buffer에 쓴 length와 status를 함께 고려해야 한다. Request object lifetime은 submit 후 completion까지 유지되어야 하며, descriptor unmap 후 upper-layer completion이 안전하게 진행된다.

Virtio-blk가 보여주는 핵심은 device-specific driver가 ring detail을 거의 모른다는 점이다. Driver는 request semantics를 SG list로 바꾼다. Ring implementation은 descriptor allocation, DMA mapping, barrier, notification을 처리한다. Transport는 queue setup과 notify를 처리한다. 이 layering이 유지되므로 virtio-blk code는 block semantics에 집중할 수 있다.

virtio-blk request shape (conceptual)

READ:
  out:  virtio_blk_outhdr {type=READ, sector=...}
  in:   data buffer(s)       // device writes disk data
  in:   status byte          // device writes result

WRITE:
  out:  virtio_blk_outhdr {type=WRITE, sector=...}
  out:  data buffer(s)       // device reads data to store
  in:   status byte

Then:
  virtqueue_add_sgs(vq, [out_sgs..., in_sgs...], request_token)
  maybe kick
  completion returns request_token; driver checks status
왜 status byte를 별도 in descriptor로 두는가?

Device가 data payload와 별개로 command result를 driver에게 써야 한다. Status를 별도 writable buffer로 두면 request header는 immutable input이 되고, result channel은 명확히 device-owned가 된다. Ownership boundary가 깔끔하다.

Research 관점에서 virtio-blk는 asynchronous block I/O ABI의 minimal form처럼 볼 수 있다. Command header, scatter-gather payload, status buffer, completion token이면 상당히 많은 storage protocol을 표현할 수 있다. NVMe처럼 richer queue semantics를 가진 protocol과 비교하면 virtio-blk의 simplicity와 transport independence가 장점이자 한계다.

13. virtio-net case study: packet, RX buffer, NAPI, callback suppression

Virtio-net은 virtqueue가 network driver의 interrupt mitigation과 어떻게 결합되는지 보여준다. Network device는 TX queue와 RX queue를 갖고, multi-queue feature가 있으면 여러 pair를 사용할 수 있다. TX는 sk_buff payload를 device-readable descriptors로 publish하고, RX는 device가 packet을 써 넣을 empty buffer를 미리 in descriptors로 post한다.

RX path의 중요한 차이는 driver가 request를 “보내는” 것이 아니라 receive buffer를 “공급”한다는 점이다. Device가 packet을 받으면 이미 available한 in buffer에 packet data를 쓰고 used entry를 publish한다. Driver는 completion으로 buffer token을 되찾고 packet을 stack에 올린 뒤, 새 RX buffer를 다시 queue에 넣는다. 따라서 RX queue는 buffer pool management와 밀접하다.

Virtio-net은 NAPI와 잘 맞는다. Interrupt가 오면 callback이 callback을 disable하고 NAPI를 schedule한다. NAPI poll은 budget 안에서 여러 used entry를 drain한다. 완료 후 callback을 re-enable할 때 새 completion이 생겼는지 확인한다. 이 pattern은 interrupt storm을 줄이고 packet burst를 cache-friendly하게 처리한다.

TX path에서는 skb를 descriptor chain으로 만들고 queue에 넣은 뒤 notify한다. Completion은 device가 skb buffer를 더 이상 읽지 않는다는 의미다. 그 시점에 skb를 free하거나 upper-layer queue accounting을 갱신할 수 있다. TX completion을 너무 늦게 drain하면 queue가 full로 보이고 netdev queue가 stop될 수 있다.

virtio-net은 feature negotiation의 영향도 크다. Checksum offload, GSO/TSO, mergeable RX buffer, control virtqueue, RSS, multiqueue 등 다양한 feature가 packet header format과 queue behavior를 바꾼다. 그러나 이러한 feature도 결국 SG descriptor와 queue token/completion mechanism 위에 얹힌다.

Source에서 virtqueue_disable_cb, virtqueue_enable_cb_prepare, virtqueue_poll이 함께 쓰이는 부분은 lost wakeup avoidance의 textbook example다. NAPI가 ring을 drain한 뒤 interrupt를 다시 켜려는 순간 device가 새 packet completion을 publish하면, enable 직후 poll check가 이를 잡아야 한다. 그렇지 않으면 packet이 ring에 있는데 interrupt가 오지 않는 상태가 가능해진다.

Virtio-net case study는 virtqueue가 단순 I/O request queue가 아니라 producer-consumer coordination primitive임을 보여준다. RX에서는 driver가 empty buffers를 produce하고 device가 packets를 produce하는 것처럼 보이지만, descriptor ownership 기준으로 보면 driver가 descriptor chain을 available하게 만들고 device가 used로 돌려주는 동일한 protocol이다.

virtio-net conceptual paths

TX:
  skb -> virtio net header + packet fragments
  out descriptors -> available ring
  kick device
  used completion -> skb can be freed / queue accounting updated

RX:
  allocate page/skb buffer
  in descriptor -> available ring (empty receive buffer)
  device writes packet into buffer
  used completion -> build skb / pass to network stack
  post replacement RX buffer

NAPI:
  interrupt callback disables virtqueue callbacks
  napi_schedule()
  poll drains used ring up to budget
  enable callbacks; poll again if race observed
RX buffer pre-posting

Network receive는 “device에게 읽을 command를 보낸다”가 아니다. Driver가 writable memory를 미리 제공하고, device가 packet arrival 시 그 memory를 채운다. Virtqueue의 in descriptor가 receive buffer ownership transfer를 표현한다.

virtio-net의 performance는 ring size, RX buffer size, mergeable buffer policy, page_pool usage, NAPI budget, notification suppression, host backend(vhost-net 등)에 크게 좌우된다. 그러나 guest-side abstraction의 center는 여전히 virtqueue다. 이 점이 virtio-net을 여러 backend와 transport에서 재사용 가능하게 한다.

14. packed ring, indirect descriptor, in-order feature

Split ring은 이해하기 쉽지만 memory touch가 흩어진다. Packed ring은 descriptor, available state, used state를 하나의 descriptor ring entry 안의 flag protocol로 통합한다. Driver와 device가 같은 ring을 보면서 avail/used bit와 wrap counter를 사용해 ownership을 구분한다. 이 방식은 cache locality와 PCIe transaction 수를 줄일 수 있지만 protocol이 복잡하다.

Packed descriptor에는 address, length, id, flags가 있고, flags 안의 avail/used 의미가 wrap counter와 결합된다. 단순히 “avail bit가 1이면 available”로 볼 수 없다. Ring이 wrap될 때 같은 bit pattern이 반복되므로 wrap counter를 함께 해석해야 한다. Linux vring_virtqueue_packed state는 next avail/used index와 wrap counter를 관리한다.

Packed ring은 public virtqueue API를 바꾸지 않는다. Driver는 여전히 SG list를 add하고 kick하며 completion token을 받는다. 이 사실이 abstraction의 힘을 보여준다. Ring layout을 바꾸는 optimization이 device-specific driver의 semantics layer로 새어 나가지 않는다. 물론 feature negotiation에서 packed ring support가 negotiated되어야 한다.

Indirect descriptor는 split/packed 모두에서 queue pressure를 줄이는 중요한 optimization이다. 많은 SG segment가 필요한 request를 ring descriptor 여러 개로 직접 소비하는 대신, memory에 indirect descriptor table을 만들고 ring에는 그 table을 가리키는 descriptor 하나를 넣을 수 있다. Queue entry 하나로 long SG chain을 표현할 수 있으므로 descriptor exhaustion이 줄어든다.

Indirect descriptor의 trade-off도 있다. Indirect table allocation과 DMA mapping overhead가 생기고, device는 pointer indirection을 따라가야 한다. 작은 request에서는 direct descriptor가 더 빠를 수 있다. Linux는 SG count, feature availability, allocation success 등을 고려해 indirect를 선택한다. 이 선택은 “ring resource pressure vs per-request overhead” trade-off다.

IN_ORDER feature는 device가 descriptor를 submission order대로 consume/complete한다는 stronger property를 제공할 수 있다. 이 feature가 있으면 일부 bookkeeping을 줄이거나 completion interpretation을 단순화할 수 있다. 그러나 일반 virtio implementation은 out-of-order completion 가능성을 고려해야 하며, used entry id로 head descriptor를 식별한다.

Packed ring, indirect descriptor, in-order는 모두 virtio의 extensibility를 보여준다. Base protocol은 descriptor ownership transfer이고, feature bits가 stronger guarantees 또는 optimized layouts를 추가한다. Linux implementation은 이러한 feature를 common ring layer에 흡수해 frontend driver complexity를 제한한다.

Packed ring intuition

split ring:
  desc table + avail ring + used ring

packed ring:
  ring[i] = {addr, len, id, flags}
  flags encode:
      descriptor chaining
      writable direction
      avail/used ownership with wrap counters

Benefit:
  fewer separate ring areas and potentially better cache/PCIe behavior
Cost:
  more subtle flag/wrap protocol
왜 packed ring이 driver API를 바꾸지 않는가?

Public API는 “buffer chain을 queue에 넣고 completion token을 받는다”다. Split/packed는 그 public operation의 encoding 차이다. 이 separation 덕분에 ring layout optimization이 device-specific driver rewrite로 이어지지 않는다.

Source를 읽을 때는 split path를 먼저 완전히 이해한 뒤 packed path로 넘어가는 것이 좋다. Packed path는 같은 concept을 더 compressed한 state machine으로 구현한다. 특히 wrap counter와 event suppression을 동시에 추적해야 하므로, “producer index, consumer index, ownership bit, wrap bit” 네 축을 표로 그리면서 읽는 편이 안전하다.

15. reset, teardown, admin queue, live migration 관점

Reset과 teardown은 hot path보다 덜 화려하지만 correctness상 중요하다. Virtqueue에 outstanding descriptor가 있는데 device를 reset하거나 driver module을 unload하면, device가 더 이상 memory를 access하지 않는다는 guarantee가 필요하다. Linux virtio_reset_device()와 transport reset path는 이 quiescence를 달성하기 위한 synchronization boundary다.

Transport reset은 status를 zero로 만들고, device가 reset을 observe할 때까지 기다릴 수 있다. PCI modern path에서는 status readback과 vector synchronization 등이 포함된다. Reset 후에는 queue memory와 callback state를 안전하게 free하거나 재설정할 수 있다. 이 과정이 없으면 device/backend가 freed memory에 DMA하는 use-after-free가 발생할 수 있다.

Virtqueue deletion은 del_vqs transport op를 통해 수행된다. Transport는 interrupt/vector mapping을 해제하고, ring memory와 private state를 정리한다. Device-specific driver의 remove path는 upper-layer queue를 stop하고 outstanding I/O를 정리한 뒤 virtqueue를 삭제해야 한다. Ordering이 어긋나면 completion callback이 이미 free된 request object를 만질 수 있다.

Admin queue는 modern virtio ecosystem에서 device management operation을 위한 별도 queue로 등장했다. 이 보고서의 중심은 data virtqueue지만, admin queue는 virtio가 data path 외 management operation도 queue-based ABI로 확장하고 있음을 보여준다. PCI modern source에도 admin vq와 관련된 cleanup/activation path가 보인다.

Live migration 관점에서는 virtio의 explicit queue state가 장점이자 과제다. Descriptor ring, avail/used index, feature set, device config, in-flight request state를 host가 migrate해야 할 수 있다. Guest driver는 보통 migration을 직접 알지 못한다. Host/backend가 virtio ABI를 유지하면서 device state를 옮겨야 한다.

vhost와 vDPA는 guest virtio ABI를 유지한 채 backend를 더 효율적인 execution context로 옮긴다. vhost-net은 QEMU userspace 대신 host kernel에서 virtqueue를 consume할 수 있고, vDPA는 hardware/DPU가 virtio datapath를 처리할 수 있다. Guest Linux driver 입장에서는 여전히 virtqueue에 descriptor를 publish한다. 이 backend transparency가 virtio ABI의 경제성을 만든다.

Teardown/recovery research에서 virtio는 좋은 model이다. Shared-memory queue를 사용하는 heterogeneous agents 사이에서, “언제 memory access가 멈췄다고 확신할 수 있는가?”가 핵심이기 때문이다. Reset status protocol, callback synchronization, DMA unmap ordering, request cancellation이 함께 맞아야 한다.

Teardown safety sketch

stop upper-layer submission
    -> disable callbacks / stop queues
    -> drain or fail outstanding requests
    -> reset device (device stops interrupts and memory access)
    -> synchronize callbacks/vectors
    -> unmap/free descriptor chains and ring memory
    -> del_vqs / unregister device state
reset은 memory safety primitive

Virtio reset을 단순한 status clear로 보면 안 된다. Driver가 queue memory를 free하려면 device/backend가 더 이상 그 memory를 access하지 않는다는 synchronization guarantee가 필요하다. Reset은 그 guarantee를 얻기 위한 protocol step이다.

이 장의 practical advice는 remove/error path를 hot path만큼 꼼꼼히 읽으라는 것이다. Virtio bug 중 많은 부분은 정상 submission/completion보다 reset, timeout, migration, hot unplug, queue resize 같은 uncommon path에서 나온다.

16. 왜 이런 구현인가: design rationale 정리

Linux virtio implementation의 가장 큰 design choice는 “control plane과 data plane의 분리”다. Control plane은 status, feature negotiation, config access, queue address setup, notification vector assignment를 다룬다. Data plane은 shared-memory descriptor ring을 통해 buffer ownership을 이동시킨다. 이 분리는 emulated register protocol보다 훨씬 적은 per-I/O control traffic으로 bulk I/O를 처리하게 한다.

두 번째 choice는 “transport와 device semantics의 orthogonality”다. Virtio-blk와 virtio-net은 PCI register layout을 알지 않는다. Virtio-pci는 block request header나 net offload semantics를 알지 않는다. 이 분리는 code reuse뿐 아니라 ABI evolution에도 중요하다. 새로운 transport나 backend를 추가할 때 device-specific driver를 크게 바꾸지 않아도 된다.

세 번째 choice는 “ring layout을 common implementation으로 숨기는 것”이다. Split ring, packed ring, indirect descriptor, event idx, DMA mapping, barrier는 모두 virtio_ring.c에 집중된다. Device-specific driver는 SG list와 token을 제공한다. 이 구조가 없었다면 각 driver가 barrier, descriptor allocation, event suppression을 중복 구현하고 subtle bug를 양산했을 것이다.

네 번째 choice는 “feature bit 기반 evolution”이다. Virtio는 single fixed ABI가 아니라 negotiated ABI다. Device가 제공하고 driver가 이해하는 feature의 교집합으로 runtime contract가 정해진다. Linux core가 negotiation을 중앙화하고 transport feature를 filtering하는 이유는, 이 runtime contract가 data path correctness를 결정하기 때문이다.

다섯 번째 choice는 “payload copy 대신 descriptor DMA”다. Ring에는 data 자체가 아니라 address/length metadata가 들어간다. 큰 packet이나 block data를 copy하지 않고 device/backend가 직접 읽고 쓸 수 있다. 단, 그 대가로 DMA mapping, cache coherency, IOMMU, memory ordering complexity가 들어온다. Linux implementation은 이 complexity를 common layer에 넣는다.

여섯 번째 choice는 “notification as hint + batching”이다. Virtio는 every-operation synchronous notify model이 아니다. Descriptor publish와 device notification을 분리하고, event idx와 callback suppression으로 wakeup을 줄인다. 이 design은 high-throughput workload에서 결정적이며, network NAPI와 block batching에 잘 맞는다.

일곱 번째 choice는 “Linux driver model에 편입”이다. Virtio bus와 driver registration은 normal device lifecycle을 제공한다. Hypervisor-specific hack이 아니라 kernel subsystem으로 들어왔기 때문에, hotplug, module, PM, freeze/restore, sysfs integration이 가능하다. 이것은 장기 유지보수성에 중요하다.

마지막으로, Linux implementation은 backward compatibility를 매우 중시한다. Legacy virtio, transport quirks, DMA API quirk, old feature combinations이 남아 있다. Source가 교과서적인 minimal implementation보다 복잡한 이유다. 그러나 이 복잡성은 deployed guests/devices/hypervisors와의 compatibility 비용이다.

구현 선택 이유
Control/data plane split Per-I/O register emulation을 줄이고 descriptor bulk protocol 사용
Transport/device split PCI/MMIO/CCW와 block/net semantics를 독립적으로 evolve
Common ring layer Barrier, DMA, notification, split/packed support를 중앙화
Feature negotiation Forward/backward compatibility와 optional optimization
Descriptor DMA Payload copy 제거, SG support, IOMMU compatibility
Notification batching VM exit/doorbell/interrupt overhead 감소
Driver model integration Linux lifecycle와 subsystem integration 확보
source가 복잡한 이유

Virtio source가 어려운 이유는 data structure 자체보다 “compatibility × DMA × memory ordering × transport × feature negotiation”의 cross-product 때문이다. 이 complexity를 device-specific driver 밖으로 밀어낸 것이 Linux virtio abstraction의 핵심 성과다.

이 장의 관점에서 보면 virtio는 OS design의 좋은 사례다. Fast path는 최소한의 shared-memory metadata로 구성하고, slow/control path는 explicit negotiation/state machine으로 안전하게 만든다. Abstraction boundary는 performance를 해치지 않을 정도로 얇지만, feature evolution과 transport portability를 가능하게 할 정도로 충분히 강하다.

17. invariants, pitfalls, debugging strategy

Virtio debugging에서 가장 먼저 확인할 invariant는 descriptor ownership이다. Driver가 available ring에 head를 publish하기 전에는 device가 descriptor chain을 consume하면 안 된다. Device가 used ring에 completion을 publish하기 전에는 driver가 descriptor chain을 recycle하면 안 된다. 이 invariant가 깨지면 data corruption이나 use-after-free가 발생한다.

두 번째 invariant는 out descriptors가 in descriptors보다 앞에 와야 한다는 점이다. Virtio spec은 device-readable elements가 device-writable elements보다 먼저 오도록 요구한다. Virtio-blk의 header/data/status format도 이 ordering에 의존한다. Driver가 SG direction을 잘못 분류하면 device parser가 틀린 memory를 읽거나 쓸 수 있다.

세 번째 invariant는 barrier/idx order다. Descriptor와 avail slot을 쓴 뒤 idx를 publish해야 한다. Used entry를 읽기 전에 used idx observe 이후의 read ordering이 필요하다. Weak memory architecture에서만 문제가 보인다고 생각하면 위험하다. Virtualization/backend scheduling 때문에 rare race가 훨씬 늦게 나타날 수 있다.

네 번째 invariant는 token lifetime이다. data token은 completion 전까지 valid해야 하고, completion 후에는 double completion이 없어야 한다. If reset/error path가 outstanding descriptors를 fail시키는 경우 token을 어떻게 반환/해제하는지도 정해야 한다. Many bugs는 normal completion이 아니라 timeout/reset/remove path에서 token lifetime을 어기며 나온다.

다섯 번째 invariant는 notification lost wakeup avoidance다. Callback을 disable한 뒤 drain하고 다시 enable할 때, enable 직후 poll/check를 수행해야 한다. Virtio-net NAPI path처럼 standard pattern을 따라야 한다. “interrupt가 올 것이다”라고 믿으면 suppress state와 race 때문에 ring에 completion이 남아도 잠들 수 있다.

Debugging할 때는 /sys/bus/virtio/devices, lspci, kernel config, tracepoints/ftrace, dynamic debug, perf, eBPF/kprobe 등을 함께 사용할 수 있다. Guest source만 보지 말고 host backend(QEMU/vhost) log도 같이 보면 notification이 실제로 전달되는지, queue index가 맞는지, feature set이 어떻게 negotiated됐는지 확인할 수 있다.

Performance debugging에서는 queue depth, SG segment count, indirect descriptor usage, notification count, interrupt rate, NAPI budget, IOMMU mapping overhead를 분리해서 봐야 한다. Virtio는 abstraction이 잘 되어 있지만 bottleneck은 어느 layer에서도 생길 수 있다. 예를 들어 virtio-blk throughput 문제는 block scheduler/queue depth일 수도 있고, host storage backend일 수도 있으며, virtqueue notification policy일 수도 있다.

증상 우선 의심할 것
Queue stuck notify lost, callbacks disabled, backend not consuming, feature mismatch
ENOSPC / queue full completion drain 부족, descriptor leak, indirect disabled
Data corruption DMA direction/map bug, barrier/order bug, buffer lifetime bug
High CPU overhead too many notifications, small batch, IOMMU mapping overhead
Rare crash on unplug/reset callback synchronization/order, outstanding token lifetime
Bad performance after migration backend state, queue affinity, feature/config mismatch
source reading checklistfeature negotiation 결과가 어떤 path를 켜는가? 2) queue가 어디서 생성되고 callback은 누구인가? 3) SG direction/order가 맞는가? 4) add path에서 publish point는 어디인가? 5) completion path에서 token은 언제 free되는가? 6) callback enable race를 닫았는가?

Virtio debugging은 distributed systems debugging과 닮았다. Driver와 device/backend가 shared memory와 notification으로 coordination한다. 한쪽 log만 보면 event order를 잘못 해석하기 쉽다. Ring index와 status bit를 timeline으로 그리는 습관이 도움이 된다.

18. research 관점의 open questions와 확장 아이디어

Virtio는 이미 mature한 standard지만, 연구 질문이 사라진 것은 아니다. 첫 번째 질문은 formal verification이다. Virtqueue protocol은 descriptor ownership, idx wraparound, event notification, reset synchronization을 포함한다. 이 state machine을 TLA+/Ivy/Coq/Lean 등으로 model하고 Linux implementation의 assumptions와 비교하는 연구가 가능하다.

두 번째 질문은 memory ordering synthesis다. Virtio barrier는 architecture와 transport에 따라 cost가 다르다. 어떤 barrier가 necessary/sufficient인지, feature/transport별로 더 약한 ordering을 사용할 수 있는지, device/backend memory model과 어떻게 compose되는지 분석할 수 있다. 특히 heterogeneous device/DPU 환경에서는 기존 VM-only assumption이 약해진다.

세 번째 질문은 IOMMU/DMA mapping overhead다. Virtio는 payload copy를 피하지만 DMA mapping cost가 커질 수 있다. Persistent mapping, batched mapping, page_pool, zero-copy, bounce buffer policy가 workload별로 어떤 trade-off를 갖는지 측정할 수 있다. vDPA/hardware virtio에서는 IOMMU policy가 performance와 isolation을 동시에 좌우한다.

네 번째 질문은 notification scheduling이다. Event idx와 NAPI/batching은 heuristic policy를 필요로 한다. Latency SLO와 throughput을 동시에 만족하기 위해 adaptive notification policy를 설계할 수 있다. Host/backend queue state와 guest driver state를 어떻게 feedback loop로 연결할지가 문제다.

다섯 번째 질문은 multi-queue scaling과 NUMA locality다. Virtio-net/blk multiqueue는 CPU affinity와 backend thread placement에 민감하다. Guest vCPU, host pCPU, backend thread, IOMMU domain, memory NUMA node가 어긋나면 queue가 많아도 scale하지 않을 수 있다. Cross-layer placement policy가 연구 대상이다.

여섯 번째 질문은 failure/recovery semantics다. Reset, hot unplug, migration, backend crash, timeout 상황에서 outstanding descriptor를 어떻게 recover할지 더 명확한 contract가 필요할 수 있다. Guest와 host가 각각 어떤 memory access를 멈췄는지, in-flight data의 fate가 무엇인지 protocol-level로 표현하는 방법도 연구할 만하다.

일곱 번째 질문은 typed virtqueue abstraction이다. 현재 Linux API는 void *data, SG list, direction count를 사용한다. Rust-for-Linux나 safer C abstraction 관점에서 descriptor chain lifetime, DMA direction, callback synchronization을 type/state로 encode할 수 있을까? Virtio는 systems language safety 연구에도 좋은 target이다.

이 보고서의 source walk는 이러한 연구 질문의 foundation이다. Performance 논문을 쓰든, verification을 하든, driver rewrite를 하든, 먼저 Linux가 실제로 abstraction boundary를 어디에 뒀고 어떤 compatibility cost를 치르는지 이해해야 한다.

PhD-level takeaway

virtio의 재미는 “ring buffer를 쓴다”가 아니라, ring buffer가 Linux driver model, DMA/IOMMU, weak memory, hypervisor/device backend, feature negotiation, notification scheduling과 만나는 지점에 있다. 좋은 research problem은 대개 이 접점에서 나온다.

Appendix A. Source map: 어디를 읽을 것인가

Source 읽을 symbol 왜 중요한가
drivers/virtio/virtio.c virtio_bus, virtio_dev_probe, virtio_features_ok, virtio_reset_device bus registration, driver binding, feature negotiation, status/reset lifecycle
include/linux/virtio.h struct virtqueue, struct virtio_device, struct virtio_driver, public virtqueue APIs frontend driver가 보는 object와 API surface
include/linux/virtio_config.h struct virtio_config_ops, virtio_find_vqs, config helpers transport vtable과 helper layer
drivers/virtio/virtio_ring.c vring_virtqueue, virtqueue_add_split, virtqueue_get_buf_ctx_split, packed/split ops ring layout implementation, DMA mapping, barrier, notification
include/uapi/linux/virtio_ring.h vring_desc, flags, avail/used layout UAPI-visible ring data structures
drivers/virtio/virtio_pci_modern.c vp_finalize_features, setup_vq, vp_modern_find_vqs, notify ops PCI modern transport, common config, queue activation
drivers/block/virtio_blk.c virtblk_add_req, virtio_queue_rq, completion path block layer request를 virtqueue SG chain으로 변환
drivers/net/virtio_net.c TX/RX queue setup, NAPI callbacks, virtqueue_disable_cb pattern network stack integration과 interrupt mitigation
OASIS Virtio 1.3 spec Chapter 2 virtqueues, initialization sequence, device type chapters normative protocol definition
docs.kernel.org virtio docs Virtio on Linux, Writing Virtio Drivers Linux-oriented conceptual overview와 kernel-doc links

추천 읽기 순서는 docs.kernel.org/driver-api/virtio/virtio.html -> include/linux/virtio.h -> include/linux/virtio_config.h -> drivers/virtio/virtio.c -> drivers/virtio/virtio_ring.c split path -> virtio_blk.c 또는 virtio_net.c -> packed path/PCI modern이다. 처음부터 packed path나 transport quirks로 들어가면 core abstraction을 놓치기 쉽다.

Appendix B. End-to-end I/O sequence pseudo-code

End-to-end virtio-blk READ (conceptual)

1. Boot/probe/control plane
   PCI transport discovers virtio device
   register_virtio_device()
   virtio bus matches virtio-blk driver
   virtio_dev_probe(): status/feature negotiation
   virtio-blk probe(): read config, create virtqueues
   transport writes desc/avail/used addresses to device
   DRIVER_OK set

2. Submit request
   block layer calls virtio_queue_rq()
   virtblk builds out header with sector/type
   data pages are in descriptors (device writable for READ)
   status byte is in descriptor (device writable)
   virtqueue_add_sgs() maps SGs and fills descriptor chain
   avail ring head is written
   memory barrier
   avail idx is incremented
   kick_prepare decides notification
   virtqueue_notify rings device doorbell if needed

3. Device/backend
   observes avail idx
   reads descriptor chain
   reads command header
   writes disk data into data buffer
   writes status byte
   writes used ring entry {id=head, len=...}
   increments used idx
   maybe interrupts guest

4. Completion
   guest interrupt -> vring_interrupt -> virtqueue callback
   virtqueue_get_buf_ctx() observes used idx
   memory barrier before used entry/data consumption
   detaches descriptor chain and unmaps DMA
   returns request token
   virtio-blk checks status
   blk_mq completes request

이 pseudo-code에서 avail idxused idx는 각각 driver->device, device->driver 방향의 commit point다. Notify/interrupt는 commit point 자체가 아니라 commit point를 보라고 알려주는 hint다. 이 구분이 virtio race를 이해하는 데 매우 중요하다.

Appendix C. Glossary

Term 설명
virtio Virtual I/O device family protocol. Guest driver와 device/backend 사이의 standard ABI.
transport PCI/MMIO/CCW처럼 device discovery, config, notification을 제공하는 layer.
virtqueue Driver와 device가 buffer descriptor ownership을 교환하는 queue abstraction.
vring Virtqueue를 ring memory layout으로 구현한 구조. Split/packed format이 있다.
descriptor Buffer address, length, flags, next를 담는 metadata entry.
available ring Driver가 device에게 처리 가능한 descriptor head를 publish하는 split ring area.
used ring Device가 driver에게 완료된 descriptor head를 publish하는 split ring area.
SG list Scatter-gather segments. 하나의 logical I/O가 여러 memory region을 가리킬 수 있다.
indirect descriptor Ring descriptor 하나가 별도 descriptor table을 가리키는 optimization.
event idx Notification suppression을 더 정밀하게 하기 위한 index-based mechanism.
DRIVER_OK Driver가 initialization을 완료했고 device가 data path를 시작해도 된다는 status.
DMA mapping CPU memory object를 device가 접근할 수 있는 DMA address/permission으로 변환하는 과정.
vhost Guest virtio ABI를 유지하면서 host kernel 등에서 backend를 실행하는 mechanism.
vDPA Virtio datapath를 hardware/DPU 쪽으로 offload할 수 있게 하는 architecture.

Appendix D. Micro-invariants by source function

virtio_dev_probe()

Status DRIVER bit를 set하고, feature intersection을 만들며, driver validate/probe와 FEATURES_OK/DRIVER_OK transition을 orchestrate한다. 이 함수의 invariant는 “driver probe가 data path를 만들기 전 feature contract가 확정되어야 한다”이다.

virtio_features_ok()

FEATURES_OK를 set한 뒤 device가 그 status를 유지하는지 확인한다. Invariant는 “device가 reject한 feature set으로 queue setup을 진행하지 않는다”이다.

virtio_find_vqs() / transport find_vqs

Device-specific driver가 필요한 queue 수와 callback/name을 전달한다. Transport는 queue size/address/vector/notify setup을 수행한다. Invariant는 “callback과 queue memory가 준비된 뒤 queue가 enable되어야 한다”이다.

vring_create_virtqueue()

Ring memory와 private vring_virtqueue state를 만든다. Split/packed, DMA policy, free list, notify callback이 이 지점에서 결합된다.

virtqueue_add_split()

SG list를 descriptor chain으로 만들고 avail ring에 head를 publish한다. Invariant는 “descriptor fields와 avail slot이 visible한 뒤 idx가 증가해야 한다”이다.

virtqueue_kick_prepare_split()

Notification suppression state를 읽어 device notify 여부를 결정한다. Invariant는 “idx publish와 event/flags read ordering이 wakeup decision에 충분해야 한다”이다.

virtqueue_notify()

Transport-specific notify를 수행한다. Invariant는 “notify는 publish 이후에만 의미가 있으며, notify 실패/return semantics를 driver가 이해해야 한다”이다.

virtqueue_get_buf_ctx_split()

Used ring에서 completion을 읽고 descriptor chain을 detach한다. Invariant는 “used idx observe 후 used entry와 DMA-written payload visibility가 보장되어야 한다”이다.

virtqueue_disable_cb() / enable_cb_prepare()

Callback/interrupt suppression을 driver policy와 연결한다. Invariant는 “enable 직후 race를 poll/check로 닫아 lost wakeup을 막는다”이다.

virtblk_add_req()

Block request를 header/data/status SG chain으로 encode한다. Invariant는 “device-readable header/data와 device-writable status direction이 request type에 맞아야 한다”이다.

virtio-net NAPI poll path

Used packet completions를 budget 안에서 drain하고 callback을 재-enable한다. Invariant는 “ring drain과 interrupt re-enable 사이에 packet이 남지 않도록 race check한다”이다.

vp_modern_find_vqs() / setup_vq()

PCI modern common config에 queue size/address/vector/notify 정보를 설정한다. Invariant는 “queue enable은 setup이 끝난 뒤 수행되어야 한다”이다.

vp_reset()

Status reset과 vector/callback synchronization을 수행한다. Invariant는 “reset 이후 device/backend가 이전 queue memory를 access하지 않아야 한다”이다.

Appendix E. References

Appendix F. Source-by-source close reading notes

이 appendix는 source를 실제로 열고 읽을 때 “무엇을 질문해야 하는지”에 초점을 둔다. 각 source file은 한 가지 abstraction boundary를 대표한다. virtio.c는 lifecycle, virtio_ring.c는 shared-memory queue, virtio_pci_modern.c는 transport, virtio_blk.cvirtio_net.c는 device-specific semantics다.

F.1 drivers/virtio/virtio.c: lifecycle을 소유하는 core

virtio.c를 읽을 때 첫 질문은 “device-specific driver의 probe가 호출되기 전에 어떤 global invariant가 성립하는가?”다. Core는 DRIVER status를 set하고 feature bitmap을 intersect하며, transport feature와 device feature를 함께 정리한다. 이 단계가 끝나야 driver는 자신이 어떤 ABI로 device와 대화할지 안다.

virtio_dev_probe()는 bus-level dispatcher 이상의 역할을 한다. Driver의 feature table을 읽고, device feature를 가져오고, unsupported bit를 제거하고, validate/finalize/FEATURES_OK sequence를 수행한다. 즉 probe는 단순 callback invocation이 아니라 negotiated protocol contract construction이다.

Error path를 함께 읽어야 한다. Feature negotiation 실패, validate 실패, driver probe 실패는 모두 status FAILED 또는 cleanup path로 이어진다. 정상 path만 보면 virtio가 쉬워 보이지만, 실제 driver reliability는 실패 path에서 queue가 생성되었는지, callback이 enable되었는지, status가 어디까지 갔는지에 달려 있다.

Reset helper의 comment와 implementation은 특히 중요하다. Reset은 device status를 0으로 만드는 operation이지만, driver 입장에서는 “device가 더 이상 interrupt와 memory access를 하지 않는다”는 assumption을 얻는 지점이다. 이 assumption 없이는 ring memory와 request object를 free할 수 없다.

F.2 include/linux/virtio.h: public surface를 작게 유지하기

virtio.h의 public struct를 보면 Linux가 frontend driver에게 무엇을 숨기고 무엇을 보여주는지 알 수 있다. struct virtqueue에는 descriptor table pointer가 없다. 대신 callback, name, index, free count, private pointer가 있다. 이는 driver가 queue resource와 callback은 알아야 하지만 ring encoding은 몰라도 된다는 설계다.

struct virtio_driver는 Linux device_driver를 embed하고 feature arrays와 lifecycle callbacks를 가진다. Device-specific driver는 자신이 요구하거나 optional로 지원하는 feature를 선언하고, probe/remove/config_changed/freeze/restore callback을 제공한다. Feature declaration이 driver binary와 ABI negotiation을 연결한다.

Public virtqueue function list는 abstraction의 “verb set”이다. add, kick, get_buf, disable/enable callback, resize/reset. 이 verb set이 변하지 않으면 split ring에서 packed ring으로 바뀌어도 frontend driver는 크게 흔들리지 않는다. 그래서 public API는 virtio architecture의 stability anchor다.

다만 public surface가 작다고 해서 driver responsibility가 작은 것은 아니다. Driver는 SG lifetime, out/in ordering, token lifetime, queue full handling, callback context constraints를 지켜야 한다. Virtqueue API는 descriptor protocol을 감추지만 asynchronous I/O programming discipline을 대신해 주지는 않는다.

F.3 include/linux/virtio_config.h: transport vtable

virtio_config_ops는 transport의 capabilities를 함수 pointer로 제공한다. Config field access, status access, reset, feature get/finalize, virtqueue discovery, queue deletion이 여기 모인다. 이 vtable을 보면 transport layer가 담당하는 scope가 명확하다.

find_vqs는 특히 중요하다. Device-specific driver는 callback/name array와 queue count를 넘기고, transport는 실제 queue memory와 interrupt vector, notify path를 준비한다. Queue creation이 driver와 transport의 rendezvous point다.

Config operations는 device-specific config space를 읽는 데도 쓰인다. 예를 들어 virtio-blk는 capacity 같은 정보를 읽고, virtio-net은 MAC/config feature 관련 정보를 읽는다. 그러나 access mechanism은 transport가 제공하므로 frontend driver는 PCI config capability나 MMIO offset을 직접 다루지 않는다.

이 vtable 구조는 new transport를 추가할 때 필요한 contract도 보여준다. 새 transport는 status/feature/config/queue/notify semantics를 virtio_config_ops로 구현하면, 기존 virtio-blk/net driver를 재사용할 수 있다. 이것이 virtio portability의 coding interface다.

F.4 drivers/virtio/virtio_ring.c: hot path와 compatibility가 만나는 곳

virtio_ring.c는 virtio source에서 가장 density가 높은 file이다. Split/packed ring, DMA mapping policy, event idx, indirect descriptor, callback suppression, descriptor free list, debug checks가 모두 들어 있다. 이 file을 읽을 때는 한 번에 전체를 이해하려 하지 말고 split add path와 split completion path를 먼저 고정하는 것이 좋다.

struct vring_virtqueue는 public virtqueue를 embed하고 split/packed private state를 union으로 가진다. 이 container pattern 덕분에 public API는 struct virtqueue *를 쓰지만 implementation은 ring layout별 state를 숨길 수 있다. Source에서 public pointer가 private pointer로 바뀌는 to_vvq 계열 conversion을 주목하라.

DMA mapping branch는 source를 복잡하게 만들지만 실제 systems issue를 반영한다. Legacy virtio, ACCESS_PLATFORM, Xen, premapped SG, DMA API 사용 여부가 분기한다. 이 부분은 “가상 device니까 address가 간단하다”는 추상화를 깨뜨리는 현실적인 부분이다.

Hot path에서 가장 중요한 줄은 descriptor write 자체보다 publish boundary다. Split add path에서는 avail ring slot과 idx update 사이의 barrier, completion path에서는 used idx와 used entry read 사이의 barrier를 찾으면 된다. 이 지점이 protocol proof의 핵심이다.

F.5 drivers/virtio/virtio_pci_modern.c: queue를 device에게 알려주는 transport

PCI modern source는 virtqueue memory를 device-visible address로 program하는 곳이다. Common config에서 queue select, queue size, descriptor address, available address, used address, queue enable 같은 field를 다룬다. Ring memory는 virtio_ring.c가 만들지만, device가 그 memory를 알게 하는 것은 transport다.

setup_vq류 path는 queue size를 읽고 vring_create_virtqueue()를 호출한 뒤 queue address와 notify data를 설정한다. 이 흐름은 data structure allocation과 hardware-visible configuration이 분리되어 있지만 sequence상 tightly coupled임을 보여준다.

MSI-X vector assignment와 notify BAR mapping은 transport-specific detail이다. Frontend driver가 callback function을 제공하지만, 그 callback이 어떤 vector/interrupt path로 불릴지는 transport가 결정한다. Queue affinity optimization도 이 layer에 걸친다.

Modern transport는 VERSION_1 requirement, common config size, feature finalization 같은 compatibility check를 수행한다. Transport가 feature negotiation에 개입하는 것은 ring/device feature와 bus-level capability가 서로 독립적이지 않기 때문이다.

F.6 drivers/block/virtio_blk.c: request shape가 명확한 frontend

virtio-blk source는 “device-specific semantics를 SG chain으로 바꾸는 code”의 가장 명확한 예다. virtblk_add_req()를 읽으면 header, data, status가 어떤 SG direction으로 들어가는지 보인다. Header는 대부분 out, status는 in, data는 read/write에 따라 direction이 바뀐다.

blk-mq integration을 함께 봐야 한다. Block layer는 request concurrency와 queue mapping을 관리하고, virtio-blk는 virtqueue를 hardware queue처럼 사용한다. Multi-queue feature가 있으면 request distribution과 CPU affinity가 성능에 큰 영향을 준다.

Commit/kick path는 batching의 실제 사용 예다. 여러 request가 ring에 추가된 뒤 한 번만 notify될 수 있다. Storage workload는 latency-sensitive request와 throughput-oriented batch가 섞이므로 notification policy가 tail latency에 영향을 준다.

Completion에서는 status byte 해석과 blk_mq completion이 이어진다. 이때 descriptor completion은 device가 buffer를 더 이상 사용하지 않는다는 의미이며, block request semantic completion은 status가 valid하고 upper layer에 결과를 report할 수 있다는 의미다. 두 completion 개념을 구분해야 한다.

F.7 drivers/net/virtio_net.c: queue가 packet pipeline이 되는 방식

virtio-net source는 훨씬 크고 feature가 많다. 처음 읽을 때는 모든 offload feature를 보려 하지 말고 TX enqueue, RX buffer posting, NAPI poll, callback suppression 네 path를 먼저 잡는 것이 좋다.

RX path는 empty buffer provisioning이다. Driver는 device-writable buffer를 queue에 미리 넣고, device는 packet arrival 시 그 buffer를 채운 뒤 used entry를 publish한다. 이 모델은 packet receive를 “request/response”가 아니라 “buffer lease”로 보는 편이 더 정확하다.

NAPI callback pattern은 virtqueue callback control API의 실전 사례다. Interrupt callback은 callbacks를 disable하고 NAPI를 schedule한다. Poll은 ring을 drain하고 re-enable race를 check한다. 이 구조가 없으면 packet burst에서 interrupt overhead가 폭증하거나 lost wakeup이 발생할 수 있다.

virtio-net의 feature set은 guest/host network performance를 크게 바꾼다. Mergeable buffers, checksum/GSO offload, multiqueue, RSS, control virtqueue는 모두 ring usage pattern을 바꾼다. 그러나 core I/O contract는 여전히 descriptor chain publish와 used completion이다.

Appendix G. Correctness proof sketch: ownership, ordering, wakeup

이 appendix는 virtqueue를 작은 concurrent object로 보고 correctness obligation을 정리한다. Formal proof는 아니지만, source를 검토하거나 patch review를 할 때 어떤 lemma가 필요한지 보여준다.

Proof obligation 의미
Lemma 1: descriptor initialization before publication Driver가 descriptor fields와 avail ring slot을 모두 쓴 뒤 avail idx를 증가시켜야 한다. Device가 avail idx를 observe하면 descriptor chain을 읽을 수 있으므로, idx write는 descriptor initialization의 commit point다. Linux split path의 write barrier는 이 lemma를 enforce한다.
Lemma 2: device completion publication before driver consumption Device가 used entry와 payload/status를 쓴 뒤 used idx를 증가시켜야 한다. Driver가 used idx 변화를 observe하면 used entry id/len과 DMA-written data를 읽을 수 있어야 한다. Driver-side read barrier와 DMA unmap/sync는 이 lemma에 대응한다.
Lemma 3: descriptor free list is driver-owned Free descriptor list는 driver software state다. Descriptor가 available로 publish된 뒤 used completion이 돌아오기 전까지는 free list에 들어가면 안 된다. Detach path가 completion 후 descriptor chain을 free list로 돌려야 한다.
Lemma 4: token uniqueness 각 outstanding head descriptor는 정확히 하나의 software token에 대응해야 한다. Completion id는 head descriptor를 identify하고 token을 반환한다. 같은 token이 두 번 반환되거나, completion 없는 token이 free되면 safety violation이다.
Lemma 5: notification is not state Notify/interrupt는 queue state 그 자체가 아니라 state를 확인하라는 hint다. 따라서 driver는 interrupt가 와도 ring을 확인해야 하고, interrupt가 없더라도 polling으로 completion을 발견할 수 있다. Lost wakeup avoidance는 ring state와 callback enable state의 ordering으로 증명해야 한다.
Lemma 6: reset establishes quiescence Reset 후 driver가 ring memory를 free하려면 device/backend가 이전 ring을 더 이상 access하지 않는다는 quiescence가 필요하다. Transport reset과 interrupt synchronization은 이 lemma의 operational implementation이다.

이 proof sketch는 Linux patch review에서 실용적이다. 예를 들어 virtqueue_add_* path를 수정하는 patch는 Lemma 1과 Lemma 3을 깨지 않는지 봐야 한다. Callback control path patch는 Lemma 5를 봐야 한다. Reset/teardown patch는 Lemma 6을 봐야 한다.

Tiny state-machine model (conceptual)

Descriptor head h states:
  FREE
    -- driver fills desc chain and token --> BUILT
    -- driver publishes avail idx --------> AVAILABLE_TO_DEVICE
    -- device consumes -------------------> IN_DEVICE
    -- device publishes used idx ---------> USED_BY_DEVICE
    -- driver detaches/unmaps ------------> FREE

Allowed transitions are one-way except reset/error recovery.
Any path that frees memory must prove no agent can still be in IN_DEVICE.

Formalizing this model requires modeling wraparound indices, descriptor chains, indirect tables, callback suppression, and reset. 그러나 minimal model만으로도 many bugs를 잡을 수 있다. 특히 FREE로 돌아가는 transition이 completion path와 reset/error path 둘 다에 존재한다는 점이 중요하다.

patch review heuristic

virtio patch를 볼 때 “이 patch가 descriptor state machine의 transition을 추가/변경하는가?”를 먼저 묻자. 그렇다면 ordering, token lifetime, DMA unmap, notification wakeup, reset interaction을 함께 검토해야 한다.

Appendix H. Performance analysis checklist와 실험 설계

Virtio performance를 분석할 때는 “virtio가 느리다/빠르다”처럼 하나의 숫자로 말하기 어렵다. Queue depth, notification rate, backend implementation, IOMMU, vCPU scheduling, NUMA, workload size distribution이 모두 영향을 준다. 이 appendix는 실험 설계를 위한 checklist다.

Metric/axis 볼 것 왜 중요한가
Queue depth outstanding descriptor/request 수 Too low이면 device/backend가 idle; too high이면 latency 증가
Batch size kick당 request 수, NAPI poll당 packet 수 Notification overhead와 tail latency trade-off
Descriptor pressure direct vs indirect descriptors, SG count ENOSPC와 allocation/mapping overhead
Notification count doorbell/MMIO writes, guest interrupts VM exit/interrupt overhead 추정
IOMMU/DMA cost dma_map/unmap latency, IOTLB miss Payload copy를 피해도 mapping overhead가 bottleneck 가능
Backend path QEMU userspace, vhost, vDPA/hardware Guest source는 같아도 host consumption cost가 다름
CPU placement vCPU, backend thread, IRQ affinity, NUMA node Cache locality와 scheduler interference
Payload size packet size, block request size, SG fragmentation Descriptor count와 cache/TLB behavior 변화

Block workload 실험에서는 fio를 사용해 request size, queue depth, job count, direct I/O 여부를 sweep하는 것이 기본이다. 같은 guest kernel/virtio-blk driver라도 host storage backend, cache mode, aio/io_uring setting, vhost-scsi/virtio-blk 선택에 따라 결과가 크게 달라진다. Guest-side tracing만으로 결론을 내리지 말고 host-side tracing도 함께 잡아야 한다.

Network workload 실험에서는 iperf, pktgen, netperf, custom packet generator 등을 사용할 수 있다. Throughput뿐 아니라 p99 latency, interrupt rate, NAPI poll budget exhaustion, drops, queue stop/wake count를 봐야 한다. Virtio-net은 offload feature가 많으므로 feature set을 고정하지 않으면 비교가 어렵다.

Instrumentation은 여러 layer에서 가능하다. Guest kernel에서는 ftrace/kprobe/eBPF로 virtqueue_add_sgs, virtqueue_kick_prepare, virtqueue_notify, virtqueue_get_buf_ctx 주변을 관찰할 수 있다. Host에서는 QEMU trace, vhost trace, perf, eBPF로 backend consumption을 볼 수 있다. Ring idx를 sample하면 guest publish와 host consume 사이의 lag를 추정할 수 있다.

Example experiment dimensions (conceptual)

for backend in [qemu-userspace, vhost, vdpa]:
  for iommu in [off, on]:
    for qdepth in [1, 4, 16, 64, 256]:
      for request_size in [4K, 16K, 128K, 1M]:
        measure:
          throughput
          p50/p99 latency
          guest CPU cycles per I/O
          notifications per I/O
          completions per interrupt/poll
          dma_map/unmap cost if observable
성능 결론의 함정

virtio source만 보고 “이 optimization이 항상 빠르다”고 말하기 어렵다. Notification을 줄이면 throughput은 좋아져도 tail latency가 나빠질 수 있고, indirect descriptor는 descriptor pressure를 낮춰도 allocation/cache overhead를 늘릴 수 있다. Workload와 backend를 함께 pinning해야 한다.

Academic artifact를 만든다면 feature negotiation 결과, kernel commit, QEMU/vhost version, host kernel, CPU topology, IOMMU setting, interrupt affinity, queue count를 반드시 기록하자. Virtio는 portability가 높지만, performance는 환경 의존성이 높다.

Appendix I. Minimal QEMU/KVM reading lab

이 appendix는 source reading과 hands-on 관찰을 연결하는 작은 lab plan이다. 실제 command는 host distribution과 kernel build setup에 따라 달라지지만, 관찰 포인트는 보편적이다.

Lab outline (not distribution-specific)

1. Build or install a Linux guest kernel with:
   CONFIG_VIRTIO=y/m
   CONFIG_VIRTIO_PCI=y/m
   CONFIG_VIRTIO_BLK=y/m
   CONFIG_VIRTIO_NET=y/m
   CONFIG_DYNAMIC_DEBUG=y
   CONFIG_FTRACE=y
   CONFIG_BPF=y if using eBPF

2. Boot QEMU/KVM with virtio-blk and virtio-net devices.
   Pin QEMU, host kernel, guest kernel versions for reproducibility.

3. Inside guest:
   inspect /sys/bus/virtio/devices
   inspect negotiated features when available
   run fio/iperf workload
   trace virtqueue add/kick/get_buf paths

4. On host:
   observe QEMU/vhost backend CPU usage
   trace vhost if enabled
   compare QEMU userspace backend vs vhost backend

5. Change one axis at a time:
   queue depth, number of queues, IOMMU, packed/split if controllable,
   backend, interrupt affinity, payload size.

Source reading lab에서 가장 좋은 질문은 “이 workload의 한 I/O가 어떤 source function을 지나갔는가?”다. 예를 들어 fio read request라면 guest block layer에서 virtio_queue_rq()로 들어가고, virtblk_add_req()가 SG를 만들며, virtqueue_add_sgs()가 descriptor chain을 만든다. Completion은 virtqueue_get_buf*를 거쳐 blk_mq completion으로 돌아온다.

Network lab에서는 RX buffer posting과 packet completion을 분리해 관찰하자. Packet이 들어오기 전에 이미 RX buffers가 virtqueue에 available 상태로 있어야 한다. Interrupt가 들어오면 NAPI poll이 used entries를 drain한다. Packet throughput이 낮을 때는 “device가 packet을 못 받는가, RX buffer가 부족한가, completion을 못 drain하는가, callback이 막혔는가”를 분리해야 한다.

Trace를 설계할 때 function boundary만 찍으면 부족할 수 있다. Queue index, avail idx, used idx, num_free, num_added, notification decision, token pointer class(request/skb/buffer)를 함께 기록하면 event timeline이 훨씬 명확해진다. 단, kernel tracing은 hot path overhead를 만들 수 있으므로 sampling과 filtering을 신중히 써야 한다.

좋은 lab notebook 형식

각 run마다 kernel commit, QEMU version, command line, negotiated features, queue count/size, workload config, tracepoints, host CPU placement를 기록하자. Virtio performance는 작은 환경 차이에도 변할 수 있다.

Appendix J. 다른 I/O abstraction과의 비교: NVMe, e1000 emulation, io_uring

Virtio를 더 잘 이해하려면 비슷하지만 다른 I/O abstraction과 비교하는 것이 도움이 된다. NVMe는 real hardware storage protocol이고, e1000 emulation은 legacy NIC register-level compatibility path이며, io_uring은 kernel-userspace asynchronous I/O interface다. 모두 queue를 쓰지만 boundary와 trust model이 다르다.

System Boundary Queue style 핵심 목적
virtio guest driver - virtual/physical virtio device shared-memory descriptor queue + feature negotiation transport-independent virtual device ABI
NVMe host driver - storage controller submission/completion queues in host memory hardware storage protocol, rich command set
e1000 emulation guest legacy NIC driver - emulated NIC register/ring semantics of old NIC compatibility high, emulation overhead often high
io_uring userspace - Linux kernel shared submission/completion rings syscall reduction for user/kernel async I/O

NVMe와 virtio-blk는 모두 queue-based storage I/O처럼 보인다. 그러나 NVMe는 hardware-defined command set과 controller semantics가 중심이고, virtio-blk는 virtual device ABI와 transport independence가 중심이다. NVMe driver는 PCI/NVMe controller details를 직접 다루지만, virtio-blk는 virtqueue API와 virtio config abstraction을 통해 transport details를 숨긴다.

e1000 emulation과 virtio-net의 차이는 더 선명하다. e1000은 guest가 legacy NIC driver를 쓸 수 있다는 compatibility 장점이 있지만, hypervisor가 legacy register semantics를 faithfully emulate해야 한다. Virtio-net은 guest에 paravirtual driver가 필요하지만, device/backend와 더 직접적인 descriptor protocol로 대화하므로 overhead를 줄일 수 있다.

io_uring과 virtio는 모두 shared ring을 사용하지만 boundary가 다르다. io_uring은 userspace process와 kernel 사이의 syscall amortization interface이고, virtio는 guest kernel driver와 device/backend 사이의 device ABI다. io_uring의 memory ordering 문제는 user/kernel shared memory에 집중되고, virtio는 DMA/IOMMU/device memory model까지 포함한다.

이 비교에서 얻을 수 있는 일반 원칙은 queue가 곧 abstraction이 아니라는 것이다. 중요한 것은 queue entry가 무엇을 의미하는지, 누가 memory를 소유하는지, notification이 state인지 hint인지, feature evolution이 어떻게 되는지, reset/error path가 어떻게 정의되는지다. Virtio는 이 질문에 대한 답을 standardized device ABI로 제공한다.

비교 연구 아이디어

동일한 storage/network workload를 virtio, emulated legacy device, passthrough device, io_uring-backed backend에서 비교하면 “queue abstraction이 overhead를 줄이는 방식”과 “compatibility가 성능에 주는 비용”을 분리해 볼 수 있다.

Appendix K. One-page source reading checklist

마지막으로, Linux virtio source를 직접 읽을 때 한 페이지 checklist로 사용할 수 있는 순서를 정리한다. 이 checklist는 patch review, seminar preparation, paper artifact evaluation에서 반복적으로 쓸 수 있다.

  1. Device가 어떤 transport로 발견되는지 확인한다. PCI modern이면 common config, notify capability, MSI-X vector setup을 본다.
  2. virtio_dev_probe()에서 negotiated feature set을 확인한다. Packed/split, indirect, event idx, access platform, device-specific offload feature가 어떤 path를 여는지 표시한다.
  3. virtio_find_vqs() 호출 지점에서 queue 수, queue 이름, callback, queue index, affinity를 기록한다.
  4. Device-specific driver가 SG list를 만드는 함수를 찾는다. Out/in direction, status/result buffer, token pointer lifetime을 표시한다.
  5. virtqueue_add_* path에서 descriptor allocation, DMA mapping, indirect descriptor 선택, publish barrier, avail idx update를 확인한다.
  6. kick_preparenotify가 어디서 분리되는지 본다. Lock hold time, batching unit, notification suppression condition을 기록한다.
  7. Completion path에서 virtqueue_get_buf* 호출 context를 본다. Interrupt callback인지 NAPI/poll인지, callback disable/enable race check가 있는지 확인한다.
  8. Reset/remove/error path에서 outstanding descriptor와 token이 어떻게 처리되는지 본다. Device memory access quiescence가 어디서 보장되는지 확인한다.
  9. Performance issue라면 queue depth, notification rate, DMA mapping cost, backend thread placement, feature set을 한 번에 바꾸지 말고 하나씩 분리한다.
  10. Source line은 master에서 drift할 수 있으므로, artifact에는 반드시 exact commit hash와 QEMU/host kernel/guest kernel version을 기록한다.

이 순서대로 읽으면 virtio를 “큰 파일 몇 개”가 아니라, lifecycle contract, ring protocol, transport programming, device-specific semantics의 조합으로 볼 수 있다. 그것이 Linux virtio 구현을 연구 수준에서 이해하는 가장 빠른 길이다.

Appendix L. Patch review examples: virtio change를 볼 때의 사고 과정

예시 1: virtqueue_add_* path에서 descriptor fill 순서를 바꾸는 patch가 있다고 하자. 가장 먼저 물어야 할 것은 “avail idx publish 전에 device가 볼 수 있는 모든 descriptor field가 initialized 되는가?”이다. Descriptor chain의 NEXT flag, WRITE flag, DMA address, length가 모두 맞고, 마지막 descriptor의 NEXT가 clear되어야 한다. 그 다음 DMA mapping error path가 partial chain을 어떻게 rollback하는지 확인한다.

예시 2: notification suppression optimization patch가 있다고 하자. 이 patch는 throughput 수치를 개선할 수 있지만 lost wakeup 가능성을 만들 수 있다. kick_prepare가 idx publish 이후 적절한 barrier를 갖는지, event idx arithmetic이 wraparound에서 맞는지, callback re-enable path와 symmetric한지 확인해야 한다. Notification은 state가 아니라 hint이므로, optimization은 “hint를 줄이는 것”이지 “work visibility를 줄이는 것”이 되어서는 안 된다.

예시 3: virtio-net RX buffer allocation policy를 바꾸는 patch라면, packet data path뿐 아니라 buffer ownership state를 봐야 한다. Buffer가 in descriptor로 publish되면 device가 쓸 수 있으므로, driver가 그 buffer를 다시 만지는 시점은 used completion 이후여야 한다. Page pool, skb build, XDP path가 섞이면 token이 가리키는 metadata와 actual page lifetime이 분리될 수 있다.

예시 4: reset/remove path patch는 항상 scary하다. Patch가 callback synchronization을 제거하거나 reset 순서를 바꾼다면, device/backend가 stale ring memory를 access하지 않는다는 proof가 필요하다. Outstanding descriptors를 fail시키는 path가 token을 정확히 한 번 반환하는지, DMA unmap이 빠지지 않는지, upper-layer queue stop과 device reset order가 맞는지 본다.

예시 5: packed ring 관련 patch는 split ring intuition으로만 보면 실수하기 쉽다. Packed descriptor의 avail/used flag와 wrap counter는 together 해석되어야 한다. Producer/consumer index update, descriptor id, event suppression state가 wrap 시점에 동시에 맞아야 한다. 따라서 patch review에서는 작은 queue size로 wrap을 강제로 일으키는 test를 상상하거나 작성하는 것이 좋다.

예시 6: DMA API 관련 patch는 platform matrix를 고려해야 한다. x86/KVM legacy guest에서는 우연히 동작하더라도, ACCESS_PLATFORM, Xen, vDPA, IOMMU-on environment에서 깨질 수 있다. SG가 premapped인지, DMA direction이 맞는지, unmap이 error path와 completion path에서 balanced 되는지 확인해야 한다.

Review mnemonic

PUBLISH: descriptor -> avail slot -> barrier -> avail idx -> optional notify
CONSUME: used idx -> barrier -> used entry/payload -> detach -> unmap -> token
RESET: stop submit -> quiesce device -> sync callbacks -> release memory
WAKEUP: disable callback -> drain -> enable -> poll/check race

이러한 review pattern은 virtio에만 국한되지 않는다. NVMe, io_uring, network driver ring에서도 publish/consume/reset/wakeup의 네 질문은 반복된다. Virtio는 이 질문들이 spec과 source에 비교적 명확히 드러나기 때문에 좋은 훈련 대상이다.

Appendix M. Seminar discussion prompts

  1. Virtio의 feature negotiation은 protocol evolution에 충분한가? Feature bits가 combinatorial explosion을 만들 때, Linux core와 device-specific driver는 어떤 방식으로 복잡도를 제한하는가?
  2. Split ring과 packed ring의 trade-off를 hardware cache coherence, PCIe transaction, implementation complexity 관점에서 비교하라. 어떤 workload에서는 split ring이 더 낫거나 충분할 수 있는가?
  3. Virtqueue API를 Rust type-state로 다시 설계한다면 어떤 state를 type으로 encode할 것인가? Descriptor ownership, DMA direction, token lifetime, callback enabled state 중 어디까지 static하게 잡을 수 있을까?
  4. Notification suppression을 adaptive하게 만들려면 guest driver와 host backend 사이에 어떤 feedback signal이 필요할까? Latency SLO를 만족하면서 throughput을 최대로 하는 policy를 어떻게 평가할 수 있을까?
  5. vDPA/hardware virtio에서 guest driver의 assumption은 어떻게 달라지는가? “Hypervisor가 memory를 읽는다”는 모델과 “real device가 DMA한다”는 모델 사이의 security/performance 차이를 논하라.
  6. Reset을 formal하게 정의하려면 어떤 guarantee가 필요할까? Device가 interrupt를 멈췄다는 것과 memory access를 멈췄다는 것은 어떻게 관찰/증명될 수 있을까?
  7. Virtio-blk와 NVMe를 비교할 때, command richness와 transport independence 중 어느 쪽이 cloud workload에 더 중요한가? Guest ABI standardization이 hardware specialization과 충돌하는 지점은 어디인가?
  8. Linux virtio_ring.c의 compatibility complexity를 줄이는 clean-slate design을 한다면 무엇을 버릴 수 있을까? Legacy support를 제거하면 barrier/DMA/feature logic이 얼마나 단순해질까?
  9. Virtio-net RX path의 buffer pre-posting model은 memory pressure 상황에서 어떤 failure mode를 만드는가? RX buffer starvation과 packet drop을 어떻게 계측할 것인가?
  10. Virtqueue를 distributed queue로 모델링할 때, notification은 message인가 interrupt인가 hint인가? 이 선택이 correctness proof와 performance model에 어떤 차이를 만드는가?

이 질문들은 보고서 내용을 세미나나 reading group으로 확장하기 위한 출발점이다. 답이 하나로 정해진 질문보다는, Linux source의 구체적 design choice와 broader systems principle을 연결하는 질문을 의도했다.