第44章 C99标准的受限指针

在某些情况下,用FORTRAN系统编译出来的程序会比用C/C++系统编译出来的程序运行得更快。例如,下面这个例子就是如此:

void f1 (int* x, int* y, int* sum, int* product, int* sum_product, int* update_me, size_t s)
{
          for (int i=0; i<s; i++)
          {
                   sum[i]=x[i]+y[i];
                   product[i]=x[i]*y[i];
                   update_me[i]=i*123; // some dummy value
                   sum_product[i]=sum[i]+product[i];
          };
};

这个程序的功能十分简单,但是里面的指针问题却发人深思:同一块内存可以由多个指针来访问,因此同一个地址的数据可能会被多个指针轮番复写。至少现行标准并不禁止这种情况。

C语言编译器完全允许上述情况。因此,它分四个阶段处理每次迭代的各类数组:

第四个阶段是否存在进一步优化的空间呢?既然前面已经计算好了sum[i]和product[i],那么后面我们应该就不必再从内存中读取它们的值了。

答案是肯定的。

只是编译器本身并不能在第三个阶段确定前两个阶段的赋值没有被其他指令覆盖。换而言之,因为编译器不能判断该程序里是否存在指向相同内存区域的指针——即“指针别名(pointer aliasing)”,所以编译器不能确保该指针指向的内存没被改写。

C99标准中的受限指针[ISO07,6.7.3节](部分文献又称“严格别名”)的应运而生。编程人员可通过受限指针的strict修饰符向编译器承诺:被该关键字标记的指针是操作相关内存区域的唯一指针,没有其他指针重复指向这个指针所操作的内存区域。

用更为确切、更为正式的语言来说,关键字“restrict”表示该指针是访问既定对象的唯一指针,其他指针都不会重复操作既定对象。从另一个角度来看,一旦某个指针被标记为受限指针,那么编译器就认定既定对象只会被指定的受限指针操作。

下面我们将为每个指针都增加上restrict修饰符:

void f2 (int* restrict x, int* restrict y, int* restrict sum, int* restrict product, int* ↙
    ↘ restrict sum_product,
          int* restrict update_me, size_t s)
{
          for (int i=0; i<s; i++)
          {
                   sum[i]=x[i]+y[i];
                   product[i]=x[i]*y[i];
                   update_me[i]=i*123; // some dummy value
                   sum_product[i]=sum[i]+product[i];
          };
};

我们看到的结果如下所示。

指令清单44.1 x64下的GCC函数f1()

f1:
         push   r15 r14 r13 r12 rbp rdi rsi rbx
         mov    r13, QWORD PTR 120[rsp]
         mov    rbp, QWORD PTR 104[rsp]
         mov    r12, QWORD PTR 112[rsp]
         test   r13, r13
         je     .L1
         add    r13, 1
         xor    ebx, ebx
         mov    edi, 1
         xor    r11d, r11d
         jmp    .L4
.L6:
          mov   r11, rdi
          mov   rdi, rax
.L4:
         lea    rax, 0[0+r11*4]
         lea    r10, [rcx+rax]
         lea    r14, [rdx+rax]
         lea    rsi, [r8+rax]
         add    rax, r9
         mov    r15d, DWORD PTR [r10]
         add    r15d, DWORD PTR [r14]
         mov    DWORD PTR [rsi], r15d         ; store to sum[]
         mov    r10d, DWORD PTR [r10]
         imul   r10d, DWORD PTR [r14]
         mov    DWORD PTR [rax], r10d         ; store to product[]
         mov    DWORD PTR [r12+r11*4], ebx    ; store to update_me[]
         add    ebx, 123
         mov    r10d, DWORD PTR [rsi]         ; reload sum[i]
         add    r10d, DWORD PTR [rax]         ; reload product[i]
         lea    rax, 1[rdi]
         cmp    rax, r13
         mov    DWORD PTR 0[rbp+r11*4], r10d  ; store to sum_product[]
         jne    .L6
.L1:
         pop    rbx rsi rdi rbp r12 r13 r14 r15
         ret

指令清单44.2 x64下的GCC函数f2()

f2:
         push   r13 r12 rbp rdi rsi rbx
         mov    r13, QWORD PTR 104[rsp]
         mov    rbp, QWORD PTR 88[rsp]
         mov    r12, QWORD PTR 96[rsp]
         test   r13, r13
         je     .L7
         add    r13, 1
         xor    r10d, r10d
         mov    edi, 1
         xor    eax, eax
         jmp    .L10
.L11:
         mov    rax, rdi
         mov    rdi, r11
.L10:
         mov    esi, DWORD PTR [rcx+rax*4]
         mov    r11d, DWORD PTR [rdx+rax*4]
         mov    DWORD PTR [r12+rax*4], r10d ; store to update_me[]
         add    r10d, 123
         lea    ebx, [rsi+r11]
         imul   r11d, esi
         mov    DWORD PTR [r8+rax*4], ebx ; store to sum[]
         mov    DWORD PTR [r9+rax*4], r11d ; store to product[]
         add    r11d, ebx
         mov    DWORD PTR 0[rbp+rax*4], r11d ; store to sum_product[]
         lea    r11, 1[rdi]
         cmp    r11, r13
         jne    .L11
.L7:
         pop    rbx rsi rdi rbp r12 r13
         ret

f1()函数和f2()函数的不同之处在于:在f1()函数中,sum[i]和product[i]数组在循环中会再次加载;而函数f2()则没有这种重新加载内存数值的操作。在改动后的程序里,因为我们向编译器“承诺”sum[i]和product[i]的值不会被其他指针复写,所以计算机会重复利用前几个阶段制备好的各项数据,不再从内存加载它们的值了。很明显,改进后的程序运行速度更快一些。

如果我们声明了某个指针是受限指针,而实际的程序又有其他指针操作这个受限指针操作的内存区域,将会发生什么情况?这真的就是程序员的事了,不过程序运行的结果肯定是错误的。

FORTRAN语言的编译器把所有指针都视为受限指针。因此,在C语言不支持C99标准的restrict修饰符而实际指针属于受限指针的时候,用FORTRAN语言编译出来的应用程序会比用C语言编译出来的程序运行得更快。

受限指针主要用于哪些领域?它主要用于操作多个大尺寸内存块的应用方面。例如,在超级计算机/HPC平台上经常进行的线性方程组求解就属于这种类型的应用。或许,这正是这种平台普遍采用FORTRAN语言的原因之一吧。

另一方面,在循环语句的迭代次数不是非常高的情况下,受限指针带来的性能提升就不会十分明显。