干过SSD固件或者存储系统底层的人,应该都有过这样的体会:整个FTL(Flash Translation Layer)里最让人又爱又恨的东西,就是L2P映射表。说它让人爱,是因为所有逻辑地址到物理地址的转换都靠它,主机的读写请求能不能命中,全看这张表查得快不快、准不准;说它让人恨,是因为一旦容量上去、并发一高、GC一启动,这张表就会变成性能瓶颈的“集火点”。我这两年正好在折腾L2P表相关的优化工作,从硬件数据通路的搭建,到GC过程中VPC表的构建瓶颈,再到运行时驻留策略的调整,踩了不少坑,也沉淀了一些可以复用的思路,这篇就把我的实践过程完整拆开讲一讲。
这篇内容主要围绕三个核心问题展开:第一,L2P表为什么需要专门的硬件级数据通路来做查表和更新;第二,垃圾回收(GC)场景下VPC表的构建瓶颈到底卡在哪里,怎么破;第三,在DRAM和SRAM资源都受限的实际产品里,L2P表运行时驻留策略应该怎么设计。做SSD固件、存储控制器验证、以及研究FTL算法的朋友,都可以在里边找到一些可以直接落地的参考方案。
1. L2P表为什么值得一条硬件级数据通路
L2P表说白了就是一张记录了“逻辑地址到物理地址”对应关系的表。主机下发一个读命令,控制器得先根据逻辑地址(LBA)去查这张表,拿到物理地址之后才能去对应的Flash位置读数据。写命令则反过来,先分配一个物理页,然后把新的映射关系更新到表里。很多人觉得查表不就是一个内存读取操作嘛,有什么好讲的?但真正放到高并发、低延迟的NVMe SSD场景下,事情远没有那么简单。
固件跑在控制器内置的CPU核上,这个核的性能比我们平时用的应用处理器差不少,频率低、核数少。主机侧可能同时压着几十K甚至上百K的IOPS,每一条IO都要走一遍L2P查表。如果这一步完全靠CPU软件去查,CPU资源很快就会被吃光,留给GC搬运、磨损均衡、坏块管理这些后台任务的算力就所剩无几了。更麻烦的是,软件查表还要经过总线读写缓存,延迟高且不可控,导致IO延迟抖动明显。所以业界的通用做法,就是把L2P的查找和数据搬运从CPU手里拿出去,专门用硬件逻辑来实现,这就是硬件级数据通路的由来。
1.1 L2P硬件数据通路的整体形态
一条完整的硬件数据通路,通常包含命令解析模块、L2P查找引擎、表项缓存、DRAM控制器接口和结果返回通路。主机命令进来之后,先由硬件解析出逻辑地址,然后查找引擎拿着这个逻辑地址去查映射表,拿到物理地址后要么直接去访问Flash,要么把结果返回给固件做后续处理。
这里有一个关键的设计点:L2P表本身是放在DRAM里的,而DRAM访问有延迟。如果每一条IO都直接去DRAM里查表,DRAM带宽会成为新的瓶颈。所以必须在硬件查找引擎和DRAM之间加一层片上缓存,通常用SRAM实现,缓存最近访问过的映射表项,原理跟CPU的TLB是一回事。我测试过一组数据,加了SRAM缓存之后,L2P查询的命中率能达到90%以上,平均查询延迟比直接访问DRAM低了一半还多。
1.2 硬件查表应该用什么数据结构
这一点特别容易被做成软件方案的简单平移,然后发现在硬件上行不通。软件环境里,L2P表用哈希表或者多级页表都行,因为CPU处理哈希冲突很灵活。但硬件逻辑做哈希冲突处理非常痛苦,逻辑复杂度直线上升,时序收敛也难。所以我在实际项目里更推荐定长多级表或者基于Radix树的硬件友好结构。
定长多级表的思路是把逻辑地址拆成多段,每一段作为一级索引,最后一级条目指向物理页地址。这样查找路径固定,硬件流水线非常好做,每个周期都能发起一次查找。缺点是可能存在内部碎片,大容量场景下内存利用率略低。但考虑到现在控制器的DRAM容量普遍在GB级别,映射表多占用的那几百MB完全在可接受范围内。哈希方案不是不行,但它的平均性能虽然好,最坏情况下冲突严重时会卡住流水线,对需要稳定延迟的存储场景不太友好。
1.3 硬件数据通路设计中的几个关键细节
设计硬件数据通路时,有几个坑是我实际踩过之后才反应过来的,这里重点提醒一下。
第一,表项更新和IO查询之间有并发一致性问题。主机在读数据的同时,固件可能正在后台更新同一批映射表项,比如GC搬移完数据之后要改映射。如果硬件查询引擎读到了更新前的旧表项,就会拿到一个已经无效的物理地址。解决思路是给映射表项加版本号或者有效位,固件更新前先做表项失效操作,查询引擎发现失效后把这条查询请求踢回给固件软件处理。
第二,回写路径的顺序保障。L2P表项的更新必须严格保序。比如主机连续写两个扇区,逻辑地址相同,第一次写的映射结果如果没有落表,第二次写的映射结果先落了表,那就会导致映射指向新数据而旧数据尚未标记无效,掉电时数据一致性就崩了。硬件通路里需要有一个FIFO队列保证同地址表项的更新按提交顺序执行。
第三,异常返回路径。硬件查表不是每次都能成功的,比如映射表项不在缓存里,或者目标表项还没分配。设计的时候必须留一条exception路径,把这种查询请求导回给固件做软件处理。还有就是从Flash读映射表的场景,映射表本身也放在Flash里,冷启动时要把整张表加载到DRAM,这个加载过程也最好做成硬件DMA搬运,否则启动时间会很难看。
2. GC里的VPC表:瓶颈到底在哪
GC(Garbage Collection)是FTL里绕不开的环节,它的作用是回收那些写满数据、但已有部分页失效的物理块。GC要做的事情概括起来就三步:选块、搬移有效页、擦除物理块。而选块和搬移过程中,VPC表就是那个决定效率和写入放大系数的关键数据结构。
VPC表,全称Valid Page Count Table,记录的是每个物理块当前还有多少个有效页。GC每次要挑一个块来回收,按什么标准挑?最朴素的标准就是挑VPC最小的块,因为有效页越少,需要搬移的数据量越小,额外写入越少。这个逻辑说起来简单,但VPC表本身的构建和维护在高容量SSD里会成为很重的负担。
2.1 VPC表的构建过程为什么慢
构建VPC表的核心操作,是从L2P表反向推导每个物理块的有效页数量。具体做法是遍历L2P表的所有条目,每一条映射拿到它的物理页地址,然后把这个地址所属的物理块的计数器加一。等到整张L2P表扫完,每个物理块的VPC就等于它的计数器的值。
问题出在这个“遍历”上。L2P表动辄几百万条条目,每条都对应一次内存读取和一次计数器增量。如果这个流程用CPU软件实现,需要花费非常长的时间。而且这不只是时间问题——遍历L2P表的同时会产生大量的CPU缓存未命中,因为映射表条目在内存里的分布是随机的,你刚读完地址A,下一个地址B很可能落在很远的位置。有次我在调试环境里测过,用软件方式扫描一份容量约1TB的L2P表,耗时超过3秒,GC期间主机的IO差不多要停摆,这种体验在产品里完全不可接受。
2.2 GC过程中VPC表选块策略的另一种思路
传统的VPC扫描选块,是一种离线、静态的方式,也就是GC发起时临时扫描全表。扫描成本高之外还有一个问题:扫描得到的VPC是某一瞬间的快照,等真正搬移数据的时候,又有不少页因为新写的IO或主机的更新操作变成无效了。快照和实际之间始终存在误差,如果误差方向是“预估有效页偏少”,那GC搬移时会额外搬一些其实已经失效的页,写入放大就上去了。
更好的方案是维护增量式VPC。也就是在正常IO路径上动态维护每个物理块的VPC,每次写分配时,新分配的物理页对应的块VPC加一;每次表项更新导致旧物理页失效时,对应的块VPC减一。这样VPC表始终是实时的,选块的时候直接查表就行,完全不需要扫描碎片。这看起来很简单,但它要求每一次IO的映射更新都必须同步维护VPC计数器。VPC表本身放在DRAM里,那意味着每次映射更新都多了一次VPC表的读写操作。如果不能和L2P表更新合并为一次原子操作,性能开销就会翻倍。
我实际采用的折中方案是:VPC表不用精确值,采用周期性校准。具体做法是在平时用增量维护保持一个近似的VPC值,GC选块的时候用近似值做粗选,选出候选块之后再做一次小范围的精确扫描,只扫描这几十个候选块的映射关系,确认它们各自的真实有效页数。这样既避免了全表扫描的开销,又能保证GC选出来的块是足够优秀的,写入放大也能控制在可接受范围。
2.3 硬件并行扫描对GC的有效加速
即使采用上面说的粗选加精选方案,GC过程中的精确校准步骤还是需要扫描一部分映射表数据。这部分工作我建议做成硬件模块,不要占用CPU。
思路是这样的:硬件扫描引擎从DRAM里按顺序读L2P表的高位段,提取出物理块号,然后在一个片上计数器阵列里做增量。因为扫描是连续内存访问,硬件可以一次性突发读很多条映射表条目,再做流水线式处理,吞吐量比CPU逐条扫描高好几倍。我还在这条通路里判断了跳过条件——如果某一段逻辑地址区间对应的物理块号全部落在同一批候选块里,就只更新这几个计数,避免不必要的比较。
实测下来,改用硬件扫描引擎之后,候选块校准时间从原来的几百毫秒缩到了几十毫秒,GC停IO的窗口大幅缩短。对前台主机IO的影响明显变小,QoS数据好看了不少。
3. L2P表运行时驻留策略:内存放不下的取舍
前面说的都是L2P表“怎么查”的问题,接下来还有一个更现实的问题:L2P表放哪儿、留多少。
消费级SSD控制器里的SRAM通常只有几MB到几十MB,DRAM也就几百MB到几GB。而L2P表的大小跟SSD容量成正比。以4KB映射粒度为例,1TB的SSD有2.68亿个逻辑页,每个映射表项假设压缩后占8字节,那么表本身接近2GB。这还没算元数据和日志占用的空间。也就是说,大容量产品里,L2P表根本不可能全量放进片上SRAM,甚至连只放在DRAM里都是个奢侈的选择。容量越大,驻留策略越关键。
3.1 全表驻留、部分驻留和分级驻留的适用场景
驻留策略可以分为三个层级:
全表驻留就是把所有映射表条目都放在DRAM里。优点是查找路径最简单,不存在换入换出的问题;缺点是DRAM容量开销大,而且掉电时整张表都要靠电容电量保住,否则重建映射会耗时极长。这个方案在早期小容量SSD里很流行,现在则主要用于企业级高端盘,因为那些盘DRAM加得足够多。
部分驻留则是当映射表总大小超过DRAM可用空间时,只把一部分表项放DRAM,其余部分留在Flash里,需要时再换入。这相当于给L2P表做了一套二级存储体系。难点在于换入换出策略怎么定——换错了会频繁触发Flash读映射表,那性能和随机写时会出现的卡顿会比想象中严重得多。
分级驻留是我个人认为在容量、性能和成本之间平衡得最好的方案。顶层是一小块片上SRAM,存最近访问过的映射表项;中间层是普通DRAM,存放常驻工作集;底层是Flash里的系统区,存完整映射表。SRAM命中走硬件快路径,DRAM命中走正常的硬件查找,两者都不中才回退到Flash加载。这种结构能让绝大多数IO只命中前两级,有效规避了Flash读映射表的高延迟。
3.2 热点表项识别与缓存替换策略
分级驻留方案的核心模块是SRAM缓存层,而缓存替换策略基本上决定了整个L2P查询的命中率。我一开始用的是最简单的LRU(Least Recently Used),但很快发现随机写场景下LRU表现很差。原因是随机写的映射表项更新很分散,每一条新表项被访问过一轮之后很快就会被赶出缓存,下次再写同一条地址时缓存已经失效了。明明整个表只有几万个热点表项,缓存容量却一直在被冷门表项挤占。
后来我改成了被反复验证过的ARC(Adaptive Replacement Cache)思路。ARC把缓存分成Recent区和Frequent区,Recent区管新出现的表项,Frequent区管经常被访问的表项。当一个表项在短期内连续两次被访问,就把它从Recent区提升到Frequent区,避免被新进入的表项挤掉。这个策略对存储场景特别适用,因为实际业务里的访问模式往往是“热点相对集中、冷热交替出现”的。
这个改动的效果非常直观。在我测的随机写模型里,SRAM命中率从原来的82%提升到了94%左右,平均IO延迟下降了差不多三成。而且ARC比LRU对突发访问模式的适应性更强,GC搬移大批映射表项时,不会被临时的突发访问打乱热点分布。
3.3 脏映射表项的回写和掉电保护
L2P表运行时驻留策略还有一个不能忽略的维度:脏表项。所谓脏表项,就是更新过但还没写回Flash的映射表条目。这部分数据只存在于SRAM或DRAM里,如果突然掉电,这些映射会全部丢失,之后只能靠日志或全盘扫描重建,非常耗时。
掉电保护的核心思路是日志先行,也就是新映射产生时,先写一条映射更新日志到Flash,日志写成功之后才算真正持久化。缓存里的脏表项什么时候落盘?有几种触发条件:一是缓存容量到达阈值,需要替出脏表项时强制执行;二是周期性后台刷盘;三是GC搬移过程中涉及到的映射更新优先落盘,避免GC完成之后掉电造成映射不一致。
我在实际调优中发现,脏表项的累积速率和刷盘频率之前存在很强的耦合关系。如果刷盘太频繁,Flash写入放大增加;如果刷盘太慢,掉电时缓存里的脏表项太多,重建时间会超出规格。平衡点建议根据实际IO负载来设,比如在写密集型的测试模型里,刷盘水位线设在缓存容量的60%比较合适,水位线到80%时触发强制回写,尽量避免缓存被脏表项挤占而影响新映射的写入。
4. 常见问题排查与实操心得
这几部分工作做下来,积累了一些实际调试中遇到的典型问题,单独写一节,顺便分享一些我的排查习惯。这些问题在文档里不一定写得很清楚,但遇到的时候都很要命。
4.1 硬件查表通路返回错误映射的排查
最诡异的一类问题就是硬件返回的物理地址在错误的时间点是对的,在某个极端时序下却是错的。这种问题通常不是逻辑错误,而是流水线上有先后的竞态。我排查的顺序是,先查命令队列深度打满时的行为,再查同地址乱序更新场景,最后用形式化验证工具跑一遍断言。简单点说,如果怀疑硬件返回了旧映射,可以在硬件通路里加一个比较器:每一条查询返回时都跟固件维护的软件映射副本做比对,不一致就记录上下文。坏处是这会在调试版本里多耗不少资源,但换来的是一条能在真实场景里精准抓到异常现场的调试通路,值。
4.2 GC选块和VPC维护的常见坑
VPC计数器维护里最容易出现的坑是计数漂移。增量维护的VPC因为某次异常流程漏减了,以后就会一直偏大。出现这种情况就很难排查,因为计数器差值不固定,GC选块的行为也会慢慢变得不优。我的建议是定期做一次全盘校准,哪怕不做精确扫描,也可以按物理块抽样校验,发现偏差超限就把对应块的VPC重新算一遍。
另一个坑是GC搬移过程中,搬出的数据写到了目标块,目标块的VPC加一,但源块那个有效页应该在搬完之后标记失效并减一。这个“搬完再减”的顺序千万不能反,否则掉电恢复之后会出现同一个逻辑页同时映射到两个物理页的异常状态。我们固件代码里把这两个操作写成了一个原子事务,要么都完成,要么都回滚,保证恢复时不会出现双映射。
4.3 驻留策略引起的QoS毛刺
L2P表部分驻留策略下,最头疼的副作用是QoS毛刺。想象一下,主机正在跑稳定的随机写IO,突然来了一波冷数据写的请求,这些地址的映射表项不在缓存里,需要从Flash重新换入,换入的过程里IO延迟直接飙升好几毫秒。对延迟敏感的企业级业务来说,这种毛刺比平均延迟高更加致命。
缓解的办法是在换入路径上做预取。当缓存未命中触发一次Flash读映射表时,顺带把该逻辑地址附近的一组映射表条目一起读上来。因为IO访问通常有局部性,相邻地址的映射大概率也会在不久后被用到。这个预取粒度要根据映射表在Flash上的排列方式设计,不要贪多,一次读太多会吃掉Flash带宽,反而拖慢正常IO。
另外还可以设计一条“慢路径旁路”机制:当缓存未命中需要从Flash换入时,主机IO不阻塞等待,先走一个低优先级的异步加载流程,同时立即返回一个超时重试指示,让主机稍后重试。这个机制在NVMe协议里有对应的状态码可以实现,缺点是增加了主机侧的交互复杂度,是否采用要权衡产品定位。
4.4 实测数据:驻留策略调整前后对比
最后给一组我在真实测试环境里拿到的数据,方便大家有个直观感知。测试模型是64KB随机写、队列深度32、SSD标称容量2TB,映射粒度4KB,SRAM缓存配置为8MB。
调整前的策略是LRU、无预取、脏表项水位线50%,实测平均IO延迟78微秒,P99延迟4.2毫秒,SRAM命中率82%。调整后的策略是ARC替换策略、带8KB映射预取、脏表项水位线60%,实测平均IO延迟55微秒,P99延迟1.8毫秒,SRAM命中率94%。P99延迟降了57%,这个数字对上线产品的体验提升是非常可感的。
这个对比也说明一个问题:L2P表运行时驻留策略不是简单选一种算法就完了,替换策略、预取粒度、刷盘水位这些参数要放在一起调,相互配合才能拿到最优解。单独调任何一个都很难有质的提升,甚至可能互相抵消。
再往深了说,L2P表这一系列优化做完之后,GC效率也跟着上来了。因为映射更新快了,GC搬移时更新映射的开销就低;VPC选块准了,搬移次数就少;驻留命中率高了,GC过程中额外读映射表的次数就少。三者是环环相扣的,单独优化某一块很难见效,必须放在一起做整体设计。我在调试过程中一度只盯着驻留策略调,结果GC性能上不去,后来把VPC维护逻辑和硬件扫描通路一起改了,问题才彻底解决。存储系统的性能优化从来不是一个点的事,而是整条链路的事。