阅读导航

这一篇在干嘛?

这篇论文叫 DirectNVM,作者来自中佛罗里达大学和北卡州立大学,发表在 ACM TECS 2022。它做的事情一句话概括:把原本运行在操作系统内核里的 NVMe SSD 驱动,搬进 FPGA 硬件里执行,从而绕开整个软件 I/O 栈,让嵌入式系统的 SSD 访问延迟降低最多 4.5 倍、吞吐提升最多 18.4 倍。如果你对”操作系统怎么管理 SSD""FPGA 能做什么""QoS 调度是什么”这些概念一知半解,这篇讲解就是为你写的——我们只假设你懂一点计算机体系结构的基础。

为什么存储 IO 会拖垮嵌入式系统

先看一个大背景。近些年,嵌入式系统(也就是放在设备里的专用计算机,比如自动驾驶控制器、边缘服务器、AR 眼镜里的芯片)的算力突飞猛进:多核 CPU、GPU、各类加速器都塞进去了。但算力快了,数据跟不上怎么办?计算机视觉、深度学习这类应用要持续地跟大容量存储打交道,如果存储 I/O(输入/输出)太慢,再强的算力也只能干等数据。所以论文开篇就指出:现代嵌入式计算的瓶颈,正在从”计算逻辑”快速转移到”数据存储与网络”。

那最直接的解法是什么?无非两条路:一是堆内存,用超大容量 DRAM 加最快的多核处理器,甚至搞存算一体(in-memory computing,让数据在内存里就地计算);二是换更快的存储介质。但嵌入式系统有个死穴——内存容量有限、能耗预算严格。你不可能像服务器那样堆几百 GB 内存,所以对嵌入式系统来说,唯一现实可行的近期方案是:给它配一个接近内存速度的二级存储

这就是 NAND 闪存固态硬盘(SSD,Solid State Drive)取代机械硬盘(HDD)的原因:SSD 没有机械磁头,随机访问快得多,能效也更高。

但光有 SSD 还不够。SSD 怎么接到系统上、用什么协议通信,差别巨大。这就引出了本文的主角之一:NVMe。

顺便交代一下另一个主角:FPGA(Field Programmable Gate Array,现场可编程门阵列)。它是一块”可以自己画电路”的芯片——不像 CPU 只能按固定指令执行,FPGA 允许你用硬件描述语言定义出任意的数字电路,天然擅长并行处理针对特定应用的定制化。FPGA 越来越多地被用进边缘计算、自动驾驶、增强现实等嵌入式场景,甚至出现了把 FPGA 和 CPU 封装在同一块芯片上的异构 SoC(System on a Chip),比如 Xilinx Zynq 系列。这为”用硬件电路直接实现 NVMe 驱动”创造了物理条件。

DirectNVM 论文要回答的核心问题就是:能不能利用 FPGA 的可重构性和原生硬件算力,把 NVMe 存储系统的驱动功能全部硬件化,让主处理器彻底从繁琐的 I/O 管理中解放出来?

NVMe 协议快在哪里

NVMe(Non-Volatile Memory Express,非易失性内存快启协议)是专门为 SSD、持久内存这类非易失性设备从零设计的传输协议。它利用标准的高速 PCIe 串行总线(也就是显卡插的那个接口),能提供 GB/s 级的线性 I/O 吞吐,随机 4K 数据访问延迟只有几十微秒——远快于老旧的 SATA 接口。SATA 本来是为机械硬盘设计的,协议里串行的命令队列根本喂不饱 SSD。

论文总结了 NVMe 在高性能嵌入式计算中的三大优势,我们逐个拆开看。

第一,超强的排队能力。 NVMe 具有多队列(multiple queuing)、I/O 合并(IO combining)、进程优先级、虚拟化多路径 I/O 等特性。对比一下数量级:传统 SAS 协议支持 1 条队列、每队列 256 条命令;SATA 更少,1 条队列 32 条命令;而 NVMe 支持 64000 条队列,每条队列还能放 64000 条命令——排队能力提升了约四个数量级。为什么排队这么重要?因为排队意味着命令可以攒起来并行下发,SSD 内部有大量闪存通道可以同时干活,命令越并行,SSD 内部并行度被利用得越充分。

第二,天生为多核和异构系统设计。 NVMe 是 NUMA 优化(Non-Uniform Memory Access,非一致性内存访问)的可扩展协议。翻译成人话:多个 CPU 核、多个计算加速器可以共享队列的所有权、优先级、仲裁机制和命令原子性,也就是说不同的计算单元都能公平高效地访问同一块 SSD。这对今天资源异构的嵌入式系统特别重要。

第三,命令乱序执行与聚合。 NVMe SSD 能把分散的命令聚合(scatter/gather)、乱序处理,从而提供更高的 IOPS(Input/Output Operations Per Second,每秒 I/O 操作次数)和更低的延迟。这些能力甚至催生了更前沿的存储概念,比如 NVMe over Fabrics(NVMe-oF,用网络把主机和远端存储用 NVMe 协议连起来)。

但是——论文在这里话锋一转——这些好处并不是接上 NVMe SSD 就能自动享受到的。在数据爆炸的今天,把 NVMe 直接塞进传统计算系统,依然会因为遗留的存储协议栈和不灵活的用户控制, suffering 低性能、高延迟和糟糕的 QoS。问题出在哪?下一节揭晓。

传统 NVMe 软件栈的开销在哪里

