blue-rdma 设计介绍(二)—— 软硬件架构
在上一篇文章里,我讲清楚了 Blue-RDMA 的整体设计动机:把 RDMA 从“网卡固件的黑盒”里解放出来,让传输协议可编程、可运维、可多租户共享。这一篇我来展开讲软硬件架构,也就是整个项目的骨架是怎么搭的。
先交代一下背景。我们在实验室里跑了很长一段时间的硬件 RDMA 网卡,功能确实强,但一旦要调内部参数就非常痛苦:拥塞控制窗口调不了,重传策略改不动,多租户隔离更是要看芯片厂商的“脸色”。Blue-RDMA 立项的核心原因,就是不想再被这些硬限制卡脖子。因此,这篇“软硬件架构”的文章,本质上是在讲我们是如何划分职责的——哪些事情必须交给硬件去做,哪些放到软件里做更划算,哪些事情干脆异步到控制面去处理。
1. 软硬件边界到底应该划在哪
1.1 最初的那个痛点
在我们 Blue-RDMA 立项之前,团队内部已经折腾了大半年的硬件 RDMA 网卡。表面上一切都很美好:延迟低、吞吐高、CPU 占用低。可真到了做多租户隔离、自定义拥塞控制,或者想在下层插一段数据加密逻辑的时候,所有人都开始头疼。网卡的固件不开放,实装驱动又是一个大黑盒,你既不知道队列调度器内部是怎么排队的,也没办法在不重启机器的情况下调整重传参数。改一段代码要等网卡更新固件,固件更新又要走完整回归测试流程,一次迭代就是好几个月。
所以当我们开始设计 Blue-RDMA 时,第一个被拿到桌面上讨论的问题不是“性能要做到多少”,而是“软硬件边界到底应该划在哪”。这个边界如果划得太靠硬件,等于什么都没改,只是换了个方式继续被锁死;如果划得太靠软件,性能和 CPU 占用率又会变得没法看,最终没人敢在生产环境部署。
1.2 三个平面各管一摊
我们最终采用的思路是经典的“三平面分离”,但落地方式和传统网络设备里的控制面/数据面分离还不太一样。Blue-RDMA 架构成这样:
| 平面 | 位置 | 主要职责 | 实现载体 |
|---|---|---|---|
| 管理面 | 用户态守护进程 | 设备发现、配置下发、资源配额、策略管理 | blue-manager |
| 控制面 | 用户态运行时 | 连接建立、队列对状态管理、重传定时器、拥塞控制决策 | blue-runtime |
| 数据面 | 用户态库 + 网卡硬件 | 报文封装、解析、校验、DMA 搬运、中断合并 | libblue + HAL 抽象层 |
这里有一个关键取舍:数据面没有完全放到软件里做。传统方案里,要么是全硬件处理,要么是像某些纯软件模拟方案那样在内核协议栈里跑仿真的 RDMA。这两种我们都不满意。全硬件处理不够灵活,纯软件模拟又会让时延和 CPU 占用彻底失控。Blue-RDMA 选择的是“软硬结合”的折中方案:报文收发、DMA 搬运这些脏活累活尽量丢给网卡,但是队列对的状态机、重传窗口、拥塞控制决策这类需要灵活调整的逻辑,全部放在用户态软件里管理。
1.3 边界决策的三条准则
在具体划分每一项功能时,我们总结出了三条准则,后来成了整个团队的口头禅。
第一条,凡是能被“查表”替代的逻辑,尽量下沉到网卡。例如接收方向的多队列分流、报文解析、DMA 目标地址映射,这些都是归类明确的映射关系,放在硬件里做可以省掉大量 CPU 中断。第二条,凡是涉及“状态变更”的逻辑,一律留在软件里。队列对的建立、迁移、销毁,超时重传计时,连接断开检测,这些都要能随时被开发和运维人员看到、改掉,绝对不能塞进网卡固件。第三条,凡是需要“跨设备协同”的决策,拿到控制面去做。比如拥塞控制需要看全网信息,单张网卡根本看不全,必须由运行在网络节点上的软件来完成。
这套准则听起来简单,落地的时候还是踩了不少坑,后面我会展开讲几个具体案例。
2. 硬件层的设计:普通网卡也能跑出 RDMA
2.1 为什么不需要专用 RDMA 网卡
很多第一次听 Blue-RDMA 架构的人会问:你既然叫 RDMA,是不是必须得有一张专用网卡?答案是否定的。Blue-RDMA 的硬件需求比想象中低很多:一张支持多队列、支持中断合并、支持简单 RSS 分流,并且能通过 PCIe 做高吞吐 DMA 的普通网卡就基本够了。
为什么能做到这一点?关键就在于我们把复杂的传输状态机搬到了主机内存里。传统 RDMA 网卡要在芯片内维护完整的连接状态,所以芯片里必须要有一大块高速 SRAM 来缓存队列对上下文,访问速度要极快,这也正是那些专用芯片贵的原因。Blue-RDMA 反其道而行之:队列对上下文放在主机内存里,由用户态软件来维护,网卡只负责两个最笨也最重要的动作——按指令取数据、按流程发数据。
这就好比传统方案是在网卡里放了一个懂交通规则的交警,Blue-RDMA 则只在网卡里留了一个“按指令执行动作的信号员”,而交规全部放在旁边的一个监控室里。这个监控室可以随时换规则,代价是每次做决定要多走一小段路。
2.2 HAL 抽象层:屏蔽网卡差异的关键
没有专用芯片不代表不挑网卡。不同厂商的网卡在队列管理、中断机制、寄存器布局上差异很大,如果我们直接对着某一张网卡写驱动,项目一开始就输了。所以我们在驱动之上做了一层硬件抽象层,叫作 HAL,它定义了五类操作原语:
- 收发原语:提交发送描述符、轮询接收描述符、获取已完成的描述符状态;
- 中断原语:开启或关闭某条队列的中断、聚合多个中断、读取中断原因寄存器;
- 门铃原语:向网卡写门铃寄存器,通知硬件有新的发送请求入队;
- 内存原语:注册内存区域、建立 DMA 映射、查询映射后的物理地址;
- 转发表原语:更新网卡内的流表项,用于接收方向的多队列分流。
HAL 的出现让 Blue-RDMA 的软件架构与具体硬件解耦。我们在实验室里验证过两类网卡:一类是普通的固定功能网卡,另一类是带可编程流处理管道的网卡。固定功能网卡的 HAL 最薄,因为可定制的地方少;可编程网卡的 HAL 稍微厚一点,但也因此能获得更好的分流和卸载能力。
2.3 硬件必须做到的“三件事”
说了这么多把逻辑搬到软件的做法,可能有人会问:是不是硬件就没用了?当然不是。我们把所有软件无法接受的高频操作收敛成了三件事,这三件事是 Blue-RDMA 对硬件的底线要求。
第一件事是高速 DMA。数据从网卡到主机内存、从主机内存到网卡,这个搬运动作必须由硬件完成,绝不能经过 CPU 拷贝,否则就没有意义了。第二件事是门铃机制。发送端软件准备好报文描述符后,往硬件写一个 64 字节的门铃,硬件就知道“描述符已就绪”,可以直接从内存里取数据发出。这个机制决定了能否在多队列场景下依然保持稳定的发送吞吐。第三件事是中断合并与背压。硬件可以先将多个完成事件合并成一次中断,让 CPU 不必每个包都醒一次;当接收队列接近满时,硬件还能通过回压机制来主动丢包,把决策交给软件。
这三件事里,第一部分和第二部分的实现是普通网卡的“标配”,第三部分的回压机制则因卡而异。HAL 里的转发表原语,实际上就是为了在接收方向给不同的流打上不同的标记,以便软件用一次遍历就能处理一整批数据。
3. 软件层设计:用户态运行时加内核驱动的组合
3.1 为什么一定要有用户态组件
硬件 RDMA 常见的使用方式是:内核驱动负责建立连接和管理资源,用户态库只负责把请求翻译成硬件能懂的描述符,然后写门铃。Blue-RDMA 比这个走得更远一步。我们希望连接管理、状态迁移、超时重传这些“控制逻辑”也能动态更新。内核态代码更新一次要重启机器,用户态代码更新只需要重新加载进程,所以 Blue-RDMA 把控制逻辑全部放到了用户态。
于是软件层就形成了这样的组合:内核态只保留最薄的驱动,负责初始化硬件、设置 DMA 映射、管理物理内存、向用户态暴露接口;用户态则有一个运行时库 libblue,负责创建队列对、维护状态机、收发控制报文、处理重传定时器。再加上一个可选的守护进程 blue-manager,负责多租户的配额管理和策略下发。
这种组合最大的优点,是“控制逻辑”可以像普通应用一样随意调试、热升级。我们在测试时经常修改拥塞控制参数,只需要重启测试进程,不需要重新加载内核模块,迭代速度完全不在一个量级。
3.2 状态管理:队列对上下文入内存
传统 RDMA 网卡的队列对上下文通常是一份极其紧凑的结构体,甚至会用 FPGA 的寄存器块来实现,访问速度快但无法被操作系统看到。Blue-RDMA 彻底改变了这个思路:我们为每一个队列对在主机内存里维护一份上下文,内容包含但不限于:发送序号、接收序号、重传超时值、拥塞窗口、对端节点信息、当前可用的内存窗口、关联的完成队列编号。
这些上下文全部由 libblue 在用户态管理,也就是说,一个调试器可以直接看到任意一个队列对的完整状态。这给我们后来排查问题时带来了巨大的方便,后面我会专门讲一个真实的故障案例。代价是访问上下文需要越过 PCIe,每次查状态都会有一次内存往返开销,大概会增加几百纳秒到一微秒,这一点我们通过批量处理和缓存策略尽量压低了。
3.3 内存管理与页固定的经验
既然要在用户态管理队列对状态,那么这些内存就必须被固定住,不能被操作系统的页换出到磁盘上。我们在 libblue 里实现了一个简单的页固定管理器:启动时分配一块大的内存池,然后调用接口将这块内存的物理地址固定下来,并建立 DMA 映射关系,后续所有队列对上下文、描述符数组、数据缓冲都从这个内存池里划拨。
这里有两个特别值得说的经验。第一,内存池的初始大小要留足余量,建议按预期并发连接数的两倍来分配,否则连接数一上来就可能触发经典的“内存碎片化”问题,内存明明还剩,却凑不出连续的 DMA 区。第二,最好使用大页内存,传统 4KB 页会让 DMA 映射表变得非常庞大,TLB 还容易抖动,而大页能显著降低地址转换开销。我们在实测中仅做这一项调整,就把 P99 时延从大约 90 微秒降到了 50 微秒以内。
4. 一条写请求的数据面之旅
4.1 连接建立:控制面先谈拢
在 Blue-RDMA 里,两个节点要通信,首先要经历一次连接建立过程。这个过程由两端的 blue-runtime 各自维护,它们通过标准的 TCP 控制连接交换连接参数,包括:队列对编号、内存窗口描述、初始发送序号、拥塞窗口初始值。整个过程不经过网卡的固件,完全由控制面软件来驱动。
这里有个设计上的小心思:控制连接和数据连接是分离的。控制连接用普通的 TCP,走传统协议栈,保证可靠到达;数据连接才走 Blue-RDMA 自己的传输机制。这样设计的好处是,即使数据面的协议还在迭代,控制面永远有一个稳定的路径可以修复问题。相当于两条路各自分工:控制面的“谈判桌”永远在,数据面的“物流通道”可以随时调整。
4.2 发起写请求:从应用 API 到门铃
当应用调用 libblue 发送一个 RDMA 写请求时,流程大致是这样:应用先把待发送的数据缓冲区注册成内存窗口,拿到一个标识符;然后构造一个发送描述符 WQE,填入目标队列对、内存窗口、数据长度、目标地址等字段;接着把这个 WQE 追加到发送队列末尾;最后往网卡写一个门铃。
网卡收到门铃后,从主机内存抓取 WQE 对应的数据,按照既有的报文格式封装成数据包,发给对端。对端网卡收到包之后,通过 DMA 把数据直接写进预先注册好的目标缓冲区,然后往完成队列里追加一条完成记录。发送端和接收端的软件除了发门铃和收完成通知,整个数据搬运过程基本不碰 CPU。
这个路径里的两个关键优化点值得展开。第一个是“门铃合并”:当应用连续提交多个写请求时,libblue 会等一批请求全部入队以后再写一次门铃,而不是每个请求都写一次,这样能把 PCIe 上的写事务次数降低一个数量级。第二个是“轮询优先”:接收端默认使用轮询模式读取完成队列,只有 CPU 实在吃紧时才切换成中断模式。轮询模式在延迟测试里表现最好,中断模式则更适合低负载场景。
4.3 重传与超时:软件兜底
数据面上的包总归会丢,Blue-RDMA 的重传逻辑全部放在用户态。接收端软件会检查数据包的序号,发现乱序或缺失时,它会记录一个状态位,并主动发送一个 NACK 给发送端。发送端收到 NACK 后,会重新从内存里抓取对应的数据再次发送。
这个逻辑在硬件 RDMA 里是网卡固件内部完成的,用户根本无法干预。在 Blue-RDMA 里则可以自定义重传策略,比如是否启用选择性重传、重传的惩罚因子、超时时间的计算公式等。我们在实验中发现,传统的固定超时在长肥网络中效果一般,于是把超时时间调整为“基线上再加一段与当前拥塞窗口成正比的时间”,显著减少了不必要的重传。这套机制是纯软件实现的,调整参数只需要改配置,不需要重新编译网卡固件,这在实际部署中价值极高。
5. 性能预期与调优思路:别拿软定义当硬加速比
5.1 我们实测的一些数字
Blue-RDMA 架构决定了它的性能必然介于“纯硬件 RDMA”和“纯软件网络”之间。在实验室环境下,我们使用支持多队列的普通网卡配合大页内存,单流延迟大约能做到纯硬件 RDMA 的 1.2 到 1.5 倍,而多流聚合吞吐基本可以跑到网卡线速的 80% 以上。这个数字对于大多数上层应用来说完全够用,毕竟我们换来的是可编程性和可维护性。
但也要说清楚,Blue-RDMA 不是用来和顶级硬件 RDMA 拼极限延迟的产品。如果业务对单次操作的延迟极度敏感,低于三微秒都不能接受,那么纯硬件方案依然会是更合适的选择。反过来,如果你更看重的是协议可定制、多租户隔离、随时热更新,那 Blue-RDMA 这套软件定义的路径就是明显的主流选择。
5.2 调优时最容易踩的坑
第一个坑是没有对网卡的队列数做正确的规划。普通网卡虽然支持多队列,但队列数是有限的,例如常见的四队列或者八队列。如果我们让每个队列对都独占一条硬件队列,等到并发连接数一多,硬件队列就明显不够用了,这时系统会退化成中断风暴,性能直线下降。我们的做法是在 HAL 层做“队列汇聚”:多个软件队列共享一条硬件队列,网卡通过流表将不同连接分流到不同硬件队列里,由此在保持连接数的同时维持硬件队列的合理负载。
第二个坑是 DMA 映射没有做到池化。初期版本在每次发送时才建立 DMA 映射,结果性能惨不忍睹。后来我们改成基于内存池的预映射,连接建立时就把数据缓冲区的 DMA 映射固定好,之后发送只是引用已经建立好的映射关系,性能立刻提升了几个档次。
第三个坑是 CPU 核绑定。轮询模式对 CPU 亲和性非常敏感,我们在一次测试中发现,如果接收线程发生核切换,P99 延迟会跳变到原来的五倍。解决办法是用线程亲和性 API 把每个队列对应的运行时线程绑死在独立的 CPU 核上,并且为管理面和数据面分配不同核,避免相互干扰。
6. 调试与可观测性:软定义换来的最大红利
6.1 队列对状态瞬间可见
Blue-RDMA 最大的“隐藏红利”,其实是全链路可观测性。因为队列对上下文全在主机内存里,我们开发了一套调试接口,可以随时导出任意队列对的完整状态:发送序号、接收序号、重传次数、拥塞窗口、最近 NACK 记录、超时定时器剩余时间,全部一目了然。
这个优势在硬件 RDMA 时代根本不存在。传统网卡固件里的状态你只能通过厂商私有的寄存器去猜测,出错的时候连个像样的日志都看不到。Blue-RDMA 让我们在线上出现问题时,能够先像查普通程序一样,把一个队列对的状态“打出来”看,定位效率提升巨大。
6.2 一次典型故障的排查过程
去年我们曾经遇到过一个诡异的问题:集群里有两台机器之间流量一高就出现重传,但其他机器之间没有问题。一开始大家以为是网卡坏了,换了张卡还是复现。我第一个反应就是导出这两台机器之间所有队列对的上下文,结果发现发送序号总是比接收序号大几百,但重传计数并不高。这说明数据包其实到达了对端,只是对端的接收确认没有被及时处理。
顺着这个线索继续排查,我们发现接收端网卡的接收队列使用的是中断模式,而中断处理线程恰好和一个高负载的日志进程共享了 CPU 核,导致中断响应被拖慢。看起来是网络问题,根源居然在 CPU 调度。如果 Blue-RDMA 没有提供队列对级别的状态信息,这种问题大概率要折腾好几天才能查出来,而那次我们只花了一个下午就定位并修复了。
6.3 可观测性建设的三条建议
事后我们复盘,总结了三条可以给其他类似架构参考的建议。第一,每一层都要有独立的指标导出,不能只统计最终吞吐,要能分平面看到控制面耗时、数据面耗时、重传分布;第二,队列对状态必须能按连接维度聚合,否则连接一多,看到的是茫茫多的数字,反而没法做出有效判断;第三,诊断接口要支持按需开启,默认关闭,否则生产环境很容易被监控自身拖垮。
这些建议现在已经成为 Blue-RDMA 后续开发的核心迭代方向,我们也在考虑把状态导出统一成标准格式,方便接入当前常见的开源监控体系。
7. 写在后面的经验与下一个节点
回到最初的问题,软硬件架构的价值到底是什么?做完这个项目以后,我的体会是:它让你知道每一步操作在哪里发生,出了问题可以沿着链路一层一层查下去。传统硬件方案给你更好的数字,但给不了这种确定性和可追溯性。
如果你打算参考 Blue-RDMA 的思路做一个类似的项目,我的建议是不要一上来就追求把所有协议功能都搬到一个软件模块里。先选一个小的传输场景,例如单队列的 RDMA 写,把链路完整打通,确认每个环节的开销都在预期范围内,然后再考虑扩展多队列、多连接、拥塞控制这些进阶能力。软件定义网络的道路,往往是从一次彻底的“看得见”开始的。
这个架构的下一步,我会重点讲讲多租户隔离和拥塞控制这两块,它们也是 Blue-RDMA 与传统硬件方案拉开差距最明显的地方。等那部分的实现细节整理完,再来继续分享实战经验。