Fortran 编译器真的好多少?

计算科学 正则 C 布拉斯 基准测试
2021-12-04 19:24:50

这个问题是最近在“ C++ vs Fortran for HPC ”的回复中提出的两个讨论的延伸。这更像是一个挑战而不是一个问题......

支持 Fortran 的最常听到的论点之一是编译器更好。由于大多数 C/Fortran 编译器共享相同的后端,因此为两种语言的语义等效程序生成的代码应该相同。然而,有人可能会争辩说,C/Fortran 对编译器来说更容易优化。

所以我决定尝试一个简单的测试:我得到一份daxpy.fdaxpy.c的副本,并用 gfortran/gcc 编译它们。

现在 daxpy.c 只是 daxpy.f 的 f2c 翻译(自动生成的代码,丑陋),所以我拿了那个代码并清理了一点(见 daxpy_c),这基本上意味着将最里面的循环重写为

for ( i = 0 ; i < n ; i++ )
    dy[i] += da * dx[i];

最后,我使用 gcc 的向量语法重写了它(输入 daxpy_cvec):

#define vector(elcount, type)  __attribute__((vector_size((elcount)*sizeof(type)))) type
vector(2,double) va = { da , da }, *vx, *vy;

vx = (void *)dx; vy = (void *)dy;
for ( i = 0 ; i < (n/2 & ~1) ; i += 2 ) {
    vy[i] += va * vx[i];
    vy[i+1] += va * vx[i+1];
    }
for ( i = n & ~3 ; i < n ; i++ )
    dy[i] += da * dx[i];

请注意,我使用长度为 2 的向量(这是 SSE2 允许的所有向量)并且我一次处理两个向量。这是因为在许多架构上,我们可能拥有比向量元素更多的乘法单元。

所有代码均使用带有标志“-O3 -Wall -msse2 -march=native -ffast-math -fomit-frame-pointer -malign-double -fstrict-aliasing”的 gfortran/gcc 4.5 版编译。在我的笔记本电脑(Intel Core i5 CPU,M560,2.67GHz)上,我得到以下输出:

pedro@laika:~/work/fvsc$ ./test 1000000 10000
timing 1000000 runs with a vector of length 10000.
daxpy_f took 8156.7 ms.
daxpy_f2c took 10568.1 ms.
daxpy_c took 7912.8 ms.
daxpy_cvec took 5670.8 ms.

所以原始的 Fortran 代码需要 8.1 秒多一点,其自动翻译需要 10.5 秒,天真的 C 实现在 7.9 中完成,而显式矢量化代码在 5.6 中完成,略少。

这就是 Fortran 比简单的 C 实现稍慢,比矢量化 C 实现慢 50%。

所以这里有一个问题:我是一个本地 C 程序员,所以我很有信心我在那个代码上做得很好,但是 Fortran 代码最后一次被触及是在 1993 年,因此可能有点过时了。由于我不像这里的其他人那样在 Fortran 中编码感到自在,任何人都可以做得更好,即与两个 C 版本中的任何一个相比更具竞争力吗?

另外,任何人都可以使用 icc/ifort 进行此测试吗?矢量语法可能不起作用,但我很想知道天真的 C 版本在那里的行为。周围有 xlc/xlf 的任何人也是如此。

我在这里上传了源代码和 Makefile 。要获得准确的计时,请将 test.c 中的 CPU_TPS 设置为 CPU 上的 Hz 数。如果您发现任何版本的任何改进,请在此处发布!

更新:

我已经将 stali 的测试代码添加到在线文件中,并用 C 版本对其进行了补充。我修改了程序以在长度为 10'000 的向量上执行 1'000'000 循环,以与之前的测试一致(并且因为我的机器无法分配长度为 1'000'000'000 的向量,就像在 sali 的原始代码)。由于数字现在有点小,我使用该选项-par-threshold:50使编译器更有可能并行化。使用的icc/ifort版本是12.1.2 20111128,结果如下

pedro@laika:~/work/fvsc$ OMP_NUM_THREADS=1 time ./icctest_c
3.27user 0.00system 0:03.27elapsed 99%CPU

pedro@laika:~/work/fvsc$ OMP_NUM_THREADS=1 time ./icctest_f
3.29user 0.00system 0:03.29elapsed 99%CPU