要看清问题,得先理解标准 NVMe 系统的完整结构。它由多个抽象层组成:文件系统让应用不必直接操心物理扇区和逻辑块地址(LBA,Logical Block Address,SSD 上数据的最小编址单元编号);系统调用(比如 ioctlreadwrite)把用户请求翻译成内核态的系统函数;最底下的 NVMe 协议栈负责在应用和存储设备之间搬运 I/O 请求,同时提供错误处理、性能剖析、公平调度等服务。

一次标准的 NVMe 数据传输是这样走完的(对应论文图 1(a) 的基线系统):

  1. 用户应用通过文件系统或直接 I/O,把 I/O 请求提交到块 I/O 层(BIO,Block I/O layer),请求要穿越整个 I/O 栈
  2. 请求最终到达运行在内核态的 NVMe 驱动;
  3. NVMe 驱动把这些请求合成为 NVMe 命令,提交给控制器。为每条命令分配系统内存中的数据缓冲区,术语叫物理区域页(PRP,Physical Region Pages),命令里还带着起始 LBA、传输大小和操作码(opcode);
  4. NVMe 控制器(在 SSD 内部)通过 DRAM 内存和 SoC 里的 PCIe Root Complex(根复合体,PCIe 树状拓扑的顶端枢纽),从预定义的内存地址取走命令并执行。

这里有 NVMe 的核心机制——队列对。初始化时,驱动会在内存里创建队列,除了必需的一对管理队列(admin queues),通常每个 CPU 核心一对 I/O 队列,每对包含一个提交队列(submission queue,驱动往里放命令)和一个完成队列(completion queue,控制器往里放结果)。整个流程是:驱动锁住队列、合成命令、入队,然后写”门铃寄存器”(doorbell register,控制器寄存器空间里的一个通知机制,就像按门铃喊”有新命令了!”);控制器听到门铃响就从提交队列取命令、开工、把响应写进完成队列;驱动收到中断或轮询到响应后,取走响应、回写完成队列的头指针表示确认,最后释放内存、把控制权还给操作系统。

这套机制设计得相当精巧,可问题在于:整条数据路径都要穿过操作系统。SSD 越快,软件栈的开销就越显眼。论文引用了一组扎心的实验数据(Stratikopoulos 等人在 Xilinx Zynq SoC 上测的):单次 NVMe I/O 操作中,设备本身的延迟只占 9%~10%,剩下 91% 的延迟全部消耗在 I/O 提交、BIO 和 NVMe 驱动这些软件环节上。软件开销和设备延迟已经是同一个量级,而且随着英特尔傲腾持久内存(Optane Persistent Memory)这类更快的存储介质出现,这个比例只会更难看。

第二个问题出在调度上。NVMe 的调度逻辑用轮询(RR,Round Robin,雨露均沾地轮流服务每条队列)方式调度多条请求队列——但 RR 完全没有 QoS(Quality of Service,服务质量)控制概念。多个应用一起跑时共同争抢 NVMe 带宽,访问模式不同的应用会受到不公平的拖累;更恶劣的场景是,一个恶意应用可以突发产生海量存储访问,把多租户平台上其他应用的 I/O 全堵死。在实时系统里这更是致命的——某个线程可能需要预留固定带宽来保证延迟上限,而 RR 的”人人平等”根本做不到这一点。

NVMe 其实还支持另一种仲裁:带紧急优先级的加权轮询(WRR,Weighted Round Robin with urgent priority class),队列可分为紧急(urgent)、高(high)、中(medium)、低(low)四档,每档可设不同权重。听起来不错,但有个坑:紧急级别和权重必须在队列创建时定死,之后不能动态改。而以 SPDK(Intel 的 Storage Performance Development Kit,一个高度优化的用户态异步 NVMe I/O 框架)为代表的传统框架用的是”亲和性”方案——每个核绑定一条具有特定优先级的队列,应用想用某个优先级就必须迁到对应的核上跑。这又带来新问题:负载不均衡,有的核排队堵死,有的核空转。

到这里,改进方向已经非常清晰了:需要一个绕开 BIO 层、带硬件加速 NVMe 驱动功能的瘦 I/O 栈,外加一套能动态调优先级的调度机制。这正是 DirectNVM 的切入点。

常见坑:把"SSD 快"当成"系统快"

很多初学者看到 NVMe SSD 标称 3500 MB/s 就以为系统 I/O 一定飞快。实际上标称值是理想条件下的设备能力,数据要经过应用→系统调用→内核栈→驱动→PCIe 一整条链路才能到 SSD。链路上任何一环的开销都可能让实际带宽腰斩。评估存储性能时,永远要看端到端的延迟和吞吐,而不是只看设备规格书。

DirectNVM 的三大架构创新

DirectNVM 的设计哲学可以浓缩成一句话:能用硬件做的,绝不让软件做。对比基线系统(图 1(a)):传统系统中一个 I/O 请求要穿越完整的 OS 栈才能到达 PCIe Root Complex;而在 DirectNVM(图 1(b))中,同一个请求只经过一个极瘦的内核驱动,立刻进入功能丰富的硬件 NVMe 引擎,然后就到 PCIe Root Complex 了——内核几乎被架空。

基线 NVMe 存储系统的整体架构,I/O 请求需穿越完整 OS 软件栈 (a) 基线 NVMe 存储系统:请求层层穿越 OS 软件栈

DirectNVM 系统的整体架构,硬件 NVMe 引擎接管了大部分驱动功能 (b) DirectNVM 系统:瘦内核栈 + 硬件 NVMe 引擎

论文用一张表对比了 DirectNVM 和现有系统的定位差异:

特性内核驱动SPDKFastPathDirectNVM
吞吐量
延迟
基于优先级的调度RR(轮询)RR(轮询)RR(轮询)WRR(加权轮询)
NVMe 驱动位置OS 内核空间OS 用户空间硬件硬件
多线程支持

