资讯动态

Virtio性能优化:Virtqueue间接描述符与Event Index机制解析

发布时间:2026/10/13 5:54:48 来源:尧图企业网站定制
1. 先从 Virtqueue 的环谈起为什么共享内存上面还要再套一张描述表接触过虚拟化的人都知道Virtio 并不是一个把read()和write()翻译成设备寄存器的协议它靠的是一块 guest 与 host 都能直接访问的共享内存区以及一个叫 Virtqueue 的队列结构。我之前调试一台虚拟化宿主机的网络吞吐时遇到过数据面几十万 PPS 上不去的现象。追到最后问题不在网卡模拟也不在 CPU 频率而是 Virtqueue 上的两个机制没有用好一个是间接描述符Indirect Descriptor一个是 Event Index。要搞懂这两个东西得先把 Virtqueue 本身拆开看。1.1 两张环与一张描述表的分工一个 Virtqueue 在内存里由三部分组成描述符表descriptor table、可用环avail ring和已用环used ring。很多刚上手的人会以为 avail ring 里放的就是数据其实不是avail ring 里放的是描述符索引。真正的数据地址和长度记录在描述符表里。描述符表是一段连续的数组每个描述符固定 16 字节由addr、len、flags、next四个字段组成。addr指向数据缓冲区的物理地址len表示长度next用于把同一个请求的多个描述符串成链表flags则标记这个描述符是否带有NEXT、是否只写write-only、以及是否是一个间接描述符。举一个最简单的 virtio-blk 读请求的例子。驱动要发一个块设备读指令通常需要三个描述符第一个指向块请求头第二个指向数据缓冲区write-only第三个指向状态尾。驱动会分配三个描述符把它们通过next链在一起然后把第一个描述符的索引写入 avail ring最后再向前推进 avail ring 的idx。设备看到 avail ring 中有新的 idx 后顺着描述符链表拿地址、做 DMA、执行请求完成后把id和长度写入 used ring最后更新 used ring 的idx。驱动收到中断后再去 used ring 里回收这次请求对应的描述符。整个过程里avail ring 和 used ring 都是固定大小的环形数组描述符表也是固定大小。queue_size决定环有多少个槽位一般是 128、256、1024 这类 2 的幂。为了追求性能这三部分通常会被放在同一页或连续页里方便设备一次性映射。Virtio 的共享内存模型很像生产者和消费者之间的环形缓冲区只是这个缓冲区存的是指向其他缓冲区的指针而不是数据本身。1.2 没有 Event Index 之前通知为什么会失控虚拟化环境里guest 和 host 的通信是有成本的。驱动每次往 avail ring 写入新请求后都需要通过virtio_notify或kick去通知设备有新活了。这个 kick 在收到通知后会产生 VM-exitCPU 要切到根模式再回到 guest 执行几个微秒就没了。设备完成请求后也要通过中断通知驱动取回结果。如果每个请求都推一次通知、发一次中断CPU 大部分时间都在处理通知而不是搬运数据。早期 Virtio 只能靠两个开关来抑制通知。第一个开关是VIRTIO_F_NOTIFY_ON_EMPTY。意思是设备只有在 avail ring 从空变为非空的时候才需要 kick否则驱动可以少打扰设备。第二个开关是 avail ring 和 used ring 结构里的flags字段。used ring 的flags可以设置VRING_USED_F_NO_NOTIFY表示这个时间段驱动不用因为我更新 used idx 而发中断。这两个机制都太粗糙了只能全开或全关无法表达你完成到第几个我再被通知。真正的问题是生产者和消费者的处理速度是动态变化的。有时设备处理很快驱动明明包了 256 个请求设备一次全部消费掉了有时设备很慢驱动连续投递 1000 个包设备可能只处理了前 50 个。这时候用固定开关必然产生大量无用的通知和中断。间接描述符解决的是描述符不够用的问题Event Index 解决的就是通知太频繁的问题。这两个特性搭配使用才让 Virtqueue 在高吞吐场景下站得住脚。2. 间接描述符描述符不够用时用一张子表换空间2.1 直接描述符的槽位限制与 DMA 开销直接使用描述符表时每个请求要占用一个或多个描述符。对于 virtio-net 的收包场景payload 可能很分散驱动需要把一个网络包拆成多个不连续的 DMA 段每个段一个描述符。假设queue_size256你的队列最多同时容纳 256 个裸描述符。如果一个包需要 4 个描述符那么同时只能有 64 个包在队列里。网络突发稍大一点驱动就会发现描述符不够用必须等设备释放。更麻烦的是 DMA 映射开销驱动每次填充数据缓冲区时默认需要把缓冲区做一次dma_map用完再做dma_unmap。对这个缓冲区说地址翻译和 TLB 失效的成本摊到每个小包上非常可观。间接描述符就像给描述符表开了一张子表。你不是直接让描述符指向数据而是让描述符指向另一块连续内存这块内存本身又是一个描述符数组。设备在处理时先看到这个描述符带有VIRTQ_DESC_F_INDIRECT标志于是进入子表把子表里的一个个描述符当作普通描述符来解析。用网络包比喻就是普通的描述符是散页间接描述符是装了目录的文件夹设备先查目录再按目录里的条目取数据。2.2 间接表的布局和构造细节间接描述符表的结构非常直接。假设我们要把 8 个分散的数据段聚合在一个请求里。驱动先分配一块大小为8 * 16字节的连续内存作为间接描述符数组。每个间接描述符的addr、len、flags按正常方式填写。然后在外层描述符表里只占一个槽位这个槽位的addr指向刚才分配的那块内存len设为8 * 16flags置为VIRTQ_DESC_F_INDIRECT。外层描述符不再指向任何数据缓冲区而是指向这张子表。struct vring_desc indirect[8]; vring_desc_init(indirect, 8); dma_addr_t indirect_dma dma_map_single(dev, indirect, sizeof(indirect), DMA_TO_DEVICE); desc[0].addr indirect_dma; desc[0].len sizeof(indirect); desc[0].flags VIRTQ_DESC_F_INDIRECT;有几个细节必须在实现时注意。间接描述符数组必须按设备要求对齐至少 4 字节但通常我建议按 16 字节对齐避免某些设备实现上有更严格的偏好。外层描述符的len必须是 16 字节的整数倍并且不能超过queue_size * 16。间接描述符里如果某个描述符又设置了INDIRECT这是不合法的。规范明确不支持多级间接设备遇到嵌套间接通常会直接报错。所以设计时就只做成单层。间接表和普通描述符表的区别普通表的位置由 virtqueue 初始化时确定间接表每次可以独立分配。从性能角度我强烈建议不要每次请求都现分配而是在驱动初始化时用kmem_cache或者预分配池维护一个间接表池。每个队列准备好几十张间接表请求来了就取一张用完了放回去。这样能避免频繁的页面分配和地址映射。2.3 什么时候该用什么时候别用间接描述符并不是银弹。它解决的是两个问题描述符槽位不够和 DMA 描述符开销过大。对于 mtu 正常的网络收发、块设备单请求如果queue_size足够大直接用普通描述符反而更简单因为少了一次间接寻址。什么时候适合用我从几个场景说。第一virtio-net 的 GSO 场景。一个大 TCP 分段请求需要 header、udo、多个分片十几个 buffer 很常见。用普通描述符会把队列的槽位迅速占满而且每个分包都要做一次 DMA map。把整个分段的所有 buffer 放进一张间接表外层只占一个槽位相当于把多个 DMA 段打包成一个描述符请求。第二virtio-blk 的多段 read/write 请求。内核块层常常把一个 bio 拆成多个 segment间接表可以把这些 segment 一次挂到一个描述符上。第三设备支持大间接表的场景。一些硬件网卡实现中设备会预缓存间接表页相当于把一次 DMA 描述符 walk 变成了一次查表吞吐提升很明显。但有些场景不适合。比如queue_size很小但请求极高频的小包场景间接表的分配和释放反而会多一次 cache miss性能甚至下降。还有设备实现比较简陋、对间接表处理又慢又复杂的情况选用间接表可能吃亏。我的做法是先跑基准比较 PPS 和延迟再决定是否默认开启。3. Event Index把每次通知改成环上的两个索引3.1 从开关抑制到精确索引Event Index 解决的是通知频度问题。前面说过旧协议只能通过简单开关抑制通知。而VIRTIO_F_EVENT_IDX协商成功后avail ring 和 used ring 的结构里会各多出一个 16 位字段avail ring 多出的used_event由驱动填写表示当 used ring 推进到某个索引时再给我发中断used ring 多出的avail_event由设备填写表示当 avail ring 推进到某个索引时再来 kick 我。初看这有点绕为什么驱动的通知阈值要写在 avail ring 里设备的工作状态要写在 used ring 里因为 Event Index 的本质是让每一方在对端管理的环上登记一个唤醒坐标。驱动想在设备完成到指定位置时被唤醒就把坐标写到 avail ring 的used_event字段设备想让驱动在投递到指定位置时来 kick就把坐标写到 used ring 的avail_event字段。谁更新自己的环谁就能顺便更新对端关心的坐标。设备每完成一个请求更新 used idx 时会检查used_event如果当前的 used idx 已经跨过这个阈值就发中断告诉驱动来取如果还没有跨过就继续闷头处理。驱动每投递一批请求更新 avail idx 时会检查avail_event如果当前 avail idx 已经跨过设备登记的坐标就说明设备可能在等活干需要 kick如果没有跨过说明设备还在忙着处理之前的请求kick 了也白搭。3.2 used_event 与 avail_event 的关系vring_need_event是这套机制的核心判断函数在很多项目里都能看到这一行static inline int vring_need_event(uint16_t event_idx, uint16_t new_idx, uint16_t old) { return (uint16_t)(new_idx - event_idx - 1) (uint16_t)(new_idx - old); }参数event_idx是对方登记的唤醒阈值new_idx是当前准备推进到的索引old是上一次更新的索引。这个函数判断的是在旧位置到新位置这段推进区间里是否经过了event_idx这个坐标。如果经过了就说明接收方已经等在那里需要通知如果没有经过说明对方的缓冲区还有足够的工作不需要通知。举个例子辅助理解。设备登记used_event120意思是驱动你先把 used 处理到 120 再来唤醒我。驱动在处理 used ring 时上次看到 used idx 是 100这次推进到 122。122 跨过了 120所以驱动应该向设备发一次中断。下一次驱动处理时设备可能已经把阈值改为 200可驱动只推进到 125那么 125 没有跨过 200就不发中断。这样通知次数就和双方的真实进度严格挂钩不再是每次操作都通知。Event Index 相当于把每来一个通知一个改成了到了路口再按喇叭。对中断侧也是同样的道理。设备把 used_idx 从 100 推进到 102而驱动登记的used_event是 102那么设备正确发中断如果used_event110设备即使推进到了 102也知道驱动还不想被唤醒继续处理即可。3.3 边界条件为什么需要绕圈比较索引是 16 位无符号数环的深度通常小于 65536但长时间运行后索引一定会回绕。比如 new_idx5、event_idx65535表面上看 5 比 65535 小其实从 65535 绕到 5 是有 10 个单位的间隔。所以 Event Index 的比较必须用无符号减法来处理环绕那个(uint16_t)(new_idx - event_idx - 1) (uint16_t)(new_idx - old)的写法正是为了规避直接比较大小带来的错误。实现时尤其注意不要自作聪明把uint16_t强制转换成int再比较。我之前就见过一个驱动实现因为担心无符号溢出而先转成int结果环在 32768 附近回绕后判断错误产生丢包。正确做法就是按照规范提供的公式原样计算利用 C 语言无符号整型数回绕的语义。这个公式还有一个好处当new_idx old右边等于 0永远返回 false可以避免空操作时发出无意义通知。Event Index 的一大优势是实现成本极低只是在原有 vring 结构末尾多了两个uint16_t字段。但它的收益很明显对虚拟化 IO 路径而言每次中断或者 kick 的代价都可能是微秒级别的宿主机切换减少一次无意义通知就等于省了一次完整的退出和重入。4. 落地实现驱动怎么搭表、设备怎么判断4.1 驱动侧的间接描述符池与拆包逻辑在 Linux virtio 驱动中内核已经帮你把间接描述符的逻辑封装起来了。真正写驱动时你更需要关注的是描述符池的设计。拿virtio_net举例send_queue里维护了一个free_desc链表和一套add_buf逻辑。如果开启了VIRTIO_RING_F_INDIRECT_DESC特性驱动在add_buf时能判断当前请求需要的描述符数量是否超过阈值。如果超过就分配一张间接表把所有 segment 填进子表然后把外层描述符挂到 avail ring。很多人在这一步踩的坑是只看到了宏开关却忘了一个物理约束间接表本身必须被 DMA 到设备所以它必须是 DMA 可访问的连续内存。不能用普通的kmalloc的内核虚拟地址。正确做法是用dma_alloc_coherent或者dma_pool分配。如果你在模拟器或用户态虚拟机里测试可能会觉得随便拿一块内存也能跑通但放到真实硬件设备上就会随机报 DMA 错误。我建议对每个 virtqueue 单独维护一个间接表缓存池池子大小不能小于 queue_size 的四分之一否则压力一大容易出现间接表被占用完只能退回普通描述符的情况。池子太小不仅不能充分利用特性还会因为来回切换增加复杂度和分支开销。Linux 原生的 vring 也做了类似的处理当间接表无法分配时会直接回退到普通描述符路径此时性能会有肉眼可见的波动所以池子一定要给足。4.2 设备侧对 Event Index 的判断与中断合并设备侧实现时重点关注 used ring 更新的路径。以 vhost 或 QEMU 为例设备每消费一个 avail buffer就会把id和len写入 used ring此时需要用virtio_need_event判断是否发中断。注意不能简单地只要 used-idx 变了就触发中断而应该把used_event读出来带入判断公式。同样的逻辑也适用于驱动侧避免每个包来一次 kick。中断合并是另一个重要因素。你可以把 Event Index 和中断合并机制一起来想中断合并是时间维度上的聚合Event Index 是索引维度上的聚合。前者控制多少微秒内最多发一次,后者控制完成到哪个进度才需要发。两者互不冲突。实测中常见配置是先把中断合并周期调到 8 到 16 微秒再配合 Event Index 阈值可以让吞吐提升 20% 以上而延迟只增加几微秒。有些驱动在used_event里填的不是当前 used idx而是当前 used idx 预算量让设备一批处理完多个请求后再统一发一次中断。这种延后唤醒策略对块设备尤其有效因为它天然是一次提交一批请求的场景。4.3 需要注意的协议项协商与兼容两个特性都需要 feature bit 来协商。间接描述符对应VIRTIO_F_INDIRECT_DESCEvent Index 对应VIRTIO_F_EVENT_IDX。在设备驱动握手阶段双方必须确认都支持这些特性才能使用。遇到老设备或老驱动时代码要给出安全的回退路径。不要假设只要开了编译选项设备就一定会用这些机制。还有一个坑是这些特性对不同 virtio 设备类型的默认策略不同。virtio-net通常默认开启virtio-blk也支持但有些老版本模拟器只支持其中一个。你可以在 guest 里检查协商结果Linux 下/sys/bus/virtio/drivers/枚举设备的 feature 位或者直接读/sys/bus/virtio/devices/virtio0/features把 feature 位和协议表对照一下就能确认设备到底认了几个。执行层面如果你的驱动需要同时兼容新旧设备建议代码里不要写死假设而要对virtio_has_feature做一次显式判断。即使你的目标平台只有新设备也要保留一条普通路径否则固件/设备异常时连调试入口都没有。5. 实测复盘三个容易翻车的工程细节5.1 间接表带来的 cache 与驻留压力间接描述符减少的是槽位占用和 DMA 映射次数但它并不是完全没有代价。每张间接表都是一块额外的内存设备处理请求时先读外层描述符再跳到一个新地址读内层描述符这会增加一次无进展缓存prefetch不命中的概率。尤其当驱动每次动态分配间接表时表的地址可能不固定设备侧的缓存友好度会变差。解决思路是让间接表地址尽量固定。我测试过一个方案在驱动初始化时为每个队列分配一整块连续页把这一页按 16 字节切分成很多间接表槽位每次请求都从同一个内存池里的固定偏移取表。性能比每次kmalloc高了 10% 左右。还有一个更激进的做法是把外层描述符和间接表放在同一物理页内。这样设备在处理时不需要跨页访问TLB 和 cache 的压力同时降低。不过这个做法对页分配有对齐要求不是所有平台都容易实现。5.2 队列深度太小Event Index 反而可能增加延迟Event Index 通知抑制做得越激进通知次数越少但延迟也会变大。如果你的队列深度只有 16驱动一次只投 1 个请求设备处理完 16 个请求后 used_idx 才可能达到驱动登记的阈值。如果驱动每次请求后都等着中断就会白白等很久。这种情况下中断被抑制得恰到好处但业务已经卡住了。所以低队列深度场景下我更建议把驱动登记的used_event拉近到当前 used idx或者干脆关闭 Event Index 走传统通知路径。高吞吐大队列场景才是 Event Index 的主场。举个例子我测试 64 队列、每队列 1024 深度时开启 Event Index 后 PPS 提升 15%换成 16 深度的小队列开启后 PPS 反而下降同时 99 分位延迟上升。调参时一定要同时看 PPS、IOPs 和时延分布不要只盯着吞吐一个维度。5.3 如何确认这两个机制真的生效了最后分享一个排查手段。很多人开了配置就跑但不确定机制是否真的生效。Linux 下可以直接用perf和/proc/interrupts观察。开启 Event Index 前中断数量会随包量线性上涨开启后中断数量会变成锯齿状吞吐高时中断量维持在比较平滑的水平而且不会出现一个包一个中断的尖峰。如果你看到中断数还是跟包量严格 1:1基本可以判断 Event Index 没生效或者是驱动在某种回退路径上运行。间接描述符是否生效可以通过抓取 avail ring 里的外层描述符 flags 来确认。用trace-cmd或者自定义的 BPF 工具在virtqueue_add路径上挂一个钩子打印每次添加 buffer 的外层 flags 值。如果密集场景下VIRTQ_DESC_F_INDIRECT始终没有出现,说明驱动检测到队列深度足够一直没触发间接描述符路径。如果你想强制验证可以把 queue_size 调小让普通描述符明显不够用此时如果吞吐没有崩溃说明间接表起了作用。我个人的习惯是每次改动后先做一次小队列 大包的压力测试。因为这正好能让两个机制都在极限条件下开工小队列逼迫驱动启用间接表大包让描述符需求骤增同时调小中断合并窗口观察 Event Index 对中断数量的压缩比例。这套测试跑通后再去调正式业务参数基本不会翻车。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