pedro@laika:~/work/fvsc$ OMP_NUM_THREADS=2 time ./icctest_c
4.89user 0.00system 0:02.60elapsed 188%CPU

pedro@laika:~/work/fvsc$ OMP_NUM_THREADS=2 time ./icctest_f
4.91user 0.00system 0:02.60elapsed 188%CPU

总之,就所有实际目的而言,C 和 Fortran 版本的结果是相同的,并且两个代码都自动并行化。请注意,与之前的测试相比,速度更快是由于使用了单精度浮点运算!

更新:

虽然我不太喜欢举证责任在哪里,但我已经用 C 重新编码了 stali 的矩阵乘法示例并将其添加到web上的文件中。以下是一个和两个 CPU 的三重循环的结果:

pedro@laika:~/work/fvsc$ OMP_NUM_THREADS=1 time ./mm_test_f 2500
 triple do time   3.46421700000000     
3.63user 0.06system 0:03.70elapsed 99%CPU

pedro@laika:~/work/fvsc$ OMP_NUM_THREADS=1 time ./mm_test_c 2500
triple do time 3.431997791385768
3.58user 0.10system 0:03.69elapsed 99%CPU

pedro@laika:~/work/fvsc$ OMP_NUM_THREADS=2 time ./mm_test_f 2500
 triple do time   5.09631900000000     
5.26user 0.06system 0:02.81elapsed 189%CPU

pedro@laika:~/work/fvsc$ OMP_NUM_THREADS=2 time ./mm_test_c 2500
triple do time 2.298916975280899
4.78user 0.08system 0:02.62elapsed 184%CPU

请注意,cpu_time在 Fortran 中测量的是 CPU 时间而不是挂钟时间,因此我将调用包装起来time以比较 2 个 CPU 的情况。结果之间没有真正的区别,只是 C 版本在两个内核上的表现要好一些。

现在对于matmul命令,当然仅在 Fortran 中,因为此内在函数在 C 中不可用:

pedro@laika:~/work/fvsc$ OMP_NUM_THREADS=1 time ./mm_test_f 2500
 matmul    time   23.6494780000000     
23.80user 0.08system 0:23.91elapsed 99%CPU

pedro@laika:~/work/fvsc$ OMP_NUM_THREADS=2 time ./mm_test_f 2500
 matmul    time   26.6176640000000     
26.75user 0.10system 0:13.62elapsed 197%CPU

哇。这绝对是可怕的。谁能找出我做错了什么,或者解释为什么这个内在仍然是一件好事?

我没有将dgemm调用添加到基准测试中,因为它们是对英特尔 MKL 中相同函数的库调用。

对于未来的测试,任何人都可以提出一个已知在 C 中比在 Fortran 中慢的示例吗?

更新

为了验证 stali 的说法,即matmul内在函数在较小矩阵上比显式矩阵乘积快“一个数量级”,我修改了他自己的代码,使用这两种方法将大小为 100x100 的矩阵相乘,每种方法 10'000 次。一个和两个 CPU 上的结果如下:

pedro@laika:~/work/fvsc$ OMP_NUM_THREADS=1 time ./mm_test_f 10000 100
 matmul    time   3.61222500000000     
 triple do time   3.54022200000000     
7.15user 0.00system 0:07.16elapsed 99%CPU

pedro@laika:~/work/fvsc$ OMP_NUM_THREADS=2 time ./mm_test_f 10000 100
 matmul    time   4.54428400000000     
 triple do time   4.31626900000000     
8.86user 0.00system 0:04.60elapsed 192%CPU

更新

Grisu 正确地指出,在没有优化的情况下,gcc 将复数运算转换为库函数调用,而 gfortran 将它们内联在几条指令中。

如果设置了该选项,C 编译器将生成相同的紧凑代码-fcx-limited-range,即指示编译器忽略中间值中潜在的上溢/下溢。此选项在 gfortran 中默认以某种方式设置,可能会导致不正确的结果。强制-fno-cx-limited-rangegfortran 并没有改变任何东西。