三个关键创新点分别对应前面发现的三大痛点:

创新一:硬件辅助的 NVMe 协议栈。 DirectNVM 在 FPGA 里设计并实例化了一个自定义 NVMe 主机控制器,称为 NVMe 引擎(NVMe engine)。标准 NVMe 驱动的绝大部分功能——命令合成、完成轮询等——全部用 FPGA 硬件原生实现。协议所需的 I/O 队列、门铃寄存器触发等组件都直接做在硬件里,整个 NVMe 引擎是完全自管理的独立 NVMe 主机控制器。这意味着它甚至可以在没有微处理器的平台上透明使用——硬件自己就是一整套驱动。

创新二:大规模并行的数据队列。 通过”瘦内核栈 + 硬件加速驱动”的组合,DirectNVM 构建了低延迟、高吞吐的 NVMe I/O 数据通路。更进一步,通过实例化多个独立的 NVMe 引擎,DirectNVM 天然兼容多核架构——传统系统由 OS 集中控制所有 I/O,多个核轮询完成队列时可能争抢共享的 DRAM 端口;而 DirectNVM 把队列系统和控制方案做成了纯分布式:每个 NVMe 引擎完全自治,互不干扰,也不打扰跑在多核处理器上的用户应用的主计算任务。

创新三:以 I/O 为粒度的 WRR 调度。 传统方案里优先级是绑在”核”上的(SPDK 的亲和性方案);DirectNVM 利用自管理的独立 NVMe 引擎,做到了每个核最多四条不同紧急级别的 I/O 队列,于是任何一个核发出的 I/O 流量都可以覆盖全部四个优先级。优先级从”核”下放到”I/O 流”这个更细的粒度,应用不再需要迁移到特定核上,QoS 控制灵活得多。

论文明确声明了两点核心目标:其一是研究如何把加速功能和 OS 功能高效地结合并委派到 FPGA 结构上,同时旁路多核处理系统以最大化存储性能;其二是实现面向应用的细粒度 QoS——让任务关键型或实时系统中的线程能预留带宽来满足延迟约束,这是主流 NVMe 框架的 RR 调度做不到的。

用户态 IO 框架与 API 设计

架构定了,怎么让用户用起来?DirectNVM 设计了一套完整的用户态 I/O 框架(图 2),让应用程序直接控制 I/O 调度策略和 I/O 优先级。一个值得注意的对比是:此前的 FastPath 方案只支持单核架构,而 DirectNVM 从设计之初就是面向多核的,其 API 可以轻松和多线程、多进程集成,把底层细节全部隐藏掉。

DirectNVM 用户态 I/O 框架总览:双层队列结构与硬件引擎的关系 图 2:DirectNVM I/O 框架总览

用户可编程的双层队列系统。 灵感来自 Linux 内核的多队列块 I/O 层设计,DirectNVM 设置了两级队列。第一级是每个核关联一个暂存队列(staging queue),临时缓冲产生的 NVMe 请求,I/O 调度(比如请求合并、重排序)也发生在这一级。目前框架只实现了一个基础的 FIFO(先进先出)调度器,但架构上支持可插拔调度器——以后可以方便地接入 CFQ(完全公平队列)或 mq-deadline 这类更复杂的调度器。第二级是硬件侧的派发队列,下面细讲。

带并行 NVMe 引擎的自治队列。 每个 I/O 句柄(I/O handle)与一个核心绑定,句柄控制该核 I/O 流量的各种特性——优先级、I/O 缓冲深度等,并且句柄与软件暂存队列是 1:1 映射的。这里有个重要的设计决策:DirectNVM 放弃了中断,采用轮询(polling)。为什么?中断式完成通知会引入上下文切换开销、中断投递延迟和额外的软件开销,对高性能低延迟的 NVMe 设备并不合适。在 DirectNVM 里,每个 I/O 句柄持续轮询未完成请求的完成状态。框架同时支持同步和异步两种 I/O 模式。

以 I/O 为粒度的 QoS 控制。 NVMe 支持低、中、高、紧急四个紧急级别,队列在创建时被分配一个级别,NVMe 以 WRR 方式处理这些队列。DirectNVM 把这个优先级控制暴露给用户态:每个软件暂存队列连接到最多四条硬件派发队列(每条对应一个紧急级别),暂存队列里的每条 NVMe 请求按照自身级别被派发到对应的硬件队列。与 SPDK 的亲和性方案不同——SPDK 里线程必须绑定到提供特定 I/O 服务的核上——DirectNVM 的线程可以被调度到任意核,不会有负载不均衡问题。每条硬件派发队列与一对提交/完成队列 1:1 映射;硬件派发队列的最大数量在硬件设计阶段预定,所有队列在 FPGA 上预分配,缓冲区的物理地址、大小等参数被硬编码进 DirectNVM 的 API(作者计划在后续版本把信息存进 FPGA 片上内存,由驱动在系统启动时查询)。

DirectNVM 提供的用户态 API 非常精简:

API功能描述
nvmedl_admin_init()初始化 NVMe,预分配 DMA 缓冲区和暂存队列
nvmedl_create_handle(id)创建一个与某核心关联的 I/O 句柄
nvmedl_set_handle(handle, param, val)为 I/O 句柄设置参数
nvmedl_write_async(handle, buf, lba, size)向暂存队列提交一条异步写请求
nvmedl_read_async(handle, buf, lba, size)向暂存队列提交一条异步读请求
nvmedl_poll(handle)阻塞直到所有未完成请求全部完成

