引言
Host 要读 NVMe 盘上数据,需要告诉 SSD 需要什么数据(LBA),需要多少数据(length),以及数据最后需要放到 Host 内存的哪个位置(buffer address)。这些信息包含在 Host 向 NVMe 盘发送的 READ 命令中。
NVMe 盘收到这个请求后,根据 LBA 查找映射表,找到该请求的数据内容所对应的闪存物理的位置,然后读取闪存获得数据。数据从闪存读上来以后,对 NVMe/PCIe 来说,NVMe SSD 会通过 PCIe 把数据写入到 Host 指定的内存中。
Host 告知 NVMe 盘 buffer address 的方式之一就是 PRP(Physical Region Page)。与之相对应的另一种是 SGL(Scatter / Gather List)——在 NVMe Spec v1.1 中增加。
这篇文章就来详细分析一下 PRP 和 SGL 都描述了些什么,它们所对应的格式是怎么样的。
PRP 数据传输方式
PRP 顾名思义,就是以页为单位来传输数据,一个 PRP 项(Entry)的定义如下图:包括页基址和页内偏移。
如果我们要读一个 4KiB 的数据,NVMe driver 就需要把 buffer 的物理地址(先假设这个地址是 4KiB 对齐的)填到 PRP (PRP1)项中。如果我们要读 8 KiB 的数据,就需要再增加一个 PRP (RPR2) 项,依次类推。但是我们提到 PRP 是封装在 NVMe 命令中给到 NVMe 盘的,一个 NVMe 命令的大小是固定的 64B,我们显然无法根据 IO 的大小去线性增加 PRP 项的数量。所以当数据大于 8K 后,我们就需要再另一个位置申请对应数量的 PRP 项,填入每个页的物理地址,然后让 PRP2 指向到存储多个 PRP 项的位置(同样是填物理地址)。这就是基本的 PRP 数据传输的原理。
NVMe 盘会根据数据长度来解析这个 PRP ,详细过程可以参考这个文档:SSD NVMe核心之PRP算法。
SPDK 如何封装 PRP 请求
Talk is cheap. Show me the code.
SPDK 对 RPR 请求的封装代码在 nvme_pcie_prp_list_append()
函数中,如下所示:
- prp1 首先保存 IO buffer 的首物理地址
- 再根据 IO 大小和 buffer 的首地址计算并填充地址到
tr->u.prp
结构中 - 当 IO buffer 只有 1 个 page 时,prp2 赋值为空;
- 当 IO buffer 有 2 个 page 时,prp2 直接保存第二段其实物理地址;
- 当 IO buffer 有 2 个以上 page 时,prp2 等于
tr->prp_sgl_bus_addr
这个成员保存的值;这里就是我们前面说的,新的物理地址,用来存放多个 PRP 项的位置。这个值理论上应该要等于 spdk_vtophys(tr->u.prp)
,这个一定要想明白。
static int
nvme_pcie_prp_list_append(struct nvme_tracker *tr, uint32_t *prp_index, void *virt_addr, size_t len,
uint32_t page_size)
{
// ....
while(len) {
// ...
if (i == 0) {
// SPDK_DEBUGLOG(SPDK_LOG_NVME, "prp1 = %p\n", (void *)phys_addr);
cmd->dptr.prp.prp1 = phys_addr;
seg_len = page_size - ((uintptr_t)virt_addr & page_mask);
} else {
if ((phys_addr & page_mask) != 0) {
SPDK_ERRLOG("PRP %u not page aligned (%p)\n", i, virt_addr);
return -EINVAL;
}
tr->u.prp[i - 1] = phys_addr;
seg_len = page_size;
}
// ....
}
if (i <= 1) {
cmd->dptr.prp.prp2 = 0;
} else if (i == 2) {
cmd->dptr.prp.prp2 = tr->u.prp[0];
// SPDK_DEBUGLOG(SPDK_LOG_NVME, "prp2 = %p\n", (void *)cmd->dptr.prp.prp2);
} else {
cmd->dptr.prp.prp2 = tr->prp_sgl_bus_addr;
// SPDK_DEBUGLOG(SPDK_LOG_NVME, "prp2 = %p (PRP list)\n", (void *)cmd->dptr.prp.prp2);
}
// ...
}
SGL 数据传输方式
顾名思义, SGL 是以链表的形式记录数据 buffer 地址的方式。一个 SGL 由一个或者多个 SGL Segment 组成,而每个 SGL Segment 又由一个或者多个 SGL Descriptor 组成。SGL Descriptor是 SGL 最基本的单元,它描述了一段连续的物理内存空间:起始地址+空间大小。
每个 SGL Descriptor 大小是 16 字节。一段内存空间,可以用来放用户数据,也可以用来放SGL Segment。根据这段空间的不同用途,SGL Descriptor 可分 4 种类型:
Data Block 描述符:该描述符指向的这段空间是用户数据空间;
- Segment 描述符:SGL 由 SGL Segment 构成的一个链表。前面一个Segment 需要有个指针指向下一个Segment,这个指针就是SGL Segment 描述符,它描述的是它下个 Segment 所在的空间;
Last Segment 描述符:链表中倒数第二个 Segment,它的 SGL Segment 描述符我们把它叫做 SGL Last Segment 描述符。它本质还是 SGL Segment 描述符,描述的还是 SGL Segment 所在的空间。
- 为什么需要把倒数第二个 SGL Segment 描述符单独的定义成一种类型?我认为是让 SSD 在解析 SGL 的时候,碰到 SGL Last Segment 描述符,就知道链表快到头了,后面只有一个 Segement 了。
- SGL Bit Bucket 描述符 :SGL Bit Bucket 描述符在 NVMe SGL 中的主要作用是作为占位符或填充,用于记录需要被忽略的数据块。例如 Host 读一个 11 KB 的数据,5KB 之后的 2KB 在实际应用需求中可以不需要读取,此时可以只配置一个 9KB 的内存,并且在 SGL 描述符中标记 5KB 占位 2KB 无需读取。对写请求来说,SGL Bit Bucket 描述符是无效的。
看完这些还是晕晕的,我们看一个实际的 NVMe 读的例子,包含了上面提到的 4 种描述符,更具象地理解这 4 种描述符的作用。如下图是一个数据读请求构成的一个 SGL 结构:Host 期望将这 13KB 数据中的 11KB 数据读到主机内存的三块非连续的地址上。这个 13KB 的读请求的 SGL 结构分为 3 个 SGL Segment:
- 第一个 SGL Segment 中有 2 个 SGL 描述符。第 1 个 SGL 描述符是Data Block 描述符,指向第一块 3KB 的内存;第 2 个是 Segment 描述符,指向下一个 Segment;
- 第二个 SGL Segment 中有 3 个 SGL 描述符。第 1 个 SGL 描述符是Data Block 描述符,指向第二块 4KB 的内存 Data Block B;第 2 个是 SGL Bit Bucket 描述符, 记录从第 7KB 开始的 2KB 内容无需传输;第 3 个是 Last Segment 描述符,指向最后一个 Segment;
- 第三个 SGL Segment 中只有 1 个 SGL 描述符,是一个 Data Block 描述符,指向最后一块 4KB 的内存。
SPDK 如何封装 SGL 请求
Talk is cheap. Show me the code.
SPDK 对 RPR 请求的封装代码在 nvme_pcie_qpair_build_hw_sgl_request()
函数中,构建 SGL 的回调函数为 bdev_nvme_queued_reset_sgl()
和 bdev_nvme_queued_next_sge()
。nvme_pcie_qpair_build_hw_sgl_request()
函数的实现如下,看起来很复杂,抽象一下如下:
- 根据 payload 的长度不断在
sgl = tr->u.sgl;
填入数据块的地址和长度,构建成多个 Data Block 描述符组成的 Segment。 在对数据处理完后,首先判断数据最终由多少个描述符组成
- 如果内存地址只需要一个描述符即可描述,即只有一个 iov ,则直接将 NVMe 命令中的 sgl 位置赋值为对应的物理地址,并将该描述符标记为
SPDK_NVME_SGL_TYPE_DATA_BLOCK
; - 如果需要多个描述符来描述内存地址,则构造一个 Last Segmet 描述符
SPDK_NVME_SGL_TYPE_LAST_SEGMENT
,指向上述由多个 Data Block 构建出来的 Segment;
- 如果内存地址只需要一个描述符即可描述,即只有一个 iov ,则直接将 NVMe 命令中的 sgl 位置赋值为对应的物理地址,并将该描述符标记为
static int
nvme_pcie_qpair_build_hw_sgl_request(struct spdk_nvme_qpair *qpair, struct nvme_request *req,
struct nvme_tracker *tr)
{
// ...
req->payload.u.sgl.reset_sgl_fn(req->payload.u.sgl.cb_arg, req->payload_offset);
sgl = tr->u.sgl;
req->cmd.psdt = SPDK_NVME_PSDT_SGL_MPTR_CONTIG;
req->cmd.dptr.sgl1.unkeyed.subtype = 0;
remaining_transfer_len = req->payload_size;
while (remaining_transfer_len > 0) {
// ...
rc = req->payload.u.sgl.next_sge_fn(req->payload.u.sgl.cb_arg, &virt_addr, &length);
// ...
remaining_transfer_len -= length;
sgl->unkeyed.type = SPDK_NVME_SGL_TYPE_DATA_BLOCK;
sgl->unkeyed.length = length;
sgl->address = phys_addr;
sgl->unkeyed.subtype = 0;
sgl++;
nseg++;
}
if (nseg == 1) {
/*
* The whole transfer can be described by a single SGL descriptor.
* Use the special case described by the spec where SGL1's type is Data Block.
* This means the SGL in the tracker is not used at all, so copy the first (and only)
* SGL element into SGL1.
*/
req->cmd.dptr.sgl1.unkeyed.type = SPDK_NVME_SGL_TYPE_DATA_BLOCK;
req->cmd.dptr.sgl1.address = tr->u.sgl[0].address;
req->cmd.dptr.sgl1.unkeyed.length = tr->u.sgl[0].unkeyed.length;
} else {
/* For now we can only support 1 SGL segment in NVMe controller */
req->cmd.dptr.sgl1.unkeyed.type = SPDK_NVME_SGL_TYPE_LAST_SEGMENT;
req->cmd.dptr.sgl1.address = tr->prp_sgl_bus_addr;
req->cmd.dptr.sgl1.unkeyed.length = nseg * sizeof(struct spdk_nvme_sgl_descriptor);
}
return 0;
}
总结
至此,PRP 和 SGL 的基本原理我们应该非常清晰了,最后对比一下 SGL和PRP 的区别:
- 一个 SGL 描述符不仅提供了内存基地址,还提供了该段内存的大小信息(Length);PRP只提供了一个基地址,内存大小需要 NVMe 控制器根据命令上下文去计算;
- SGL 可描述任意的内存空间,相对 PRP 来说更灵活, PRP 只能按页访问内存;
SGL 提供一个新的 Bit Bucket 机制,对于一段连续的 LBA 空间,可以指定其中的一部分数据不传输
参考链接
- 蛋蛋读NVMe之三
- NVM Express 1.0e. January 23, 2013
- SSD NVMe核心之PRP算法
Comments | NOTHING