高性能计算负载均衡:从等开销到动态调优的工程实践
2026/9/9 1:45:43 网站建设 项目流程

凌晨一点半,我提交的一个 16000 核作业终于跑完了,打开日志一看,心里凉了半截:25 个分区里,有 19 个分区在最后半小时基本处于“干等”状态,真正拖住整堵墙时钟的,是最慢的 3 个进程。这 3 个进程多跑了将近 40 分钟,就把整个作业的完成时间往后拉了 40 分钟。这就是高性能计算负载均衡最让人头疼的地方——它不是“优化得好不好看”的问题,而是直接决定你这个项目能不能按时交付的问题。

我在超算集群上折腾了十多年,从核数几百的小规模并行,到几万核的大规模算例都跑过。对高性能计算里“负载均衡”这四个字,我从一开始觉得“就是让每个核干一样多的活”,慢慢变成了现在更谨慎的判断:真正的负载均衡,目标是让所有计算资源在同一条时间线上同时完成任务,而不是机械地追求“每个进程分配的数据量相同”。这篇内容,我想把自己踩过的坑、验证过的思路和判断方法一起写出来。尤其是“等开销负载均衡”这个概念,很多人听过,但真正理解它和“平均分配”区别的人,并不多。

1. 慢速进程决定墙钟时间:负载均衡不是玄学

先回到最基础的事。并行程序的墙钟时间不取决于平均速度,而取决于最慢的那一个进程。假设你有 80 个 MPI 进程在跑同一个模拟,其中 79 个进程都只用了 10 分钟完成任务,但有一个进程因为分区里包含了一些特别难算的网格,用了 19 分钟。那么整个作业的完成时间就是 19 分钟。

这个例子很好算:

  • 总计算时间大约:79 × 10 + 19 = 809 分钟(如果我们把所有进程的 CPU 时间加起来)
  • 墙钟时间是:19 分钟
  • 理论最优墙钟时间是:809 / 80 ≈ 10.11 分钟
  • 实际效率大约是:10.11 / 19 ≈ 53.2%

一半多的性能,就这么被一个慢进程吃掉了。更让人难受的是,如果你只看每个进程的“平均利用率”,这个程序还挺好看的:总 CPU 时间 809,总墙钟资源是 80 × 19 = 1520,利用率仍然有 53% 左右。但在真实场景里,那 79 个已经算完的进程是在那儿空转等待,占着计算资源不干活。这个“看起来还行”的 CPU 利用率,掩盖了真实的负载失衡。

我在实际项目中经常用下面这个公式来快速判断负载均衡质量:

负载均衡效率 = 各进程完成时间的平均值 / 所有进程完成时间的最大值

这个值越接近 1,说明大家完成时间越接近,负载越均衡。如果低于 0.8,就值得停下来认真查一查了。

这里要特别提醒一点:墙钟时间必须用程序内部每个进程自己测到的时间,而不是你在作业提交系统里看到的节点运行时长。因为一个节点上可能运行了多个进程,共享同一份节点资源,如果你只看节点级别的开始结束时间,会被其他负载干扰掉。

我也见过很多团队做性能报告时,只展示“每个进程算了多少个格点”或“每个进程分到了多少粒子”,然后得出结论说“我们是均衡的”。这种判断方法在规则网格上勉强成立,一旦遇到自适应加密、粒子聚集、可变参数模型,就是灾难。你分到的格点数一样,不代表每个格点的计算代价一样。这也是我接下来要重点说“等开销”的原因。

2. 等开销负载均衡:不是平均分配,是同时结束

“等开销负载均衡”这个词,最近在高性能计算讨论里出现得很多。很多人把它理解成“每个进程承担相同开销”,这个方向没错,但远不够精确。

等开销的准确含义是:每个计算进程在同一个时间段内完成自己的全部工作,进程之间没有明显的时间差,整个程序在同步点上的等待时间降到最低。关键不是“数据量相等”,而是“完成时间相等”。开销是一个综合指标,它包含了计算量、通信量、内存访问延迟、I/O 等待、同步开销等等所有会让进程停留在当前步的因素。

