返回博客

从 443 秒到 37 秒:一次 WRF 在 128 核集群上的性能优化

Lazybones Follow 0 2026年10月23日 经验总结 40 次阅读
HPC

从 443 秒到 37 秒:一次 WRF 在 128 核集群上的性能优化

这不是一篇“调几个编译参数把程序跑快”的文章。真正把 WRF 从 443 秒压到 37 秒的,不是某个神奇的 flag,而是三件事:先把测量做准,再把配置查全,最后才轮到编译器。后者只贡献了 16.4%,而且已经接近它的理论上限。

起点:443 秒并不是一个公平的基线

任务是在一台 128 核的 Xeon 集群上跑 WRF,评分的指标是单次运行时间。拿到机器时,程序能跑通,但一次运行要 443 秒。直觉会让人去翻编译选项,但第一步其实应该是确认:这个 443 秒里,有多少是“程序本来就这么慢”,有多少是“配置错了”。

后来的数据说明,443 秒里绝大部分属于后者。把 OMP_NUM_THREADS 显式设为 1 之后,同一个二进制从 443 秒降到 85 秒,单这一项就是 5.2 倍。原因并不复杂:WRF 的 wrf.exe 在编译时静态链接了 OpenMP runtime,ldd wrf.exe | grep omp 能看到 libgomp;此时如果 MPI 已经按核数铺满进程,每个进程又默认拉起和核数一样多的 OMP 线程,就会产生严重的线程超订。机器看起来 128 核全忙,实际大量时间耗在上下文切换和同步上。

一个经验:凡是“能跑,但慢得离谱”的 HPC 程序,先做配置体检,再谈编译器优化。 编译器优化有上限,错误配置没有。

先把测量做准,再谈优化

这次任务里最贵的一课不是某个参数,而是一次测量事故。

同一个 LTO 二进制,在共享负载下测出 227 秒,在空闲系统上测出 37 秒——6 倍误差。如果只看那次 227 秒,结论会是“LTO 导致崩溃或严重退化”,从而彻底放弃一条正确的优化路径。还有一次,同一天内同一配置从 36 秒漂到 42 秒,单次测量把热漂移误读成了配置差异。

所以我们把测量协议本身当成第一生产力,分成四级:

级别 协议 分辨率 能分辨的差异
L1 单次测量 ±10% 只能看出 2 倍级差异
L2 3 次均值 ±5% 20% 级差异
L3 A/B 交叉、交替、冷却 ±2% 10% 级差异
L4 L3 + 负载守卫 + 空闲判定 ±1% 37 秒上 2–3 秒的差异

最后能下结论的,都是 L4 级别的数据。具体做法很朴素:每次 benchmark 前记录 load average,负载高于阈值就等;A/B 两组交替跑,而不是先跑完 A 再跑 B;对同一配置至少重复三次,报中位数而不是最好成绩。

这一步听起来“不产出结果”,但它决定了后面所有结论是事实还是掷骰子。

收益瀑布:配置层碾压编译器层

把整个优化过程按时间顺序拆开,收益结构非常清楚:

443s ──[OMP 超订修复]──> 85s    (5.2x,单点最大收益)
 85s ──[负载环境修正]──> 59s    (不是优化,是从共享负载回到空闲系统)
 59s ──[PGO]──────────> 55.3s  (-6.3%)
55.3s ──[IO 格式修复]──> 43s    (-22.2%)
 43s ──[全程序 LTO]────> 37s    (-14.0%)

这张表里有两个容易误读的地方。

第一,85 秒到 59 秒不是优化。那只是把测量从“别人也在跑”的环境换到了空闲系统。做性能记录时必须把这类“环境修正”单独标出来,否则会高估自己的优化贡献。

第二,443 秒到 85 秒的 5.2 倍,不应被当成“优化技巧”来炫耀。它更像一次配置事故的止损:程序本来就不该在超订状态下跑。真正公平的对比,是空闲基线 59 秒到最终 37 秒,约 1.6 倍;以及与同机最好的 GCC 构建 55.8 秒相比,最终版本快约 34%。

从 59 秒空闲基线起算,收益可以分成两层:

层级 项目 收益
配置/构建层 IO 格式修复 12.3s(20.8%)
配置/构建层 OMP 修复(全链路看) 358s(5.2x)
配置/构建层 numtiles + 进程数 约 5–15%
编译器层 PGO + LTO 9.7s(16.4%)
系统层 THP / NUMA balancing 约 3s(需 sudo)

结论是:在 HPC 性能工程里,发现一个错误配置的价值,通常比反复调编译参数高一个数量级。

五个关键转折

1. OMP 超订:一个环境变量,5.2 倍

这是全链路最大的单点收益,也是最没有技术含量的一项。判断方法很简单:

ldd wrf.exe | grep omp
# 有输出,说明二进制带 OpenMP runtime

纯 MPI 运行时,必须显式设置 OMP_NUM_THREADS=1。如果不设,OpenMP 会按核心数创建线程,和 MPI 进程数相乘,形成超订。修复后 443 秒降到 85 秒。

2. PGO:6.3%,但别高估

PGO(Profile-Guided Optimization)用一次代表性运行的 profile 指导分支布局和函数内联。26 个模块插桩、训练、再用 -fprofile-use 重编译,收益 6.3%。它有效,但远没有“重新编译一遍就能翻倍”那么夸张。

3. netCDF4 压缩输出:隐藏的 22%

这是最隐蔽的一项。WRF 默认输出 netCDF4 压缩格式,看起来“更省磁盘”,但每次写 wrfout 都要做压缩,IO 打包和写盘时间显著上升。把输出改成 classic(64-bit offset)后,55.3 秒降到 43 秒,收益 22.2%,比 PGO 还大。

验证方法:

ncdump -k wrfout_d01_*
# 期望看到 "64-bit offset" 或 "classic",而不是 "netCDF-4"

如果赛题没有强制要求 netCDF4,这就是一笔白捡的收益;如果强制要求,就只能接受它,并从别处找补。

4. 全程序 LTO:14%,且和 PGO 不叠加

在 43 秒基础上开启全程序 LTO,最终到 37 秒,收益 14%。这里有一个反直觉的发现:LTO + PGO 的组合(38 秒)几乎等于纯 LTO(37 秒)。也就是说,两者的优化空间高度重叠——LTO 的全程序分析已经覆盖了 PGO 能带来的大部分分支布局收益。

组合优化之前,先测单项天花板。 否则很容易把“没有叠加”误判成“组合配置有问题”,在一个已经被覆盖的方向上反复投入。

5. 系统层:THP / NUMA 只有 3 秒

透明大页和 NUMA balancing 的调整确实有效,但需要 sudo,收益约 3 秒,而且在共享集群上不一定允许。它属于“有余力再做”的层级,不应该排在配置体检之前。

为什么“MPI 等待 30%”不能直接优化

perf 采样显示 impi_pause 占 30% 左右,直觉会认为“优化 MPI 通信可以省 30%”。但这是典型的等待者谬误:

  • 等待是负载不平衡的投影:快进程在同步点空转,等最慢的进程;
  • 总时长由最慢的进程决定,减少等待者的空转时间不改变总时长;
  • 我们实测了分解形状、PIN、SHM 传输、SPIN 等所有可配置方向,全部没有收益;
  • 唯一可能有效的动态负载均衡,需要源码级改造。

判断标准不是“这个热点占比多少”,而是“它是否在关键路径上”。等待者不在关键路径上,最慢的那个进程才在。

编译器优化的天花板

perf 反推的时间构成大致如下:

计算核心      ~6s    (可被编译器优化,已优化)
IO(打包+写) ~5s    (classic 已最优)
内存操作      ~3s    (LTO 已向量化)
辐射等物理    ~4s    (不可配置)
MPI 等待     ~11s    (负载不平衡,源码级才行)
其他          ~8s

计算核心只占 15–20%,所以编译器优化的理论上限大约是“计算核心占比 × 压缩效率”。实测 PGO + LTO 合计 16.4%,已经贴近这个上限。

当热点分析显示计算核心占比低于 30% 时,继续调编译器 flag 的收益上限就在那里。这时候正确的动作不是继续调,而是承认结构极限。

