一、先搞懂:为什么老Fortran代码在超算上跑不动?

很多用Fortran写的老代码,早年可能在普通电脑上跑的好好的,一搬到超级计算机上,要么速度没快多少,要么直接卡成狗。其实核心问题不是超算不够强,是老代码没跟上超算的“脾气”——超算是由一堆性能超强的CPU/显卡(专业点叫“计算节点”)拼起来的,每个节点内部有很多小核心,节点之间还得互相传数据,要是代码不知道怎么把任务拆给这些核心,不知道怎么减少节点间的“传数据时间”,那超算的性能根本发挥不出来。

举个最常见的例子:早年很多人写Fortran代码,都是按“一步一步算”的思路,比如要算100万个点的温度,就从第一个点算到第100万个,一个一个来。但超算有几千个核心,这种“单线程”代码只会用1个核心,剩下的全闲着,速度当然上不去。

1.1 超算的核心性能瓶颈到底是什么?

超算的瓶颈主要分两类: 第一类是“计算节点内部的瓶颈”:比如单个节点里的核心数很多,但核心之间共享内存,要是代码里的变量是乱序访问的,或者有很多重复计算,核心的计算能力就浪费了; 第二类是“节点之间的瓶颈”:要是一个任务拆成多个部分,每个部分要反复从别的节点拿数据,那“传数据的时间”会比“计算的时间”还长,反而更慢。

二、第一步:把代码拆成能并行跑的任务(数据并行)

要让超算跑起来,第一步就是把原来“一个一个算”的任务,拆成多个能同时算的小任务,最常用的思路是“数据并行”——比如把要处理的100万个点,分成1000份,每份1000个点,分给1000个核心同时算。

这里要用到Fortran专门的并行工具,最常用的是OpenMP(专门管单个节点内的多核心并行)和MPI(专门管多个节点之间的并行),我们先从OpenMP开始,这个最简单,适合先改老代码。

2.1 OpenMP的示例:把循环拆给多核心

技术栈:Fortran + OpenMP 先看一段老的Fortran代码,功能是算100万个点的平方和:

program old_code
    implicit none
    integer, parameter :: N = 1000000  ! 要计算的点数
    real :: sum = 0.0
    integer :: i
    ! 循环算每个点的平方,再加到总和里
    do i = 1, N
        sum = sum + i * i
    end do
    print *, "平方和为:", sum
end program old_code

这段代码只有一个核心在跑,要改造成多核心并行,只要加3行OpenMP的指令就行:

program openmp_parallel
    implicit none
    integer, parameter :: N = 1000000
    real :: sum = 0.0
    integer :: i
    ! 加这一行:告诉编译器,下面的循环要拆给多核心跑
    ! 注释说明:!$omp parallel do 是OpenMP的并行指令,必须加在循环前面
    !$omp parallel do reduction(+:sum)  ! reduction(+:sum)是专门处理共享变量的,避免多核心同时改sum出问题
    do i = 1, N
        sum = sum + i * i
    end do
    ! 加这一行:告诉编译器,并行部分结束了
    !$omp end parallel do
    print *, "平方和为:", sum
end program openmp_parallel

这里要注意一个坑:原来的代码里sum是所有核心共用的变量,要是每个核心都随便改sum,会出现“数据冲突”——比如两个核心同时读到sum=10,都加了5,结果都写成15,那本来应该是20的sum就错了。所以用reduction(+:sum)这个指令,让每个核心先算自己那部分的sum,最后再把所有核心的sum加起来,既快又不会错。

2.2 MPI的示例:把任务拆给多个节点

要是任务特别大,比如要算10亿个点,单个节点的核心不够用,就得用MPI把任务拆给多个节点。MPI的核心思路是“每个节点算自己的任务,最后汇总结果”。

技术栈:Fortran + MPI 比如要算10亿个点的平方和,我们可以把10亿个点分成10份,分给10个节点,每个节点算1亿个点,最后把10个节点的结果加起来:

program mpi_parallel
    use mpi  ! 导入MPI的库
    implicit none
    integer, parameter :: N = 1000000000  ! 总点数
    integer :: rank, size, local_N, i_start, i_end, i
    real :: local_sum = 0.0, total_sum = 0.0
    ! 初始化MPI,获取当前节点的编号(rank)和总节点数(size)
    call MPI_Init(ierr)
    call MPI_Comm_rank(MPI_COMM_WORLD, rank, ierr)
    call MPI_Comm_size(MPI_COMM_WORLD, size, ierr)
    ! 把总任务拆给每个节点:每个节点算local_N个点
    local_N = N / size
    i_start = rank * local_N + 1  ! 当前节点的起始点编号
    i_end = (rank + 1) * local_N  ! 当前节点的结束点编号
    ! 要是总点数不能被节点数整除,最后一个节点多算剩下的点
    if (rank == size - 1) then
        i_end = N
    end if
    ! 每个节点算自己那部分的平方和
    do i = i_start, i_end
        local_sum = local_sum + i * i
    end do
    ! 把所有节点的local_sum汇总到总节点(rank=0的节点),得到total_sum
    call MPI_Reduce(local_sum, total_sum, 1, MPI_REAL, MPI_SUM, 0, MPI_COMM_WORLD, ierr)
    ! 只有总节点输出结果
    if (rank == 0) then
        print *, "总平方和为:", total_sum
    end if
    ! 结束MPI
    call MPI_Finalize(ierr)