所以这实际上是反对使用 gfortran 进行数值计算的一个论点:即使正确的结果在浮点范围内,对复数值的操作也可能溢出/下溢。这实际上是一个 Fortran 标准。在 gcc 或一般的 C99 中,除非另有说明,否则默认是严格执行操作(阅读 IEEE-754 兼容)。

提醒:请记住,主要问题是 Fortran 编译器是否比 C 编译器生成更好的代码。这里不是讨论一种语言相对于另一种语言的一般优点的地方。我真正感兴趣的是,是否有人可以找到一种方法来哄骗 gfortran 使用显式矢量化生成与 C 中的 daxpy 一样高效,因为这说明了必须专门依赖编译器进行 SIMD 优化的问题,或者Fortran 编译器胜过 C 对应的情况。

4个回答

您的时间差异似乎是由于手动展开单位步幅 Fortran daxpy以下时序是在 2.67 GHz Xeon X5650 上,使用命令

./test 1000000 10000

英特尔 11.1 编译器

Fortran 手动展开:8.7 秒
Fortran 无手动展开:5.8 秒
C 无手动展开:5.8 秒

GNU 4.1.2 编译器

Fortran 手动展开:8.3 秒
Fortran 不带手动展开:13.5 秒
C 不带手动展开:13.6 秒
C 带矢量属性:5.8 秒

GNU 4.4.5 编译器

Fortran 手动展开:8.1 秒
Fortran 不带手动展开:7.4 秒
C 不带手动展开:8.5 秒
C 带矢量属性:5.8 秒

结论

  • 手动展开有助于此架构上的 GNU 4.1.2 Fortran 编译器,但会损害较新版本 (4.4.5) 和 Intel Fortran 编译器。
  • GNU 4.4.5 C 编译器与 Fortran 相比,与 4.2.1 版本相比更具竞争力。
  • 矢量内在函数允许 GCC 性能与英特尔编译器相匹配。

是时候测试更复杂的例程了,比如 dgemv 和 dgemm?

我参加这个聚会迟到了,所以我很难从上面来回跟踪。这个问题很大,我认为如果您有兴趣,可以将其分解为更小的部分。我感兴趣的一件事就是你的daxpy变体的性能,以及在这个非常简单的代码上,Fortran 是否比 C 慢。

在我的笔记本电脑(Macbook Pro、Intel Core i7、2.66 GHz)上运行,您的手动矢量化 C 版本和非手动矢量化 Fortran 版本的相对性能取决于使用的编译器(使用您自己的选项):

Compiler     Fortran time     C time
GCC 4.6.1    5408.5 ms        5424.0 ms
GCC 4.5.3    7889.2 ms        5532.3 ms
GCC 4.4.6    7735.2 ms        5468.7 ms

因此,似乎 GCC 在 4.6 分支中的循环向量化方面比以前更好了。


在整体辩论中,我认为几乎可以用 C 和 Fortran 编写快速和优化的代码,几乎就像用汇编语言一样。但是,我要指出一件事:就像汇编程序比 C 编写起来更繁琐,但可以让您更好地控制 CPU 执行的内容一样,C 比 Fortran 更底层。因此,它使您可以更好地控制细节,这有助于优化 Fortran 标准语法(或其供应商扩展)可能缺乏功能的地方。一种情况是显式使用向量类型,另一种是手动指定变量对齐的可能性,这是 Fortran 无法做到的。

我在 Fortran 中编写 AXPY 的方式略有不同。这是数学的精确翻译。

m_blas.f90

 module blas

   interface axpy
     module procedure saxpy,daxpy
   end interface

 contains

   subroutine daxpy(x,y,a)
     implicit none
     real(8) :: x(:),y(:),a
     y=a*x+y
   end subroutine daxpy

   subroutine saxpy(x,y,a)
     implicit none
     real(4) :: x(:),y(:),a
     y=a*x+y
   end subroutine saxpy

 end module blas

现在让我们在程序中调用上述例程。

测试.f90

 program main

   use blas
   implicit none

   real(4), allocatable :: x(:),y(:)
   real(4) :: a
   integer :: n

   n=1000000000
   allocate(x(n),y(n))
   x=1.0
   y=2.0
   a=5.0
   call axpy(x,y,a)
   deallocate(x,y)

 end program main

现在让我们编译并运行它...

