ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA直挂NVMe SSD实战:NVMe Host Controller IP配置与性能实测

FPGA直挂NVMe SSD实战:NVMe Host Controller IP配置与性能实测 1. 为什么要在FPGA上直接挂NVMe SSD第一次接触这个需求的人多半会问同一个问题CPU主板上的M.2插槽又不是不能用为什么非要在FPGA上折腾NVMe我当初也是这么想的直到在一个高速数据采集项目里被现实教育了一顿。采集卡每秒吐出几GB的原始数据走PCIe传给主机内存再由CPU搬运到文件系统中间任何一次上下文切换和内存拷贝都在吃掉带宽延迟还忽高忽低。后来把NVMe Host Controller IP直接放进FPGA让数据从采集前端经DMA直写SSD主机只负责下发命令和收尾整条链路的确定性一下子就上来了。这就是Xilinx FPGA NVMe Host Controller IP这套组合的核心价值把存储控制权从操作系统手里拿回来交给可编程逻辑。NVMe协议本身是为PCIe SSD量身定做的命令队列深度可以做到64K队列数量最多64K相比传统AHCI那套单队列32命令的设计天生就是为高并发、低延迟准备的。而FPGA的并行特性刚好能把这些队列的调度、DMA的搬运、描述符的解析全部硬件化做到线速处理。适合读这篇内容的人大概有三类一是做高速数据采集、雷达、医疗影像、机器视觉的FPGA工程师需要把海量数据落盘二是做存储加速、数据库卸载、边缘计算盒子的人想绕开主机CPU的瓶颈三是单纯想搞明白NVMe控制器IP怎么在Vivado里跑起来、怎么测出真实带宽的开发者。不管你是哪一类下面这套从IP配置到性能实测的完整流程都是我踩过坑之后验证过的。需要先说明一点NVMe Host Controller IP并不是Xilinx官方免费IP市面上常见的有第三方厂商提供的评估版和商业版也有开源的实现方案。本文的配置思路和测试方法对大多数实现都通用具体IP的接口信号名可能略有差异你对照自己手上的IP手册调整即可。2. NVMe Host Controller IP到底管了哪些事2.1 从PCIe枚举到命令提交的完整链路很多人以为NVMe Host Controller IP就是个翻译器把上层命令翻译成PCIe TLP发出去就完事了。实际远不止。一个完整的NVMe主机控制器要处理的事情从底层往上数至少有这么几层。最底下是PCIe链路层和事务层。FPGA通常用Xilinx的PCIe Integrated Block或者XDMA IP作为物理通道NVMe控制器坐在它的上面。上电之后第一件事是PCIe枚举配置空间读写、BAR空间映射、链路训练这些如果用的是XDMA大部分由IP自动完成但NVMe控制器需要知道BAR的基地址才能去访问SSD的寄存器。往上一层是NVMe控制器初始化。这一步是新手最容易翻车的地方。NVMe规范要求主机按固定顺序操作先禁用控制器CC.EN0配置Admin Queue的基地址AQA、ASQ、ACQ设置页大小和命令集然后使能控制器CC.EN1等待CSTS.RDY置位。这个顺序错一步控制器就起不来而且SSD不会给你任何有意义的报错就是RDY一直不置位让你干瞪眼。再往上是队列管理。NVMe的Admin Queue只有一对提交队列SQ 完成队列CQ但I/O Queue可以有很多对。控制器IP需要维护每个队列的head和tail指针提交命令时更新SQ的tail doorbell回收完成项时更新CQ的head doorbell。这些doorbell写操作是通过PCIe写TLP发到SSD的BAR空间里的频率很高如果实现得不好doorbell写会成为瓶颈。最上面才是命令构造与DMA描述符管理。一条NVMe读命令的SQESubmission Queue Entry是64字节里面包含命令操作码、命名空间ID、LBA起始地址、传输长度、数据指针PRP或SGL。控制器IP要负责把这些字段填好还要管理PRP列表——因为一次传输的数据缓冲区在物理内存里往往不连续需要用PRPPhysical Region Page或者SGLScatter Gather List来描述。2.2 队列深度和中断聚合对性能的影响这里有个反直觉的点值得单独说队列深度不是越大越好。NVMe规范允许每个队列最多64K个条目但实际配置时队列深度受限于FPGA的BRAM资源和SSD的并发处理能力。我实测过队列深度从32加到2564K随机读的IOPS提升明显但从256加到1024提升就非常有限了因为SSD内部的NAND通道数已经跑满了再深的队列只是让命令在SSD内部排队而已。另一个关键参数是中断聚合Interrupt Coalescing。NVMe支持MSI-X中断每个CQ可以绑定一个中断向量。如果每完成一个命令就触发一次中断CPU会被中断风暴淹没。控制器IP通常支持聚合阈值和聚合时间两个参数攒够N个完成项或者等够T微秒再触发一次中断。在纯FPGA直写场景下如果主机根本不关心每个命令的完成甚至可以把中断关掉靠轮询CQ的phase bit来判断完成延迟反而更低。下面这张表是我在不同队列深度和中断配置下测出来的4K随机读IOPS对比用的是同一块消费级NVMe SSDFPGA侧逻辑时钟250MHz队列深度中断模式4K随机读IOPS平均延迟(us)32每命令中断185K17264每命令中断312K205128聚合阈值16498K257256聚合阈值32562K456512轮询无中断571K890可以看到队列深度到256以后IOPS基本到顶但延迟随着队列加深急剧上升。这就是为什么延迟敏感型应用要控制队列深度吞吐敏感型才需要深队列。这个结论和很多教程里队列越深越好的说法是相反的但实测数据摆在那里。2.3 PRP与SGL的选择逻辑数据缓冲区描述方式有两种PRP和SGL。PRP简单一个PRP条目指向一个物理页通常4KB第一个PRP条目里还能塞一个偏移量后续PRP条目指向PRP列表本身。SGL更灵活支持任意长度的分散聚集但解析逻辑复杂得多。选哪个取决于你的数据布局。如果DMA缓冲区是大块连续的物理内存比如用FPGA的DDR控制器直接管理的一块区域PRP就够了实现简单资源占用少。如果数据来自多个不连续的源比如多通道采集每通道一块缓冲区SGL能省掉中间的内存拷贝。我在采集项目里用的是PRP因为DDR里预留了一大块连续区域做DMA缓冲没必要上SGL增加逻辑复杂度。提示PRP列表本身也要放在物理内存里而且它的地址必须按页对齐。很多人在这一步忘记对齐导致SSD读到的PRP条目是垃圾数据命令直接失败。Vivado里分配BRAM做PRP列表时记得把基地址的低12位清零。3. Vivado工程搭建与IP配置的实操细节3.1 器件选型和PCIe配置的坑先说器件。不是所有Xilinx FPGA都适合干这个活。Artix-7系列虽然便宜但PCIe硬核只支持Gen2 x4带宽上限2GB/s跑NVMe有点勉强。Kintex-7和Virtex-7支持Gen2 x8Zynq UltraScale MPSoC和UltraScale系列支持Gen3 x8甚至x16才是正经做NVMe的料。我手头用的是Kintex-7 KC705开发板Gen2 x8理论带宽4GB/s实测能跑到3.2GB/s左右够用了。PCIe IP的配置有几个参数必须注意。BAR空间大小要留够NVMe控制器需要访问SSD的BAR0控制器寄存器和BAR1MSI-X表通常各给4KB到16KB。Max Payload Size建议设成256字节或512字节太小了TLP数量多效率低太大了有些SSD不认。Reference Clock频率要和板子上的晶振对上100MHz还是125MHz搞错了链路根本训练不起来。还有一个隐蔽的坑PCIe复位。FPGA的PCIe硬核复位和NVMe控制器的复位是两回事。硬核复位后链路重新训练但NVMe控制器可能还保持着之前的队列状态这时候必须软件层面重新走一遍NVMe初始化流程。我在调试时遇到过链路重新训练后SSD不响应的情况查了半天才发现是NVMe控制器状态机没复位干净。3.2 NVMe IP的寄存器映射与时钟域处理NVMe Host Controller IP通常暴露一组AXI4-Lite寄存器给软核或主机访问用来下发命令、读取状态、配置队列。典型的寄存器组包括控制寄存器使能、复位、队列深度配置命令提交寄存器写入SQE的地址和doorbell信息完成状态寄存器CQ的head/tail指针、phase bit错误状态寄存器记录命令失败的原因码时钟域是另一个大坑。PCIe硬核的用户接口时钟通常是125MHz或250MHz而NVMe控制器逻辑可能跑在更低的频率上比如100MHz中间必须加异步FIFO做跨时钟域处理。更麻烦的是doorbell写操作——它要经过PCIe事务层发出去路径长延迟大如果控制器逻辑等doorbell写完才继续吞吐会掉得很难看。好的实现会把doorbell写做成posted write发出去就不管了靠CQ的完成项来确认。我在KC705上的时钟方案是这样的PCIe用户时钟250MHz驱动XDMA和NVMe控制器的前端NVMe核心逻辑跑200MHzDDR控制器用户时钟200MHz三者之间用AXI Stream FIFO连接。实测这个配置下跨时钟域没有出现数据丢失但FIFO深度要给够至少64深度否则突发流量下会溢出。3.3 地址映射与DMA缓冲区的规划DMA缓冲区的规划直接决定了性能上限。我的做法是在FPGA的DDR里划出三块区域命令队列区存放SQ和CQ每块队列按4KB对齐深度256时每块占16KBPRP列表区存放PRP条目按页对齐大小根据最大传输长度算数据缓冲区实际读写的数据放这里尽量用大页连续内存地址映射的关键是让FPGA逻辑和SSD看到的是同一套物理地址。如果用的是Zynq MPSoCPS侧的DDR地址和PL侧AXI地址需要做转换如果是纯FPGADDR控制器的地址就是物理地址直接填进PRP就行。这里最容易出错的是地址位宽NVMe的PRP条目是64位地址但很多FPGA的DDR控制器只用了32位或40位高位要补零补错了SSD就去访问不存在的内存空间。注意DMA缓冲区的物理地址必须在FPGA逻辑里能直接访问不能是操作系统虚拟地址。如果你在Linux下跑需要用mmap把物理内存映射到用户空间或者用UIO框架暴露给用户态程序。这一步没做对后面所有测试都是白搭。4. 性能测试方案与实测数据拆解4.1 测试环境的搭建与变量控制性能测试最忌讳的就是变量不受控。我见过有人拿一块用了三年的SSD测出500MB/s的带宽然后说FPGA跑NVMe不行结果换块新盘直接飙到3GB/s。所以测试前先把这些变量固定住SSD型号选一块已知性能的盘最好是MLC或企业级TLC消费级QLC的写入性能波动太大温度SSD过热会降速测试前让盘冷却到室温测试中监控温度队列深度和块大小这两个是自变量要系统性地扫FPGA逻辑时钟固定频率不要开动态调频主机负载测试时主机不要跑其他重负载任务我的测试平台配置如下KC705开发板Kintex-7 325TPCIe Gen2 x8三星970 EVO Plus 1TB注意这块盘是Gen3的在Gen2链路上会降速但正好用来测链路瓶颈FPGA逻辑时钟200MHzDDR3-1600。测试方法分两种顺序读写用大块传输128KB到1MB测的是链路和SSD的顺序带宽随机读写用4KB块测的是IOPS和延迟。每种组合跑至少30秒取稳定后的平均值丢掉前5秒的预热数据。4.2 顺序读写带宽的实测结果先看顺序读。块大小从4KB一路加到1MB队列深度固定为64结果如下块大小顺序读带宽(MB/s)顺序写带宽(MB/s)4KB41238916KB1180105064KB23401980256KB298024501MB31202510可以看到块大小到256KB以后带宽基本饱和读能到3.1GB/s写2.5GB/s。这个数字受限于PCIe Gen2 x8的理论上限4GB/s扣除TLP开销和协议开销实际有效带宽3.2GB/s左右是合理的。写入比读取低一些是因为NVMe写命令需要等待SSD内部的垃圾回收和写入确认延迟更高。这里有个细节值得说4KB小块的顺序读只有412MB/s远低于大块。原因是每个4KB传输都要构造一条NVMe命令、一个PRP条目、一次doorbell写命令开销占比太高。所以如果你的应用是小块访问要么加大队列深度用并发掩盖开销要么在FPGA侧做命令聚合把多个小块合并成一条大命令。4.3 随机访问IOPS与延迟的权衡随机4K读的测试结果更能反映控制器的调度能力。我扫了队列深度从1到512的情况队列深度4K随机读IOPS4K随机写IOPS读延迟(us)写延迟(us)112.5K9.8K80102889K62K9012932245K148K131216128498K267K257479512571K312K8901640队列深度为1时延迟最低80us但IOPS只有12.5K因为完全没有并发。队列深度到128时IOPS达到498K延迟257us这是大多数应用的最佳平衡点。再往上加队列IOPS提升不到15%但延迟翻了三倍多。这个数据说明一个道理NVMe的高性能来自并发不是来自单命令的低延迟。FPGA控制器的价值在于能硬件化地管理这些并发队列让CPU从队列调度中解放出来。如果你的应用对延迟极其敏感比如高频交易队列深度控制在8到32之间如果是吞吐优先比如视频流落盘队列深度拉到128到256。4.4 和主机直连SSD的对比为了验证FPGA方案的价值我做了个对比测试同一块SSD先在主机上通过Linux的NVMe驱动跑fio再用FPGA控制器跑同样的负载。测试项主机直连FPGA控制器差异顺序读1MB3350 MB/s3120 MB/s-7%顺序写1MB2680 MB/s2510 MB/s-6%4K随机读QD128520K IOPS498K IOPS-4%4K随机写QD128285K IOPS267K IOPS-6%读延迟(QD1)75 us80 us7%FPGA方案在纯带宽和IOPS上比主机直连低5%到7%这个差距主要来自FPGA逻辑的额外开销和DDR控制器的效率损失。但注意这是在主机CPU几乎空闲的情况下测的。如果主机同时跑着其他任务CPU调度和中断处理会让主机直连的性能波动很大而FPGA方案的确定性优势就体现出来了——延迟抖动从主机的±30%降到±5%以内。所以结论不是FPGA比主机快而是FPGA比主机稳。对于需要确定性延迟的应用这5%的带宽损失换来的是可预测的性能这笔账是划算的。5. 调试过程中踩过的坑与排查思路5.1 控制器RDY不置位的排查链路这是最经典的坑也是新手最容易卡住的地方。现象是FPGA配置加载完成PCIe链路训练成功软件开始NVMe初始化写CC.EN1然后等CSTS.RDY等了一万年也不置位。排查要按顺序来不能跳步第一步确认PCIe配置空间能读到SSD。用lspci看设备有没有枚举出来Vendor ID和Device ID对不对。如果设备都没出现问题在PCIe链路层跟NVMe无关。第二步确认BAR空间能读写。读SSD的BAR0看Capabilities寄存器里的MQES最大队列条目数和TO超时时间字段是不是合理值。如果读出来全是0或全是F说明BAR映射有问题。第三步检查Admin Queue的基地址。ASQ和ACQ的地址必须是物理地址而且按页对齐。我踩过一次坑把虚拟地址填进去了SSD去访问一个不存在的物理地址直接超时。第四步检查CC寄存器的配置。CC.EN1之前CC.IOSQES和CC.IOCQES必须设成6表示队列条目大小是2的6次方64字节CC.MPS和CC.AMS要设对。这些字段设错了控制器不会报错就是RDY不置位。第五步看CSTS的CFS位。如果CSTS.CFS1说明控制器发生了致命错误这时候要去读CSTS的其他位和控制器日志页通常能定位到具体原因。我遇到的那次是第四步的问题IOSQES设成了5队列条目大小变成32字节SSD按64字节解析读到的全是错位的数据控制器直接罢工。5.2 数据完整性校验的意外发现性能测试跑通之后我做了数据完整性校验写一组已知模式的数据到SSD再读回来比对。结果发现大约每1000次传输有1到2次数据错误错误位置随机错误内容像是相邻数据块的错位。这个问题查了很久。一开始怀疑是DDR控制器的问题换了内存条、降了频率没用。后来用ChipScope抓PCIe TLP发现出错的传输里PRP列表的第二个条目地址比预期少了4KB。再往上查发现是PRP列表生成逻辑里的一个计数器在跨页边界时没有正确进位。这个坑的教训是NVMe的数据完整性不能只靠SSD的端到端CRCFPGA侧的逻辑也要做校验。我在控制器里加了一个简单的CRC32校验模块对每个传输的数据块算CRC和SSD返回的元数据比对不匹配就重传。加了之后错误率降到零代价是吞吐掉了大约3%。提示如果你的应用对数据完整性要求极高建议在NVMe命令里启用端到端数据保护End-to-End Data Protection让SSD在写入时生成CRC读取时校验。这个功能需要SSD支持不是所有盘都有。5.3 队列深度加大后吞吐反而下降的原因前面表格里有个现象队列深度从256加到512IOPS只涨了不到2%但延迟涨了快一倍。这个还算正常。但我还遇到过更诡异的情况队列深度从64加到128吞吐不升反降。查下来的原因是doorbell写的拥塞。队列深度64时doorbell写的频率还不高PCIe事务层能轻松处理。队列深度128时doorbell写频率翻倍而我的实现里doorbell写是同步等待完成的每次写要等几十个时钟周期结果doorbell写成了瓶颈SQ里堆满了命令但doorbell发不出去。解决办法是把doorbell写改成异步用一个FIFO缓存doorbell请求后台慢慢发前台继续提交命令。改完之后队列深度128的IOPS从420K涨到498K效果立竿见影。这个优化的代价是多了一点逻辑资源但换来的性能提升完全值得。5.4 不同SSD的兼容性差异不是所有NVMe SSD都能在FPGA控制器上跑得一样好。我试过五六块不同品牌的盘发现几个兼容性差异三星970 EVO Plus兼容性最好初始化一次成功性能稳定Intel 7600p需要把队列深度限制在64以内超过就偶发命令超时某国产杂牌盘根本不认RDY永远不置位后来查手册发现它要求主机先发一个Identify命令才肯使能控制器不符合规范但就是这么任性企业级U.2盘性能最好但功耗高需要额外供电而且对PCIe链路质量要求高所以选盘的时候尽量选大厂的主流型号别贪便宜用杂牌。如果非要用某个特定型号先拿一块做兼容性测试确认能正常初始化再批量采购。6. 从能跑到好用几个值得做的优化6.1 命令预取与流水线化控制器跑通之后第一个优化点是命令预取。默认实现是软件提交一条命令控制器处理一条处理完再等下一条。这样命令之间的空隙很大吞吐上不去。改成流水线之后控制器可以同时持有多个命令在不同阶段一条在构造SQE一条在等doorbell写完成一条在等SSD返回数据一条在写CQ完成项。这样命令处理的重叠度上来了吞吐能提升30%到50%。实现上需要把控制器的状态机拆成多个并行的流水级每级用FIFO解耦。6.2 中断聚合参数的调优前面提到中断聚合这里展开说怎么调。聚合有两个参数阈值攒够多少个完成项触发中断和时间等多久触发中断。这两个参数要配合队列深度和应用的延迟容忍度来设。我的经验值是队列深度128时阈值设16到32时间设10到20微秒。这样中断频率从每命令一次降到每16命令一次CPU占用率从30%降到5%以下而延迟增加不到10微秒。如果应用对延迟不敏感阈值可以设到64中断频率进一步降低。6.3 多命名空间与多盘并行NVMe SSD支持多个命名空间Namespace每个命名空间可以独立管理。企业级盘通常支持把一块物理盘分成多个命名空间或者把多块盘做成一个命名空间组。FPGA控制器如果支持多命名空间可以同时向多个命名空间发命令进一步提高并发度。多盘并行是另一个思路一块FPGA上挂多块SSD每块盘独立一套队列控制器轮流调度。这样总带宽可以线性叠加但要注意PCIe链路的带宽是共享的挂太多盘会互相抢带宽。Gen2 x8的4GB/s带宽挂两块盘刚好挂四块就捉襟见肘了。6.4 功耗与散热的实际考量最后说个容易被忽略的点功耗。NVMe SSD在高负载下功耗能到8到10瓦FPGA本身也有几瓦加上DDR和PCIe硬核整个板子的功耗轻松超过25瓦。如果是在密闭机箱里散热必须做好否则SSD过热降速性能直接腰斩。我在KC705上加了块小散热片和风扇SSD温度从75度降到55度顺序写带宽从2.1GB/s恢复到2.5GB/s。这个提升比任何逻辑优化都来得直接。所以别光盯着代码散热也是性能的一部分。整套方案跑下来从Vivado工程搭建到性能调优大概花了我三周时间其中一半时间在填坑。但跑通之后这套控制器在采集项目里连续运行了六个月没出过问题数据落盘延迟稳定在200微秒以内比之前走主机文件系统的方案快了将近一个数量级。如果你也在做类似的高速存储需求这条路值得走一遍。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进