I/O 虚拟化(三):Packed Virtqueue

Table of Contents

这篇文章被转载到 Kata Containers 公众号「Virtio I/O 虚拟化(二):Packed Virtqueue」。

上一篇「Virtio I/O 虚拟化(一):Split Virtqueue」介绍了 split virtqueue 的实现细节。Virtio 1.1 版本引入了全新的 packed virtqueue,但是现有介绍资料寥寥无几。本篇文章将继续深入探讨 packed virtqueue 的实现细节。

为什么?

一个很自然的问题:为什么有了 split virtqueue 我们还需要 packed virtqueue?

Packed virtqueue 最早是在 2018 年提出,为了解决 split virtqueue 的性能和实现复杂性问题

Split virtqueue 使用了 descriptor table、avail ring 和 used ring 三个独立的数组,并在 driver 和 device 之间共享。数据被更新时,不同的 CPU 就不可避免刷新缓存。数据散落在不同数组会导致 cache 频繁刷新 [1],进而导致 cache 利用率不足 [2,4]。Packed virtqueue 将数组合并以提升 cache 利用率。

在实现 packed virtqueue 的 patches [3] 中,Tiwei Bie 提到:在 DPDK vhost 场景中,有约 30% 的性能提升。

A performance test between pktgen (pktgen_sample03_burst_single_flow.sh) and DPDK vhost (testpmd/rxonly/vhost-PMD) has been done, I saw ~30% performance gain in packed ring in this case.

第二个是 device 更容易实现 [5]。之前我们提到 split virtqueue 有非常多的规矩,比如 device 不能写 descriptor table 和 avail ring,driver 不能写 used ring。在开发 device 时需要遵守这些规矩,进而导致开发复杂度增加。Packed virtqueue 只有一个 descriptor table,且 device 和 driver 都有读写权,降低了实现复杂度(当然以增加理解复杂度为代价)。

实现

Descriptor Table

Packed virtqueue 核心概念依然还是 descriptor table、avail ring 和 used ring,但是实现上就天差地别了。

Descriptor table 新增了两个 flags,分别是 VRING_DESC_F_AVAILVRING_DESC_F_USED。Avail ring 和 used ring 通过这两个 flags 被整合到了 descriptor table 中。也即在 host 和 guest 之间,仅需共享 descriptor table。

Driver 和 device 内部都各自维护了 avail wrap counter 和 used wrap counter,其类型是布尔值。配合上面两个新 flags 实现区分 avail ring 和 used ring,具体细节我们稍后展开。

Descriptor table 的元素定义也有了变化,如下所示:

// for split virtqueue
struct vring_desc {
__virtio64 addr;
__virtio32 len;
__virtio16 flags;
__virtio16 next;
};
// for packed virtqueue
struct vring_packed_desc {
__le64 addr;
__le32 len;
__le16 id;
__le16 flags;
};

其中:

  • 结构体长度没有变化,都占用 16 bytes,因此在申请内存的时候可能会出现混用结构体的情况。
  • addrlenflags 没有变化,split virtqueue 的 next 被移除了,取而代之的是 id 字段,具体的原因稍后展开。

Descriptor State

为了说清楚 id 字段,我们不得不先讨论下 virtqueue 内部实现:descriptor state(简称 desc state)和 descriptor table(简称 desc table)的关系和作用。

Desc table 和 desc state 的长度是一样的,比如 virtqueue size 是 256,那么它们的长度都是 256。

Desc state 是一个数组,其元素的类型是 struct vring_desc_state_packed,定义如下所示:

struct vring_desc_state_packed {
void *data;
struct vring_packed_desc *indir_desc;
u16 num;
u16 next;
u16 last;
};

其中:

  • data: 指针指向一个 buffer;
  • num: descriptor(简称 desc)的长度,比如一个普通的 desc,其长度为 1,一个含有 3 个 descs 的 chained descriptor(简称 cdesc),其长度为 3。
  • nextlast: 用于组成一个循环链表。

