从 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,唯一的环境级余量 |
把这些负结果写进文档,和正结果同等重要。它们的价值是“决策节约”:让后来者用五分钟避开五小时。
小结
这次优化的主线可以压缩成五步:
- 配置正确性审查(10 项体检)——最高 ROI,先做;
- 构建优化(激进参数 → LTO/PGO)——16% 天花板;
- 并行参数扫描(进程/numtiles,A/B 协议)——5–15%;
- 系统级调优(THP/NUMA/pin,需 sudo)——0–8%;
- 热点诊断(perf 确认结构极限)——决定“何时停止”。
最后再强调一次:先测量,再优化。 在共享集群上,测量协议不是辅助工作,它就是第一生产力。级别不够时,所有结论都是掷骰子。
完整脚本、体检表和负结果清单整理在 wrf-hpc-optimizer。它现在也可以作为 Agent Skill 直接加载,但即使不用 Agent,里面的 Bash 脚本和检查表也可以独立使用。