一、先搞懂:为什么老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 技术优缺点
优点:
- 充分发挥超算的性能:能把超算的几千个核心都用起来,速度比原来的代码快几十倍甚至上百倍;
- 适配超算的架构:不管是单个节点的多核心,还是多个节点的并行,都能覆盖;
- 改造成本低:只要在原来的代码上加几个OpenMP的指令,或者调整一下循环的顺序,不用把整个代码重写。
缺点:
- 有一定的学习成本:得懂OpenMP和MPI的基本用法,知道怎么避免数据冲突;
- 对代码的结构有要求:要是原来的代码结构特别乱,比如有很多全局变量,或者循环之间有依赖,改起来会比较麻烦;
- 调优需要时间:得反复测试,找到最优的拆分方式和优化细节,有时候调优的时间比写代码的时间还长。
4.3 注意事项
- 先测再改:改代码之前,先测原来的代码的速度,改完之后再测,确保速度真的提升了,而且结果是对的;
- 避免过度并行:不是拆的任务越多越好,要是拆的任务太小,“传数据的时间”会超过“计算的时间”,反而更慢;
- 注意数据对齐:超算的内存有对齐要求,要是数组的内存地址没对齐,访问速度会很慢,Fortran里可以用
!$omp align指令来对齐; - 用超算的优化编译器:超算上的编译器(比如Intel Fortran编译器)有专门的优化选项,能自动优化代码,比如
-O3选项能打开最高级别的优化。
五、文章总结
把老的Fortran代码改造成适配超算的并行代码,核心思路就是两步:先把任务拆成多个能同时跑的小任务(用OpenMP管单个节点,用MPI管多个节点),再优化代码的细节(去掉重复计算,让内存访问更顺)。
这个过程不用把整个代码重写,只要在原来的代码上做小的修改,就能让速度提升很多,适合各种计算量大的任务。不过要注意,改代码之前一定要先测结果的正确性,避免改完之后结果错了,同时要根据任务的大小,选择合适的并行方式,不要过度并行。
评论
围绕“Fortran并行计算架构优化:适配超级计算机性能瓶颈的代码重构思路”参与讨论