ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

并行计算中的Transport选路:拓扑位图与多路径优化实践

并行计算中的Transport选路:拓扑位图与多路径优化实践 做并行计算时间长了你会发现一个规律越是贴近底层通信的东西越容易在关键时刻把整个作业拖垮。SHMEM 这类 PGAS 模型上层给程序员的是一个看似线性的对称地址空间你只需要shmem_put、shmem_int_add但底层同时面对着共享内存、InfiniBand verbs、RoCE、甚至 TCP 兜底这几种完全不同的传输路径选哪条路走、什么时候切换直接决定了跑出来的延迟是 1 微秒还是 50 微秒。我在实现通信子系统时花最多精力的不是协议头怎么编码而是 Transport 选路机制和它背后的拓扑位图。这篇文章我会把手上的设计思路、位图编码方式、选路评分模型、以及实测调优时踩过的坑全部摊开讲。适合正在做 SHMEM、MPI 或其他 PGAS 运行时通信层优化的朋友也适合对高性能网络路由决策感兴趣、想看看拓扑信息怎么落地成可执行代码的读者。不会只给结论重点放在“为什么这么设计”和“实际动手时有哪些细节”。1. 为什么会盯上 Transport 选路机制1.1 多 Transport 共存是常态不是可选项只要能跑大型科学计算的地方底层网络基本都不是单一形态。以一套 64 节点的 GPU 集群为例节点内有多块 GPU通过 NVLink 或 PCIe 互联GPU内存这种路径其实也可以抽象成一种 Transport节点间则可能是 InfiniBand HDR100也可能是 RoCE加上为了兼容性和运维备份总得留一条 TCP sockets 的 fallback 路径。SHMEM 作为一个可移植的编程模型不可能假设所有平台都提供 verbs所以通信子系统的第一件事就是把“能用的传输方式”注册成一个可枚举的列表。我在做的实现里定义了一组统一的 Transport 操作接口包括init、finalize、put、get、amo、barrier、progress等。每个底层模块各自实现这些接口。这样一来逻辑上通信操作和物理传输解耦了但紧接着就出现新的问题同一个shmem_put调用到底走哪个模块总不能每次都轮询一圈也不能简单写死“永远用 UCX”因为不同消息大小、不同目标节点距离、甚至不同时间点的网络负载最优选都不一样。这里穿插一个真实场景老老实实用 UCX 跑小消息延迟大概在 1.5 微秒左右但在某些机型上同机架内通过共享内存路径直接拷贝甚至能压到 0.8 微秒。反过来大消息跨节点带宽明显是 RDMA 的强项TCP 撑死跑到 RDMA 的 1/3。你要是只认准一个 Transport就等于把性能上限交给了最差路径。多 Transport 共存不是“为了架构好看”而是物理现实逼着你这么做。1.2 选路机制究竟解决什么问题选路机制要解决的简单说就是“在正确的时间把正确的操作交给正确的传输后端”。但展开来看至少包含四件事。第一延迟和带宽的平衡。小消息最敏感的是连接建立、握手、以及软件协议栈的固定开销这时候优先选择延迟最低的路径大消息主要拼带宽所以要避开那些需要多次拷贝或者额外封装开销的模块。第二拓扑感知。同样是跨节点通信同一个机架里的两个节点和分属不同叶子交换机下的两个节点物理路径完全不同。如果选路时不考虑拓扑就可能出现两个邻居节点之间的消息绕了大半个网络再到目的地的情况白白增加几十倍时延。拓扑位图的作用就是在这里提供“两点之间有多近”的快速判断。第三负载和故障隔离。某个 Transport 对应的物理链路拥塞了或者某个网络端口状态异常选路机制应该能动态调整。这里可以简单到“禁用列表”也可以复杂到实时探测拥塞窗口但核心是要让上层操作尽量不受单点故障影响。第四语义支持。不同 Transport 能做的事情不一样。比如普通 TCP sockets 要支持远程原子操作必须通过软件模拟加锁性能很差而 verbs 或者 CXICornell? 其实 CXI 是 HPE Slingshot 的接口则可以直接做硬件原子操作。选路时如果忽略了这个, 就会出现 shmem_int_add 在某个 Transport 上可以 0.5 微秒完成在另一个 Transport 上却要半微妙加一个锁。所以选路不是做一次静态绑定而是每个通信操作都可能会触发的一个实时决策。当然每次操作做太重的决策也不行否则选路本身的耗时比通信还高那就本末倒置。这也是我们引入拓扑位图的原因之一让决策过程极轻量基本上一两次位运算就能拿到关键参数。2. 拓扑位图把网络拓扑编码成一个整数集合2.1 位图结构与距离编码拓扑位图这个名字听起来唬人其实本质就是用 bit 来代表“某个目标节点在某层是否可达”然后通过位运算快速判断两个节点之间的最近公共层级。理解了这个你就会发现它和路由算法里的子网掩码是同一个思路。具体到实现我根据常见的集群分层结构定义了四层距离等级距离等级名称含义典型介质0LOCAL同进程/共享内存域内存拷贝、CMA、GPU P2P1RACK同一个机架同 leaf 交换机leaf 下挂 RDMA/TCP2LEAF同叶交换机不同机架leaf 内二层转发3SPINE跨叶子交换机经过 spinespine 三层路由4OFFC跨集群/跨资源池WAN、网关转发在这个结构下每个节点维护一个位图数组bitmap[level]是一个长度等于节点总数的 bit 集合第 N 位置为 1 表示当前节点到节点 N 在 level 层级上是连通的、或者说距离不超过该层级。需要说明的是这不是记录整张拓扑图的完整邻接关系而是记录每一层的“连通分区”。举个例子节点 A 和节点 B 在同一个 rack 下那么在 A 的bitmap[LOCAL]中 B 的位置可能是 0但在bitmap[RACK]中 B 的位置是 1。所以要判断两点之间的最近距离只需要从 LOCAL 开始往上逐层检查两个节点在对应层级的位是否为 1第一个同时为 1 的层级即可作为距离度量。用代码表达就是static inline int topo_distance(int self_pe, int dst_pe, const uint64_t *self_bitmap, const uint64_t *dst_bitmap) { // 这里为了可读性只展示两层实际是循环 if (bit_get(self_bitmap[LOCAL], dst_pe) bit_get(dst_bitmap[LOCAL], self_pe)) return TOPO_LOCAL; if (bit_get(self_bitmap[RACK], dst_pe) bit_get(dst_bitmap[RACK], self_pe)) return TOPO_RACK; if (bit_get(self_bitmap[LEAF], dst_pe) bit_get(dst_bitmap[LEAF], self_pe)) return TOPO_LEAF; return TOPO_SPINE; // 剩下统一按跨 spine 处理 }实际工程中不会真写这么多 if而是用一个 for 循环配合位图数组从 0 到 4 逐层探测。为什么需要同时检查发送方和接收方的位图因为位图是基于节点自己视角构建的如果两边拓扑信息不一致比如构建时间不同单边判断可能出错。两边同时置位才认定可达这个策略可以降低信息过期带来的误判代价是代码和通信量稍微多一点但安全性好很多。2.2 拓扑位图的构建和更新位图不是每次机器启动都手动填的实际构建分两条路径静态发现和动态上报。静态发现阶段我们读取系统提供的拓扑服务。SLURM 环境下可以从scontrol show topology或SLURM_TOPOLOGY_BITS等变量拿到节点归属和交换机层级信息。在裸机环境下更可靠的做法是在启动时做一次“拓扑探测”也就是每个节点通过 verbs 或者 socket 向其他节点发包记录往返时延和路径字节数然后按阈值归类到不同层级。这一步听起来简单实际会有几个坑。第一个是 IP 和节点 PE 的映射不一定是连续的如果做集群扩容新节点的 PE ID 可能跟旧节点交错。我们最终的处理是构建一个全局的 “PE → 拓扑坐标” 映射表位图本身不直接按 PE 连续存储而是按拓扑坐标排列这样扩容时只需要追加新坐标不用把整个位图重建一遍。动态更新则关注两类事件。一类是节点状态变化比如某个节点因为检修而下线对应位要清零或者新节点上线位要置位。另一类是拓扑变化比如重新组网、调整分区。这些事件通常意味着需要重新构建部分位图而不必重启全部进程。我们在实现里加了一个“topology epoch”计数器每次拓扑更新计数器加一进程在通信过程中如果发现对面返回的 epoch 和本地不一致就触发一次拓扑信息同步从而避免长期跑批时位图过期。位图的存储开销其实很小。假设节点数在 4096 以内一个层级位图就是 512 字节四层总共 2KB。每个节点存储自己视角的位图以及一个全局拓扑坐标表总内存占用可以忽略不计。真正要注意的是位图构建阶段的通信风暴如果 4096 个节点同时互相探测连接数会爆炸。实际做法是让每个节点只探测距离自己较近的节点和部分随机节点然后通过聚合交换把局部信息传播成全量拓扑信息类似 gossip 协议效果很好。3. 选路机制的完整实现流程3.1 选路评分模型与权重设计有了拓扑位图提供的距离等级下一步就是把它变成选路决策的输入。我在设计里把这个问题形式化成一个评分模型每个候选 Transport 都会得到一个 0 到 1 之间的分数最终选择分数最高的那个。评分函数长这样score w1 * latency_factor w2 * bandwidth_factor w3 * topology_factor w4 * capability_factor w5 * reliability_factor其中每个 factor 都是归一化到 0~1 的指标权重 w1~w5 是可以在运行时通过环境变量调整的。把这几个项拆开看latency_factor根据消息大小和操作类型查预先测好的性能表得到该 Transport 在当前距离下的基准延迟。延迟越低得分越高。bandwidth_factor对大消息尤其重要。同样是根据消息大小和距离查表得到该 Transport 的期望带宽然后和当前所有候选中的最大值做比值。topology_factor是一个和距离强相关的修正项。比如在 LOCAL 层级共享内存 Transport 的额外加分就很大在跨 SPINE 层级可能要抑制只有 10G 的 TCP 路径鼓励走 100G RDMA 路径。capability_factor用来检查操作语义是否被支持。如果是shmem_int_add这类原子操作TCP 模块如果没有硬件原子支持评分就会被打到很低。reliability_factor是基于最近一段时间的传输失败率或拥塞信号给出的动态惩罚。比如某条路径出现过stream disconnected类似的中断错误这个因子会下降。权重不是拍脑袋定的。初期我全用等权重结果发现大消息场景下延迟因子和带宽因子互相扯皮该走 RDMA 的被 TCP 抢走了因为 TCP 在某些小消息延迟测试里表现不错。后来调整成消息小于 2KB 时w1 权重是 w2 的 3 倍消息大于 64KB 时反过来w2 权重是 w1 的 3 倍。中间区间动态插值。这样才把“按消息大小自动切换路径”的行为理顺。3.2 关键代码路径与数据结构选路模块的核心数据结构就两个Transport 描述表transport entry list和拓扑视图topology view。Transport 描述表在初始化时构建每个条目包含模块名、能力掩码、性能表指针、状态机指针。拓扑视图则包含前面提到的位图数组和坐标映射表。选路函数的简化版实现如下transport_t *select_transport(shmem_op_t op, size_t size, int self_pe, int dst_pe) { int level topo_distance(self_pe, dst_pe, topo_view.self_bmap, topo_view.peer_bmap); float best -1.0f; transport_t *chosen NULL; for (int i 0; i topo_view.num_transports; i) { transport_t *t topo_view.transports[i]; if (!(t-cap_flags op.cap_required)) continue; float score score_transport(t, op, size, level); if (score best) { best score; chosen t; } } return chosen ? chosen : topo_view.fallback_transport; }每次操作都走一遍这个循环听起来很贵但实际上传输描述表通常只有 4~5 个条目循环一次顶多几十纳秒而一次真正的通信至少几百纳秒所以这个决策开销完全可以接受。真正的优化在于不用每次操作都重新做topo_distance某些高频操作可以在连接建立或内存注册时把目标 PE 的距离等级缓存下来。我们给每个 PE 保持了一个peer_attr缓存包括距离等级和最佳 Transport 的快速索引。只有拓扑更新事件才会使缓存失效。3.3 从位图到 Transport 的一条调用链完整走一遍调用链你就知道每个环节负责什么。假设一个进程调用shmem_int_add(target, 1, dst_pe)运行时先拿到操作句柄确定目标是远程归因地址。进入选路入口select_transport(SHMEM_OP_AMO, sizeof(int), self_pe, dst_pe)。topo_distance从拓扑位图中查出self_pe和dst_pe的距离等级。遍历 Transport 列表逐个调用score_transport打分。返回最优 Transport 之后调用对应的t-amo(op, dst_pe, ...)。Transport 内部根据自身机制完成底层发送。如果是共享内存路径直接映射目标进程的堆空间做原子操作如果是 verbs 路径走ibv_post_send提交原子 request。这条链路里最容易被忽略的是第 4 步“打分”时的性能表准确性。性能表如果是在早期小集群上测出来的拿到大规模集群上很可能失真。比如共享内存路径在 NUMA 拓扑不同的机器上差异很大两个进程如果被分到不同 NUMA node远程内存拷贝的延迟可能翻倍。所以在实测环节我们会特意做一次“拓扑感知校准”在每个节点上用微基准重新填充性能表而不是硬编码默认值。4. 实测集群上的调优与性能验证4.1 实验环境和测试方法我跑测试的环境是一套 64 节点的集群每个节点双路 CPU节点内 8 个 NUMA 域节点间是 HDR100 的 InfiniBand另有 25G RoCE 和 TCP 作为备选。操作系统用的是常见的 Linux 发行版OpenSHMEM 的量和运行库是我自己编译的带调试符号的版本。测试的微基准是自己写的三个小函数一个是put测试遍历不同消息大小一个是amo测试测shmem_int_add的往返延迟还有一个allreduce测试看端到端同步性能。每个测试都会在三种 Topology 场景下分别跑同机架同 leaf、同 leaf 不同机架、不同 leaf 跨 spine。选路机制的验证不能只看最终性能还要观测它是否真的按预期选择了路径。所以我在编写时添加了选路日志开关通过环境变量SHMEM_ROUTE_DEBUG1打开每次决策都会打出一行包括操作类型、消息大小、距离等级、候选 Transport 以及最终选择。4.2 结果分析与参数对拍对比结果中最明显的现象是小消息延迟在不同距离等级下的差异被选路机制抹平了不少。同一个 rack 内的小消息 put共享内存路径和 RDMA 路径延迟接近但是共享内存得分更高因为它还能顺便省掉一次端点网络协议栈开销。跨 spine 后共享内存路径不可达RDMA 或者 RoCE 自动成为首选TCP 始终排在最后。大消息带宽的结论更有意思。在跨 spine 场景下RoCE 和 InfiniBand 的带宽差距不大但 RoCE 的 PFC 流控在一定并发下容易出现降速可靠性因子会捕捉到重传增长然后动态把分数压低选路自然切到 InfiniBand。这个变化不是瞬时的而是经历了几百个消息之后逐渐完成的。对于一批十秒级的通信模式这种慢收敛是安全的但对于非常短的运行任务可能影响就大一些。我把一小段典型结果放这里场景消息大小距离等级选路结果延迟/带宽同机架 put64BRACK共享内存路径1.02 us同机架 put4KBRACK共享内存路径1.9 GB/s同机架 put1MBRACKRDMA verbs11.2 GB/s跨 spine put64BSPINERDMA verbs1.61 us跨 spine put1MBSPINERDMA verbs11.0 GB/s跨 spine amo4BSPINERDMA verbs2.03 us只看这个表格你可能觉得没什么稀奇。但真正让调优苦不堪言的是“距离等级判错”导致选路异常。有一段时间同机架内的延迟一直偏高打日志发现距离等级被标成了 SPINE因为构建拓扑位图的阶段只用了 PING 延迟阈值而当时网络上有其他业务流量干扰导致机架内延迟超过了阈值上限。后来改成“PING 延迟 交换机访问关系”双重校验这个问题才消失。4.3 一个典型用例的完整选路日志打开调试日志跑一个 put 测试能看到这样的输出[route] opPUT size1024 self3 dst7 [route] topo_levelRACK (bitmap hit at level 1) [route] candidateshm latency0.92us bw2.1GB/s score0.91 [route] candidateverbs latency1.21us bw11.0GB/s score0.85 [route] candidateroce latency1.30us bw10.5GB/s score0.80 [route] candidatetcp latency4.50us bw0.8GB/s score0.31 [route] select shm for PUT size1024 to PE 7这条日志能解释很多问题。你看候选verbs的带宽分其实很高但最终总得分输给了shm因为 1KB 消息下延迟权重更大共享内存路径的延迟优势压过了带宽劣势。这就是为什么一定要在日志里同时打出“候选分”而不只是打“选中谁”否则调优时根本不知道是哪个因子主导了决策。在这个基础上我们还做了一个压力测试连续跑一小时的全交换put期间人为断掉一条 leaf 到 spine 的上联链路观察选路是否把所有流量切到备用路径。结论是拓扑位图在 10 秒内检测到链路收敛变化重算距离等级并把受影响 PE 对的选路结果从 RDMA 切到 TCP性能下降到原来的 1/3但没有出现连接中断和作业失败。这对生产环境来说是可以接受的至少比直接崩掉好得多。5. 遇到过的坑与排查技巧5.1 传输中断错误背后的选路问题调试过程中最常见的一种报错是类似stream disconnected before completion: transport error: network error的信息。虽然这个文本更像通用 Web 服务的传输层错误但在 SHMEM 通信里有一个很相似的对应场景RDMA 连接因为选路目标不可用而断开重试多次最终失败。有一次跑大规模同步shmem_barrier 直接挂死。看日志发现选路模块选了一个已经 down 掉的 Transport 路径导致 barrier 消息发不出去进程一直等待。后来定位发现是拓扑位图没有及时更新节点下线的消息没能在所有进程中传播开来。这个问题带了很深刻的教训选路机制在高可用场景下必须和健康检查强耦合不能只靠静态拓扑信息。排查步骤如下先确认选路日志看选中了哪个 Transport然后检查该 Transport 的状态计数是否存在连接失败接着用该 Transport 自带的诊断工具如ibv_devinfo、ibstat看端口状态是否 Active最后检查拓扑位图的更新时间戳如果与当前集群状态差异过大就要触发一次强制刷新。5.2 拓扑位图过期引发的路径漂移拓扑位图在静态集群里很容易让人觉得“这东西不可能错”但现实是集群维护总是在最意想不到的时间发生。比如某个交换机固件升级节点还在跑但是 leaf 和 spine 之间的链路带宽临时降为 1Gbps再比如热迁移虚拟机导致网络接口的物理位置变化原先的本地共享内存路径变成了跨主机路径。这些问题映射到选路就是“路径漂移”同一个 PE 对前后两次运行的选择结果完全不同性能差异大到离谱。要发现这种问题建议在每次启动运行时都打印拓扑位图的哈希值。如果两次运行的位图哈希值不同说明拓扑感知的基础发生了改变选路结果变化就有解释依据。我们后来在finalize阶段加入一个统计项输出每个 PE 对的选路切换次数一旦发现某两个 PE 一直在切换就要重点排查是不是位图构建时用了脏数据。5.3 快速定位选路问题的工具和思路调选路问题我的个人经验是至少准备三件工具可开关的选路日志、性能表可视化、以及一个能强制指定 Transport 的环境变量。选路日志不必多说关键是输出格式要固定方便用脚本批量分析。性能表可视化是把每个 Transport 在四个距离等级下的延迟、带宽曲线画出来很多时候一眼就能看出某条曲线出现异常尖刺比如 TCP 在 RACK 等级的延迟比 SPINE 还高这明显就是测试节点和路径选得不对。最后一个强制指定 Transport 的开关很有用比如设置SHMEM_FORCE_TRANSPORTverbs可以快速验证“如果不用选路某个场景最快能跑多快”从而估算选路开销和误判代价。结尾写到最后分享一个我自己的体会选路机制和位图这个东西设计时总想做到完美但真正让它稳定的反而是“机制简单、状态明确、日志可观测”。位图只是把拓扑变成了一串整数选路只是几个权重加几行查表真正复杂的是如何在错误发生时知道为什么选错了。所以每次在实现里加新功能我都会把“调试成本”放在和“性能”同等重要的位置宁可少做一点优化也要确保出问题时能快速定位。如果你也在做类似的工作建议一开始就把选路日志和拓扑校验接口设计好后期能少熬很多个通宵。
返回列表