并行参数都是倒 U 形

进程数、numtiles、线程数,没有一个“越多越好”:

参数 左侧(太小) 甜点 右侧(太大)
MPI 进程数 计算不足 250–500 格点/进程 通信/打包主导
numtiles IO 串行 2(小网格) tile 通信主导
线程数 并行不足 1(小网格) fork-join 开销主导

跨任务验证也支持这个规律:100×100 网格的最佳进程数是 40,170×130 网格是 48。网格沿轴放大 2.2 倍,进程数只从 40 调到 48,因为甜点的物理量是“每进程格点数”,不是进程数本身。

迁移口诀:沿网格轴缩放进程数,其余照搬,然后 A/B 验证。

一份可复用的 10 项体检查表

任何新的 WRF 部署,先按这个顺序过一遍:

# 检查项 判据/命令 不通过的代价
1 OMP_NUM_THREADS 已设置 ldd wrf.exe \| grep omp 有输出则必须设 =1 -81%(5.2x)
2 输出格式为 classic ncdump -k wrfout = "64-bit offset" -22%(+17s)
3 未设置 I_MPI_PIN_ORDER=compact env \| grep I_MPI_PIN -14%
4 未将进程绑到单 NUMA 节点 numactl --show,无 cpunodebind -60%
5 numtiles 在 &domains 组 grep namelist 启动失败
6 进程数在甜点区 约 250–500 格点/进程 -10~30%
7 构建与链接工具链匹配 LTO 需 llvm-ar;file *.o 链接失败/无 LTO
8 未启用未插桩模块的 PGO 检查 -fprofile-use 范围 -3~5%
9 混合模式已评估 小网格禁 MPI×OMP -200%+
10 测量系统空闲 before-run load < 4 污染(±45%)

这张表的顺序是按“发现收益/成本比”排的,不是按技术含量排的。第 1 项和第 2 项加起来就能解释 443 秒里的大部分差距。

什么时候该停

最终 37 秒的时间构成里,MPI 等待约 11 秒,属于负载不平衡;IO 约 5 秒,classic 已经最优;辐射等物理约 4 秒,不可配置。理论可达下限大约 27–30 秒,还需要源码级改造。

当前 37 秒距离这个极限约 20–25%。当剩余空间小于 25%,且全部位于结构极限类时,停止优化是正确决策。继续投入的方向应该是比赛策略(系统级调优还能拿 3–8%)或者等待新硬件,而不是继续挖掘配置。

被固化的负结果

这次任务里最有价值的不只是“什么有效”,还有“什么无效”:

尝试 结论
-mprefer-vector-width=512、对齐、prefetch、SVML、fast-math 无收益,编译器参数已饱和
numtiles 4–32、进程 16/64、MPI×OMP 混合 更慢,甜点位已确认
分解形状全扫描、SHM/PIN/SPIN 调优 无收益,MPI 等待不可配置优化
LTO + PGO、PGO + SVML 组合 无叠加,收益重叠
THP / NUMA balancing +3s,需 sudo,唯一的环境级余量

把这些负结果写进文档,和正结果同等重要。它们的价值是“决策节约”:让后来者用五分钟避开五小时。

小结

这次优化的主线可以压缩成五步:

  1. 配置正确性审查(10 项体检)——最高 ROI,先做;
  2. 构建优化(激进参数 → LTO/PGO)——16% 天花板;
  3. 并行参数扫描(进程/numtiles,A/B 协议)——5–15%;
  4. 系统级调优(THP/NUMA/pin,需 sudo)——0–8%;
  5. 热点诊断(perf 确认结构极限)——决定“何时停止”。

最后再强调一次:先测量,再优化。 在共享集群上,测量协议不是辅助工作,它就是第一生产力。级别不够时,所有结论都是掷骰子。

完整脚本、体检表和负结果清单整理在 wrf-hpc-optimizer。它现在也可以作为 Agent Skill 直接加载,但即使不用 Agent,里面的 Bash 脚本和检查表也可以独立使用。

评论 (0)

暂无评论,来说点什么吧。

登录 后可以发表评论