一个典型的使用流程是这样的(对应论文的示例代码):

#include "libnvmedl.h"
#include <sched.h>
 
int main() {
    // 初始化 NVMe
    nvmedl_admin_init();
    // 为当前核创建句柄
    struct handle nvme_handle =
    nvmedl_create_handle(sched_getcpu());
    // 设置暂存队列深度和优先级
    nvmedl_set_handle(nvme_handle, "iodepth", 16);
    nvmedl_set_handle(nvme_handle, "priority", LOW);
    // 从 LBA 0 读取 4096 字节到地址 0x800000000 的数据缓冲区
    nvmedl_read_async(nvme_handle, 0x800000000, 0, 4096);
    // 等待暂存队列中剩余请求全部完成
    nvmedl_poll(nvme_handle);
}

程序开始时先调用 nvmedl_admin_init() 复位 NVMe 控制器寄存器和加速引擎(清空管理队列和 I/O 队列),完成队列和预分配的 DMA 数据缓冲区的初始化。然后通过 nvmedl_create_handle() 创建句柄,参数是当前线程所在核的 ID;创建句柄的同时,一条预分配的连续数据缓冲区和一条专属暂存队列就按 CPU ID 绑定给了这个句柄。用户需要配置两个重要参数:暂存队列深度和优先级。之后就能用异步读写接口提交请求——如果暂存队列满了,提交函数会等待队列中现有请求全部提交并完成后再入队。最后调用 nvmedl_poll() 等待所有请求完成。

常见坑:没有文件系统,只有 LBA

当前版本的 DirectNVM 不支持文件系统,你只能用逻辑块地址(LBA)直接访问 NVMe 设备,每个逻辑块的大小由厂商决定、可配置。这意味着你得自己管理”哪块数据放在哪个 LBA”,不能像写普通程序那样 open("data.txt")。同时,和 SPDK 类似,驱动功能全在用户态意味着当前版本与 Linux 内置 NVMe 驱动不兼容,只允许单个用户应用独占 NVMe 设备。这两点都是作者留给未来工作的。

硬件模块实现 HDU 与 NAE

接下来深入硬件侧。DirectNVM 硬件架构(图 3)里有两个关键模块:硬件派发单元(HDU,Hardware Dispatch Unit)和 NVMe 加速引擎(NAE,NVMe Acceleration Engine)。

DirectNVM 硬件架构:HDU 与 NAE 的连接关系与数据通路 图 3:DirectNVM 硬件架构

硬件派发单元(HDU) 是软件和硬件之间的桥梁:由 I/O 句柄直接控制,负责从软件暂存队列取请求、推向硬件派发队列。由于 DirectNVM 为每个核分配了四条不同优先级的队列(而不是传统方案的一核一队列一优先级),HDU 能把请求按所需优先级推给对应的 NAE——论文的实现里,每个处理核有四个 NVMe 加速引擎映射到同一个 HDU。其他系统参数(比如提交请求数量)存在寄存器里,这些寄存器内存映射到 Linux 虚拟地址空间,由对应的 I/O 句柄配置。

NVMe 加速引擎(NAE) 是整个架构的核心部件,每对提交/完成队列都关联一个独立的 NAE,由 NVMe 提交引擎(NSE,NVMe Submission Engine)和 NVMe 完成引擎(NCE,NVMe Completion Engine)组成(图 4)。NAE 负责 NVMe 命令的构造与提交、门铃寄存器更新和完成轮询,完全自管理、处理器全程不参与。当然,全硬件化也有代价:当前 NAE 没有任何错误处理逻辑,论文只处理”每条命令都成功取走、执行并响应”的理想情况,不支持错误重试。

NVMe 加速引擎 NAE 的架构,由提交引擎和完成引擎组成 NAE 内部结构示意

NVMe 提交引擎 NSE 与完成引擎 NCE 的详细架构 图 4:NVMe 加速引擎(NAE)架构:(a) 提交引擎 NSE;(b) 完成引擎 NCE

NSE 的工作流程是这样的:请求从硬件派发队列流入后,命令构造单元把每条请求转换成 64 字节的 NVMe 提交条目(submission entry)——目前只支持基本的读写操作,flush、compare、atomic 等命令留待未来集成。构造好的条目被推进 NVMe 提交循环队列,同时计数寄存器加一;一个边界检查模块盯着循环缓冲区,决定何时敲提交尾部门铃寄存器、激活完成引擎。这里有个优化细节:NVMe 控制器支持突发模式一次性取走多条提交条目,所以 NSE 会尽量在单次事务里塞给控制器尽可能多的条目,减少握手开销。

循环队列与指针管理是理解 NSE/NCE 的关键(图 5)。每条 I/O 循环队列有两个指针:尾指针(tail)指向下一个待填充的空槽位,头指针(head)指向下一个待消费的槽位。对提交队列而言,尾指针是用户逻辑可见可控制的,头指针只属于 NVMe 控制器内部逻辑。控制器比较更新后的尾门铃寄存器和内部头门铃寄存器,就能算出有多少新命令、然后批量取走。

NVMe 循环队列管理示意图,展示头尾指针在空、写入、读出、写满四种状态下的变化 图 5:NVMe 队列管理。HP 为头指针,TP 为尾指针。(a) 初始(空);(b) 写入 2 条后;(c) 读出 2 条后(空);(d) 写入 7 条后(满)

一个容易忽视的细节:队列满的判定是”头指针比尾指针多 1”,此时实际占用槽位数是队列深度减一——也就是说队列永远要空出一个槽位来区分”满”和”空”两种状态。所以在实现中,每当计数等于深度减一、或所有命令都已发出,提交引擎就会停顿,把当前提交队列的占用数告知完成引擎,并敲尾门铃通知控制器来取命令。

