ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

高性能计算负载均衡:从等开销到动态调优的工程实践

高性能计算负载均衡:从等开销到动态调优的工程实践 凌晨一点半我提交的一个 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% 的好进程都调到一样快更能提升整体墙钟表现。我自己在踩了无数次坑之后现在看一个并行程序的第一反应不再是“分了多少个任务”而是“哪个任务最晚结束、为什么”。把这个问题想透高性能计算负载均衡的很多难题其实已经解决了一大半。
返回列表