F-Stack 2.0 前瞻 4 : F-Stack ff_rss_check 优化实践 2 – 从静态端口表到 rte_thash 反算,多队列客户端选源端口的三层加速

1. 本功能主要作用和特点

F-Stack 在多队列/多进程(share-nothing)部署下,进程作为客户端主动发起 TCP/UDP 连接时,必须选出一个”聪明”的本地源端口——让该连接的回包(SYN-ACK、响应数据)经网卡 RSS 哈希后落回发起连接的这个进程的接收队列。选错队列的后果是连接建立不起来或数据”串门”到别的进程,这在多进程架构下是致命的。这个选端口的活就是 ff_rss_check(lib/ff_dpdk_if.c)干的。

选端口有两个约束:既要保证回包落本队列(RSS 亲和),又要保证端口没被占用(四元组唯一)。原始实现是逐端口软算 Toeplitz hash 扫描,平均每次建连要几百个 tsc、且在高进程数下可能反复重试几十次,成为短连接场景的建连瓶颈。

本功能围绕这条选端口路径做了三层优化:

  • 静态表快路径(上游 F-Stack 官方已有,commit e54aa4317,随 13.0→15.0 升级一度丢失后回迁):预先算好”对某个远端四元组,哪些本地端口落本队列”,建连时直接查表轮转取端口,省掉逐端口软算。
  • rte_thash 反算路径(本仓库超越上游的新增):静态表未命中时,用 DPDK 的 rte_thash_adjust_tuple() 按目标队列直接反算出满足约束的源端口,替代逐端口软扫描

【注意】thash反算可选端口范围受限(因为只是改动部分bit位),所以并发连接很大,所需端口很多时需要斟酌使用。

  • IPv6 全链路(上游也没有,全新增):把上面两条路径,以及最早的 ff_rss_check 完整扩展到 IPv6(16+16+2+2 的 36 字节 tuple)

另外还有两处配套:反算后的二次软算复核做成运行时开关(默认关,性能优先)、bind(addr,0) 后 connect 的端口延迟分配(对齐 Linux 的 IP_BIND_ADDRESS_NO_PORT 语义 + RSS 亲和)。

这套东西就一个特点:热路径分层降级、每层独立可用、错队列零容忍。静态表命中走最快路径,未命中走 thash 反算,反算失败回退软算扫描(端口都被占用不会回退),任何一层都保证最终选出的端口经独立软算确认落本队列(或按开关明示接受轻微分发不均)。

2. 本功能的主要适用场景

2.1 多进程/多队列部署下的客户端短连接

这是最典型的场景。F-Stack 经典部署是 1 primary + N secondary 共享网卡队列,每个进程只收自己队列的报文。进程做客户端(比如 nginx 反向代理连上游、网关主动探测)时,如果选出的源端口哈希不到自己的队列,回包就落错进程。连接越短、建连越频繁,选端口的开销占比越大,本功能收益越明显。

2.2 反向代理 / 网关类高频建连

nginx_fstack 反代场景:客户端用长连接打进来,nginx 作为客户端用短连接连上游。每一条上游连接都要走一次选端口,静态表对这种”远端地址相对固定”的场景命中率极高(上游规则里配好常用的 upstream 即可)。

2.3 需要精确控制回包落核的多队列应用

任何关心”回包落本核”的应用都适用——不只是性能问题,多进程架构下回包落错队列意味着功能不可用。

2.4 不太适合的场景

  • 纯服务端 listen + accept 的应用(选端口只发生在主动 connect,纯监听不触发),用不到本功能
  • 远端地址完全随机、无法预先配置静态表、且并发连接特别高的场景(静态表不命中,退化为 thash 反算(可选端口范围受限严重,斟酌使用)/软算,功能正确但收益打折)
  • 单队列部署(只有一个队列,选什么端口都”落本队列”,走的是零开销的短路路径)

3. 本功能的架构特征

3.1 选端口三层决策路径

一条 connect 进来,内核侧 in_pcb_lport_dest(freebsd/netinet/in_pcb.c)按下面的顺序选择源端口:

三层路径逐层降级、各自独立:静态表是纯查表(最快),thash 反算是数学反解(次快,单候选百 ns 级),软算扫描是全量 Toeplitz 计算(最慢但永远正确)。任何一层失败都不影响正确性,只是慢一点。

3.2 反算的核心:按”回包字段序”定位源端口

这里有个非常反直觉的设计点,是整个 thash 反算正确性的基石。

Toeplitz hash 是非对称的:hash(src, dst, sport, dport) ≠ hash(dst, src, dport, sport)。出向包(本地→远端)和回包(远端→本地)经网卡 RSS 落不同队列。我们反算源端口的目的是让回包落本队列,所以必须按回包的字段序构造 tuple:

出向包:srcIP=local  dstIP=remote  srcPort=local  dstPort=80
回 包:srcIP=remote dstIP=local   srcPort=80     dstPort=local ← 本地端口在 dstPort 字段!

因此反算的 helper 要加在回包 tuple 的 dstPort 字段位上:IPv4 tuple 12 字节,本地端口在 byte10 = bit80(不是出向包的 sport 字段 byte8 = bit64);IPv6 tuple 36 字节,本地端口在 byte34 = bit272(不是 bit256)。代码里 FF_RSS_THASH_V4_SPORT_OFF=80、FF_RSS_THASH_V6_SPORT_OFF=272(lib/ff_dpdk_if.c:176/187),注释里明确写了这个”回包 dstPort 字段”的理由。

【注1】这个设计不是一开始就对的。最早的实现按出向包 sport 字段反算(v4 offset=64),结果 listen 成功但连接不通——出向包落对了、回包落错了。修正成 dstPort 字段后才端到端打通。详见 4.5。

3.3 三方 key 对齐架构

thash 反算要成立,有个前置条件:反算用的 RSS key、软算复核用的 key、网卡实际用的 key 三者必须完全一致。架构上做了两件事保证:

  • ff_rss_thash_build_key(port_id, reta_size)(lib/ff_dpdk_if.c:3459)在 dev_configure 之前串行构造 v4→v6 的 thash ctx,并把构造产出的统一 key(KEY_FINAL)发布到全局 rsskey,由调用方在 dev_configure 时编程进网卡——三方从此共用同一把 key
  • secondary 进程走 rte_thash_find_existing 复用 primary 已建的同名 ctx,不再各自独立初始化(这也顺带解决了多进程下 ctx 重复初始化的 EEXIST 报错)

4. 具体做了哪些改造、遇到了哪些问题

后续比较枯燥,不需要深入研究实现过程的可以跳过本节,直接看第 5 节怎么用。

4.1 起点:上游的静态表优化(e54aa4317)