上一章节介绍 split virtqueue 时,我们说 desc table 是“一个指向实际数据 buffer 的指针”。数据真正的持有者是 desc state,也即 data 字段指向的就是真正的 buffer(desc table 的 addr 字段存放的是 DMA 地址)。

Driver 会在 desc state 维护一个空闲链表,表示 driver 目前可用的 descs。Virtqueue 提供一个 free_head 字段作为空闲链表的入口,通过 next 字段将空闲链表组织在一起,如下图所示。左边的 id 一列表示 desc state 的索引,不会被显式保存。

packed-virtqueue.drawio

以 Virtio-Vsock 为例:介绍 Desc Table 和 Desc State 的关系

想要全面了解 desc table 和 desc state,需要理解 buffer、desc、scatterlists 等之间的关系。为了理清各中关系,本章节以 virtio-vsock 为例,介绍 vsock 发包(tx queue)流程。

Virtio-vsock 数据包在 guest 内部的结构体如下所示:

struct virtio_vsock_pkt {
struct virtio_vsock_hdrhdr;
struct list_head list;
struct vsock_sock *vsk;
void *buf;
u32 buf_len;
u32 len;
u32 off;
bool reply;
bool tap_delivered;
};

其中:

  • 该结构在代码中一般被声明为 struct virtio_vsock_pkt *pkt
  • hdrbuf 分别表示包头部和原始数据,包头部包含了数据包的源地址和目的地址等信息;
  • 只有 hdrbuf 需要被传递给 device。

Virtio-vsock 会准备 out_sgsin_sgs 两个 scatterlists,分别存放 driver 向 device 写的数据和 device 向 driver 写的数据。在我们的例子中是 driver 发送 device 的数据,因此 in_sgs 是空的。

Virtio-vsock 会将数据包的 hdrbuf 分别放到 out_sgs[0]out_sgs[1],并将 pkt 转换为 void * 类型,传递给 virtio 层的 virtqueue_add_sgs()

packed-virtqueue-Page-2.drawio

virtqueue_add_sgs() 最终调用 virtqueue_add_packed() 方法,将数据包添加到 packed virtqueue,最终效果如下图所示。

packed-virtqueue-Page-3.drawio

Scatterlist 组成了一个 chained descriptor,列表中的每个元素都会对应一个 desc 并存放到 desc table 和 desc state 中。Desc 的 id 字段保存的是该 chained descriptor 的第一个元素在 desc state 的索引(desc state 的虚线 id 字段),hdr 和 buf 对应的 desc 的 id 字段的值都为 1。由于 desc state 不会被共享到 host,因此该值仅对 driver 有意义

有了 id,driver 能够找到对应的 desc state 的元素,就能获取到:

  • data 字段指向的数据包,也即 pkt
  • num 字段保存了这个 chained descriptor 有几个 descriptors,在这个例子里其值是 2;
  • lastnext 字段构成了 chain descriptor 的循环链表。

从这个结构中,我们可以得出一些结论:

  • Desc state 的 data 字段指向的 buffer 与真正在 virtqueue 数据是不一样的,一个 buffer 可以被分成多个部分(hdr 和 buf 两个部分);
  • Desc state 表中,只有 chained descriptor 的第一个元素实际保存数据,其他的仅填充 next 字段,用于将链表串起来,比如 id 为 3 的主要数据都是空的;
  • 对于一个 chained descriptor 内的 descriptors 来说,desc table 的存储顺序是线性的,desc state 则不一定;
  • 新的数据到来时会存储到 free_head 指向的空闲链表中,更新完毕后 free_head 字段会重新指向空闲链表新的入口,比如更新前 free_head 的值是 1,更新后的值是 4。

区分 Avail Ring 和 Used Ring

Desc table 最大的变化之一是取消了实体 avail ring 和 used ring,取而代之的是两个全新 flags 和 wrap counter。Wrap counter 是一个 bool 类型的值,初始阶段都会被设置为 1(即 true)。

Driver 和 device 都维护了 avail wrap counter(简称 avail wc)和 used wrap counter(简称 used wc),但是它们并不会被共享,都是各自维护各自的状态。

先说规则:

| Ring | Wrap Counter | Flags | | ---------- | ------------- | --------------------------------------------- | | avail ring | avail wc == 1 | VRING_DESC_F_AVAIL |(!VRING_DESC_F_USED) | | avail ring | avail wc == 0 | (!VRING_DESC_F_AVAIL) |VRING_DESC_F_USED | | used ring | used wc == 1 | VRING_DESC_F_AVAIL |VRING_DESC_F_USED | | used ring | used wc == 0 | (!VRING_DESC_F_AVAIL) |(!VRING_DESC_F_USED) |

看到这里,不禁会问一句:为什么会要这样设计?

我们以 avail ring 为例来说明,在 avail wc 为 1 的时候,有 VRING_DESC_F_AVAIL 标志位表示 avail ring,这很符合直觉。如下图所示,在上一轮 desc table 全部槽位已经用尽的情况下,如何新一轮中继续区分 avail ring?

packed-virtqueue-Page-4.drawio

其中,W 表示 VRING_DESC_F_WRITE,A 表示 VRING_DESC_F_AVAIL,U 表示 VRING_DESC_F_USED

这张图的 desc table 有 4 个槽位,已经都被填满了,在新一轮中需从第一行开始填充,那么现在你会面临一个问题:如何继续表示 avail ring?

一个非常直接的方法是清除所有的 flags,之后再添加 "A" flag。虽然非常符合直觉,但是有几个问题:

  • 清除全部的数据会导致缓存失效,带来性能问题;
  • 在进入新一轮的时候,并不是全部都被 device 消费了,那么在清除过程中需要通过额外机制来区分:哪些被消费了,哪些没被消费(即上一轮剩余的、还没被 device 消费的 descriptors),这将引入更高的复杂性。

Chained Descriptor

Chained descriptor(简称 cdesc)也有了一些变化。从 descriptor 的定义中能够看出,split virtqueue 的 next 字段被删除了,取而代之的是 VRING_DESC_F_NEXT flag。

Split virtqueue 允许一个 cdesc 内的 descs 以链表方式保存,因为 desc table 有一列 next 字段。但是 packed virtqueue 要求一个 cdesc 内的 descs 必须顺序存放,并且除了最后一个 desc 都需要有添加一个 VRING_DESC_F_NEXT flag。

通常 cdesc 的长度大于 1,driver 会写入多个 descs 到 desc table。不过 device 在归还的时候,只需要写回到第一个 desc 即可。

一个 cdesc 被写入到 desc table 时,除了首 desc 之外的其他 descs 会被首先写入到 desc table(当然首 desc 的位置是被提前空出来了),然后插入一个写屏障后,再写入首 desc 到 desc table。 这个机制保证了 device 看到首 desc 时,整个 cdesc 已经就绪了。

生产和消费顺序

Packed virtqueue 的变化使我们更在意它的生产和消费顺序,比如 cdesc 是严格的顺序存放的。在 OASIS 文档中 [6],有三段对生产和消费顺序的描述:

The Descriptor Ring is used in a circular manner: the driver writes descriptors into the ring in order. After reaching the end of the ring, the next descriptor is placed at the head of the ring. Once the ring is full of driver descriptors, the driver stops sending new requests and waits for the device to start processing descriptors and to write out some used descriptors before making new driver descriptors available.

Similarly, the device reads descriptors from the ring in order and detects that a driver descriptor has been made available. As processing of descriptors is completed, used descriptors are written by the device back into the ring.

Note: after reading driver descriptors and starting their processing in order, the device might complete their processing out of order. Used device descriptors are written in the order in which their processing is complete.

上面这一大段话总结起来就这 5 个规则:

  1. Driver 写 desc table 是顺序的;
  2. Avail ring 没有空位的时候,driver 等待直到有位置可写;
  3. Device 读 desc table 是顺序的;
  4. Device 处理 avail ring 不是顺序的;
  5. Device 写 desc table 是顺序的。

示例

上面规则说了非常多,还是要通过一个具体的例子来理解究竟 packed virtqueue 是什么,究竟是如何运作的。