我习惯用一个搬箱子的例子来解释。如果让你和一队人把一堆箱子从 A 点搬到 B 点,箱子有大有小,有的还特别重。有人搬 20 个小箱,有人搬 5 个大箱,表面上看起来“每个人搬的箱子数量不同”,但只要你让每个人花的总时间大致相同,这条流水线就是均衡的。等开销负载均衡,本质上是在做这件事:每个人分配到的任务,是在其自身能力和当前环境下,能够在同一时间点完工的工作量。

在程序里,我们没法直接测量“开销”,但可以用时间作为代理指标。每个 MPI 进程在进入主循环前启动一个计时器,在结束时记录自己的总耗时;然后把所有进程的耗时做一次归约,取平均值和最大值。通过比较这两个值,就能量化当前负载均衡的失真程度。

我之前在一个分子动力学项目里就吃过亏。当时按原子数量均匀切分区域,每个进程分到的原子数几乎一模一样,我们都觉得这波稳了。结果跑起来之后,效率只有 60% 多一点。后来用 MPI_Wtime 把每个进程的单步耗时打印出来,才发现差距基本全部来自“邻居列表构建”的耗时。有些区域的原子数虽然差不多,但局部密度差异极大,导致某些进程需要反复构建更大的查找域,单步耗时比别的进程多了一半。

那次之后,我定了两条规矩。第一,每次性能分析必须埋计时点,把每个阶段的时间单独记录,不要只记录一个总时间。第二,在判断是否均衡时,可以先用上一次迭代的实际耗时作为本次划分的权重依据,而不是依赖理论模型。这种“用实测时间反馈给下一次划分”的方式,就是动态负载均衡的基础思路,后面我会专门讲。

3. 静态负载均衡:任务开始前就要想清楚的事

大部分并行程序的第一步,是静态负载均衡。也就是说,在计算开始之前,就把整个计算域或工作集划分好,分配给各个进程。这种做法的好处是开销小、实现简单、可预测性强,适合计算代价基本稳定、随时间变化不大的问题。

常见的静态负载均衡方法有几种:

  • 块划分:把一维数组切成连续段,每段给一个进程。适合维度均匀、计算规律的问题。
  • 循环划分:把元素按序号轮流分配给进程,负载不够均匀但局部性差一些。
  • 多维块划分:在二维或三维网格上沿多个方向切分,兼顾局部性和并行度。
  • 图划分:把计算问题抽象成图,顶点是计算单元,边是通信关系,然后用 METIS、ParMETIS、Scotch、Zoltan 这类库来做切分,目标是让各顶点权重之和尽量接近,同时让切割掉的边权最小。

图划分是我比较推荐的一种方式,尤其是处理非规则网格和稀疏矩阵计算时。它把负载均衡和通信优化放在同一个框架里解决。很多新手会忽略一个点:图划分时顶点权重和边权重需要你根据实际计算代价来设置,不是随便给个 1 就完事。顶点权重应该对应这个顶点计算需要的时间,边权重应该对应两个顶点之间通信量。如果你的网格是自适应加密的,不同加密级别的顶点计算代价能差出几十倍,权重必须反映这个差异。

静态负载均衡还有一层容易忽略的维度:硬件异构。

现在很多集群表面上看起来是“同构”的,实际上节点之间的性能差异可能超出你想象。CPU 频率、内存通道开启情况、散热条件、固件版本都会影响实际计算速度。我在某个集群上做过一次简单测试:同样的计算内核,在两批“看起来一样”的节点上运行,速度差距能达到 15%。如果你在分配任务时完全按“每个进程一份数据”来切,慢节点就会变成前面说的那个拖后腿进程。

解决办法有两个方向。第一个方向,是在任务分配时按节点能力加权:给快节点多分一些数据,给慢节点少分一些数据。第二个方向,是让快节点多跑一些计算次数,或者说通过进程数量的差异来补偿节点性能差异。前者比较常用,尤其在领域分解类算法中。加权划分的权重可以在正式计算前跑一小段采样,测量不同分区上的实际耗时,再折算成权重。

另外,静态负载均衡还要考虑通信拓扑。每个 MPI 进程不是孤立的,它在每一轮迭代都需要和邻居交换数据。如果你只把计算量分得均匀,却把通信链路比较重的进程放到了跨交换机的位置,那通信延迟会抵消掉你辛苦拉平的负载均衡效果。这个问题我单独放在后面讲,因为它的坑特别多。