与 FastPath 的重要区别:FastPath 把提交/完成队列放在处理器侧的 DRAM 里,而 DirectNVM 用片上块内存(on-chip block memory)实现循环队列,直接把队列放进各自的提交/完成引擎内部。片上内存让 NAE 完全自管理、对上游逻辑透明——NAE 因此可以用在完全没有处理器的平台上,数据处理逻辑也能方便地直接耦合进来。

那队列深度怎么办?NVMe 协议支持 64000 对队列、每队列 64000 条目,这显然塞不进有限的片上内存。但论文给出了关键论据:Intel 对 SSD 的一系列基准测试表明,NVMe 性能随队列深度增加是渐近饱和的;作者自己的实验也验证了队列深度超过 64 之后吞吐几乎不再增长(深度再翻倍只能多带来约 4% 的吞吐)。所以”浅队列 + 片上内存”在工程上完全成立,后文的数学模型会进一步解释这一点。

NCE 负责完成轮询和敲头门铃。 确认命令完成有两种方式:中断和轮询。传统观点认为轮询更快,理由有二:轮询虽然空转烧 CPU 周期,但免除了上下文切换;中断请求的调度和处理本身有额外开销。但论文的研究指出,这套结论不能直接照搬到 DirectNVM 架构上:其一,NVMe 引擎是专干 NVMe 流量的专用电路,根本不存在上下文切换,让它空转等待没有任何损失;其二,硬件中断处理的开销远低于软件中断处理。DirectNVM 最终仍选择轮询,真正的理由是时序:完成条目一进队列,轮询立刻能感知到;而中断要等中断信号投递完成——这段等待时间在硬件设计里完全没必要。

完成条目的格式由 NVMe 规范定义,每条 4 个 32 位双字(dword):

字段31~2423~1615~87~0
DW0命令特定信息(Command-specific)
DW1保留(Reserved)
DW2SQ 标识符SQ 头指针
DW3状态字段P(相位标签)命令标识符

其中最巧妙的机制是相位标签(phase tag):完成队列中所有条目的相位标签初始为零,控制器每写入一条新完成消息就翻转一次该位。NCE 持续监视头指针所指向槽位的相位标签,就能判断”这条完成是不是新来的”——不需要任何额外的同步标志。每个完成队列还有一个计数寄存器,每收到新完成就加一;当计数等于 NSE 传来的”待等待完成数”时,NCE 更新头门铃寄存器,宣告这批完成全部收到,把控制权交还 NSE。注意这个设计:头门铃只在所有完成都收到后更新一次,而不是每条完成敲一次门——节省了 PCIe 带宽和握手开销,多队列同时访问 NVMe 设备时这个节省更重要。

原型平台与排队论性能模型

原型平台(图 6)用的是 Xilinx ZCU106 开发板,搭载 Zynq UltraScale+ XCZU7EV-2FFVC1156 MPSoC。这个芯片的厉害之处在于单颗芯片里同时集成了:处理系统(PS)里的四个 ARM A53 核 + DDR4 内存,以及可编程逻辑(PL)部分硬化的 PCIe Gen3 模块——正好是”CPU + FPGA”异构 SoC 的典型代表。PL 里实例化了 4 个 HDU 和 16 个 NAE(4 核 × 4 优先级),通过提交/完成队列创建命令告知 NVMe 控制器所需的 I/O 队列数量。

DirectNVM 原型平台:ZCU106 开发板、FPGA Drive FMC 转接卡与 M.2 NVMe SSD 的连接 图 6:DirectNVM 原型平台

SSD 通过 FPGA Drive FMC 转接卡接到开发板上;NVMe 控制器用 Xilinx 的 XDMA PCIe IP 直接访问 PS 侧的 DDR 内存和各 NAE,该 IP 配置为 PCIe 根复合体。内存的分工是:所有 NVMe 管理队列、数据缓冲区和软件暂存队列放在 PS 的 DDR 里;所有 HDU、NAE 和 NVMe I/O 队列做在 PL 里。操作系统选的是用 Xilinx PetaLinux SDK 定制的嵌入式 Linux,定制时修改了设备树,把队列和缓冲区占用的连续内存预留出来,OS 任何情况下不得动用。作者还开发了两个线程安全的 Linux 内核模块:DirectNVM 驱动(管理预留的缓冲区和队列)和 HDU 驱动(管理 HDU 寄存器)。

有个工程限制值得一提:每个 NAE 需要两个端口访问 PCIe IP,每个紧急级别每核需要一个 NAE,于是支持 N 个紧急级别就需要 8N 个端口共享 PCIe IP,但每个 AXI IP 最多只允许 16 个连接——所以当前原型最多支持两个紧急级别。换用 Xilinx Integrated Block for PCI Express IP(摆脱 AXI 连接)可以解除这个限制,作者留给了后续版本。注意:这个限制来自 AXI 互联的端口数量,而不是硬件资源不够。

裸机版与 OS 版。作者还实现了一个不依赖 OS 的裸机(baremetal)版 DirectNVM 库:软件栈直接控制物理地址,不需要虚拟地址翻译——这对 PL 用 DMA 访问 PS 内存特别友好(DMA 本来就要物理地址)。代价是裸机版不支持多线程、内存管理和进程管理,难以榨干四核处理器的全部性能。后文实验对比了两个版本。

