做交换芯片这行,绕不开的一个坎就是数据通路怎么搭。把一颗 64 口的交换 ASIC 拆开看,撇开 SerDes、MAC、包解析这些外围模块,真正决定它能不能跑满线速、能不能扛住微突发、多播下会不会当场塌掉的,其实就四件事:Crossbar 怎么连、VOQ 怎么排、Shared Buffer 怎么管、Cell Fabric 怎么切。这四个词听着像四个独立模块,实际是一条链上的四段——任何一段选型不合身,整颗芯片的吞吐、时延、门限管理都会立刻露馅。
我做过几轮交换芯片的架构评估和流片后的调优,最深的体会是:交换芯片微架构这东西,看论文和看实测完全是两码事。论文里 iSLIP 三轮迭代收敛得很好看,实测一上非均匀流量就得靠加权和加速比去兜;教科书说 VOQ 消除 HOL 阻塞就能跑满,实际上 N² 的队列描述符怎么放进片上 SRAM 本身就是个硬约束。这篇就按我自己的理解,把 Crossbar、VOQ、Shared Buffer、Cell Fabric 这四块从头到尾捋一遍,重点讲清楚每个设计选择背后"为什么这么干"以及"不这么干会怎样"。
写给自己人看的,所以参数、公式、踩过的坑我都会尽量给具体数,能直接抄去用的方案我会标出来。适合刚入行做交换芯片的验证/架构同学,也适合做数通设备选型、需要对芯片内部行为有个心理模型的系统工程师。下面按照从宏观到微观的顺序展开,先讲整体数据通路怎么切,再一块块往下钻。
1. 数据通路的整体设计思路与方案取舍
1.1 从线速这个硬指标倒推架构
交换芯片的架构不是先有方案再找场景,而是先被线速这个数字逼出来的。一颗标称 64×400Gbps 的芯片,聚合带宽 25.6Tbps,意味着平均每 0.8ns 就要吞吐 25.6 bit 的数据,而且这个平均值不能是"偶尔冲一下",必须是持续可维持的稳态。所有微架构的选择,本质都是在回答一个问题:怎么在不炸功耗、不炸面积的前提下,让每一条输入到输出的通路都能无条件跑满。
于是架构被打成三段流水:入方向负责收包、切片、查表、入队;交换核心负责在输入队列和输出队列之间搬运数据;出方向负责重组成包、排队、发包。这三段各自有各自的瓶颈——入方向卡在查表和队列入队速率,交换核心卡在仲裁和内存带宽,出方向卡在重组和整形。设计时最忌讳的就是只优化中间那段 Crossbar,结果入方向的入队速率跟不上,或者出方向的共享缓存反压一上来就把整条链拖住。
我的经验是,评估一个交换芯片方案,先把"最坏情况"列出来:全端口线速单一目的、全端口线速多播、单端口被长流打满、突发流量叠加。这四个场景跑一遍,基本就能看出数据通路的短板在哪一段。
1.2 入队、交换、出队三级的职责边界
把流水划清楚之后,下一步是决定"缓存放哪"。这个问题在交换芯片历史上演化过好几代,大致有四种形态:
- 输入排队(IQ):缓存全放在输入端口,Crossbar 之前。好处是缓存靠近入口、访问集中;坏处是队头阻塞,FIFO 结构下吞吐上限只有约 58.6%。
- 输出排队(OQ):缓存全放在输出端口,Crossbar 之后。理论上吞吐可以做到 100%,但要求内存带宽是线速的 N 倍,N 一大就完全不可实现。
- 共享缓存(Shared Buffer):所有端口共用一块内存池。兼顾了内存利用率和突发吸收能力,是当今主流中低端方案的核心。
- 输入排队 + 输出缓存(CIOQ/组合式):输入侧用 VOQ,输出侧保留小缓存做整形,中间 Crossbar 加一定加速比。这是大容量芯片最常见的选择。
职责边界的划分直接决定了后面几节的走向。选共享缓存,就必须做 Cell 化存储和链表管理;选 VOQ 输入排队,就必须做 N² 队列和带权重的匹配仲裁。没有一种方案是全赢的,只有匹配端口数、带宽和成本约束的方案。
1.3 为什么是 Crossbar 而不是共享总线或 Ring
很多人第一次看交换芯片内部结构时都会问:为什么不用一条足够快的高速总线把包广播出去,谁要谁取?答案简单粗暴——带宽不够。下面这张表是我在做方案评审时常用的对比框架:
| 互联结构 | 聚合带宽上限 | 仲裁复杂度 | 主要问题 | 适用场景 |
|---|---|---|---|---|
| 共享总线 | 单 cell 时间传 1 个 cell,约 1×R | 低,集中式仲裁 | 带宽无法随端口数扩展 | 8 口以下的低速芯片 |
| Ring | 受环总线宽度限制,约 1~2×R | 中,分布式令牌 | 时延随端口数线性增长,公平性差 | 多核互联、片上网络 |
| Crossbar | N×R(无阻塞时可同时建立 N 条通路) | 高,O(N²) 匹配 | 交叉点面积、仲裁开销、功耗 | 主流交换芯片 |
关键点在于:Crossbar 的每一对输入输出之间有一个独立的交叉点开关,只要没有两个输入同时抢同一个输出,理论上可以同时建立 N 条完全独立的通路,聚合带宽直接到 N×R。这是共享总线靠提高时钟频率永远追不上的——总线的带宽上限被"同一时刻只能有一个主设备"绑死了。
代价也很明确。N×N 的交叉矩阵有 N² 个交叉点,64 口就是 4096 个,128 口就是 16384 个,面积和漏电都随平方增长。而且交叉点本身不解决冲突,冲突得靠前面的仲裁器解决,这就引出了后面的 VOQ 和匹配算法。所以完整的一句话是:Crossbar 提供的是"可能性",匹配算法才决定实际能跑出多少。
2. Crossbar 交换矩阵的核心细节与实现要点
2.1 Crossbar 的基本结构与调度约束
Crossbar 的物理形态,可以想象成一张纵横向的网格:纵向是 N 个输入通道,横向是 N 个输出通道,每个交叉点是一个由仲裁结果驱动的选择器。当调度器决定"输入 i 在第 t 个 cell 周期连接到输出 j"时,就打开第 i 行第 j 列的那个交叉点,数据从输入 i 直通到输出 j。
这里有个必须记住的硬约束:同一个 cell 周期内,任何一个输入只能连一个输出,任何一个输出也只能被一个输入连。这意味着 Crossbar 天然要解一个二分图匹配问题——左边 N 个输入,右边 N 个输出,连边的条件是"输入 i 的队列里有发往输出 j 的包"。
最优匹配(maximum matching)能把匹配数量做到最大,但算法复杂度是 O(N^2.5) 量级。对于一个每 1ns 就要重新仲裁一次的系统来说完全不可接受,尤其 N=64 的时候,光算一次就超过好几个 cell 周期了。所以工程上一定用最大匹配(maximal matching):不保证最优,但保证快速,而且保证匹配结果不能再加边。经典的 PIM、iSLIP、DRRM 都属于这一类。
2.2 仲裁器:iSLIP 与轮询指针的工程实现
iSLIP 是这里面最有代表性的一个,几乎所有讲交换调度的教材都会拿它当例子。它的核心是"请求—授权—接受"三步加指针推进,下面这段是我整理过的简化伪代码:
// N 个输入,N 个输出,每轮做 3 次迭代 // g[j]: 输出 j 的轮询指针;a[i]: 输入 i 的轮询指针 for iter in 1..3: // Step 1: Request —— 每个输入向所有有包的输出发请求 for i in 0..N-1: for j in 0..N-1: if VOQ[i][j] 非空: request[i][j] = 1 // Step 2: Grant —— 每个输出在请求它的输入里挑一个 for j in 0..N-1: for k in 0..N-1: i = (g[j] + k) % N if request[i][j]: grant[j] = i break // 找到即停,不推进指针 // Step 3: Accept —— 每个输入在授权它的输出里挑一个 for i in 0..N-1: for k in 0..N-1: j = (a[i] + k) % N if grant[j] == i: accept[i] = j a[i] = (j + 1) % N // accept 时才推进输入指针 break这里最容易被忽略、也是 iSLIP 区别于 PIM 的关键:输出侧指针只在授权被接受之后才推进。原始的 PIM 是每次 grant 就推进指针,结果在重负载下会退化成同步的轮询,吞吐反而下降。iSLIP 这个"延迟推进"的小改动,把均匀流量下三次迭代的吞吐从约 63% 拉到了接近 96%,四次迭代基本逼近 100%。
我在实测里验证过这一点:把指针推进改成标准的 PIM 逻辑,同一个非均匀流量模型下,长流打满时的抖动明显变大,尾包时延 p99 涨了将近一倍。所以如果你的实现里指针推进时机和图里不一致,先怀疑这里。
2.3 加速比、多播与交叉点缓存的取舍
纯 Crossbar 加上 VOQ,理论上可以做到 100% 吞吐,但实际芯片里往往会加一个加速比(speedup),常见是 1.5x 到 2x。为什么?因为匹配算法本身不可能永远完美,尤其是非均匀流量下,瞬时匹配失败会造成输入侧堆积。给交换核心留一点额外带宽,就能把这种瞬时失衡吸收掉,代价是 Crossbar 内部时钟更高、功耗更大。
另一个常见变体是带交叉点缓存的 Crossbar(CICQ):在每个交叉点加一个小 FIFO,输入把 cell 丢进去就可以不管了,输出侧自己从交叉点缓存里取。好处是输入输出彻底解耦,不需要每周期做全局匹配,也就不需要加速比;坏处是 N² 个小 FIFO 的 SRAM 面积非常可观,64 口就是 4096 个,每个哪怕只放 2~4 个 cell,总量也很吓人。
多播是另一个容易踩的坑。单播的匹配是"一对一",多播是"一对多"——一个输入要同时发给多个输出。这里有两条路:一是在 Crossbar 层面支持多播,一个输入 clone 出多份并同时连到多个输出(需要输出侧冲突检测);二是把多播在入口展开成多个单播,走普通单播通路。前者省带宽但仲裁复杂,后者实现简单但会占用额外的交换容量。我见过的大多数中端芯片选的是前者加一个多播组表(fanout table),高负载多播场景下效率明显更好。
注意:加速比不是越高越好。从 1.5x 提到 2x,Crossbar 动态功耗大致按比例上升,而吞吐收益在均匀流量下几乎为零,只在极端的非均匀和突发场景下才体现出来。加加速比之前,先用真实流量模型跑一遍收益曲线。
3. VOQ:虚拟输出队列如何解决 HOL 阻塞
3.1 HOL 阻塞的量化损失
队头阻塞(Head-of-Line Blocking)这个问题的经典结论是:在均匀随机流量下,采用普通 FIFO 输入队列的交换结构吞吐上限只有2 - √2 ≈ 0.586。这个数字的来源不复杂:考虑一个输入口的 FIFO,队头包要去的输出口正忙,后面所有包无论要去哪里都被堵住,于是有相当比例的时间里,输入明明有可以发出的包,却发不出去。
举个具体例子。输入口 0 的队列里依次排着:去往端口 3 的包 A、去往端口 1 的包 B。当前端口 3 正被输入口 1 占用,端口 1 空闲。按理想情况,包 B 完全可以立刻发出去,但因为包 A 堵在队头,包 B 只能等。等端口 3 空出来、包 A 发走之后,可能端口 1 又被别人占了。这种"能发却不能发"的浪费累积起来,就是那 41.4% 的吞吐损失。
关键在于,这个损失不是负载高才会出现,它在中等负载下就已经很明显,而且随着端口数 N 增大而加剧。所以只要端口数超过 8,基本就必须处理 HOL 阻塞,否则标称带宽根本跑不满。
3.2 VOQ 的组织方式与地址映射
VOQ 的思路非常直接:每个输入口不再维护一条队列,而是维护 N 条队列,每条对应一个输出口。这样包 A 排队列 3,包 B 排队列 1,互不影响,队头阻塞就消失了。
代价同样直接——队列数量从 N 变成 N²。64 口芯片就是 4096 条队列,128 口就是 16384 条。这个规模决定了 VOQ 不可能用"每条队列一块独立存储"的方式实现,必须把队列结构和管理信息分开:
- 数据存储:cell 本体统一放在共享的 data buffer 里,按物理地址存放,跟队列解耦。
- 队列描述符:每条 VOQ 一组元信息,通常是 head pointer、tail pointer、当前长度、丢弃计数,一般 4~8 个 32bit 字,放在片上 SRAM 里。
- 链表结构:cell 之间用 next pointer 串成链表,链表节点信息可以存在 cell 头里,也可以单独放一张索引表。
按这个组织方式算一下:4096 条 VOQ × 8 个字 × 4 字节 = 128KB 的描述符 SRAM,这是完全可以接受的。如果每条队列单独分存储,哪怕只给 16 个 cell 的空间,4096 × 16 × 256B = 16MB,芯片上根本放不下。所以"数据池 + 描述符"这个分离设计不是优化,是必要条件。
3.3 调度器与 VOQ 的配合、以及权重与公平性
VOQ 解决了"能不能发"的问题,但"先发谁"的问题交给了调度器。第 2 节讲的 iSLIP 就是跑在 VOQ 上的:输入 i 的 VOQ[i][j] 非空,就代表输入 i 可以向输出 j 发请求。
纯轮询类算法有个众所周知的毛病:对流量模式不敏感,长流和短流被同等对待,导致短流被长流"压住",尾时延很差。所以真实芯片里几乎都会在轮询基础上叠加权重,常见做法有几种:
| 调度策略 | 依据 | 优点 | 缺点 |
|---|---|---|---|
| 轮询(RR/iSLIP) | 队列非空即等权 | 实现简单,无饥饿 | 不区分业务优先级 |
| 加权轮询(WRR) | 每队列配置权重 | 支持 QoS,公平性好 | 权重静态,突发下不敏感 |
| 最长队列优先(LQF) | 队列长度 | 吞吐高,天然抗突发 | 实现贵,可能饿死短队列 |
| 最老 cell 优先(OCF) | 队头 cell 的到达时间 | 时延表现最好 | 需要维护时间戳,成本高 |
我在实际项目里的选择是:默认走加权轮询,权重按队列的业务等级配置;同时在 VOQ 描述符里维护一个可选的时间戳字段,在高优先级队列上启用 OCF 做兜底。这样大部分流量走便宜的路径,只有真正敏感的业务才吃额外成本。
还有一个容易忽略的细节:每 VOQ 需要做入口限速和丢弃。因为同一个输入口的 N 条 VOQ 共享一条入向链路带宽,如果某条 VOQ 被一个目的口持续反压,它的长度会一直涨,最后吃光描述符和 buffer 配额。所以每条 VOQ 都要有长度上限,超了就丢或者做标记,这个上限跟出口的反压水位是联动的。
4. Shared Buffer:共享缓存池的地址管理与反压
4.1 共享缓存的收益与代价
共享缓存的核心思想是:与其给每个端口配一块固定大小的缓存,不如把所有端口的数据都放进一个公共内存池,谁需要谁用。这样做的好处主要是三点:
第一,统计复用。端口 0 的突发流量可以借用端口 1 暂时用不到的缓存空间,总的缓存需求比"每口独占 × N"小得多。按经验,同样能扛住相同的突发,共享方案的缓存总量大概是独占方案的 1/3 到 1/2。
第二,缓存利用率高。独占方案在轻载时大量缓存闲置,共享方案下闲置的缓存可以被任何端口使用。
第三,反压响应更平滑。共享池能看到全局的占用情况,可以做动态门限,避免某个端口把整池吃光。
代价同样明确:内存带宽。N 个端口同时收、同时发,每个 cell 周期内要做 N 次写和 N 次读,也就是 2N 次内存访问。以 64 口、单口 400Gbps、cell 周期按 64B 估算,单 cell 周期约 1.28ns,需要 128 次访问/1.28ns,折算下来共享内存的总带宽需求在 3.2Tbps 量级。这个量级靠单片 SRAM 是不可能的,必须做多 bank 并行 + 宽总线,把访问分散到几十个 bank 上,每个 bank 独立编址,通过哈希把 cell 地址打散,避免热点。
提示:bank 冲突是共享缓存设计里最隐蔽的性能杀手。地址哈希函数如果选得不好(比如简单取低位),在流量模式规律的时候会出现大量访问撞到同一个 bank,实测吞吐直接掉两成。稳妥的做法是用 CRC 或者乘法散列取高位做 bank 选择。
4.2 链路列表与 cell 的存储组织
共享缓存里的存储管理,主流做法是基于 cell 的链表式队列。整个 buffer 被切成固定大小的 cell 槽位,每个槽位有一个物理地址;系统维护一张空闲链表(free list),记录当前所有未使用的槽位。
写一个 cell 进来时的动作序列大致是:
- 从 free list 头部摘一个空闲槽位,拿到物理地址 P。
- 从该包所属队列的描述符里取 tail pointer,把上一个 cell 的 next pointer 指向 P(或者把描述符的 tail 更新为 P)。
- 把 cell 数据写入地址 P,同时写入该 cell 的 next pointer(初始为 NULL)。
- 更新队列描述符的 tail = P,长度计数加一。
读 cell 出去的时候反向操作:从描述符取 head pointer,读出数据,head 更新为 next pointer,把释放的槽位还回 free list。
这里有两个工程上很实在的细节。一是 free list 的实现:用 SRAM 存一个真正的链表,每次分配/释放都是一次 SRAM 读写,延迟可控;另一种是用 bitmap,64K 个槽位就是 8KB 的 bitmap,但"找第一个空闲位"这个操作在硬件里比较贵,需要层次化 bitmap(一级 64bit 找哪组有,二级在组内找哪一位)才能做到单周期。
二是链表顺序和入队顺序的一致性。VOQ 里的包必须保持先进先出,所以 cell 挂链必须沿着队列方向挂,不能在中间插。如果你的实现里做了"按优先级插队",那就得另外维护多个子链,不能直接破坏 FIFO 链。我见过一个实现为了省事直接在链表中间插节点,结果同一个 TCP 流的数据包在出口重排后出现乱序,上层直接触发快速重传,吞吐腰斩。
4.3 门限、反压与各种水位怎么定
共享缓存最难的部分不是存储,而是门限管理。池子就那么大,怎么分给 64 个端口和几千条队列,直接决定了芯片在高负载下是优雅降级还是直接崩掉。
静态门限最简单:每端口上限 = 总容量 / N × 系数。问题是突发场景下,一个端口需要更多、另一端口用不到,静态分配就浪费了。
动态门限是主流。常见形式是:
TH(port) = α × (FREE_TOTAL - RESERVED)其中 FREE_TOTAL 是当前共享池的空闲 cell 数,RESERVED 是为 PFC/反压预留的水位,α 通常取 1/8 或 1/4。举个具体的数:共享池 8MB,cell 大小 256B,总共 32768 个 cell。预留 8192 个 cell 给 PFC headroom,可动态分配的剩 24576 个。当池内空闲 20000 个时,α 取 1/4,单端口动态上限就是 5000 个 cell 约 1.28MB。如果这个端口持续占用,空闲池会缩小,门限自动收紧,其他端口依然有空间可用。
这套机制的关键是 α 的取值:α 太大,单端口可以把池子吃光,其他端口饿死;α 太小,突发吸收能力不足,白白浪费共享池的价值。我的经验值是,64 口芯片上 α 取 1/4 到 1/8,具体要看最坏情况下的 RTT 和收敛时间。
**预留水位(headroom)**是另一个必须算准的量。它的作用是:当出口开始反压、上游还在继续发(链路传播时延 + 上游处理时延造成的在途数据)时,这部分在途数据必须有地方落。计算公式大致是:
headroom = 链路速率 × 往返时延 × 端口数 × 安全系数比如单口 400Gbps,反压响应往返 10µs,那就是 400G × 10µs = 500KB 在途数据,乘上安全系数 1.5,单个端口要预留 750KB 以上。这个数非常可观,如果 headroom 算少了,就会出现"反压信号发出去之前包已经被丢了"的经典问题,表现为随机丢包且极难复现。
5. Cell Fabric:定长信元与切片交换
5.1 为什么要把变长包切成定长 cell
这是初学者最容易困惑的一点:明明以太网帧是变长的,为什么芯片内部非要把包切成固定长度的 cell?原因有四个,每个都很实在:
第一,调度必须同步。Crossbar 的匹配是周期性的,每个周期做一次仲裁、建立一组连接。如果数据是变长的,仲裁周期就没法对齐,一个包可能跨好几个仲裁窗口,实现上极其别扭。切成定长 cell 之后,每个 cell 周期固定,仲裁、传输、写内存全都节拍一致。
第二,内存管理简单。定长 cell 的槽位是等大的,分配和回收只需要操作链表指针,不存在外部碎片问题。变长存储就得上伙伴系统或者更复杂的分配器,硬件实现代价高得多。
第三,时延可预测。每个 cell 的处理时间是固定的,整条流水线的时延就直接等于 cell 数 × 单 cell 时延,对做整形和流量管理非常友好。
第四,切分点天然对齐。64B 的 cell 大小和以太网的最小帧长对齐,短包不用额外填充,长包切成整数个 cell,尾部填一些无效字节即可。
cell 的大小选择本身是个权衡。太大会浪费(短包占了整块空间),太小会导致 cell 数量暴增、头部开销占比上升、调度周期过短难以收敛。业界常见的是 64B、128B、256B 这几个量级,具体看 SerDes 速率和内部时钟频率。
5.2 cell 的格式与 fabric 切片
一个典型的内部 cell 头部大概长这样(字段和位宽是常见量级,各家实现会有差异):
| 字段 | 位宽 | 说明 |
|---|---|---|
| 目的端口/位图 | 8/64 | 单播放端口号,多播放位图 |
| 源端口 | 8 | 用于学习、统计和 ACL |
| 优先级/TC | 3~4 | 映射到输出队列 |
| 流 ID / 队列号 | 12~16 | 标识所属的出口队列 |
| cell 序号 | 16 | 用于出口重组和乱序检测 |
| 首 cell / 尾 cell 标志 | 2 | 标识包边界 |
| 校验 | 8~16 | 覆盖头部,防传输错误 |
| 载荷 | 64B×k | 实际数据 |
Fabric 切片是另一个绕不开的话题。Crossbar 内部不可能真的做一根 25.6Tbps 宽度的总线,必须把一个输入通道的带宽拆成多条并行的窄通道,这就是 slice。举个例子:单口 400Gbps,加速比 1.5,交叉点通道需要 600Gbps。如果内部时钟跑 1GHz,那每个通道位宽就是 600bit,实际实现上会拆成 4 条 150bit 的子通道并行,每条子通道对应 Crossbar 里的一列交叉点。这样一来,Crossbar 的规模从 N×N 变成了 N×(N×4),面积换带宽,是必须付的账。
切片带来的一个副作用是cell 的条带化(striping):一个 cell 的数据可能被同时打到多条 slice 上,出口再拼回来。这要求所有 slice 的时延严格一致,任何一条 slice 上的缓冲深度不同,都会造成重组的乱序。所以 slice 之间要么完全对称,要么就得在出口加重排序缓冲。
5.3 重组、保序与乱序处理
出口侧拿到一串 cell 之后要重组成原始包,这里面有几个必须处理的点。
保序问题:同一队列的 cell 是按序发出的(因为 VOQ 是 FIFO),但跨 slice 传输时可能因为通道延迟差异而乱序。解决办法通常是在 cell 头里带序号,出口侧维护一个小的重排窗口,按序号顺序吐出。窗口深度要覆盖最大的通道延迟差,一般几个到十几个 cell。
包边界识别:靠首/尾标志位。出口侧收到尾标志才认为一个包完整,才能触发后续的查表、计数、发包动作。如果尾 cell 丢了(比如因为门限丢弃),整个包要整体丢弃,不能只发前半段——这点在半途丢弃(drop-tail)场景下要特别小心,否则会出现"只发了包前半段的残包"这种非常难查的问题。
带宽放大:一个包被切成 K 个 cell,如果每个 cell 都带一份完整头部,实际内部带宽比用户数据多了 K × 头部开销。64B 载荷配 16B 头部,开销是 25%,这还没算 slice 编码和校验。所以 cell 设计上要尽量压缩头部,或者用共享头(第一个 cell 带完整头,后续 cell 只带精简头)。这是纯工程取舍,芯片面积和内部带宽都得算这笔账。
6. 常见问题与排查技巧实录
6.1 典型问题速查表
下面这张表是我这些年踩坑攒出来的,基本都是现象相似但根因完全不同的题,遇到时按这个顺序排查能省不少时间:
| 现象 | 可能原因 | 定位手段 | 处理方式 |
|---|---|---|---|
| 高负载下吞吐卡在 60% 左右 | 输入队列是 FIFO,HOL 阻塞 | 看入口队列深度分布是否集中 | 改成 VOQ 或加队列分类 |
| 尾时延 p99 突然变差 | 仲裁不公平,长流压制短流 | 统计各 VOQ 的平均等待时间 | 引入加权或 OCF |
| 随机丢包,低负载也复现 | headroom 预留不足 | 查反压发出到生效的时延窗口 | 加大 RESERVED 水位 |
| 单口打满时其他端口抖动 | 动态门限 α 太大 | 观察共享池空闲水位曲线 | 调小 α,加端口硬上限 |
| 吞吐周期性波动 | bank 冲突,地址哈希不均 | 统计各 bank 的访问次数分布 | 换哈希函数,取高位散列 |
| 上层出现大量乱序重传 | 链表插入破坏 FIFO 顺序 | 抓出口包序与入口比对 | 修正链表操作为尾插 |
| cell 校验错但链路没问题 | slice 之间时延不一致 | 逐 slice 统计延迟 | 加出口重排缓冲 |
6.2 计数器与抓包的定位思路
交换芯片的调试,最有效的永远是计数器,而不是抓包。原因很简单:线速下抓包本身就会改变行为,而且抓到的往往是结果不是原因。我一般按这个顺序看:
第一层,看入口丢弃计数器。按端口、按 VOQ 分桶统计。如果只有某个 VOQ 在丢,说明是出口反压导致的局部拥塞;如果所有 VOQ 都在丢,说明共享池整体告急。
第二层,看共享池水位。把 FREE_TOTAL 的随时间变化画出来,正常应该是锯齿形,随流量起伏;如果一直是贴底的直线,说明缓存严重不足或者门限设太松。
第三层,看仲裁统计。每个输出的授权次数、每个输入的接受次数,如果某个端口的授权次数明显低于理论值,说明匹配算法在该流量下收敛不好,需要加迭代次数或者加加速比。
第四层,才轮到抓包。一般只抓异常流的头部,看 cell 序号有没有断、首尾标志是否成对。这一步主要用来确认是不是重组逻辑的问题。
6.3 我自己踩过的几个坑
第一个坑是过度相信仿真。早期做 VOQ 的方案验证时,用的是均匀随机流量模型,iSLIP 三轮迭代跑出来 97% 的吞吐,大家都很满意。结果上板一跑真实业务,全部是少数几条大象流跨端口打,吞吐掉到 70% 以下。教训是流量模型一定要包含非均匀和突发两类,而且非均匀的比例要按真实业务来设,不能图省事全用均匀模型。
第二个坑是门限参数拍脑袋定。当时动态门限的 α 直接抄了一个参考设计里的 1/8,没算过。上线之后发现缓存利用率极低,突发吸不住,测试里 100µs 的突发直接丢包。后来老老实实按公式重算:先算最坏情况下的在途数据量,倒推 headroom,再根据剩余空间和期望的单口最大占用算 α,最后实测微调。这套流程走下来,同样的 8MB 池子,突发吸收能力比原来提升了将近一倍。
第三个坑是忽略了 cell 头部开销。设计时按"用户数据带宽"算的交换容量,结果加上 16B 头部、slice 校验、重排序号之后,实际内部带宽需求比设计值高了 20% 多,加速比留的余量直接被吃掉。后面重新按"线速 × (1 + 头部开销比)"来算交换核心容量才对齐。这个错误很常见,因为很多人算带宽的时候下意识只算载荷。
第四个坑是多播的 VOQ 处理。多播包在入口到底复制几份、放哪条队列,一开始没想清楚,结果是每条多播流都往所有相关 VOQ 里塞一份,缓存瞬间被吃光。后来改成入口只存一份,用多播组表在交叉点层面做 fanout,缓存占用一下子降下来了,代价是交叉点要多做冲突检测。
最后一个不是技术问题但更值钱的经验:所有和缓存、门限、水位相关的参数,都必须做成可动态配置的寄存器,并且能在运行时读回实际值。这些参数的合理值跟具体业务强相关,流片之后再改是来不及的,只能在设计阶段就把可调性留足。我见过太多项目,架构做得没问题,最后卡在一个只能硬编码的阈值上,只能回片解决,代价非常大。
后续如果还有机会,我想接着说输出侧整形、PFC 死锁避免、以及多级 Clos 架构下这几块是怎么级联的——那部分和单芯片内部的取舍逻辑完全不一样,坑也更隐蔽。有在做的同学欢迎一起交流,尤其是非均匀流量下的调度器参数怎么标定这块,我一直觉得还有优化空间。