end program mpi_parallel

这段代码里,每个节点只算自己的那部分任务,节点之间只在最后汇总结果的时候传一次数据,避免了频繁传数据的问题,适合特别大的任务。

三、第二步:优化代码,减少超算的“额外开销”

拆完任务之后,还得优化代码的细节,不然就算拆了任务,速度也上不去。最常见的优化点有两个:减少不必要的计算,减少内存访问的时间。

3.1 优化1:去掉重复计算

很多老代码里会有重复计算的问题,比如同一个公式里的常数,每次循环都算一遍,其实只要算一次就行。比如下面这段代码:

program old_calc
    implicit none
    integer, parameter :: N = 1000000
    real :: a, b, c, x, res
    integer :: i
    a = 2.0
    b = 3.0
    c = 4.0
    do i = 1, N
        x = real(i)
        ! 每次循环都算a*b*c,其实这个值是固定的
        res = a * b * c * x**2 + 5 * x
    end do
end program old_calc

优化之后,把a*b*c算一次就行:

program new_calc
    implicit none
    integer, parameter :: N = 1000000
    real :: a, b, c, const, x, res
    integer :: i
    a = 2.0
    b = 3.0
    c = 4.0
    const = a * b * c  ! 只算一次固定值
    do i = 1, N
        x = real(i)
        res = const * x**2 + 5 * x
    end do
end program new_calc

这个优化看起来小,但要是循环次数特别多,能省很多时间。

3.2 优化2:让内存访问更“顺”

超算的内存访问有个特点:要是代码按“连续的顺序”访问内存,速度会特别快;要是乱序访问,速度会慢很多。比如Fortran里的数组是按“列优先”存储的,也就是说,二维数组a(i,j)里,a(1,1)、a(2,1)、a(3,1)……是连续存在内存里的,而a(1,1)、a(1,2)、a(1,3)……是分开的。

举个例子,下面这段代码是按行访问二维数组的,速度会很慢:

program bad_access
    implicit none
    integer, parameter :: N = 1000
    real :: a(N,N)
    integer :: i, j
    do i = 1, N
        do j = 1, N
            a(i,j) = i + j  ! 按行访问:先i=1,j从1到N;再i=2,j从1到N……
        end do
    end do
end program bad_access

优化之后,改成按列访问,速度会快很多:

program good_access
    implicit none
    integer, parameter :: N = 1000
    real :: a(N,N)
    integer :: i, j
    do j = 1, N
        do i = 1, N
            a(i,j) = i + j  ! 按列访问:先j=1,i从1到N;再j=2,i从1到N……
        end do
    end do
end program good_access

这个优化对超算来说特别重要,有时候能让速度提升好几倍。

四、应用场景、优缺点和注意事项

4.1 应用场景

这种代码重构的方法,适合所有用Fortran写的、计算量特别大的任务,比如:

  • 气象预报:要算全球的温度、气压、风速等,数据量特别大;
  • 流体力学:要算飞机、船舶周围的流体流动,计算量很大;
  • 量子化学:要算分子的结构和性质,需要大量的数值计算;
  • 地震模拟:要模拟地震波的传播,需要处理大量的网格数据。

4.2 技术优缺点

优点:

  1. 充分发挥超算的性能:能把超算的几千个核心都用起来,速度比原来的代码快几十倍甚至上百倍;
  2. 适配超算的架构:不管是单个节点的多核心,还是多个节点的并行,都能覆盖;
  3. 改造成本低:只要在原来的代码上加几个OpenMP的指令,或者调整一下循环的顺序,不用把整个代码重写。

缺点:

  1. 有一定的学习成本:得懂OpenMP和MPI的基本用法,知道怎么避免数据冲突;
  2. 对代码的结构有要求:要是原来的代码结构特别乱,比如有很多全局变量,或者循环之间有依赖,改起来会比较麻烦;
  3. 调优需要时间:得反复测试,找到最优的拆分方式和优化细节,有时候调优的时间比写代码的时间还长。

4.3 注意事项

  1. 先测再改:改代码之前,先测原来的代码的速度,改完之后再测,确保速度真的提升了,而且结果是对的;
  2. 避免过度并行:不是拆的任务越多越好,要是拆的任务太小,“传数据的时间”会超过“计算的时间”,反而更慢;
  3. 注意数据对齐:超算的内存有对齐要求,要是数组的内存地址没对齐,访问速度会很慢,Fortran里可以用!$omp align指令来对齐;
  4. 用超算的优化编译器:超算上的编译器(比如Intel Fortran编译器)有专门的优化选项,能自动优化代码,比如-O3选项能打开最高级别的优化。

五、文章总结

把老的Fortran代码改造成适配超算的并行代码,核心思路就是两步:先把任务拆成多个能同时跑的小任务(用OpenMP管单个节点,用MPI管多个节点),再优化代码的细节(去掉重复计算,让内存访问更顺)。

这个过程不用把整个代码重写,只要在原来的代码上做小的修改,就能让速度提升很多,适合各种计算量大的任务。不过要注意,改代码之前一定要先测结果的正确性,避免改完之后结果错了,同时要根据任务的大小,选择合适的并行方式,不要过度并行。