缓存一致性。ARM 处理器和 NAE 共享数据缓冲区与暂存队列,必须保证双方看到的数据都是最新的。Zynq UltraScale+ 平台有两类 PS AXI 从端口:S_AXI_HPC(支持硬件缓存一致性,但实现复杂)和 S_AXI_HP(不支持,但简单)。实测两者性能差异不大——因为缓存管理只占总执行时间的一小部分——所以全部实现都基于 HP 端口。裸机版需要手动调用缓存失效/回写函数;OS 版则调用 pgprot_noncached() 把内存区域标记为不可缓存,交给 MMU(内存管理单元)管理。实验结果显示,OS 的 MMU 缓存管理比裸机的手动管理性能更好——这也是后文 DL-OS 延迟略低于 DL-Baremetal 的原因。

三级队列的深度关系。架构中有三条队列:软件暂存队列(深度 )、硬件派发队列(深度 )和 NVMe I/O 队列(深度 ),三者的深度相互制约。HDU 以 AXI 突发方式访问暂存队列,每次突发长度必须小于 ,否则派发队列满了之后 AXI 从设备会被阻塞等待,连累共享同一 DDR AXI 端口的其他 HDU。设计中取突发长度为 :当派发队列一半槽位被 NAE 消费掉,HDU 就发起一次突发读来补充。为让 NSE 不因等待硬件队列而停顿,需满足 ,设计中取 由用户逻辑决定,多数 NVMe SSD 场景下设为 32。作者实现并评估了 8、16、32、64 四档 I/O 队列深度。

排队论性能模型。这么多参数(队列深度、优先级、并发数)耦合在一起,靠试错调优太低效,于是论文用经典排队论给系统建了个数学模型,把 I/O 数据流抽象成一个两阶段开放排队网络(图 7):第一阶段是一个带 个服务台、缓冲区大小为 的队列,对应软件暂存队列,服务台模拟按紧急级别派发命令的行为;第二阶段是 条多服务台队列(缓冲区 ),对应硬件派发队列以及 NVMe I/O 队列与 SSD 控制器的整体行为。

DirectNVM 系统的排队网络模型抽象 图 7:排队网络模型 (a) 两阶段开放排队网络整体结构;(b) 第二阶段 M/D/1/K 队列细节

模型假设:每个服务台的服务时间服从速率为 的指数分布;I/O 命令按速率 的泊松过程到达第一个节点;由于 WRR 策略和可能的服务阻塞,到达第一个服务台的命令会以阻塞概率 被阻塞。第二阶段每条队列建模为 M/D/1/K 队列(M:泊松到达,D:确定性服务时间,1:单个服务台,K:有限缓冲),服务时间为常数 。阻塞概率为:

其中负载 ,且在生产阻塞(production blocking)策略下:

于是这条 M/D/1/K 队列的吞吐量就是 。进一步推广到 WRR 场景:设 SSD 能提供的平均吞吐为 ,高、中、低三个优先级的权重分别为 ,则高优先级的平均吞吐为:

其中 ,而:

这套模型的实际用途很实在:在硬件资源固定的约束下,算出最优队列深度——不必盲目把队列做深,因为吞吐会饱和。推导细节作者放在了引用文献里,初学者只需记住结论:队列深度超过 64 之后收益递减,这与实验观察吻合。

通关标准:你能画出整条数据通路吗?

读到这里,检验自己是否真正理解 DirectNVM:合上资料,能否按顺序说出一次异步读请求的完整旅程?参考答案:应用调用 nvmedl_read_async → 请求进入该核的软件暂存队列 → HDU 按优先级从暂存队列取出请求,推入对应紧急级别的硬件派发队列 → NSE 把请求转成 64 字节提交条目,写入片上循环队列 → NSE 敲提交尾门铃 → SSD 控制器经 PCIe 批量取走命令并执行 → 控制器把完成条目(含翻转的相位标签)写回完成队列 → NCE 轮询到相位翻转,计数满后一次性更新头门铃 → 句柄的轮询感知完成,数据已在预分配的 DMA 缓冲区里,全程零拷贝、零系统调用。能走通这条链,本文的核心你就吃下了。

实验结果全面解读

实验环境:三星 970 EVO Plus NVMe SSD 250GB,其理论性能如下表(随机性能规格书只给 IOPS,论文用 IOPS 乘以 4K 的 I/O 大小换算成带宽):

项目数值
顺序读3500 MB/s
顺序写2300 MB/s
随机读(4KB,QD32)976.56 MB/s
随机写(4KB,QD32)2148.44 MB/s
随机读(4KB,QD1)66.41 MB/s
随机写(4KB,QD1)234.38 MB/s

(QD 指 NVMe I/O 队列深度。)测试时暂存队列、硬件派发队列、I/O 队列深度默认设为 64、64、32;每个核独立访问 SSD 中 32 MB 数据,各核的 I/O 地址范围互不重叠。对照组是 PetaLinux I/O 栈及其内置 NVMe 驱动,用 FIO(Flexible I/O,Linux I/O 子系统标准测试工具)跑基线。

单核性能。DirectNVM 的 OS 版(DL-OS)和裸机版(DL-Baremetal)的吞吐都大幅超越基线(图 9(a))。延迟方面,为公平起见把暂存队列深度设为 1,让每个请求阻塞式处理:DL-Baremetal 和 DL-OS 相比基线取得最高 3 倍的加速(图 9(b))。DL-OS 的延迟比 DL-Baremetal 还略低一点,得益于 MMU 更高效的缓存管理。

单核吞吐量对比图 (a) 单核吞吐量对比

单核平均延迟对比图 (b) 单核平均延迟对比

单核平均延迟分解图 (c) 单核平均延迟分解

