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

实验前提 同一个 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 救了 Pyton 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)

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