login1$ ifort -fast -parallel m_blas.f90 test.f90
ipo: remark #11000: performing multi-file optimizations
ipo: remark #11005: generating object file /tmp/ipo_iforttdqZSA.o

login1$ export OMP_NUM_THREADS=1
login1$ time ./a.out 
real    0 m 4.697 s
user    0 m 1.972 s
sys     0 m 2.548 s

login1$ export OMP_NUM_THREADS=2
login1$ time ./a.out 
real    0 m 2.657 s
user    0 m 2.060 s
sys     0 m 2.744 s

请注意,我没有使用任何循环或任何显式OpenMP指令。这在 C 中是否可行(即不使用循环和自动并行化)?我不使用C所以我不知道。

我认为,编译器如何为现代硬件优化代码不仅有趣。尤其是在 GNU C 和 GNU Fortran 之间,代码生成可能非常不同。

因此,让我们考虑另一个示例来显示它们之间的差异。

使用复数,GNU C 编译器对复数进行几乎非常基本的算术运算会产生很大的开销。Fortran 编译器提供了更好的代码。让我们看一下 Fortran 中的以下小示例:

COMPLEX*16 A,B,C
C=A*B

给出(gfortran -g -o complex.fo -c complex.f95; objdump -d -S complex.fo):

C=A*B
  52:   dd 45 e0                fldl   -0x20(%ebp)
  55:   dd 45 e8                fldl   -0x18(%ebp)
  58:   dd 45 d0                fldl   -0x30(%ebp)
  5b:   dd 45 d8                fldl   -0x28(%ebp)
  5e:   d9 c3                   fld    %st(3)
  60:   d8 ca                   fmul   %st(2),%st
  62:   d9 c3                   fld    %st(3)
  64:   d8 ca                   fmul   %st(2),%st
  66:   d9 ca                   fxch   %st(2)
  68:   de cd                   fmulp  %st,%st(5)
  6a:   d9 ca                   fxch   %st(2)
  6c:   de cb                   fmulp  %st,%st(3)
  6e:   de e9                   fsubrp %st,%st(1)
  70:   d9 c9                   fxch   %st(1)
  72:   de c2                   faddp  %st,%st(2)
  74:   dd 5d c0                fstpl  -0x40(%ebp)
  77:   dd 5d c8                fstpl  -0x38(%ebp)

这是 39 字节的机器码。当我们在 C 中考虑相同时

 double complex a,b,c; 
 c=a*b; 

并查看输出(以与上面相同的方式完成),我们得到:

  41:   8d 45 b8                lea    -0x48(%ebp),%eax
  44:   dd 5c 24 1c             fstpl  0x1c(%esp)
  48:   dd 5c 24 14             fstpl  0x14(%esp)
  4c:   dd 5c 24 0c             fstpl  0xc(%esp)
  50:   dd 5c 24 04             fstpl  0x4(%esp)
  54:   89 04 24                mov    %eax,(%esp)
  57:   e8 fc ff ff ff          call   58 <main+0x58>
  5c:   83 ec 04                sub    $0x4,%esp
  5f:   dd 45 b8                fldl   -0x48(%ebp)
  62:   dd 5d c8                fstpl  -0x38(%ebp)
  65:   dd 45 c0                fldl   -0x40(%ebp)
  68:   dd 5d d0                fstpl  -0x30(%ebp)

这也是 39 字节的机器代码,但功能步骤 57 引用的功能是完成工作的正确部分并执行所需的操作。所以我们有 27 字节的机器码来运行多操作。后面的函数是 muldc3 提供的libgcc_s.so,在机器代码中占用了 1375 字节。这会显着减慢代码速度,并在使用分析器时提供有趣的输出。

当我们实现上面的 BLAS 示例zaxpy并执行相同的测试时,Fortran 编译器应该比 C 编译器提供更好的结果。

(我在这个实验中使用了 GCC 4.4.3,但我注意到其他 GCC 发布的这种行为。)

所以在我看来,当我们考虑哪个是更好的编译器时,我们不仅要考虑并行化和矢量化,我们还必须看看基本的东西是如何翻译成汇编代码的。如果这种翻译给出了错误的代码,那么优化只能使用这些东西作为输入。