
1. 项目背景与缓存架构选型1.1 为什么需要共享缓存模块先交代一下这个项目的背景。前几年我参与了一款多端口以太网交换芯片的设计片内需要一块数据缓存来吸收端口速率不匹配和瞬态拥塞。所谓高速多端口共享缓存模块简单来说就是在芯片内部做一个所有端口共用的大缓存池进来的报文先切成长度固定的cell存进去需要发出时再从池子里取出来往目的端口送。之所以强调共享是因为早期的交换芯片很多采用每端口独立缓存的简单做法每个端口配一块固定大小的缓存。这个方案实现最简单但有一个致命问题不同端口的流量模型差别很大有的端口拥塞时缓存不够用有的端口却很空闲缓存白白浪费。我在项目里见过一个实际场景某个端口背压时持续丢包而旁边的端口缓存还有80%以上空着。共享缓存的初衷就是把所有缓存资源放在一个池子里按需动态分配谁需要谁用本质上是在做统计复用。不过共享也带来了代价最直接的就是内存带宽要求高。每个cell写入一次、读出一次缓存模块的读写带宽必须覆盖所有端口的线速总和否则就成了瓶颈。这也就是高速二字的由来——不是缓存能用就行而是要在几十Gbps甚至更高的聚合带宽下还能稳定工作。1.2 三条主流路线的取舍设计初期我们评估过三种方案共享总线、Crossbar、共享缓存。共享总线是最古老的做法所有端口挂一条总线分时使用优点是简单缺点是带宽受限基本只适合端口数量少、速率低的早期芯片现在很少用了。Crossbar的思路是每个输入端口和每个输出端口之间建立一条独立通道不存在共享带宽的竞争问题但需要复杂的调度器而且每个输出端口还是需要自己的队列缓存。最终我们选了共享缓存架构主要是看重两点一是缓存利用率最高池化资源不会浪费二是实现相对集中队列管理逻辑可以统一做这对多端口场景非常重要。它的代价是存储器的读写带宽必须做到端口数倍于线速对存储设计和调度逻辑的压力更大。当时项目定的规格是16个万兆端口聚合带宽160Gbps意味着缓存模块至少要有320Gbps的读写吞吐能力这个数字直接决定了后续存储阵列的结构。这里要说一句选型没有绝对的好坏关键看应用场景。如果端口数很少、面积功耗受限独立缓存也不是不行但如果要支持几十个端口的高密度交换共享缓存基本是绕不开的选择。我们在立项阶段的结论是共享缓存用于多端口、中高密度交换场景综合收益最高。2. 模块核心结构设计2.1 定长cell与缓存粒度共享缓存首先要解决一个问题进来的报文长度从64字节到9K字节不等如果直接按变长报文存储会出现严重的外部碎片而且存储地址分配变得非常复杂。我们的做法是报文先切成定长的cell这里选的是128字节。为什么是128字节这个数值是综合考虑了内部总线位宽、存储单元大小和协议开销后的折中。cell太长内部碎片率变高比如一个129字节的报文会被浪费掉127字节cell太短地址管理开销变大每个cell都要一条链表指针记录。以常见的以太网报文分布来看128字节cell能把内部碎片率控制在可接受范围内同时管理开销不算太高。cell化带来的另一个好处是所有缓存操作都变成固定长度、固定粒度的读写。后面的空闲链表、地址分配、阈值判断全都基于cell数量而不是字节数来做极大简化了硬件逻辑。硬件工程师看到所有判断都是整数个cell这种设计会非常舒服否则一个非对齐的字节级操作会带来一堆边界判断。2.2 多bank交织存储阵列前面提到16个万兆端口的聚合带宽要求缓存模块具备320Gbps的读写吞吐。单块SRAM的带宽显然不够怎么办答案是做多bank交织。我们把整个缓存池划分成多个独立的bank用低比特位做bank选择相邻的cell轮流写入不同bank。这样多个端口可以并行访问不同的bank等效带宽成倍提升。比方说用8个bank每个bank工作在40Gbps总和就能达到320Gbps。这个思路跟CPU多通道内存有点像本质是拿并行度换带宽。bank数量的选择有几个约束。首先是端口数每个端口请求的仲裁粒度决定了同时活跃的bank数其次是SRAM的物理尺寸bank太多会导致面积碎片化、布线资源紧张最后是bank内突发长度的限制。我们当时分析下来bank数是端口数的一半到相等比较合理太少带宽不够太多面积功耗不划算最终定在8个bank。bank交织的细节设计中有一个容易忽略的点不同端口对bank的访问冲突是突发的如果几个高优先级端口同时都打到同一个bank瞬时等待还是会产生。所以bank的选择逻辑不能是简单的静态映射我们最终做了轮转交织——即bank映射随cell序号动态轮转这样能摊平短时间内的热点访问。2.3 地址链表与空闲管理共享缓存的地址管理是模块的核心控制逻辑。整个缓存池被划分为固定大小的cell槽位每个槽位在链表结构中充当一个节点。空闲链表Free List维护所有可分配的cell地址每个端口/队列有一条占用链表按报文到达顺序把分配的cell串起来发送时从头到尾依次读出。这块硬件逻辑的核心有两个一个是入口侧的地址分配器负责从空闲链表中取地址另一个是出口侧的地址回收器负责把读完的cell地址归还给空闲链表。这两条路径不能互相阻塞否则整个流水线就停了。所以通常会用两个独立的链表指针寄存器组加上乒乓缓存来平滑吞吐波动。有一个实操上的坑我必须说一下链表的指针更新是典型的先读后写操作在高速时钟下需要插入流水级但插流水级又会让指针更新延迟一个周期。如果你在同一个周期里既想把新地址挂到链表尾又想读链表头就会面临冲突。解决思路是把链表头尾指针放在不同的寄存器组里或者用分组跳变的链表结构降低更新频度这个我们在调试过程中反复迭代过好几次。2.4 多播复制的缓存策略多播组播/广播报文在交换芯片里非常常见一个报文要复制到多个端口。如果为每个复制都单独存一份cell缓存消耗会成倍增加而且地址分配逻辑要重复上锁。共享缓存的天然优势是多播报文只需要在缓存池里存一份通过多个队列的链表节点引用同一批cell地址发送时各队列按自己的节奏读取即可。问题在于引用计数。一个cell被几个队列同时引用必须记录引用数每个队列读完一次就减一减到零才能归还到空闲链表。引用计数的更新频率极高是典型的多端口并发访问点这里必须用原子操作否则计数错误会导致cell被过早回收或者泄漏。我们当时踩过一个教训多播报文的引用计数更新时机必须精确到cell粒度而不是报文粒度。一开始图省事按报文维护引用计数结果同一个报文里不同cell被各端口消费的进度不一致某些cell被重复读、某些cell被提前释放最后丢包率异常升高。改成cell粒度后问题才解决代价是计数器组资源多了不少但这个开销不能省。3. 关键机制与算法落地3.1 动态门限管理共享缓存的资源池是所有端口共用的一个简单的问题随之而来如果某个拥塞端口占完了所有缓存其他端口怎么办所以必须有阈值管理机制限制单个端口或队列能占用的最大cell数。当然可以给每个队列一个固定的上限值但问题又来了固定阈值在流量均衡时导致缓存利用率上不去在局部拥塞时又不够灵活。我们采用的方案是动态阈值算法Dynamic Threshold阈值不是常数而是随空闲缓存总量动态调整。基本原理是队列可占用的最大缓存量等于总缓存量减去一个保留量再除以当前活跃队列数。流量越分散单个队列的阈值越低流量越集中活跃队列越少单个队列的阈值越高。这个机制能让缓存资源在公平性和利用率之间自动找到平衡点。动态阈值的参数整定在验证阶段花了不少功夫。全局保留量设太大会浪费缓存空间设太小又无法应对极端拥塞参与计算的活跃队列数量要用近似方法估计精确计数会带来过长的组合逻辑延迟。我们最终的做法是每1024个周期采样一次活跃队列数做窗口滤波后参与阈值计算这样既避免了每周期更新的频率压力又能跟随流量变化。3.2 反压与流控机制当缓存占用接近上限时交换芯片必须产生反压信号。以太网场景里常用的手段是PAUSE帧802.3x和PFC优先级流控802.1Qbb。在模块内部核心机制是维护一组高/低门限缓存占用超过高门限时向上游发送反压低于低门限时解除反压。门限之间需要足够的滞回区间Hysteresis否则会出现频繁的反压—解除—反压振荡。我见过一个实际案例滞回区间设得太窄结果反压信号以微秒级频率抖动上游端口的队列深度一直在临界值附近摆动转发时延抖动严重超标。后来我们把高门限设在85%低门限设在60%多出了25%的滞回空间振荡才被压住。这里有一个链路级经验反压信号从缓存模块产生经过队列管理、调度器再到MAC层发出PAUSE帧中间每一级都有流水级延迟。这意味着门限设置必须考虑反应时间即从缓存占用到达高门限到上游真正停止发包中间这段时间还会进来多少数据。如果没算好这个量极端情况下缓存还是会溢出。3.3 调度策略与优先级处理缓存模块本身不负责端口级的调度但它的队列管理和阈值判断必须对调度算法友好。我们接口上暴露了每个队列的占用状态和超阈值标志调度器选择某个队列之前先查这个标志被限流的队列直接跳过。这样把缓存保护和调度决策解耦各做各的事。对于优先级模块内部按COSClass of Service区分队列高优先级队列在地址分配和读出发送上都享有优先权。但在极端拥塞时如果高优先级流量无限抢占低优先级队列可能完全饿死starvation。我们引入了一个权重式的防饿死机制连续多个周期高优先级被服务后强制让低优先级队列至少获得一次分配机会保证带宽公平性。4. 验证与调试实录4.1 验证环境搭建要点这类高速存储模块的验证光靠定向测试是根本测不透的。我们搭了一套基于UVM的验证环境最关键的是随机约束测试随机生成报文长度、端口分布、优先级组合、多播比例让DUT在随机的流量压力下跑几百万个周期再用参考模型比对数。缓存模块的数据面行为比较确定只要地址管理逻辑正确比对很容易找出偏差。另外有一个容易被忽视的点时序收敛验证。共享缓存模块的路径通常很长——地址分配、链表更新、bank调度、数据读回中间涉及大量组合逻辑和跨时钟域处理。我们早期在综合后的时序报告里发现空闲链表指针更新的关键路径长到无法在一个时钟周期内收敛只能通过插入流水级解决。这个问题如果等到后端阶段才暴露返工代价会非常大所以建议验证阶段就做带时序的等价性检查。4.2 典型性能测试与参数整定性能测试有一个最关键的case叫做多端口同时向同一端口涌流N-to-1。16个万兆端口同时往1号端口发包1号端口线速只有10Gbps其余15个端口的报文全部要在缓存池里排队等待。这种情况下缓存必须能吸收15倍线速的突发数据并且不丢包、不死锁。我记录一下当时的实测数据缓存池设计容量约16MB换算成128字节cell就是131072个cell。在N-to-1场景下1号端口正常发送速率为10Gbps如果15个源端口持续灌入150Gbps的流量那么缓存的填满速度约为140Gbps可以用大约0.9毫秒填满整个缓存池。这意味着从源端开始灌流量到缓存耗尽只有不到1毫秒的反应时间反压机制必须在这之前生效否则必丢包。测试结果验证了反压生效时间在200微秒级远快于缓存耗尽时间这是设计里最重要的一条安全裕量。队列深度和缓存占用关系我们做了这样一个换算1个万兆端口线速为10Gbps按最小64字节报文计算每秒钟可以收到约1488万个报文。如果缓存只分配给这个端口128个cell每个cell承载128字节那么它只能缓存约16KB数据在突发流量下1微秒都用不到就能填满。所以必须保证每个队列至少有一个合理的floor配额不能全被动态机制收走。4.3 死锁与活锁问题排查共享缓存模块有一个经典的死锁场景当多个队列同时在等待对方释放cell空间时就形成了循环等待。比如队列A等待队列B的cell回收而队列B又在等待队列A的回收如果两者的回收路径都因缓存空间不足被阻塞系统就卡死了。这个问题的标准解法是保留区机制每个队列保证保留最少可用的cell数这个保留区不允许被其他队列抢占。即使所有队列都占满每个队列仍有最基本的接收能力报文可以源源不断地进来、转发、释放打破循环等待。活锁的问题我们也在组播场景里遇到过。当多个多播队列竞争同一个cell的引用计数时如果仲裁逻辑一直偏向某个方向另一个方向的队列可能永远拿不到引用权。这需要在仲裁器里加公平计数连续N次选中同一方后强制切换。5. 常见问题与避坑总结5.1 内存带宽估算的常见误区很多人估算共享缓存的带宽需求时只算了每个端口读一次、写一次的2倍关系。但对于多端口共享缓存实际的带宽需求是写入带宽 所有端口线速总和读出带宽 所有端口线速总和再加上每一条cell链表的指针读写开销。如果数据按128字节cell处理每个cell伴随一个指针操作指针操作的带宽大约是数据带宽的1/128看似很小但在高速实现中时序就不能完全忽略。我见过一个项目因为没算清同一个cell可能被多个周期共享导致带宽估算偏差。具体来说如果一个cell被多播到4个端口它的数据只需要从存储阵列读一次然后复制分发到4个输出通道而不是读4次。如果是按每个目的端口都单独算读带宽你的存储阵列规模会白白做大一倍。后来我们在存储阵列后面加了扇出寄存器组一份数据读出后并行送多端口带宽节省效果非常明显。5.2 链表一致性问题的现场复盘这里分享一个我印象深刻的bug。某次压力测试中随机丢包率从万分之几突然跳变到百分之三而且只在多播比例超过50%时出现。定位了很长时间最终发现是多播报文的cell引用计数在并发更新时出现了一个时钟周期的竞态——两个端口同时完成对同一个cell的读取引用计数在这个周期内被连续减了两次但减到0后没有正确触发空闲链表的回收。这个问题的排查给我们的教训是高速模块的引用计数更新逻辑即便只差一个周期也会在长时间运行下累积成可见的错误。这类问题靠仿真很难100%抓到必须做针对性的时钟周期精确的形式化验证或者在硬件上加入引用计数校验功能比如周期性统计每个cell的累计引用次数和理论值对比在运行期发现问题、定位问题甚至做纠错复位。后来我们在这类模块里固化了一个debug模式可以把cell状态链完整导出现场故障复现的速度提升了一个数量级。5.3 跨时钟域设计的几点经验共享缓存模块不可避免地要处理不同时钟域之间的交互。比如MAC层接口时钟和内部核心时钟不同频甚至不同源。所有跨时钟域的指针、状态、计数信号必须做同步处理否则会出现亚稳态导致的偶发错误。我们的实践是所有跨域信号一律经过两级同步器ptr/计数类信号还要做成格雷码形式再同步因为普通二进制计数在跨时钟域采样时可能中间态出错。格雷码每次只有1位变化即使在采样点附近跳变最多也只是采到旧值而不会采到一个完全错误的中间值。另外还有一个小坑跨时钟域的FIFO读写指针比较直接比较二进制数容易造成误判空或满。我们花了一天时间定位的bug就是这么来的——FIFO实际已经满了但读时钟域的指针采样延迟导致写端认为还没满继续写入了数据导致覆盖。改成格雷码比较后问题彻底消失。5.4 关于缓存模块验证的几条实操建议结合这个项目的后期经验我给后来做同类模块的朋友几条比较实际的建议第一per-cell级参考模型一定要做得足够细致。缓存模块的状态变化主要就是cell的分配、读取、释放参考模型如果只用报文级比对很多内部状态错误会被漏掉。我们后来把参考模型细化到cell操作级和DUT每周期比对链表头尾指针和空闲链表计数效果立竿见影。第二随机验证时要把水位状态也作为约束条件。不能只随机流量参数要让DUT经常处于接近满半满接近空等不同状态下运行很多指针边界bug都是在这些边际状态下才冒出来的。第三性能压测不要只在满配置下做。把端口数减半、再减半做梯度测试往往能快速暴露出仲裁逻辑在资源竞争度变化时的问题。比如我们在16端口全开时一切正常但在8端口模式发现了bank负载不均的问题原因是bank交织参数没有随端口数自适应调整。这些经验有些来自纸面推演更多来自一次次reset后的波形抓取和仿真日志比对。做高速共享缓存这种模块耐心和细心比聪明更重要——它不像算法那么有创造性但每一个细小的状态错误都会在高负载下被放大成丢包排查起来非常烧时间。这个项目后续我还在继续迭代主要是往低功耗方向上做优化——缓存阵列在不活跃时进入休眠模式、空闲链表指针的低功耗编码等。如果后面有进展我再单独写一篇分享。