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

> 作者: Lazybones | 日期: 2026-10-22

# 从 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；对同一配置至少重复三次，报中位数而不是最好成绩。

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

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

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

```text
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 倍

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

```bash
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 还大。

验证方法：

```bash
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 反推的时间构成大致如下：

```text
计算核心      ~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](https://github.com/LiaoZiqi-GZFLS/wrf-hpc-optimizer)。它现在也可以作为 Agent Skill 直接加载，但即使不用 Agent，里面的 Bash 脚本和检查表也可以独立使用。