4. 动态负载均衡:计算过程中边走边调

静态负载均衡处理不了的一类问题是:计算代价随时间剧烈变化。典型的例子包括自适应网格加密、天体物理中的粒子聚集、燃烧模拟中的化学反应波前传播。在这些场景里,某一时刻负载很均衡的区域,下一步就可能因为局部需要加密细化,计算量暴增。

动态负载均衡的思路,就是在程序运行过程中周期性地评估负载分布,当失衡超过阈值时,把部分工作从高负载进程迁移到低负载进程。实现上有三种常见模式:

第一种是周期性重新划分。每隔若干步,把所有子区域的计算量收集起来,用动态图划分库重新切分一次。这种方式的好处是划分质量高、通信优化好,缺点是需要做数据迁移,迁移成本可能很大。我见过有程序每隔一步就重新分区一次,结果性能反而下降了一半以上——因为数据搬来搬去,缓存全丢了,通信数据量也暴涨。

第二种是工作池模式,也叫任务队列模式。把计算任务拆成很多小块放到一个或几个工作队列里,进程算完一块就去取下一块。这种办法对不规则任务效果明显,天然地实现了动态均衡。缺点是如果只有一个中央队列,所有进程都来取任务,很容易变成瓶颈。我处理过的一个粒子模拟就是这个问题:计算刚开始时队列模式跑得很流畅,但进程数一多,队列服务器的通信压力就成了新的瓶颈。后来改成多级队列,每个节点一个本地队列,节点之间定期协调,才把性能拉回来。

第三种是任务窃取模式。每个进程先维护自己的任务队列,当发现自己的队列空了、而别的进程队列还很长时,主动去“偷”一部分任务过来。任务窃取在共享内存并行编程里用得很多,比如 C++ 的 TBB、Java 的 ForkJoinPool,在 MPI 程序里也能通过单边通信实现。它的好处是去中心化,扩展性好;坏处是负载均衡的粒度取决于任务块的大小,块切得太大,偷任务时抗峰效果差;块切得太小,取任务和通信的频繁性又会拖累性能。

实践中,我判断一个程序需不需要动态负载均衡,标准很简单:负载偏移超过 20%,且预计会持续若干步长。

具体操作是,在每个进程的主循环里累计一次实际耗时,每隔 5 到 10 步做一次全归约,把最大值、平均值和各自进程的耗时汇总下来。如果发现最大值比平均值高出 20% 以上,且不是因为单次通信的瞬时抖动,那就触发一次负载再均衡。

值得注意的一点是,动态负载均衡的触发频率要跟迁移成本一起考虑。如果在重分区之后需要传输大量边界数据和内部状态,那你必须估算一下:这次再平衡省下的时间,是否覆盖得了迁移和重新建立通信关系的开销?否则就是负优化。我经常在程序里加一个“迁移开关”,默认频率是每 20 步检查一次,触发重分区的阈值是 25%;真正跑大算例时再根据 profiling 结果手动调整。

另外,动态负载均衡里千万不要只用“数据量”作为负载指标。我给你举一个实际例子:在一个格子玻尔兹曼流体模拟中,不同区域的弛豫时间参数不同,有的格子计算一步要用 50 纳秒,有的格子要用 200 纳秒。如果只看格点数量,你会误判得很厉害。最可靠的负载指标,是每个进程最近一个统计周期内的实际墙钟时间。如果实在不好采样,退而求其次可以用浮点操作数预算或者迭代次数替代,但一定要在调试阶段和真实耗时做一次交叉验证。

5. 切得均衡不等于跑得快:通信拓扑结构同样重要

这是很多做过大规模并行的人都会反复掉进去的坑:你非常努力地把计算负载均衡到了 95% 以上,结果跑出来速度反而更慢了。为什么?因为通信开销被忽略了。

并行程序的单步时间,约等于最大计算时间加最大通信等待时间加同步时间。只优化前者,不优化后者,效果很容易被通信吞掉。

