
上周我调一块存储控制器的板子现象特别典型单跑随机4K读的时候P99延迟只有180微秒左右整体很漂亮。结果把垃圾回收任务一开P99直接飙到27毫秒翻了两个数量级。更奇怪的是我掐指算了下后台任务占用的DDR带宽按字节量算都不超过DDR峰值带宽的10%可用户IO就是被拖得不成样子。问题不在于后台任务吃掉了多少带宽而在于我们从来没认真回答过“带宽怎么分”这个问题。后台任务不是均匀流用户IO也不是恒定流两边同时涌向DDR控制器的时候到底按什么规则分、分完各自还剩多少、峰值撞在一起会发生什么这才是DDR带宽需求建模第四篇真正要聊的事。前三篇我分别聊过流量拆解、测试基线建立和地址映射策略这篇重点回答一个问题用户IO和后台任务带宽怎么分。这个内容适合做SSD/存储控制器、SoC内存子系统、实时系统性能建模的工程师也适合正在被“后台一开线上就卡”折磨的同学参考。下面我按实际建模的推进顺序把账目、模型、DDR协议行为和校准方法一起拆开讲。1. 带宽账本DDR请求里到底有多少是“用户IO”多少是“后台杂音”1.1 用户IO的显式流量与隐性损耗先算账再吵架。很多人建模时习惯把用户IO的带宽需求等同于主机侧看到的IO大小比如主机发了4KB写就觉得DDR要消耗4KB带宽。这是最大的误区。一次4KB的用户写请求在典型的存储控制器架构里数据可能先由主机DMA写入DDR中的Staging Buffer固件再从DDR里读出来搬运到NAND写缓冲区最后还要更新L2P映射表、写日志条目。这三个动作加起来DDR侧的读写字节数往往是4KB的2到3倍。我做个简单的拆分主机写4KB到DDR消耗4KB写带宽固件从DDR读4KB用于搬运消耗4KB读带宽固件把元数据写回DDR再消耗几百字节到几KB。某些架构里NAND写前还需要做随机化或者对齐处理数据还得再读一遍。所以最终DDR侧的实际消耗通常可以用一个放大系数来表达用户IO的DDR流量 主机IO大小 × 用户路径放大系数 元数据固定开销这个系数跟数据路径设计强相关。我见过有人用DDR带宽监控计数器实测过同样是4KB写在A架构下DDR侧要消耗约10KB流量在B架构下只要5KB。这就是为什么我强调建模时不能只看命令大小第一步要先把“一次用户IO从入口到落盘DDR被读写了几次”画出来。1.2 后台任务的七种常见流量后台任务就更加隐蔽了。很多同学以为后台任务只有垃圾回收实际上在真实系统里后台任务少说也有六七种。我列一下在存储控制器场景里最常见的几种垃圾回收搬运读源块数据、写目标块数据每搬1单位的有效数据DDR侧通常会产生读一次、写一次的流量合计2倍以上。L2P映射表刷新/持久化映射表是DDR里的大户定期要把脏页刷到NAND刷之前要在DDR里做合并、排序。磨损均衡搬移把冷热数据交换位置读一块、写一块同样占用DDR带宽。数据完整性巡检周期性读回NAND数据做校验DDR要一路搬运。坏块扫描与健康度监测低强度但持续的读操作。快照/一致性点数据复制做写时复制时需要读原数据块再写新块。掉电保护流程异常掉电瞬间DDR里的缓存数据要紧急搬写到NAND或专用掉电保护区域。这些任务的特征差异非常大。GC搬运典型是高带宽、大块、持续数秒到数十秒的流量映射表刷新是中带宽、短促、周期性出现的流量巡检则是低带宽但永远在跑的“背景噪声”。它们加在一起才是后台任务在DDR带宽账本上的真实总量。1.3 一张流量画像表时延敏感度、突发性、可压缩性为了给后面的分账方案提供依据我习惯在建模一开始就给所有流量源画一张画像表。你不需要特别精确但一定要把每个流量源在“时延敏感度、突发系数、可否延迟、可否分片”四个维度上的特性标出来。流量源时延敏感度突发系数可否延迟可否分片典型方向用户读高高否不宜读为主用户写高高否不宜写为主GC搬运低极高可以可以读写混合映射表刷新中中可以可以写为主磨损均衡低中可以可以读写混合巡检扫描低中可以可以读为主快照复制低高可以可以读写混合这张表最大的作用是告诉你哪些流量可以做带宽管制、哪些不能。用户IO不能压、不能延迟只能给它让路后台任务则几乎都是“可以延迟、可以分片”的从调度角度来说它们就是天然的弹性负载。这个认知是后面所有分配模型的地基。2. 四种“分账”方案从固定比例到动态水位各有什么代价2.1 固定比例硬切的实现与局限最朴素的做法是给后台任务设一个固定比例比如“后台任务最多占DDR带宽的30%”。这个方案在固件里实现起来非常简单后台任务每次提交DDR请求前查一下当前的带宽计数如果后台累计消耗超过阈值就暂停提交等下一个统计周期再放开。这种方案的好处是模型简单、行为可预测、容易对上层解释。但坏处也很明显固定比例在用户IO轻载时是浪费的。比如凌晨两点系统几乎没用户IO后台任务本来可以全速跑把GC尽快做完结果被30%的天花板卡住白白延长了后台任务的完成时间。反过来用户IO满载时30%的后台比例依然会放进来进一步挤压用户请求的可用带宽。哪怕你调成10%只要这个比例是固定的就必然存在某一侧不满意。这就是固定比例方案的根本矛盾它不是一个跟随系统状态变化的自适应策略而是一个静态的妥协。2.2 低水位补货把剩余带宽让给后台更合理的做法是低水位保护。思路是给用户IO设一个保留带宽比如用户IO的P95需求是1.8GB/s那么DDR侧保证前1.8GB/s优先给用户只有当用户IO带宽消耗低于这个水位时后台任务才能使用剩余部分。用公式表达就是后台可用带宽 max(0, 当前DDR估算可用带宽 - 用户IO保留带宽)这个方案的好处是用户IO得到了硬保障后台任务在用户轻载时还能自动提速。但实践中有个坑后台任务已经开始执行后DDR内部已经有一堆后台请求排队了这时候用户IO突然爆发你就算在调度器层面冻结后台任务也已经提交到DDR控制器里的那批请求还是会造成延迟尖峰。所以低水位补货通常要配合“预算门控”和“队列深度限制”一起用。我实际项目里的做法是后台任务在提交请求之前先看DDR写缓冲和水位计数器的占用情况如果用户IO近1毫秒内的平均带宽已经接近保留水位就提前把后台请求的提交速率降下来而不是等撞上了再反应。2.3 令牌桶与时间窗组合第三个方案是令牌桶用于后台任务的限速。它的思想是后台任务有一个令牌桶令牌按固定速率补充桶的容量限制了单次突发的大小。后台任务每消耗一定量的DDR带宽就得消耗对应令牌令牌不够就等待。这个方案的优势在于平滑突发你不会看到后台任务在一瞬间把所有请求全砸进去而是像水龙头一样匀速流出。但令牌桶只能管后台自己的发送速率管不了用户IO和后台请求在DDR控制器里如何交织。系统峰值时即便后台平均速率很低只要突发窗口和用户IO峰值重叠依然可能出问题。所以工程上更常用的是时间窗方案把时间切成固定大小的窗口每个窗口内部给用户IO和后台任务分配不同的执行时间段。比如每100微秒为一个周期前80微秒允许用户IO优先占用DDR后20微秒才允许后台任务提交请求。后台任务就算突发再猛也只能在这个时间窗内爆发窗口边界一到就必须让路。时间窗方案的好处是行为非常确定你很容易算出后台任务最坏情况下的带宽和时延影响。缺点是如果用户IO在窗口前半段没有用完带宽后半段后台任务才能用整个系统的带宽利用率取决于窗口设置的合理性。窗口太小切换开销大窗口太大用户IO的平均延迟又会被后台任务拖高。2.4 选型建议组合策略最实用我在实际项目里验证过这几种方案最终落地的往往是组合策略用户IO由DDR控制器硬件优先级保证标记为高优先级请求。后台任务先用令牌桶做平均速率限制再用时间窗做突发上限限制。同时加一个“紧急出口”开关当用户IO负载明显低于保留水位时允许后台任务临时加大令牌桶速率把空闲带宽捡起来用。换句话说最后的设计不是选某一个方案而是让不同机制各管一段。令牌桶管平均速率窗口管突发上限水位管用户保障三者合起来才能应对“后台任务一开延迟就崩”的局面。后面第4章的算例会告诉你这些参数具体该怎么定。3. DDR控制器视角的“隐形税”切换惩罚、Bank冲突与写缓冲争用3.1 读写切换的bus turnaround损耗前面讲的分账方案都是在“请求提交”这个层面做文章。但请求进了DDR控制器之后还有一笔账要算DDR数据总线是半双工的读操作和写操作切换时需要插入额外的总线换向时间。这个时间在DDR协议里叫做bus turnaround虽然每次只有几个时钟周期但只要读写频繁交替累积起来的损耗非常客观。我举个实际例子。如果一个DDR通道的理论峰值是3.2GB/s理想情况下连续读或者连续写可以做到90%以上的效率。但一旦读流量和写流量交替穿插比如每读一小段就切去写一小段总线换向开销会让实际可用带宽掉到70%甚至更低。后台任务恰恰是读写混合的典型GC搬移要读源块、写目标块天然就是读一段写一段。如果后台任务的请求粒度很小读写切换就会非常频繁效率更难看。这就是为什么我特别强调后台任务请求要“聚类”把后台读请求攒成一大块连续读把后台写请求攒成一大块连续写让DDR总线上尽量出现“长读段”和“长写段”而不是一个个小碎块来回切换。这个优化不需要改动分配算法只需要后台任务管理器在提交请求前做一次重排收益却非常明显。3.2 后台请求与用户请求的Bank/Page冲突第二个隐形税是Bank/Page冲突。DDR内部按Bank组织每个Bank里有一个激活的行Row命中了就是Row Hit没命中就要执行Precharge和Activate插入额外的惩罚周期。如果后台任务和用户请求恰好落在同一个Bank的不同Row上就会互相踢行。用户请求刚激活了Row A后台请求一来要求访问Row B被迫先把Row A关掉等用户再回来时Row A又得重新激活。一来一回两个请求都慢。要避免这个问题靠分配算法是做不到的得靠地址映射策略。我的经验是后台任务的源地址和目标地址最好分散到不同的Bank Group中让它们和用户IO的访问地址天然避开。这样即使后台任务在跑DDR控制器里的Bank状态也不需要频繁切换。地址交织的具体做法在系列第三篇里详细聊过这里提醒一句如果建模时忽略Bank冲突损失模型预测的可用带宽会明显偏乐观校准阶段迟早会补这一课。3.3 写缓冲满与优先级反转的坑第三个坑也是我最想强调的一个优先级反转。很多DDR控制器为了吸收写流量会配置一个写缓冲Write Buffer。后台任务的写请求进入写缓冲后如果写缓冲满了DDR控制器可能被迫暂停接受新的写请求包括用户IO的写请求。这时候会出现一个反直觉的现象用户IO明明是最高优先级但后台任务把写缓冲占满了用户写请求排队等缓冲释放延迟直接飙升。你从统计上看后台任务只占用了3%的带宽但在某个瞬间它占满了写缓冲造成用户IO卡顿几十微秒甚至几毫秒。我踩过这个坑之后在后来的设计里都会给写缓冲设置高水位阈值。后台任务的写请求提交前先检查写缓冲占用如果超过阈值暂停后台写请求等写缓冲drain到安全水位再恢复。这个“水位反馈 暂停恢复”的机制比任何复杂的分配算法都管用。3.4 让后台任务“对DDR友好”的组织方式综合上面三个隐形税我在设计后台任务时总结出了几条硬性原则后台任务请求必须按方向聚类攒够一定量的读请求再统一发写请求同理避免读写频繁交错。后台任务请求的粒度不能太小通常建议单次请求不低于32KB这样DDR的行命中率和burst效率才高。后台任务必须能被“拧松拧紧”支持在执行过程中暂停、恢复每个子任务要有检查点否则调度器只能对它束手无策。这三点做不好上面第2章的任何分账模型都会在实际运行中失效。因为DDR控制器的真实行为并不完全受软件分配策略控制。4. 三个典型场景的带宽推演4.1 垃圾回收大块搬运如何嵌入在线负载我以一个具体配置为例。假设DDR峰值带宽为3.2GB/s用户IO的平均带宽需求为1.2GB/s峰值需求为2.4GB/s。后台GC要搬移16GiB的源数据按每搬1单位数据产生约2.2倍DDR流量计算读源、写目标、加元数据更新GC总共需要约35GiB的DDR流量。如果不加任何限制GC全速跑起来可能吃掉2GB/s以上的带宽。用户峰值2.4GB/s加上GC的2GB/s合计4.4GB/s远超DDR峰值系统必然崩溃。所以必须给GC限速。按组合策略来定参数后台令牌桶平均速率设为0.8GB/s突发上限设为1.6GB/s时间窗设为100毫秒窗口内后台任务最多占用30%的时间。这个配置下用户典型负载1.2GB/s加上GC平均0.8GB/s合计约2.0GB/s远低于DDR峰值留足了突发空间。GC完成时间估算35GiB ÷ 0.8GB/s ≈ 44秒如果允许夜间负载低时提高令牌桶速率到1.5GB/sGC完成时间可以缩短到约23秒。这个延展性正是固定比例方案给不了的。但这里有个容易被忽略的点后台任务平均速率再低只要突发窗口够大依然可能打穿用户延迟。比如GC只在每小时的前10秒全速跑2GB/s虽然平均速率只有0.005GB/s左右但这10秒内用户IO照样被拖垮。所以限速一定要同时限制平均速率和突发窗口只限一个都是耍流氓。4.2 快照与数据迁移突发型后台任务的控制快照场景和GC不太一样。快照刚创建时基本不占带宽但后续写时复制机制会在后台持续搬运数据。更麻烦的是快照的搬运任务不是匀速的它跟用户写IO的地址分布强相关用户写到某个已快照块后台就立刻要复制那个块的原始数据属于典型的“突发事件驱动”任务。对这种任务我的做法是反馈控制而不是固定预算分配。后台复制任务每提交一小批比如64KB请求之后检查一次用户写请求在DDR控制器里的排队深度。如果排队深度超过阈值后台暂停一个检查周期如果排队深度很低后台继续往下搬。这个方案看起来不像一个“模型”但实际效果比任何带宽百分比都好因为它直接感知了用户侧的真实压力。数据迁移/重建场景也类似但它多了一个硬约束必须在期限内完成。比如72小时内必须重建完一块盘的数据这时你需要先算出一个最低后台带宽最低后台带宽 待迁移数据量 ÷ (期限 × 60%)这个60%是一个工程经验系数给系统留出余量防止迁移任务拖到最后一刻还没完成。然后把这个最低后台带宽和用户IO保护水位比较取两者中的较大值作为后台任务的目标速率。用户负载越低后台跑得越快尽量把时间余量留出来。4.3 刷洗巡检低强度持续任务的参数设定巡检类任务的特点是持续不断、单次读取量不大、带宽需求不高但它永远是后台流量里的“底噪”。如果处理不好它会和用户IO形成长期的微小争用导致性能曲线不稳定。我一般把巡检任务的带宽目标定在0.1到0.2GB/s左右。看起来很少但巡检请求通常是连续读可以做很好的地址聚合把巡检的读请求按物理地址顺序聚合成大的连续读块一次读完一整个Zone然后停一小段时间再读下一个Zone。这种方式下巡检请求在DDR侧表现为“短促的大块连续读”对DDR行命中和总线效率都很友好。实测下来0.2GB/s的巡检任务对用户P99延迟的影响可以控制在5%以内基本可以忽略。5. 用计数器校准模型别让“计算值”骗了你5.1 原型板上应该盯哪些计数器模型建完了最终还是要上板验证。DDR控制器通常自带性能计数器我建议至少盯这几个DDR总线利用率多个时间段内的实际占用比例。读/写带宽分别统计注意不要把读带宽和写带宽混在一起看否则会掩盖读写切换问题。平均请求大小和请求大小分布用来验证后台任务的请求聚类是否生效。写缓冲占用率看后台任务是否频繁把写缓冲推高。Bank冲突计数有的控制器会统计Row Miss和Bank Conflict次数。固件侧也要统计后台任务实际完成了多少数据量、用户IO的实际读写字节数。这样两边一对照才能看出模型估算和实际运行之间的偏差从哪来。5.2 单跑与联合跑争用损耗系数怎么量我最常用的校准方法是三组对照实验。第一次只跑固定负载的用户IO测量实际可用带宽第二次只跑后台任务测量后台任务的实际带宽第三次两者一起跑测量联合场景下的总带宽。然后用下面这个公式计算争用损耗系数争用损耗系数 (用户单跑带宽 后台单跑带宽 - 联合总带宽) ÷ (用户单跑带宽 后台单跑带宽)这个损耗系数的来源就是第3章讲的bus turnaround、Bank冲突、调度非理想性等因素。我实测的一组数据是用户单跑2.0GB/s后台单跑1.2GB/s联合场景只有2.8GB/s比两者相加少了0.4GB/s损耗系数就是0.4÷3.212.5%。这个系数不是常数它会随着读写比例、请求大小、后台任务类型变化。所以我会在每种典型场景下各测一组数据得到损耗系数的分布区间。如果区间很大建模时就取偏保守的值宁可把余量留大一点。5.3 校准后回填模型的一个算例把损耗系数回填到分配模型里整个带宽约束就变成(用户IO带宽 后台任务带宽) × (1 争用损耗系数) ≤ DDR峰值带宽 × 目标利用率沿用前面的算例DDR峰值3.2GB/s目标利用率0.9争用损耗系数0.125。那么系统允许的请求带宽合计为3.2 × 0.9 ÷ 1.125 ≈ 2.56GB/s如果用户IO正处于典型负载1.2GB/s后台任务允许的平均带宽约为1.36GB/sGC完成时间约26秒。如果用户IO处于峰值2.4GB/s后台任务允许的带宽只剩0.16GB/sGC几乎要停下来等待完成时间会拖到几分钟。这个结果告诉我们后台任务限速不是固定值它是用户IO负载的反函数。模型最终输出的不是“后台占30%”而是一条随用户负载动态变化的限速曲线。我最后还想分享一个体会模型算得再精也不如把后台任务设计成“可以暂停、可以恢复、可以按批提交”的结构。一个检查点清晰的GC任务能在用户IO爆发时50微秒内让路比任何复杂的调度算法都管用。反过来如果后台任务结构上没法让路分配模型画得再漂亮DDR控制器也救不了你。所以带宽怎么分一半靠模型和策略另一半靠后台任务本身的工程实现两者缺一不可。