这套机制的源头是 F-Stack 官方的一次优化(commit e54aa4317,官方 wiki 有介绍,公众号文章 https://mp.weixin.qq.com/s/x5wWecqEKWGdcVbhne78tw)。核心思路就是空间换时间:启动时按配置的 (本地地址, 远端地址, 远端端口) 规则,把”哪些本地端口会哈希到本队列”预先算好存进 ff_rss_tbl,建连时查表轮转取端口,把”动态计算”变成”静态查找”。官方实测:常规场景 QPS 提升约 2~6%,特殊场景(8/16 进程)提升可达 36.94% / 38.79%——某些进程数配置下原始实现的随机选端口次数远超数学期望(8 进程时实测要试 46~60 次,理论期望 8~12 次),查表直接把这部分开销干掉了。

4.2 问题一:13.0→15.0 升级把内核侧钩子丢了(R-A 回迁)

升级 FreeBSD 13.0 → 15.0 时,用户态接口(ff_rss_check、ff_rss_tbl_init/set/get_portrange 等)都完整保留了,但内核侧消费这些接口的 #ifdef FSTACK 钩子没有移植——15.0 的 in_pcb.c 里 INPLOOKUP_LPORT_RSS_CHECK 宏只剩一个没人用的 #define,整个 RSS 选端口机制在内核侧完全失效,退回原生随机选端口。

修复(commit 22462f58d):按 15.0 的代码结构重新回迁。15.0 相对 13.0 有三个适配点:in_pcb_lport_dest 形参变成了 const struct inpcb *(RSS 逻辑只动局部变量);in_pcbconnect_setup 被合并进 in_pcbconnect(ff_in_pcbladdr 的对接点跟着挪);lookup 系列调用统一多了 RT_ALL_FIBS 入参。回迁后真机实测:primary/secondary 各 connect 200 次,200/200 全部落本队列。

4.3 改造二:thash 反算替代逐端口扫描(R-B)

静态表命中是快路径,但未命中时仍要逐端口软算。用 DPDK 24.11.6 的 rte_thash_adjust_tuple() 反算可以把这个 O(端口数) 的扫描变成直接反解。关键是把”落本队列”精确翻译成反算的 desired_value:本仓库落队列判定是 ((hash & (R-1)) % Q) == queueid,所以 desired = queueid + (rand % ceil(R/Q)) * Q,让反算出的 hash 低位精确落进目标队列的集合。反算成功后再用软算 ff_rss_check 复核一遍(可选,用于DEBUG,默认关闭提升性能)。adjust_sport 内 attempts 用尽(如端口都被占用,因为反算端口范围有限)则返回 -1,由内核侧 in_pcb_lport_dest 的 do-while 全端口扫描 + ff_rss_check 软算兜底。

4.4 改造三:IPv6 全链路(R-C)

上游的静态表是 IPv4-only。按方案 A 全新增 v6 独立符号(ff_rss_check6、ff_rss_tbl6_*、ff_rss_adjust_sport6),不动 v4 任何结构和签名。内核侧在统一的 in_pcb_lport_dest 里加 AF_INET6 并行分支、in6_pcbladdr 对接。IPv4 零回归的证据是硬邦邦的:R-C 相对 R-B 的 git diff 里 in_pcb.c 是 +86/-0,纯新增无一行 v4 删除。

4.5 问题二:Toeplitz 非对称——反算要按回包字段序(c42340d5 / R-G)

thash 反算落地后,物理机上出现诡异现象:listen 成功但连接不通,IPv4 正常、IPv6 不行(后来确认是两版都有这个坑,先修的 v4 后对称修 v6)。根因就是 3.2 讲的 Toeplitz 非对称:最初按出向包的 sport 字段位反算(v4 offset=64、v6 offset=256),结果出向包落对了队列、回包 SYN-ACK 落错队列,握手当然完不成。

修复(IPv4 commit c42340d5,IPv6 对称移植 R-G):把 helper offset 改到回包 dstPort 字段位——v4 64→80、v6 256→272,tuple 按回包字段序填充,复核也按回包字段序调 ff_rss_check。修复后物理机端到端验证:v6 多队列主动 connect 的回包 100% 落本进程队列、0 落错,IPv4 零回归。

4.6 问题三:add_helper 改写 key 导致三方 key 不一致(R-F)

修复 offset 后单测里发现一个更隐蔽的问题:thash 反算的”单候选等价率”只有 ~22-27%(意味着反算出的端口大概率过不了软算复核,得重试 3~6 次)。排查到最后发现根因是 rte_thash_add_helper 会用 LFSR 原地改写 ctx->hash_key——于是 rte_thash_adjust_tuple 用改写后的 key 反算,而 ff_rss_check 软算和网卡硬件用的是原始 key,三方 key 不一致,那 22-27% 纯属巧合命中。

修复:ff_rss_thash_build_key 在 dev_configure 前串行构造 ctx、发布统一 KEY_FINAL 到全局 rsskey 并编程进网卡,三方对齐后等价率应达 ~100%。

4.7 改造四:recheck 复核默认关闭(R-D)

三方 key 对齐后,反算结果的可信度上来了,那道”反算成功后强制软算复核”的保险丝就成了纯开销——microbench 实测单次 ff_rss_check 复核约 99.4 ns/call,而 recheck=0 的热路径只要 ~0.31 ns/call,差了约 300 倍;换算到每连接(平均 3.9 次 adjust 调用)约省 390 ns,v6 reta=512 长尾场景每连接能省约 2 us。

于是把复核做成运行时开关 recheck(config.ini [rss_check] 段),默认 0(性能优先),debug/运维时开 1 维持零容忍硬门。失败兜底链(attempts 用尽 → 软算扫描)不受影响。

4.8 改造五:bind-then-connect 端口延迟分配(R-E)

还有一个漏网路径:应用先 bind(local_addr, 0) 再 connect(remote)。原生 FreeBSD 在 bind 阶段就分配了匿名端口,connect 时发现”端口已经有了”就绕过了 RSS 选端口逻辑,选出的端口不落本队列。这正好对齐 Linux IP_BIND_ADDRESS_NO_PORT 的语义——端口推迟到 connect 按完整四元组分配。

修复(commit ff9e3c449,+16/-1,全宏门控):v4 的 in_pcbbind 入 hash 块加 lport != 0 门控、in_pcbbind_setup 的端口分配加 #ifndef FSTACK 门控;v6 对称改造。bind 阶段不固化端口 → connect 天然进 RSS 选端口分支。另有一个有意思的坑:nginx 会先调 setsockopt(IP_BIND_ADDRESS_NO_PORT),这个选项的 Linux 数值 24 恰好撞上 FreeBSD 的 IP_BINDANY=24——v4 被静默误设成透明代理、v6 直接 EINVAL。在 syscall 转换层拦截(commit a2537e143)后彻底解决。

4.9 测试与验证情况

  • 单测:39 run / 36 PASSED / 3 SKIPPED(3 个 SKIPPED 是 DPDK EAL 在单测环境无法 init 的既有降级),v4/v6 full-loop 落队列 100% 硬断言全过
  • 真机(R-A 软算路径):primary/secondary 各 200 connect,200/200 落本队列
  • 物理机(v6 reverse-path):具备真实 v6 RSS 能力的网卡上端到端通过,0 落错
  • 本机 virtio reta_size=0:thash ctx 无法初始化,真机上走的是软算降级路径(这恰好验证了降级链的正确性),thash 路径的正确性由单测 reta=128/512 全量覆盖

5. 如何使用、如何配置、使用效果

5.1 配置

config.ini 的 [rss_check] 段(示例):

[rss_check]
# 总开关:1=启用静态 RSS 表选端口,0=关闭(默认)
enable=1
# debug 开关:1=thash反算成功后强制软算复核(默认 0,性能优先)
recheck=0
# thash 反算开关(与静态 RSS 表的 enable 解耦):1=多队列下启用反算(默认),0=只走软算扫描
thash_adjust=1
# 静态表规则:<网卡端口ID> <本地地址> <远端地址> <远端端口>,多条用分号分隔
rss_tbl=0 192.168.1.1 192.168.2.1 80;0 192.168.1.1 192.168.2.1 443

静态表规则建议配好常用的 upstream(远端地址+端口),命中率越高收益越大;同一 saddr/sport 二元组最多 16 个、同组最多 4 个 daddr,超出的配置被忽略。

5.2 使用效果

静态表路径(官方实测,e54aa4317 时代数据):

lcores原始动态 QPS静态表 QPS提升
138,09338,456+0.95%
273,56675,009+1.96%
4139,205142,325+2.24%
6196,471202,068+2.85%
8201,823276,368+36.94%
12371,362394,151+6.14%
16398,376552,894+38.79%

常规场景 2~6%,8/16 进程这种”原始实现随机重试爆炸”的场景 35%+。

thash 反算路径(本仓库实测):

  • recheck=0 热路径 ~0.31 ns/call vs recheck=1 的 ~99.5 ns/call(microbench,约 300 倍差距)
  • 每连接省约 390 ns(v4)、v6 reta=512 长尾省约 2 us
  • full-loop 落队列 100%(单测硬断言),错队列零容忍在 recheck=1 下由软算复核守护

5.3 注意事项

  • 静态表与 thash 反算是”加速层”,最终正确性由软算兜底链保证,任何一层失败自动降级,不需要手动干预
  • 在不支持RSS的环境(如本机 virtio 网卡) reta_size=0,thash 反算在真机会自动降级为软算——这是设计行为不是 bug

6. 延伸阅读:

F-Stack ff_rss_check()优化介绍

1. 概述

本文档旨在介绍 F-Stack 中对 ff_rss_check() 函数的一项重要优化。该优化通过引入一个预计算的静态端口查找表,显著提升了应用程序作为客户端主动发起大量短连接时的性能,解决了原 ff_rss_check() 函数在高并发场景下可能成为性能瓶颈的问题。

核心优化提交:e54aa4317b5d81f9f8643e491d8ec0ec1e72282a

2. 背景与问题分析

ff_rss_check() 函数在 F-Stack 中负责一个关键任务:当应用程序作为客户端发起新的 TCP 连接时,为其分配合适的本地源端口。

  • 原有实现机制: 每次需要建立新连接时,都会动态调用 ff_rss_check()。该函数会根据目标服务的 IP 和端口(4元组信息),通过一个哈希计算来选择一个 RSS(Receive Side Scaling)友好的本地端口,以确保数据包能被高效地分发到正确的 CPU 核心上处理。
  • 性能瓶颈: 在需要频繁创建短连接(例如,处理 HTTP 请求且未开启 keep-alive)的场景下,每次连接建立都需要执行一次或多次端口选择和冲突检查。这个过程涉及多次的toeplitz_hash计算(平均每次耗时约300+tsc,平均次数至少为该端口的总队列(进程)数),在高并发压力下,会消耗可观的 CPU 资源,并成为限制连接建立速率的瓶颈。

3. 优化方案:静态 RSS 端口表

为了克服上述性能问题,优化方案的核心思想是:变“动态计算”为“静态查找”。

  1. 预初始化静态表:
    • 在应用程序启动阶段(ff_init() 时),根据配置文件中的预定义规则,预先计算并初始化一个静态的端口查找表(ff_rss_tbl),。
    • 该表包含了预先调用ff_rss_check()计算好的哪些本地端口发出去的包可以返回本进程对应的网卡队列,表中包含 {{远程地址,远程端口}:{本地地址, [所有可用本地端口]}}组合,以及一些辅助数据结构,如可用端口的起始、结束索引,上次选择的到的端口索引等。
  2. 高效的端口选择:
    • 当需要建立新连接时,首先尝试从这个预先生成的静态表中进行查找。
    • 如果能找到一个与当前连接目标地址/端口匹配且未被占用的本地端口,则直接使用它。这个过程几乎是无锁且开销极低的(平均耗时约100-250tsc)。
    • 最重要的是,静态查找表无需多次选择并计算RSS,只需要查找一次本地端口即可保证远程回包可以回到本进程的队列进行处理,极大提升了选择源端口的效率,进而提升总体的QPS性能。
  3. 优雅降级:
    • 如果静态表中所有符合条件的端口都已被占用或该四元组未配置静态查找表,系统会自动降级到原有的 ff_rss_check() 动态计算流程,确保功能的正确性。

4. 配置说明

此功能需要通过配置文件(例如 config.ini)手动开启和定义。相关配置段如下:

# 启用或禁用静态 ff_rss_check 表。
# 若启用,F-Stack 将在 APP 启动时初始化该表。
# 此后当 APP 作为客户端连接服务器时,会首先尝试从该表中选择本地端口。
[ff_rss_check_tbl]
# 启用开关:0-禁用,1-启用。默认为 0。
enable = 1
# 定义端口分配规则(4元组)。
# 格式:<网卡端口ID> <本地地址 daddr> <远程地址 saddr> <远程端口 sport>
# 单个四元组内用空格分隔,多个四元组之间用分号分隔。
# 注意:saddr/sport 二元组最多支持 16 个,同一 saddr/sport 下最多支持 4 个 daddr。
# 因此,最多支持 64 种组合,超出的配置将被忽略。
rss_tbl=0 192.168.1.10 192.168.2.10 80;0 192.168.1.10 192.168.2.11 80;0 192.168.1.10 192.168.2.12 80

配置参数详解:

  • enable: 总开关,必须设置为 1 才能启用此优化功能。
  • rss_tbl: 定义端口分配规则。每一条规则指定了网卡端口ID用于获取网卡的队列配置,对于特定的目标服务(saddr + sport),使用哪个预先分配好的本地地址(daddr)。

5. 性能提升

根据内部测试数据,该优化带来了显著的性能提升:

  • 测试场景: 客户端(wrk)向F-Stack Nginx发起http请求,使用长连接。F-Stack Nginx设置反向代理,作为客户端,频繁向多个远程服务器发起短 TCP 连接(未使用 keep-alive)。示例图如下所示:
  • 测试数据:如下表所示
  • 常规 QPS 提升约 2-6%左右,且提升的比列会随着进程数的增加而增加,因为进程数越多,原有的ff_rss_check()需要计算的平均次数就越多。
  • 某些特殊场景下提升可以达到35%以上,主要是因为在某些进程数的配置下,原始动态计算方式,随机选择源端口的次数会远超数学期望的总进程数+少量次数,导致需要大量额外调用ff_rss_check()进行计算,消耗了大量CPU资源。
    • 【注意】不同的upstream服务器配置(数量、IP、端口等)可能会导致不同的进程数存在类似问题,在本测试场景下配置8或16进程时,会出现该问题。导致该问题的具体原因则尚未完全明确,查看相关静态表、端口是否正在被占用和请求测试都正常。
    • 如下表所示分别为8和12进程Nginx调用ff_rss_check()次数的统计,可以看到8核时的随机选择次数明显超出了数学期望的(应该在8-12次左右),16核同理。
      • 其中randomtime表示内核参数net.inet.ip.portrange.randomtime的设置,因为F-Stack框架的特性,作为客户端选择源端口时此时完全随机选择效果更好,且F-Stack对随机性要求并不高,早前已经替换了性能好好很多的伪随机函数。
  • perf top截图

8进程间歇性随机(大部分不随机)时ff_rss_check()调用次数太多,热点很高

8进程完全随机,ff_rss_check()调用次数热点有一定降低

12进程,ff_rss_check()的热点则大幅下降

  • 8进程间歇随机和完全随机选择源端口QPS性能对比

6. 其他参数优化调整

F-Stack对应调整了部分参数,具体如下,其他业务如何配置可以根据自己业务的具体特点灵活调整

[freebsd.sysctl]
net.inet.tcp.delayed_ack=1 # 可以提升大并发的吞吐量
net.inet.tcp.fast_finwait2_recycle=1
net.inet.tcp.finwait2_timeout=5000
net.inet.tcp.maxtcptw=128 # 尽快释放TIME_WAIT状态的端口,增加空闲端口,减少in_pcblookup_local()的调用次数,进而减少ff_rss_check()的调用次数
net.inet.ip.portrange.randomized=1
# Always do random while connect to remote server.
# In some scenarios of F-Stack application, the performance can be improved to a certain extent, ablout 5%.
net.inet.ip.portrange.randomtime=0 # 对某些特定配置条件下原动态计算ff_rss_check()方式有一定性能提升

7. 总结

本次对 ff_rss_check() 的优化是 F-Stack 追求性优化能的一个典型例子。它通过以下方式解决了核心瓶颈:

  1. 空间换时间: 使用预计算的静态表换取运行时动态计算的开销。
  2. 减少调用次数: 极大降低了端口选择时的ff_rss_check()和in_pcblookup_local()的重试调用次数,实现总体系统2-6%,特殊极限场景35%以上的总体系统性能提升。
  3. 保证兼容: 通过降级机制保障了在任何情况下的功能正确性。

建议启用此功能的场景: 所有使用 F-Stack 作为客户端、需要高频率创建新连接(特别是短连接)的应用程序。启用后,只需在配置文件中预先定义好常用的目标服务地址,即可获得显著的性能收益。

【AI使用声明】本文初始版本使用DeepSeek-V3.1生成,然后进行人工调整;示意图片由元宝根据人工提供的提示词生成和修改