在这个例子中,我们期望 driver 分别生产一个长度为 2 的 cdesc 和一个 desc,device 消费上述两个 descs,整个生产消费过程重复两次。

packed-virtqueue-case-step-1.drawio

Fig.1 展示了 desc table 刚被初始化时的状态。这个例子中的 desc table 的长度为 4。Driver 和 device 都会维护一些不被共享的内部状态:

  • "Avail n" (avail next) 表示 avail ring 的下一个写入/读取的位置,"used n" (used next) 表示 used ring 的下一个写入/读写位置;
  • "Used_wrap_counter" 和 "avail_wrap_counter" 已经介绍过了,它们在初始化阶段都会被置为 1;
  • "Avail flag" 和 "used flag" 是根据 wrap counter 计算出来的,driver 和 device 内部没有存储该字段,这里写出来仅仅是为了方便理解。

Fig.2 展示了 driver 分别写入了一个 cdesc(0x800000000x81000000)和一个 desc(0x82000000)。当前的 avail_wrap_counter 的值是 1,因此 avail ring 对应的 flag 是 A|(!U)。这两个 descriptors 都是有 "W" flag,期望由 device 填充数据。

packed-virtqueue-case-step2.drawio

Fig.3 展示了 device 处理在 avail ring 中的 cdesc 的过程。内部的 "avail n" 指针后移 2 个单位(cdesc 的长度),device 会将 addr 字段保存的 DMA 地址映射为 VMM 的内存。

Fig.4 展示了 device 处理在 avail ring 中的 desc 的过程,与之前介绍的一样,不过 "avail n" 指针只会后移 1 个单位。

packed-virtqueue-case-step-3.drawio

Fig.5 展示了 device 向 used ring 归还 cdesc 的过程。一个 cdesc 在归还的时候只需要写回首元素(第一行),且不需要写回 addr 字段,因为 driver 的 desc state 记录了有关 buffer 的一切信息。虽然只写回一行数据,但是 "used n" 指针需要跳跃 2 个长度(cdesc 的长度)。

Fig.6 展示了 device 归还另一个 desc 的过程。基本流程相同,除了 "used n" 指针只后移 1 个单位。

packed-virtqueue-case-step-4.drawio

Fig.7 展示了 driver 从 used ring 获取 buffers 的过程。基本上与上面无差别,所以不再赘述。最终 "used n" 跳跃 3 个长度指向了第四行,在这个过程中不会向 desc table 写入任何数据。

Fig.8 展示了 driver 继续写入一个长度为 2 的 cdesc。第一个 desc 写入到了第四行之后,driver 转回第一行继续写入第二个 desc。在写同一个 cdesc 时,虽然进入了新一轮,但是 avail/used wrap counter、avail/used flag 的值都暂时不会改变,直到当前 cdesc 写入完成。从图中能看到的是,地址 0x82000000 的 desc 的 flag 依然是 A|(!U)

在 Fig.8 的 driver 中新增了一个 free 字段,它表明在 driver 视角下 desc table 还有多少是空余的,如果 free 降低到 0,则 driver 向 desc table 的写入操作将会被阻塞。

packed-virtqueue-case-step-5.drawio

Fig.9 展示了 driver 写入一个 desc 的过程。由于 avail_warp_counter 已被翻转,因此标记 avail ring 的 flag 变为了 (!A)|U

Fig.10 展示了 device 读取、处理和归还 descs 的最终结果,具体过程留给读者思考。

引用

  1. https://plantegg.github.io/2021/05/16/CPU_Cache_Line%E5%92%8C%E6%80%A7%E8%83%BD
  2. http://blog.chinaunix.net/uid-28541347-id-5819237.html
  3. https://lore.kernel.org/lkml/[email protected]/
  4. https://www.redhat.com/en/blog/packed-virtqueue-how-reduce-overhead-virtio
  5. https://mails.dpdk.org/archives/dev/2018-January/089417.html
  6. https://docs.oasis-open.org/virtio/virtio/v1.3/csd01/tex/packed-ring.tex
All rights reserved
Except where otherwise noted, content on this page is copyrighted.