Packed virtqueue:描述符发布、完成与回绕
一、Packed virtqueue 的共享描述符环
split virtqueue 把缓冲区描述、可用链头和完成记录放在三个区域。设备处理一个请求,需要读取 available、找到描述符,再把结果写到 used。高请求率场景中,这些元数据访问也会占用缓存和内存带宽。packed virtqueue 将发布与完成状态放到同一个描述符环,减少分散访问,并通过状态位和 wrap 计数区分槽位在不同轮次中的使用状态。
本文先用 4 个槽位、每项请求只占一个描述符的例子说明发布、完成和回收,再扩展到描述符链与通知。队列格式依据 VIRTIO 1.2;初始化时必须协商 VIRTIO_F_RING_PACKED,没有协商就继续使用 split。演示让设备按提交顺序完成,便于观察位变化;它没有启用 IN_ORDER 的合并完成功能。
源码版本:QEMU 10.2.0、Linux 6.18。
二、一个描述符环,加两个事件抑制结构
| 区域 | 组成 | 职责 |
|---|---|---|
| Descriptor Ring | N 个 16 B 描述符:addr:64 / len:32 / id:16 / flags:16 | 驱动发布缓冲区,设备写入完成记录 |
| Driver Event Suppression | off_wrap:16 / flags:16,共 4 B | 驱动告诉设备何时需要完成通知 |
| Device Event Suppression | 相同的 4 B 格式 | 设备告诉驱动何时需要可用缓冲区通知 |
三个区域分别由队列配置地址定位,均用于交换队列元数据。报文或块请求数据位于 addr 指向的缓冲区,门铃位于设备通知接口。id 是驱动为请求分配的标识,完成时用它找回上下文:槽位编号定位环内记录,Buffer ID 关联请求资源。
以 N=4 计算,描述符环为 64 B,加两个事件结构共 72 B,未计外部对齐填充与请求数据。split 在不带 EVENT_IDX 时对应三个区域合计 112 B。这个小例子展示的是元数据布局差别,不能直接换算成相同比例的吞吐提升。packed 的队列大小也不要求是 2 的幂;选 4 只是为了方便观察回绕。
Mermaid 源码
flowchart TB
accTitle: Packed virtqueue 的共享布局与访问方向
accDescr: 驱动和设备共同访问一个描述符环,驱动事件区控制完成通知,设备事件区控制新请求通知。环外缓冲区存放数据,Buffer ID 关联驱动上下文。
D["客户机驱动<br/>发布请求 / 回收完成"]
R["Packed 描述符环<br/>每项 16 B:addr / len / id / flags<br/>槽 0 → 1 → 2 → 3 → 0"]
V["设备后端<br/>读取请求 / 发布完成"]
B["实际数据缓冲区<br/>位于环外的客户机 RAM"]
DE["Driver Event · 4 B<br/>控制设备 → 驱动的完成通知"]
VE["Device Event · 4 B<br/>控制驱动 → 设备的新请求通知"]
D <-->|"AVAIL / USED + wrap"| R
R <-->|"AVAIL / USED + wrap"| V
R -->|"addr / len"| B
D -->|"写"| DE
V -->|"读取通知条件"| DE
V -->|"写"| VE
D -->|"读取通知条件"| VE
三、AVAIL、USED 与 wrap 的状态判定
flags 的 bit 7 为 AVAIL,bit 15 为 USED。发布、设备消费、完成和驱动回收分别维护所需的环位置与一位 wrap,初始 wrap 为 1,描述符内存清零。某个进度越过最后一个槽位时,对应的 wrap 翻转;不同进度不要求同时翻转。下方演示将设备消费与完成合为一步,展示发布、完成和回收三个进度。
| 当前期望 wrap | 驱动发布后的 A / U | 设备完成后的 A / U |
|---|---|---|
| 1,第一圈 | 1 / 0 | 1 / 1 |
| 0,第二圈 | 0 / 1 | 0 / 0 |
以槽 0 为例:初始化的 0/0 表示没有新工作;第一圈发布后成为 1/0,设备据此读取缓冲区;完成后成为 1/1,驱动据此归还资源。再次绕回槽 0 时,双方期待的 wrap 已变成 0,旧的 1/1 就不会被误判为新完成。
设备判断可用时检查 A == expected_wrap && U != expected_wrap;驱动判断完成时,典型条件是 A、U 均等于回收位置的 expected_wrap。wrap 给相同位组合加上轮次:初始 0/0 与期望的 wrap=1 不匹配,第二圈的 0/0 才与 wrap=0 的完成状态匹配。
驱动回收完一个请求后,不需要把槽位清零。旧 flags 留在内存里,下一圈根据翻转后的 wrap 区分新旧。驱动根据已回收的描述符数量限制继续发布;Buffer ID 和数据缓冲区必须等对应请求完成后才能复用。完成记录可以位于不同于原请求的位置,环上槽位与请求资源的生命周期需要分开。wrap 解决代际识别,不能代替空闲数量和所有权管理。QEMU 的可用状态判断
四、操作一个四槽队列,观察第二圈发布
每次“发布”提供一个容量 256 B 的设备可写缓冲区;设备完成时写入 64 B。按顺序点击发布、完成、回收,也可以先发布 4 项再批量完成。观察第 4 次发布、完成和回收各自越过末尾后,三个进度的 wrap 如何分别变化。Buffer ID 使用 2、0、3、1 的次序,与槽位编号分开。
初始环为 0/0,双方期望 wrap=1,因此没有可用项或完成项。
演示仅包含单描述符、顺序完成,不模拟 DMA 时延、乱序完成和 IN_ORDER 批量归还。A/U 显示状态位,非完整 flags 数值。
发布时先写地址、长度、ID 和数据准备,再以必要内存顺序发布 flags。设备观察到可用状态后才能读取请求;完成时先写数据及完成字段,再发布完成状态。CPU 或编译器若把状态写提前,另一方就可能看见“已就绪”却读到旧内容。通知只是唤醒提示,不能代替这两个发布边界。
五、描述符链沿相邻槽位前进,ID 负责找回请求
1. 直接链使用连续槽位
split 描述符含 next 字段,可以把不相邻槽位串起来;packed 没有 next 字段。设置 NEXT 后,链的下一项就是环中的下一槽,越过末尾便从槽 0 继续。一个请求占槽 3、0、1 时,驱动需要按各位置所属的 wrap 填好所有状态,最后才发布槽 3 这个链头,防止设备读到半条链。
直接链的 Buffer ID 放在最后一个描述符中。设备处理完整条链后,只为整项缓冲区写一个完成描述符;驱动用完成 ID 找回私有上下文和该链占用的描述符数,然后跳过相应槽位继续回收。used 中 NEXT 是保留位,不能拿它来重新遍历提交时的链。
2. 完成记录的位置与原请求的位置可以不同
未协商 IN_ORDER 时,设备可以按不同于提交的顺序完成请求,但完成记录仍沿完成位置在环中依次写入。此时不能根据“used 记录位于槽 0”推断完成的是原来在槽 0 提交的请求;应读 Buffer ID,并查询驱动保存的链长度及缓冲区关联。单描述符且顺序完成的教学例子刚好原位覆盖,不代表一般规则。
协商 INDIRECT_DESC 后,还可以让主环一项指向环外的间接描述符表,减少主环占用。packed 的间接项使用 packed 描述符布局,顺序遍历,内部仅 WRITE 标志有效;不能直接拿 split 的 next 索引规则解释。协议要求与特性选择必须在初始化时就一致。QEMU 沿环取下一项、读取直接链和间接表
六、事件位置减少通知,复查关闭竞态窗口
每个事件结构含 off_wrap 和 flags。off_wrap 的低 15 位为描述符位置,高 1 位为该位置所属 wrap。flags 可以要求启用通知、禁用通知,或者在协商 EVENT_IDX 后按指定位置触发通知。不能只比较裸索引大小:从槽 3 到槽 0 虽然数字变小,队列实际上继续前进了。
两个事件结构由等待方填写:驱动用 Driver Event 请求完成通知,设备用 Device Event 请求新工作通知。发布方先让描述符状态可见,再读取对方的事件配置,按进度变化判断是否通知。等待方从轮询切回通知时,重新检查切换窗口内到达的工作。
如果等待方先看到没有工作,发布方恰好在通知仍被抑制时发布,等待方随后直接睡眠,就可能错过唤醒。packed 改变共享队列布局,没有消除生产者与消费者并发产生的通知竞态。内存屏障、通知抑制和最后复查仍共同构成正确交接。
七、QEMU 的链长度记录与完成发布
virtqueue_packed_pop() 从 last_avail_idx 读取链,建立可读与可写 iovec,保存 Buffer ID 和 ndescs。后者对普通直接链是占用的主环描述符数量,对间接表则为 1。I/O 完成后,fill 暂存 ID、长度和 ndescs,flush 根据完成顺序写回记录并推进 used_idx。
virtqueue_packed_pop()
检查 AVAIL / USED 与 last_avail_wrap_counter
解析链或间接表,保存 element.index 与 element.ndescs
设备后端完成 I/O
virtqueue_packed_fill():暂存完成结果
virtqueue_packed_flush():按 ndescs 推进完成位置
virtqueue_packed_fill_desc():写 ID / len,再发布 flags
驱动读取完成 ID,找回上下文与链长度,归还资源
批量 flush 会先准备后续完成,再带发布顺序约束地写出首项,使读者不会越过尚未完整写出的批次。IN_ORDER 还允许一条记录表示一批按序处理的缓冲区,这需要额外的批次推导;不能只删除逐项完成写入而保持驱动逻辑不变。完成字段与 flags、链长度与完成位置推进
八、紧凑布局带来的收益与复杂度
packed 随 VIRTIO 1.1 引入,使高吞吐设备可以用更紧凑的共享元数据交换请求。它减少某些路径上的环间跳转和内存占用,但双方都会写描述符环,也可能带来缓存行共享;实际收益依赖后端实现、批量大小、队列深度和 CPU 拓扑。
相对于 split,packed 的实现增加了 wrap、Buffer ID、跨尾部的链、完成位置及事件偏移之间的配合。双方在特性协商时选定共同布局,运行期间按这一布局访问队列;迁移目标也需支持客户机已经选定的格式。实现和验证的工作主要集中在资源归属与各方进度的对应关系上。
检查演示时,可以先发布四项,再完成两项但暂不回收:驱动仍不能继续发布,直到回收释放相应资源。这个状态把“设备已处理”与“驱动可继续提交”分开,也说明 wrap 判断必须配合空闲容量管理。评价时可以在相同报文或块请求负载下对比每核吞吐、尾延迟、缓存未命中与通知次数。小队列更省元数据,并不代表能容纳同样多的复杂请求;直接链与间接表的占用也要固定。split 仍有成熟兼容性与清晰的生产者区域,packed 提供另一种局部性取舍。
九、参考资料
- VIRTIO 1.2 §2.8:packed 布局、wrap、描述符链、完成与事件抑制。
- VIRTIO 1.1:packed 队列进入正式规范。
- QEMU virtio_ring.h:packed 描述符与事件结构。
- QEMU virtio.c:可用检查、链解析、完成发布及 IN_ORDER 分支。
- Linux virtio_ring.c:驱动发布、回收及事件判断。