以一个二维网格划分为例。假设你有一个 10000 × 10000 的规则网格,要分给 100 个进程。如果按 10 × 10 的方块切分,每个进程分到 1000 × 1000 的网格,每个进程的边界格子大约是 4000 个。边界格子占该进程总格点数的比例大约是 4000 / 1000000 = 0.4%,这个通信代价非常低。

但如果你切成 1 × 100 的长条,每个进程分到 10000 × 100 的格子,边界格子数量大约是 20200 个,边界比例一下子变成 2.02%。看起来 2% 和 0.4% 差不多?但当通信需要频繁同步时,长条形的划分模式会导致每个进程需要和更多邻居通信,通信消息总数和竞争都可能上升,实际差的不只是五倍。

三维情况下这种差异更夸张。立方体划分的边界格子比例大概正比于 n^(2/3),长条划分的边界比例会大得多。所以同样是“负载均衡”,不同的划分形状,带来的通信代价可能差出几十个百分点。

所以我在做高性能计算负载均衡时,从来不把“均衡”当作唯一目标。真正要把整体墙钟时间最小化,必须在计算负载均横和通信最小化之间做权衡。用更形式化的语言说,你要最小化的目标是:

总耗时 ≈ 最大计算时间 + 最大通信时间 + 同步开销 + 数据迁移开销

这里最大计算时间要靠负载均衡去压,通信和同步要靠图划分、拓扑感知映射去压。

另一个容易被忽略的问题是 MPI 进程在物理节点上的摆放。你在代码里看到的 rank 0 和 rank 1 是邻居,但这不代表它们在物理硬件上也是邻居。如果 rank 0 和 rank 1 坐落在同一个节点里,数据交换走共享内存,速度快得惊人;但如果它们被分别分配到了不同机柜、隔着好几层交换机,那一次简单的邻居通信可能就要多出几百微秒延迟。当程序每迭代都要做这种跨交换机通信时,累积下来的开销非常可观。

我在实际项目里处理这个问题,一般会用三层手段:

  • 第一层,在作业提交脚本里用--map-by或类似参数指定进程与硬件拓扑的映射关系,尽量让经常通信的进程组落在同一个节点或同一个 NUMA 域内。
  • 第二层,在程序里避免“逻辑邻居”和“物理邻居”出现交叉映射。比如二维网格划分为 4 × 4 时,rank 编号最好按行主序排列,而不是按蛇形或随机排列。
  • 第三层,如果通信模式比较固定,可以自己构建通信组,把频繁通信的进程放进同一个节点组里,再用 MPI 通信子来管理。

还有一个非常实用的经验:在做大规模测试前,先跑一个纯通信微基准,把不同节点间、不同交换机下的 MPI 延迟测出来。这个数据会告诉你,哪些拓扑布局对当前程序来说是“高于负载均衡本身”的优先级。我见过不止一次,某个程序的负载均衡效率只有 75%,但因为它通信很轻、同步点很少,跑起来反而比一个负载均衡效率 96% 但通信频繁的程序快得多。高性能计算永远要看墙钟时间,不是看单一指标。

6. 别把 HPC 负载均衡和 F5 那套网络负载均衡混为一谈

我注意到很多人搜“负载均衡”时会连带搜到 F5、Nginx 这些东西。作为从业者,我想把这几类容易混淆的东西梳理清楚,因为它们在面试、项目沟通和技术方案评审里经常被摆在一起说,但其实是三个层次的问题。

F5 这类设备,属于网络层的负载均衡,核心工作是分发网络请求。用户把你的服务域名解析到 F5 虚拟 IP 上,F5 按照某种策略(最小连接数、轮询、加权轮询、响应时间等)把 HTTP 请求分发到后端的多台 Web 服务器上。它关心的是连接、请求、会话、健康检查,以及后端服务器的当前连接数。它天然适合无状态或轻状态的服务,比如 Web 页面、API 接口、微服务网关。

而高性能计算里的负载均衡,关心的是“并行程序中计算任务和数据的划分方式”。它针对的是计算内核在进程之间的分配,让所有进程尽量同时完成当前迭代,减少同步等待。典型的工具是 METIS、ParMETIS、Zoltan,以及你自己写在代码里的动态任务调度逻辑。它跟 F5 面对问题的维度完全不同。

