开放通道 SSD
最后更新于
这有帮助吗?
最后更新于
这有帮助吗?
NAND 闪存固态硬盘广泛用作主要存储设备,因其低功耗和高性能。然而,固态硬盘存在不可预测的 IO 延迟、日志重叠问题以及资源利用不足等问题。
原文链接:Open Channel固态硬盘
作者:ARKA SHARMA、AMIT KUMAR、ASHUTOSH SHARMA
随着固态硬盘的普及,对更可预测的 IO 延迟的需求也在增长。传统的固态硬盘通常向主机暴露一个块接口,但常常无法满足这一需求。原因在于 NAND 闪存的工作方式。通常,在固态硬盘内部,闪存被划分为由多个芯片组成的单元。每个芯片包含若干个 die,每个 die 可以独立执行闪存命令(读/写/擦除)。这些 die 内部包含多个 plane,多个 plane 可以一次性执行相同的闪存命令,从而提高效率。每个 plane 内包含多个块,块是擦除单位,而块内包含的页则是读写单位。
这些芯片可以组织成多个通道,能够独立地在 NAND 和闪存控制器之间进行数据传输。众所周知,NAND 中的页无法被直接覆盖,因此必须先擦除一个块,然后才能将新数据写入该块的页。块的擦除次数是有限的,这个次数也叫做 PE(编程/擦除)次数,不同类型的 NAND 的 PE 次数各不相同。例如,SLC NAND 的 PE 次数大约为 100,000,MLC 的 PE 次数在 1,000 到 3,000 之间,而 TLC 的 PE 次数范围则是 100 到 300。通常,固态硬盘内部会运行一个闪存转换层(FTL),它实现了一个日志结构方案,通过使前一个内容失效来为主机提供就地更新的抽象。FTL 还实现了一个映射方案来支持这一过程。
与任何日志结构实现一样,随着时间的推移,写操作会导致碎片化,从而需要进行垃圾回收(GC)以擦除无效数据并创建空闲块。在固态硬盘的情况下,这意味着需要将有效页从一个块(GC 源)移动到另一个块(GC 目标),然后擦除源块并将其标记为空闲。整个任务对主机是透明的,但主机会遭遇固态硬盘性能下降,同时,GC 操作还会影响闪存介质的使用寿命,因为它将有效数据写入 GC 目标块。已有若干研究和解决方案来减轻这一问题,如引入 TRIM/UNMAP 机制,旨在通过以某种方式使主机的数据无效,从而最小化 GC 操作中需要移动的页数。多流固态硬盘是一种尝试将数据按生命周期相似的方式存储在同一个擦除块中的技术,从而减少碎片化,这也在一定程度上缓解了 GC 的压力。工作负载分类是另一种减少碎片化的方式。开放通道固态硬盘(OCSSD)则通过将部分 FTL 的职责转移给主机来提高预测性和更好的资源利用。通常,固态硬盘的职责可以分为以下几个类别:数据放置、I/O 调度、介质管理、逻辑到物理(L2P)地址转换和错误恢复。
OC固态硬盘可以将全部(完全主机管理的开放通道固态硬盘(1.2))或部分(主机驱动的开放通道固态硬盘(2.0))职责转移给主机。我们的工作受 LightNVM 启发,LightNVM 是 Linux 实现的开放通道固态硬盘,Linux 特有的部分已被修改以适应 FreeBSD 的生态系统。正如在 LightNVM 中观察到的,共享职责模型能够更好地平衡各方,而不会过度增加主机的负担。我们探索了一种 OC固态硬盘模型,其中数据放置、L2P 管理、I/O 调度和部分 NAND 管理由主机完成。错误检测和恢复等任务仍然在设备端进行。OC固态硬盘向主机暴露一个通用的抽象几何结构,包括介质(NAND)的几何信息、磨损均衡阈值、读/写/擦除时序以及写入约束(最小/最佳写入大小)。
几何信息通常通过通道数、芯片、块和页数来描述底层 NAND 媒体中的并行性。主机可以通过命令查询块的状态,并获得以下信息:LBA 起始地址、块内当前写入偏移量以及块的状态(已满、空闲、开放、坏块)。驱动器提供关于块健康状况的实时反馈,从而提醒主机在需要时将数据从这些块中移走。
目前,基本的读写用例已经通过 FIO 测试,但由于带宽不可用,垃圾回收(GC)这一必须具备的功能尚未开发。所有开发工作都在 QEMU 上进行,因此当前也没有性能基准数据。在我们收到有关 Linux 5.15 中移除 LightNVM 的更新之前,我们计划将这一解决方案实现为 GEOM 类,并结合一些特定的方案,例如使用带有 NVRAM/NVDIMM/PCM 缓存的自定义设备,并与开放通道固态硬盘配合使用。但现在,我们已决定放弃这些想法。未来,我们期待参与 FreeBSD 中与 NVMe ZNS 相关的工作。
我们将工作分为两个部分:我们称之为 pblk 的 FTL 部分和我们称之为 lightnvm 的驱动程序,命名方式与 Linux 中的 LightNVM 保持一致。我们遵循 nvd 模型编写了 lightnvm 驱动程序。lightnvm 驱动程序创建了一个 DEVFS 条目 “lightnvm/control”,可以通过该条目由各种工具(如 nvmecli)管理 OC固态硬盘设备。我们在 nvmecli 中添加了对 OC固态硬盘设备的支持。底层 NVMe 驱动程序(sys/dev/nvme)初始化设备并通知 lightnvm 驱动程序。lightnvm 驱动程序将设备注册到 lightnvm 子系统,lightnvm 系统启动初始化过程,并通过 NVMe Geometry 管理命令(http://lightnvm.io/docs/OCSSD-2_0-20180129.pdf)从设备查询底层介质的几何信息。设备几何信息填充完成后,lightnvm 子系统将设备及其几何信息和其他 NAND 属性注册。
当用户通过 nvmecli 发起创建 OC固态硬盘目标时,lightnvm 驱动程序从 OC固态硬盘中划分出请求的空间,并为该目标创建一个与 geom 子系统交互的“磁盘”实例。I/O 请求通过策略例程进行拦截,并转发到 pblk 子系统进行进一步处理。I/O 操作完成后,nvme 会通知 lightnvm,lightnvm 将通知 pblk,并最终传递到 geom 层。
我们将 pblk 层中的 FTL 算法保持与 LightNVM 大致相同。我们将映射单元定义为 4K(也称为扇区),这意味着每个大小为 4K 的逻辑页将映射到物理页的 4K 部分,而物理页通常大于 4K。我们使用 nvmecli 来划分并创建目标。在创建目标时,我们可以选择目标类型,从而选择底层的 FTL(如果有多个 FTL 的话)。
如前所述,NAND 被划分为芯片/Die/Plane/块。在 LightNVM 的背景下,并保持与 OC固态硬盘规范一致的术语,我们将通道称为组,将芯片称为 PU(并行单元),将块称为 Chunk。OC固态硬盘规范还定义了物理页地址(PPA),它通过组、PU、Chunk 和 Chunk 内的页号来定位 NAND 中的物理页。符合 OC固态硬盘规范的设备通过 OC固态硬盘规范中定义的 "geometry" 命令暴露 NAND 几何信息,并对底层 NAND 媒体的一些特殊性进行了抽象。这允许用户选择目标的一部分并选择起始和结束的并行单元。这样也使得底层 FTL 能够定义“行”(lines),即跨不同并行单元的块数组,从而可以将数据条带化,以利用底层 NAND 的并行性。可以通过两种方式实现这一点:如果目标包含连接到不同 NAND 通道的 PU,那么固态硬盘控制器可以同时将数据发送到 NAND 或从 NAND 接收数据。如果目标的 PU 连接到相同的通道,那么数据流就不能并行进行。然而,待数据流完成并且闪存命令在 PU 内部执行,通道就可以被用于将数据传输到/从其他 PU。在目标只包含一个 PU 的情况下,正如预期的那样,我们不能实现并行性。
在写入数据时,我们通常会将数据写入缓存,并将成功状态返回给 geom。我们有一个写线程将缓存中的数据写入 NAND。缓存的大小计算为必须容纳在某个页之前要写入的页数,这些页必须写入缓存,以便能够从该页读取数据。假设底层 NAND 有一个限制,即在一个页之前必须写入 16 个物理页,假设我们想从第 10 页读取数据。为了可靠地从该块中读取数据,必须写入直到第 26 页。如果我们考虑条带化操作,将需要更多时间来填充这些页,因为行中的所有块都将有相同的限制。此外,我们还必须确保缓存中可以容纳一次向量写入命令中可以写入的块中最大扇区数。这是因为块可能发生编程失败,并且为了替换块并重试写入命令,我们需要将这些数据保留在缓存中。缓存必须能够容纳这些数据,乘以目标中 PU 的数量。因此,为了避免数据丢失,我们需要确保这些页可以适配到缓存中。L2P 映射数据保存在三个地方:在主机内存中,映射整个目标;在行的末尾,仅映射该行中写入的页;以及在物理页的备用区域,包含逻辑页的数据。如前所述,由于带宽不可用,垃圾回收(GC)尚未实现。
如上所述,我们有一个写线程,它从缓存中读取数据并将其写入 NAND 设备。由于我们将设备的映射单元定义为 4K 大小,因此我们按条目划分缓存和环形缓冲区,每个条目对应 4K 的用户数据。我们在环形缓冲区中存储一些计数器,这些计数器充当指针,指导写线程选择正确的环形缓冲区条目,将数据刷新到 NAND 设备,确认刷新成功,并更新 L2P 映射,使逻辑页映射到物理页,而不是缓存条目。这些计数器存储缓存信息,例如:缓存的大小(按环形缓冲区条目计算,4K)、缓存中可写/空闲的条目数量、尚未提交到 NAND 设备的条目数量、尚未从设备接收确认的条目、已经从设备收到确认的条目,以及需要从缓存地址更新到设备 PPA 的物理映射的条目。因此,现在借助这些计数器,写线程将计算需要刷新到设备的环形缓冲区条目。接着,它将检查需要刷新的条目数是否大于最小写入页数据(即最佳写入大小)。假设最佳写入大小为 8 个扇区(8 * 4K)。如果条目数少于 8,则线程将退出并在下次运行时重试。但如果条目数大于或等于 8(最佳写入大小),则它将从缓存中读取这些条目。在形成向量化写入命令以将数据写入物理页时,我们为每个页创建一个元数据区域,并在其中写入关联页的 LBA。这样做是为了在发生断电时能够恢复映射。在当前的实现中,我们只有一个活动的写入端,这意味着我们将一直写入同一行,直到该行写满或发生编程失败。在这种情况下,我们将分配一个新行并继续写入。当内存页面中有 8 个(最佳写入大小)扇区(数据 + 元数据)可用时,我们将数据写入设备,并更新设备的写指针(WP),同时 NAND 页内部的 LBA 信息将在备用区域更新。如果设备的写请求失败,我们将把这些失败的 I/O 添加到重新提交队列中。重新提交队列的消费者仍然是写线程。这时,写线程将只读取环形缓冲区(缓存)中的失败条目。因此,如果条目数少于 8(最佳写入大小),我们将添加填充(虚拟页),并将写请求重新提交给设备。
对于读请求,我们接收到需要读取的扇区数量、起始扇区和数据缓冲区,这些信息封装在一个 bio 结构中。假设有一个读取 8 个扇区的请求。首先,我们读取第一个扇区的 L2P 映射。如果第一个请求扇区的逻辑地址映射到缓存中,即数据位于缓存/环形缓冲区中,那么我们计算数据存储在缓存中的连续扇区数量。假设所有 8 个扇区的逻辑地址都映射到缓存,那么我们只需要将这 8 个扇区的数据从缓存复制到读取的 bio 结构的页中,并调用 bio_done 将数据返回给上层(geom)。
在另一种情况下,如果第一个请求扇区映射到设备,我们计算数据存储在设备中的连续扇区数量,并为这些连续扇区创建一个子 bio,使用适当的 PPA 向设备发送读取请求。假设所有 8 个扇区的逻辑地址都映射到 NAND 设备,那么我们将创建一个包含 8 个页的子 bio,并将这 8 个扇区的读取请求发送到设备。与此同时,父(读取)bio 会等待,直到收到设备的读取完成确认。之后,GEOM 发送的读取 bio 将使用子 bio 中读取的数据更新其缓冲区,并调用 bio_done 将数据发送回 geom。
现在还有另一种混合情况,其中部分数据位于设备上,剩余数据位于缓存中。假设前两个扇区位于设备上,第三和第四扇区位于缓存中,其余四个扇区再次位于设备上。那么,第一步与之前相同,即我们发现第一个扇区的映射在设备上,连续扇区的数量为 2。我们创建一个包含两个页的子 bio,向设备发送读取请求。接下来,我们会发现第三个扇区的逻辑地址映射在缓存中,再次得到连续扇区数量为 2。因此,我们读取两个合适的环形缓冲区条目,并将它们的数据复制到读取(父)bio 的页中。接下来,我们发现第五个扇区的映射在设备上,连续扇区数量为 4。这时,我们创建另一个子 bio 从设备读取剩余的四个扇区。父(读取)bio 必须等待,直到收到设备对两个子 BIO 的确认。最终,读取 I/O 将从两个子 BIO 和缓存中获取数据,然后我们调用 bio_done 完成读取请求。
ARKA SHARMA 具有在各种存储组件(如驱动程序、FTL 和 option ROM)上的工作经验。在 2019 年进入 FreeBSD 之前,他曾在 WDM mini-port 和 UEFI 驱动程序方面工作。
AMIT KUMAR 是一名系统软件开发人员,目前从事基于 FreeBSD 的存储产品工作。他自 2019 年起成为 FreeBSD 用户。在业余时间,他喜欢探索 FreeBSD I/O 堆栈。
ASHUTOSH SHARMA 目前在 Isilon 担任软件工程师。他的主要兴趣领域是存储子系统。在过去,他曾在 Linux md-raid 上工作。