记录一次高性能计算代码迁移的经历

发布于

## 实验前提
同一个 CFD 求解器用三种语言各写了一遍:Fortran 90 原版(2.5 万行源代码)、NumPy+Numba 移植、Julia 移植。

关键不是写了三遍,而是约束:0 ULP 位级一致,1500 步迭代后,三种实现的输出文件必须逐字节相同。任何一次浮点舍入的差异都会被迭代放大成可见分歧。这个约束听起来变态,但它带来一个好处:**三者跑的是完全相同的算法、相同的浮点运算序列**。性能差异只能来自语言本身,没有算法不一样的借口。

## 结果
| 语言 | 1 线程 | 8 线程 | 每步耗时(1T) |
| --- | --- | --- | --- |
| Fortran ( gfortran 16.1, -O3 ) | 59.9s | — | 39.9ms |
| **Julia 1.12.6** | **50.8s** | **38.9s** | **33.9ms** |
| Python + Numba 0.65.1 | 96.9s | 65.6s | 64.6ms |

Julia 单线程赢 Fortran 15%,8 线程赢 Numba 8T 达 40.7%。Fortran 代码无多线程实现,Julia 的并行能力是额外红利。Python 即使 Numba 加持、8 线程拉满,仍追不上 Fortran 单线程。

## Julia 为什么能赢 Fortran
Fortran 统治 HPC 六十年,靠的不是历史惯性,而是编译器对数值循环的极致优化。但这次它输了,原因值得分析。

**对比是否公平?** Fortran 和 Julia 都是列主序、1-based 数组,内存访问模式一致,谁也没有转置或跨步的额外成本。

**约束是否公平?** 为了保证 0 ULP,Fortran 编译必须加 `-ffp-contract=off`,禁止把 `a*b+c` 融合成 FMA (FMA 只舍入一次,结果不同)。Julia 默认就不生成 FMA,大家让了同一条胳膊。

**为什么输了?** LLVM 后端的即时优化更懂这颗 CPU。gfortran 的前端优化已经很强,但 Julia 的 JIT 在同一个 LLVM 基础上做了一层运行时特化,配合 `@inbounds` 消掉全部边界检查,在通量组装这类不规则访存循环上生成了更紧凑的代码。15% 的差距不大,但足以说明 Fortran 不可超越已经是上个时代的故事。

## Numba 救了 Python 500 倍,但救不了全部
先看一组惊悚数字,纯 Python 跑这个求解器是 34.4 秒/步,Numba 编译热循环后是 64.6 毫秒/步,500 倍提升,Numba 无愧于 Python 科算生态的救星。

但剩下的 1.9 倍差距(对 Julia)来自三个硬伤:
- **FFI 开销**:0 ULP 要求超越函数逐位复现旧 msvcrt.dll 的实现,Python 只能走 ctypes 调用,每次调用都是一次解释器边界的跨越,而且 ctypes 进不了 Numba 的 njit 内核,含 exp/pow 的循环只能留在纯 Python 层。Julia 的 `ccall` 是零开销原生调用,同一个 msvcrt,两种命运。
- **行主序的诅咒**:Fortran 数据是列主序,NumPy 默认 C-order。要么转置(拷贝成本),要么跨步访问(缓存不友好),二选一都疼。
- **两遍法的隐性税**:为了并行时保持求和顺序不变,面循环要拆成并行写缓冲 + 串行累加两遍。Numba 里缓冲区分配和拷贝的成本比 Julia 高出一截。

## 内存带宽是多线程天花板
两种语言的多线程加速比都算不上好看:Julia 1.31x,Python 1.48x,而且都在 2~4 线程就饱和了。

教科书会告诉你这是 Amdahl 定律,LUSGS 求解器的 Gauss-Seidel 递推占 30%,本质串行。但算一下就知道不对:就算 30% 纯串行,8 线程的理论加速也有 2.5 倍。实测差得远,真正的瓶颈是**内存带宽**。通量循环对流场大数组做流式读写,工作集几十 MB,远超 L3 缓存。一个核就能把 DDR5 双通道吃出大半,八个核只是在排队等数据。

还有个插曲:i7-12700KF 是 P/E 混合架构,我们把 Julia 线程钉到 P 核上,结果反而慢了 12%(45.7s vs 40.4s)——Windows 调度器把线程撒到全部 20 个逻辑核上,反而摊薄了带宽竞争。把调度器从 `:static` 换成 `:dynamic`(让快的核多干点),又白捡 4%。这类微优化的收益因机而异,没有普适答案。

## 结论
**性能天花板**:Julia ≈ Fortran ≫ Python+Numba。新项目写数值内核,Julia 已经是第一梯队的选择,不是 "潜力股"。

**生态互操作**:0 ULP 场景是互操作成本的照妖镜。Julia 的 ccall 零开销直连 Fortran 时代的二进制库,Python 的 ctypes 每次调用都在交税。存量 Fortran 资产越重,这个差距越值钱。

**精确控制浮点行为的能力**:这是本场对比的隐藏主题。要求逐位复现遗产代码时,Julia 给出了一套组合拳:原生 Float32/Float64 互转(复现 `REAL(a)/REAL(b)` 的 f32 商)、默认无 FMA、整数幂自动展开为乘法链、列主序。Python 每一条都要手工绕(`np.float32` 商、libm ctypes、`_powi` 手写),每一条都是 bug 的滋生地。

## 测试环境
硬件与平台:i7-12700KF / Windows 10,所有实现对同一算例 1500 步输出逐字节一致。

编译器版本信息:
- gfortran 16.1 (-O3 -march=native -ffp-contract=off -flto)
- Julia 1.12.6
- Python 3.11 + Numba 0.65.1 (TBB)

测试仅针对本项目而言的,不同项目可能有所差异,理性对比。

---

原文链接:[点击查看](https://www.v2ex.com/t/1229579)

评论

暂无评论。