论文还做了延迟分解(图 9(c)),把单请求延迟拆成三段:slat(把请求提交进软件暂存队列的延迟)、cache(为维持一致性做缓存失效/回写的延迟)、clat(DirectNVM 子系统处理已提交请求的延迟)。结论是 clat 占绝对主导,缓存管理不是瓶颈——这也解释了 DL-OS 与 DL-Baremetal 的延迟差异为何可以忽略。

队列深度的影响(图 10)。NVMe I/O 队列加深,吞吐上升;深度超过 64 后增长显著放缓——加深一倍只多约 4% 吞吐。这从实验上证明了”用片上 BRAM 做浅 I/O 队列”的可行性。软件暂存队列深度的影响类似:超过 64 后性能增长极慢。

软件暂存队列深度对裸机单核吞吐的影响 (a) 软件暂存队列深度对裸机版单核吞吐的影响

软件暂存队列深度对 OS 单核吞吐的影响 (b) 软件暂存队列深度对 OS 版单核吞吐的影响

NVMe I/O 队列深度对裸机单核吞吐的影响 (c) NVMe I/O 队列深度对裸机版单核吞吐的影响

NVMe I/O 队列深度对 OS 单核吞吐的影响 (d) NVMe I/O 队列深度对 OS 版单核吞吐的影响

多核性能(图 11)。四核齐开时:写吞吐达到理论带宽的 80%,大幅领先基线;随机读达到理论带宽的 60%,略好于基线;而顺序读几乎与基线持平。为什么顺序读不行?论文的解释很有意思:多核产生的多个顺序访问队列,其队列间调度由 SSD 内置的队列调度逻辑决定,这种多路顺序访问模式反而无法利用 SSD 的内部并行性,吞吐甚至低于随机读——SSD 内部并行度更适合被打散成随机小请求来喂饱。作者还指出这一现象与使用同代设备的其他文献结果一致,且可能是厂商相关的。

四核吞吐量对比图 (a) 四核吞吐量对比

NVMe I/O 队列深度对四核吞吐的影响 (b) NVMe I/O 队列深度对四核吞吐的影响

软件暂存队列深度对四核吞吐的影响 (c) 软件暂存队列深度对四核吞吐的影响

队列深度对四核性能的影响与单核结论一致:I/O 队列深于 64 后吞吐增长极慢,暂存队列深于 64 后吞吐几乎恒定。图 11(b) 里深度 32 的表现略好于 64,作者认为这属于多次测量间的正常波动。

一个文献中未见报道的发现:紧急级别本身会影响吞吐(图 12)。直觉上,把所有队列都设成同一级别,SSD 内部按 RR 处理,性能应该一样。但实测不然:给所有队列分配”紧急(urgent)“级别时,随机写和顺序写的吞吐都比分配”高(high)“级别时更高——随机写甚至超过了规格书的理论带宽!而顺序读和随机读在”紧急”级别下反而更差。作者怀疑这源于厂商特定的 SSD 内部调度器,具体机理留待后续研究。这个发现对实践很有价值:调 QoS 时,优先级标签不只是”谁先谁后”的问题,还会实实在在地改变总带宽。

紧急级别对系统吞吐的影响 图 12:紧急级别对系统吞吐的影响

差异化 I/O 服务(QoS 实战)。这是 DirectNVM 最能体现”用户价值”的实验。设置:两个线程分别扮演前台应用(对延迟敏感)和后台应用(大量数据搬运),后台线程发起两次突发 NVMe 请求——这模拟了非常日常的场景,比如前台在处理数据、后台网络应用同时在下载媒体文件。多数情况下我们希望前台吞吐保持稳定。

第一组实验:两个线程都设为”high”优先级,没有任何 WRR 控制。结果如图 13(a):后台突发一来,前台吞吐被狠狠砸下去。

无 WRR 控制时两个线程的吞吐:后台突发严重挤压前台 (a) 未启用 WRR 控制:后台突发严重挤压前台吞吐

第二组实验:用户态代码把前台设为”high”、后台设为”medium”,WRR 自动给”high”分配更多带宽。结果前台吞吐基本稳住了(图 13(b)、13(c))。作者进一步研究了权重的敏感性:把”medium”权重固定为 1,调节”high”的权重——权重设 128 时前台吞吐明显高于设 8 时。

WRR 权重 8:1 时的差异化服务效果 (b) WRR 权重 8:1:前台吞吐受影响明显减小

WRR 权重 128:1 时的差异化服务效果 (c) WRR 权重 128:1:前台吞吐得到更强保护

与 SPDK 的硬碰硬对比。SPDK 是业界最先进的用户态 I/O 框架,通过绕过内核栈获得优化的 NVMe 性能,跑在一台搭载 AMD Ryzen 2700x CPU 的台式机上(配同款三星 970 EVO Plus)。为了让 DirectNVM 也能用 FIO 测试,作者为它写了一个 FIO 插件(SPDK 的插件是 Intel 官方提供的)。此时队列深度配置为暂存 64、硬件派发 128、I/O 队列 64。由于 SPDK 的 FIO 插件不支持在单个 NVMe 命名空间上多线程,只比较单线程性能。为减少内部缓存影响,先测写再测读,I/O 大小 4KB,访问 10GB 数据范围。

结果(图 14(a)、14(b))非常有说服力:DirectNVM 跑在一颗弱得多的嵌入式 ARM 核上,却在大多数测试项里赢了跑在桌面级 AMD 处理器上的 SPDK——吞吐最高高出 2.2 倍,延迟最低缩短 1.3 倍。论文摘要里还提到与 PetaLinux 内核驱动相比的总体数字:吞吐最高 18.4 倍、延迟最低 4.5 倍的改善