还有一个容易被混在一起的概念是作业调度器,比如 SLURM。它做的“均衡”是资源调度层面的:多个用户提交多个作业,调度器决定哪个作业先跑、哪些作业可以回填、哪些节点分给哪个作业。它平衡的是作业队列和集群整体利用率,而不是单个作业内部各进程的完成时间。你通过 SLURM 申请到了节点,不代表程序内部天然就会负载均衡——那依然是你的代码要解决的问题。

我把三者的差异整理成一个表格,方便对照:

维度HPC 计算负载均衡作业调度器(如 SLURM)网络负载均衡(如 F5/Nginx)
均衡对象计算任务、网格分区、数据块整个作业、节点资源、队列网络连接、请求、会话
作用范围单个并行程序内部整个集群多个用户服务入口和后端服务器之间
核心指标各进程完成时间、同步等待集群利用率、排队时间、公平性连接数、响应时间、健康状态
常用工具METIS、ParMETIS、任务队列、动态划分SLURM、PBS、LSFF5、HAProxy、Nginx、LVS
主要代价数据迁移、通信开销、同步开销节点空闲、排队延迟会话保持、健康检查开销

弄清楚这三个层次,对团队协作很有帮助。我参加过一次技术评审,硬件团队说“我们已经用 F5 做了负载均衡”,算法团队说“我们的程序负载均衡有问题”,两边各说各话。原因就是“负载均衡”这个词在不同语境下含义不同。后来我们明确分工:网络入口归 F5 负责,计算资源分配归调度器负责,程序内部的进程负载均衡归并行算法团队负责。这么一拆,所有问题就都能对号入座了。

再说回“等开销负载均衡”这个说法,它在 HPC 里强调的依然是“每个计算进程的完成时间等开销”,跟 F5 的“每台后端服务器连接数接近”并不是同一个“开销”。前者是计算任务维度的时间对齐,后者是网络连接维度的数量均衡。翻译成大白话就是:F5 关注的是“请求怎么分给服务器”,HPC 负载均衡关注的是“算力怎么分给任务块”,两者解决的问题不同,但都叫负载均衡。

7. 跑大规模作业前,我习惯做的一次小检查

最后,分享一套我自己在每次跑大规模作业前都会过一遍的检查清单,虽然不是标准文档,但确实帮我避免过不少次“看起来均衡、跑起来跪了”的情况。

第一,核对自己作业里的“负载指标”到底是什么。是网格点数、粒子数、指令数估计,还是实测耗时的归一化值?如果只是前者,先预留采样阶段,用头几十步的实际耗时绘制一次负载分布图。这个图一旦画出来,很多隐藏问题就藏不住了。

第二,检查 MPI 进程与计算节点之间的映射关系。尤其在大规模多节点作业里,进程拓扑、节点拓扑、通信数据量三者是否匹配。简单的做法是,在作业脚本里打开进程拓扑报告,确认逻辑邻居没有大量落在远端节点上。

第三,在关键循环外加一段可选的计时代码,以便在出现异常时快速定位是哪几个进程在拖时间。不要把所有的耗时都堆在一个总计时器里,至少要分计算、通信、I/O 三块来记录。这样出问题时,能立刻判断是负载不均衡,还是通信拥塞,还是文件系统抖动。

第四,如果程序本身有动态负载均衡机制,确认触发阈值和迁移成本之间的平衡值。过低阈值会导致频繁重分区,过高阈值会让慢进程继续拖,最佳值依赖具体算例,务必用中小规模算例先做一次敏感性测试。

第五,别忘了在正式算例前,把“最坏情况进程”单独拎出来跑一遍性能测试。在我的经验里,那 1% 的最慢进程往往不是计算量最大的,而是内存访问模式最差、通信等待时间最长、或者节点恰好处于热降频状态的。把这些进程的实际耗时拉平,比把 99% 的好进程都调到一样快更能提升整体墙钟表现。

我自己在踩了无数次坑之后,现在看一个并行程序的第一反应不再是“分了多少个任务”,而是“哪个任务最晚结束、为什么”。把这个问题想透,高性能计算负载均衡的很多难题,其实已经解决了一大半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询