virtio packed virtqueue
spilt virtqueue因其简约的设计而备受欢迎,但是它有一个基本的问题:avail, used ring 是分离的,cpu cache miss的概率比较大,从硬件角度来看意味着每个descriptor的读写操作都需要几个PC事务。packed virtqueue通过将三个ring合并到一起来改善这个问题.不过这种方式看起来非常复杂,远不如split virtqueue简约。在split版本中如果认识到在获取driver提供的数据之后device可以丢弃或者重写覆写这块buffer区域,基本就能理解packed virtqueue的实现了。
在virtio设备初始化阶段,split virtqueue和packed virtqueue的过程是相同的:协商feature, 申请virtqueue,packed virtqueue的布局如下:
struct virtq_desc {
le64 addr;
le32 len;
le16 id;
le16 flags;
};
id域不再是device寻找buffer的索引了,它只是一个对driver有意义的值。
driver同时维护了一个内部的bit位表示ring的回绕情况,初始化为1.每次回绕都会反转该位,我们称为wrapper_count,只能为0和1。
和split 中相同,第一步是更新addr,len, id, flags域。但是packed增加了两个新的flag:AVAIL(0x7) and USED(0x15)。为了标记一个desc为avail,driver必须置位AVAIL(0x7)为它的回绕标志,对于used是相反的。
一个二进制标记很容易实现,但是它会阻碍一些又用的优化,后面会讲。
通过下面的例子讲packed 数据是如何流转的.
driver现在有一个device可写的buffer:起始于0x80000000 ,长度为0x1000。之后更新desc指向这块buffer, flags中AVAIL位和wrapper_count,系统初始化为1,所以目前flags位:W|A, 整个desc如下:
Avail idx | Address | Length | ID | Flags | Used idx |
---|---|---|---|---|---|
0x80000000 | 0x1000 | 0 | W|A | ← | |
→ | … |
注意,avail idx和used idx列只是为了展示状态,他们并不在descriptor table中,只是device和driver的内部状态,每一端都需要直到需要下一次该从哪里读或者写,同时device还需要追踪driver的wrapper_counter.
最后,driver需要通知device,如step3.
如图中所示,没有avail,used ring,只有一个descriptor table.
和driver一样,device内部也维护一个wrapper_counter,初始化为1,同时它假设driver内的wrapper count是初始状态是1。当device第一次寻找driver发布的buffer时,它会需找ring中的第一满足条件的desc:flag.AVAIL, flag.USED等于driver内部的wrapper counter.
当device消费完数据,通知driver时,和split used ring一样,需要更新写入数据的长度,id为消费的desc id。最后device会设置flag.AVAIL和USED,和它内部维护的wrapper counter相同。
如下图,descriptor table如下。当flag.AVAIL == flag.USED == device.wrapper_counter时,device知道这个buffer已经返回给driver.返回的地址不重要,重要的是id。
Avail idx | Address | Length | ID | Flags | Used idx |
---|---|---|---|---|---|
0x80000000 | 0x1000 | 0 | W|A|U | ||
→ | … | ← |
当driver使用完descriptor table之后,会反转内部的wrapper_counter。所以在第二轮,再次设置avail desc时就完全不一样了,flags.AVAIL = 0并且flags.USED=1 表示avail,同样的device会意识到这种情况。让我们看一个例子:
如果我们descriptor table中有两个desc时,driver内部的wrapper_counter初始化为1,并且填充了两个avail buffer后,driver反转它内部的wrapper_counter,此时为0,此时descriptor table如下表:
Avail idx | Address | Length | ID | Flags | Used idx |
---|---|---|---|---|---|
→ | 0x80000000 | 0x1000 | 0 | W|A | ← |
0x81000000 | 0x1000 | 1 | W|A |
在此之后,device意识到descritor id 0 和 id 1都已经是可用状态: flags.AVAIL 和 它内部的wrapper_counter相同,并且没有设置 flags.USED. 如果device使用了desc id 1的buffer,descriptor table如下标,注意此时desc id 0仍然属于desc.
Avail idx | Address | Length | ID | Flags | Used idx |
---|---|---|---|---|---|
→ | 0x80000000 | 0x1000 | 1 | W|A|U | |
0x81000000 | 0x1000 | 1 | W|A | ← |
现在,driver 看到desc id 1已经被使用过了,因为 flags.AVAIL == flags.USED == device.wrapper_counter.
如果device 继续使用了desc id 0,descriptor table就如下表:
Avail idx | Address | Length | ID | Flags | Used idx |
---|---|---|---|---|---|
→ | 0x80000000 | 0x1000 | 1 | W|A|U | ← |
0x81000000 | 0x1000 | 0 | W|A|U |
但是更有趣的是仍然乱序使用,device目前只使用desc id 1, driver 看到desc id 1已经被使用,driver再次使用了 desc id 1作为avail buffer, 如下图,两个id都是 1,而且device都可用
Avail idx | Address | Length | ID | Flags | Used idx |
---|---|---|---|---|---|
0x81000000 | 0x1000 | 1 | W|(!A)|U | ← | |
→ | 0x81000000 | 0x1000 | 1 | W|A |
注意:在上次的wrap之后,driver需要反转它的wrapper_counter和used flag.当device 回绕寻找avail buffer时,它需要寻找这种符合条件的desc,很明显, index 1 的 desc id 1符合条件,之后回绕之后反转wrapper_counter, 此时index 0 和 index 1 的 buffer都可用,此时buffer id 0和 id 1都属于device,它可以再次决定首先使用desc id 1.
Chained descriptors
链式的descriptor工作方式:在desc中没有 next 域来指向下一个 desc 的索引,因为 下一个 desc一定会在它的下一个位置。但是在split used ring中, 只需要返回chain desc的头部,根据next 就可以找到后面所有的 desc; 在packed virtqueue中需要返回 尾部的desc.
回到used ring,每次我们使用chained descriptors,used idx都会滞后于avail idx. 在一次性传输多个avail desc给device,我们只会在avail ring中放一个头部的 idx, device 使用完之后也只会返回一个头部的idx . 在split ring中,这个不是问题,但是在packed virtqueue中这个就会引入一个问题。
最直接的方法是,device 标记 chained desc中的每一个desc为 USED。 但是这种方式代价比较大,因为我们修改的是共享内存,会引入cache颠簸性问题。
但是, driver 已经知道这个chain, 它可以忽略最后一个id之前的所有的chain desc,这也就是我们为什么需要比较used/avail 和driver/device wrapper_counter: 在跳转后,我们不知道下一个 desc是否是可用的,或者
如下表, 我们一个desc table中有4个desc, driver通过chain desc发布了三个avail desc:
Avail idx | Address | Length | ID | Flags | Used idx |
---|---|---|---|---|---|
0x80000000 | 0x1000 | 0 | W|A | ← | |
0x81000000 | 0x1000 | 1 | W|A | ||
0x82000000 | 0x1000 | 2 | W|A | ||
→ | 0 |
在此之后, device 发现index 0之后的desc是可用的,然后将其标记为used,然后发布给driver: 只在 index 0位置处写入了flags.USED, 并且 id = 2; 当driver 查看是否已经被使用,它将会跳过index 1 和 2, driver知道这3个desc是chained 关系。
Avail idx | Address | Length | ID | Flags | Used idx |
---|---|---|---|---|---|
0x80000000 | 0x1000 | 2 | W|A|U | ||
0x81000000 | 0x1000 | 1 | W|A | ||
0x82000000 | 0x1000 | 2 | W|A | ||
→ | 0 | ← |
现在 driver 发布了另外带有2 个desc的chained desc, 在这个过程中它会反转内部的wrapper,状态如下表:
Avail idx | Address | Length | ID | Flags | Used idx |
---|---|---|---|---|---|
0x81000000 | 0x1000 | 1 | W|(!A)|U | ||
→ | 0x81000000 | 0x1000 | 1 | W|A | |
0x82000000 | 0x1000 | 2 | W|A | ||
0x80000000 | 0x1000 | 0 | W|A | ← |
之后,device 使用这2个desc的chained desc, 现在只需要更新chained desc中的第一个: id = 0, flags.AVAIL = flags.USED = device.wrapper_counter;
Avail idx | Address | Length | ID | Flags | Used idx |
---|---|---|---|---|---|
0x81000000 | 0x1000 | 1 | W|(!A)|U | ||
→ | 0x81000000 | 0x1000 | 1 | W|A | ← |
0x82000000 | 0x1000 | 2 | W|A | ||
0x80000000 | 0x1000 | 0 | W|A|U |
尽管index 0位置处的desc看起来像是avail的:flags.AVAIL != flags.USED, 但是device通过判断它的flags组合知道它不是,正确的标志应该是flags.AVAIL = 0, flags.USED = 1;
Indirect descriptors
indirect descriptor工作机制和split virtqueue非常相似。首先driver 分配一个 indirect desc 的空间,它和普通的packed virtqueue desc中的布局是完全相同的。 之后设置每一个indirect desc指向的buffer信息,如下图中的step 1-2; 最终在desc 中设置,指向indirect desc table,并且标记上 VIRTQ_DESC_F_INDIRECT ,如下图step 3,起始地址和长度对应于indirect desc table的信息。
在packed virtqueue中, buffer的顺序完全依赖于indirect desc table中的顺序,在indirect desc table中 id 是完全没有意义的,唯一有意义的flag是VIRTQ_DESC_F_WRITE。 driver 通知 device的条件和普通的packed virtqueue是相同的。
假如我们要发布三个buffer,通过indirect table,首先需要申请三个desc空间的indirect desc table,长度为48字节,更新indirect table项指向三个buffer:
Avail idx | Address | Length | ID | Flags | Used idx |
---|---|---|---|---|---|
0x80000000 | 0x1000 | … | W | ||
0x81000000 | 0x1000 | … | W | ||
0x82000000 | 0x1000 | … | W |
假如indirect table的地址在 0x83000000 处,我们需要更新desc table, 第一个可用的位置为index 0:
Avail idx | Address | Length | ID | Flags | Used idx |
---|---|---|---|---|---|
0x83000000 | 48 | 0 | A|I | ← | |
→ | … |
当indirect buffer被消费后,device 将会在index 0 返回 id 0, flags.USED. desc表和普通的packed desc表基本相似,只是flags.INDIRECT被置位了。
Avail idx | Address | Length | ID | Flags | Used idx |
---|---|---|---|---|---|
0x80000000 | 48 | 0 | A|U|I | ||
→ | … | ← |
在此之后,device就不能再访问这个indirect table除非driver将其再次发布为avail状态,此时driver可以释放这块内存或者重用它。
Notifications
和split virtqueue一样, device/driver 每一端都维护了一个相同的结构来控制对方发送通知给自己。driver维护的对于device来说是只读的,同样的,device维护的对于driver来说也是只读的。
struct pvirtq_event_suppress {
le16 desc;
le16 flags;
};
flags的值的意义:
0: 通知总是enabled的
1:通知总是disable的
2: 当desc满足条件时再通知
如果flags == 2, 另一方判断是否通知的条件:
- 内部的wrapper counter == desc的最高位
- desc的index == desc & 0x7fff
这种方式通过VIRTIO_F_RING_EVENT_IDX来呈现,需要device/driver都支持并且在初始化过程中协商好;
这些机制都不时100%可靠的,因为当我们设置这些值时可能对方已经发送了notifications, 如果真的不想被通知,那就直接禁止它吧。
注意,packed virtqueue的size并不一定要求是2的次方,所以notifications结构可以和desc一同放在同样的一个页内,这样在某些实现上有优势。