单线程吞吐量对比图 (a) 单线程吞吐量对比:DirectNVM(嵌入式 ARM)对战 SPDK(桌面 AMD)

单线程平均延迟对比图 (b) 单线程平均延迟对比

单线程延迟的标准差对比图 (c) 单线程延迟的标准差对比:DirectNVM 的延迟波动远小于 SPDK

也有例外:DirectNVM 的 4K 随机读带宽意外偏低。论文给出两个解释:第一,新一代 SSD 的 4K 随机读带宽本来就常常低于 4K 随机写(与传统硬盘和旧一代 SSD 相反);第二,FIO 测试框架本身带来了可观的软件开销,DirectNVM 和 SPDK 的带宽在 FIO 基准下都下降了。

延迟细节上:SPDK(桌面 PC)的平均延迟比 DirectNVM(Zynq MPSoC)略短,因为 SPDK 的异步完成轮询软件开销更小,且 CPU 时钟频率更高;另外 DirectNVM 的所有命令都要经 AXI 互联从主机发到控制器,单请求阻塞式测试时这部分 AXI 开销被放大(成批提交时会被摊薄)。但差距已经小到什么程度呢?考虑到 DirectNVM 的 FPGA 时钟是 250 MHz,延迟差距折算下来只有约 13 个时钟周期

比平均延迟更重要的是延迟方差(图 14(c)):DirectNVM 的延迟波动远小于 SPDK——因为大部分驱动功能在硬件里,不依赖 OS 调度。对实时和延迟约束型应用,低方差往往比低均值更关键(想象一个要求”每次响应必须在 100 微秒内完成”的控制系统,偶发的长尾延迟比整体略慢更致命)。唯一的例外是 4K 随机读,DirectNVM 的方差比 SPDK 高 5.3 微秒——但折算到 250 MHz 时钟只有 1.3 个时钟周期,对硬件实现而言可以忽略。作者相信经过更专门的优化,DirectNVM 能取得比 SPDK 更低的延迟。

资源开销。支持两个紧急级别的 DirectNVM(共 8 个 NAE)在 250 MHz 时钟下的资源占用如下表:

时钟频率LUTFFBRAM
250 MHz59348(26%)68112(15%)186.50(60%)

LUT 和 FF 的占用率都不高,BRAM 用到 60% 主要是大量片上队列的开销。下图可以直观看到各模块在 FPGA 版图上的分布。

DirectNVM 的 FPGA 资源利用率布局图 图 8:支持两个紧急级别的 DirectNVM 资源利用率。黄色高亮为 8 个 NAE,绿色高亮为 4 个 HDU,红色高亮为 ZynqMP 处理器

通关标准:记住三个关键数字

如果只记三个数字,请记住:91%(传统系统里软件栈吃掉的延迟比例——这是 DirectNVM 存在的理由);18.4 倍 / 4.5 倍(对比嵌入式内核驱动的吞吐/延迟提升——这是硬件化的收益上限);60% 与 80%(随机读/写达到 SSD 理论带宽的比例——这是硬件化的现实天花板,剩下的差距来自 SSD 内部调度,而非主机软件栈)。

局限性与未来展望

论文在结尾坦诚地交代了当前实现的局限和改进方向,这对想复现或扩展这项工作的读者很有价值。

NVMe I/O 队列映射的优化空间。标准 NVMe 协议支持五个不同的紧急组,而当前 DirectNVM 为每个核的每个紧急级别单独分配一条队列,可扩展性受限于 PL 能容纳的 I/O 队列数量。作者发现了一个可以省资源的技巧:多个优先组其实可以复用同一条 NVMe I/O 队列,因为不同优先级之间只有头尾指针不同。共享队列和 NVMe 控制器后,处理核数与 I/O 队列数之比可以从 1:4 降到 1:1,显著节省硬件资源。

更细粒度的 QoS 控制。当前实现以同步批量方式提交 I/O 请求,同一批次内的请求必须同优先级(不同批次可以有不同优先级)。解决办法是把混合优先级的批次在 API 层拆成多个子批次,作者计划在未来版本里集成这种更细粒度的 QoS 控制。

文件系统与多应用支持。前面提过,当前版本没有文件系统、只允许单应用独占设备,这两点都留待未来。

综合来看,DirectNVM 的贡献可以归纳为四条:设计并开源了符合 NVMe 1.3 协议、可自管理、不依赖处理器、可任意并行化的 FPGA NVMe 控制器 IP;提出了 FPGA 加速的用户态 I/O 框架 DirectNVM,通过自管理控制器有效避免多核同时轮询完成队列时对共享 DRAM 端口的争抢;提出了把 WRR 优先级控制暴露给每条 I/O 流量(而非每个核)的差异化 I/O 服务;在真实 SoC 平台上完成了原型验证——对比商用标准 NVMe 实现,数据通路延迟最多缩短 4.5 倍、系统吞吐最多提升 12.6 倍;对比 SPDK,吞吐最高提升 2.2 倍、延迟最多缩短 1.3 倍。除了性能,作者还指出这套架构能帮助数据中心大规模存储节点显著降低成本和功耗开销。演示与源码已开源在 GitHub(yu-zou/DirectNVM)。

对初学者而言,这篇论文最大的启示或许不是某个具体数字,而是一种思维方式:当软件栈的开销与设备本身同量级时,把”驱动”这类系统软件下沉为硬件电路,可能是比继续优化代码更彻底的出路——前提是你手里有 FPGA 这样可重构的硬件,以及像 Zynq 这样 CPU 与 FPGA 紧耦合的 SoC 平台。

此文件夹下有0条笔记。