循环优化方法如数家珍

译者注:原文<Loop Optimizations: taking matters into your hands>

你可以先阅读 上一篇文章 来了解编译器如何对循环进行优化,然后再继续阅读这篇文章。

在你了解了编译器如何优化你的代码之后,接下来的两个问题是:你如何帮助编译器更好地完成它的工作,以及何时手动进行优化是有意义的? 在这篇文章中,我们试图对这两个问题给出答案,当然这并不容易。

优化“杀手”

首先你需要知道的是,两个最大的优化杀手是函数调用和指针别名。这两个是最大的杀手是因为,对于很多编译器优化,编译器必须假设某个变量在它试图优化的关键代码中是常数。但是,当存在函数调用和指针别名的情况下,优化将不会发生,从而导致编译器产生非优化版本代码。

函数调用

函数调用是性能杀手有两个原因。第一个原因是,就编译器而言,该函数可能已经改变了全局内存的完整状态。以下面为例:

for (int i = 0; i < n; i++) {
   ...
   if (debug) { 
       printf("The data is NaN\n"); 
   }
}

正如代码所示,debug 是一个全局变量,或一个栈分配的变量或者一个类成员。本质上,其他的函数可以修改这个变量的值。作为开发者,我们知道变量debug 不会在代码中修改其值。因此,编译器原则上可以进行优化:循环判断外提 (编译器会创建两个版本的循环,一个版本的循环是debug值为true ,另一个版本循环其值为false ,通过循环外检查debug 的值来确定具体进入哪一个循环)。

然而,编译器并不知道这一点,因为,其假设printf可以修改debug的值。解决这个问题的一个方法是,在循环体内部,你把简单的全局可访问变量复制到局部变量。函数不能修改局部变量的值,这段代码可以安全地进行优化。因此,代码如下所示:

bool debug_local = debug;
for (int i = 0; i < n; i++) {
   ...
   if (debug_local) { 
       printf("The data is NaN\n"); 
   }
}

编译器会自动进行循环判断外提优化。

函数导致无法优化的第二个原因是,在有函数调用的情况下,编译器的优化能力下降。考虑下面的例子:

double add(double a, double b) {
   return a + b;
}
for (int i = 0; i < n; i++) {
    c[i] = add(a[i], b[i]);
}

如果编译器可以内联函数add,它就可以安全地进行其他编译器优化。例如,它可以将循环向量化。但如果该函数是不可l内联的,那么编译器必须生成一个标量版本的循环,并在循环的每个迭代中调用函数add

你可以通过打开链接时优化来提升内联性能。

指针别名

指针别名也是编译器优化杀手。在存在指针别名的情况下,编译器不能使用寄存器来维持数据,取而代之的是,使用缓慢的内存来维持数据。也不能保证某一个值是常量(因为任何值都有可能被其他指向该值的指针所改变)。最后,指针别名抑制了矢量化,其原因我们在详细解释。

为了解释指针别名,考虑下面的例子:

for (int i = 0; i < n; i++) {
   b[i] = 0;
   for (int j = 0; j < n; j++) {
      b[i] += a[i][j];
   }
}

对于每一行 i,编译器计算矩阵 a 的第 i 行所有元素的总和,并将其存入 b[i]。

假设编译器没有关于数组 b 和 a 的指针别名信息。我们还假设,矩阵 a 是动态分配的,即它是一个指针数组,每个指针都指向另一行。这些都是相当合理的假设。

这段代码有几个优化的潜力。内循环对 j 进行迭代,所以 b[i] 的值可以存储在一个寄存器中。并且,内循环原则上是可矢量化的。

但是,让我们假设数组 a 和 b 是以如下方式初始化的:

double** a = new double*[n];
double* b = new double[n];
for (int i = 0; i < n; i++) {
    a[i] = b;
}

在矩阵 a 中,所有的行指针都指向同一个内存块。此外,a 的行和指针 b 的行相互别名。如果你将 5 写入 b[5]中,这个值相当于写入 a[3][5] 中。

在这种情况下,编译器无法使用寄存器来维护 b[i] 的值。 另外,由于存在依赖关系,它不能对循环进行矢量处理。其结果是生成的代码性能很差。

你会说,这种情况在真实的代码库中从未发生过,但编译器必须假设最坏的情况。编译器不会抛出警告,你需要查看编译器的优化报告以了解发生了什么。我们将在下一篇文章中谈及编译器的优化报告。

如果你确定两个指针没有互相别名,那么你可以使用 __restrict__ 关键字来告诉编译器,指针是互相独立的。此外,你可以用一个寄存器手动替换对数组中同一元素的写入,像这样:

for (int i = 0; i < n; i++) {
   double sum = 0;
   double* __restrict__ a_row = a[i];
   for (int j = 0; j < n; j++) {
      sum += a_row[j];
   }
   b[i] = sum;
}

通过对矩阵 a 的行使用 __restrict__ 关键字(第3行),并引入一个临时标量变量来保存中间结果,编译器现在可以自由地更好地优化循环。

关于指针别名的补充说明

指针别名是编译器所做的最复杂的分析之一,许多编译器优化只能在编译器能够保证没有指针别名的情况下进行。也就是说,通过使用局部变量而不是全局变量、使用 __restrict__ 关键字或使用编译器 pragmas(例如#pragma ivdep)告诉编译器忽略循环携带的依赖关系,从而简化编译器的分析。

在分析你的代码时,就指针别名而言,编译器会区分三种类型:

  1. 编译器确定该数据没有被其他指针别名,所以对它进行编译器优化是安全的。
  2. 编译器确信该数据被其他指针别名,所以它必须假定其值可以改变,省略某些编译器优化,并将数据保存在内存中,而不是寄存器中。
  3. 编译器不能保证数据不被指针所别名。

在(1)和(2)的情况下,解决方案是明确的。在(3)的情况下,编译器有一个选项,可以生成两个版本的代码:一个是优化版本的,另一个是未优化版本。然后,它运行时检查指针别名,并相应地选择优化或者未优化的版本。

然而,请注意,运行时别名分析有几个方面的限制: 它只适用于编译器可以推导出长度的标量和数组。由于每个指针都需要与其他指针进行检查,执行分析所需的计算很快就会变得难以管理。你不应该过多地依赖它。

对于热循环,检查编译器的优化报告,同时将全局值复制到本地,使用 __restrict__ 关键字,使用 pragma(例如#pragma ivdep)告诉编译器忽略热循环的向量依赖,将允许编译器优化。

从可读性的角度来看,将 globals 变量复制到 locals 并使用 locals 来代替,可以得到更可读的代码。__restrict__ 关键字并不为许多开发者所知,如果使用不当,将导致不正确的程序行为。

优化方法如数家珍

在把事情交给你处理之前,我必须提出一个警告。根据我的经验,代码可读性和代码可维护性的重要性高于速度。优化代码的非关键部分永远不会带来的速度提升。因此,你计划对代码所做的所有修改应该只集中在关键循环上。

循环不变式外提和循环判断外提

你应该尽量让编译器来完成循环不变式外提和循环判断外提。然而,在某些情况下,你想手动完成这些工作:

  • 编译器不能确定参数是一个常数:这里适用的规则与指针别名的规则相同。要么将循环不变的条件复制到一个临时的局部变量,要么使用宏或C++模板将其转换为编译时常量。
  • 参数太多,编译器无法取消对循环的切换:循环判断外提将增大代码的大小;若存在多个判断,那么不进行这个优化。使用 C++ 模板或宏的手动取消切换将强制取消切换。

更多关于手动取消开关的信息可以在 博客 中找到。

去除迭代器变量的依赖性计算

如果在循环中存在一个条件,并且它不依赖于数据,而只依赖于迭代器变量,我们称它为迭代器变量依赖条件。编译器通常不会对这些条件进行优化,可以通过循环剥离或循环展开来优化它们。

循环展开

根据我的经验,你几乎不应该用手来做循环展开。手动展开循环会使编译器的分析变得复杂,而且所产生的代码也会更慢。然而,你可以使用编译器的 pragmas 来强制在编译器上进行循环展开。(例如,LLVM提供了pragma clang loop unroll)。

更新:一些读者反对这一意见,我觉得应该对其进行更新。通常情况下,开发人员会将循环展开几次,然后重新排列循环中的语句,类似于循环流水优化。在后台发生的情况是,与这些语句对应的指令与语句一起移动。如果你足够幸运,早先是瓶颈的 CPU 单元将不再是瓶颈,你的循环将运行得更快。

然而,这种 “性能调整 “并不一定可以移植,在不同的编译器之间,在同一编译器上的不同硬件架构之间,以及同一编译器的不同版本之间,这种调整并不一定都适用。只要结果不变,编译器可以随意安排指令。使用这种方法获得的性能往往很小(例如,程序运行时间减少20%)并且不可移植。而且代码将变得复杂。

也就是说,有时你需要那额外的 20% 的性能提升,以这种方式它是有意义的。有时这可以节省金钱、时间或精力。也许在这种情况下,这比可维护性和可移植性更重要。

循环流水优化

译:流水优化示例

就循环流水优化而言,最好让编译器来进行这种优化,而且只适用于那些能从中受益的硬件架构。

这条规则有一个例外:如果你的循环以一种不可预测的模式访问数据,你可以预期会有大量的数据缓存缺失,这会大大降低你的代码速度。如果是这种情况,明确的软件预取与循环流水线相结合,可以帮助缓解一些问题。这种技术相当复杂,但可以说是值得努力的。你可以在这里找到更多关于它的信息。

矢量化

手动将代码矢量化的方法有使用矢量化 pragmas,如:#pramga omp simd (然而,你需要向编译器提供 -fopenmp 或 -fopenmp-simd 配置,以方便移植),或者按照不同编译器的规则来进行编写,如LLVM编写为#pragma clang loop vectorize(enable) 。然而,我本人是反对使用这种方式的,下面是原因。

如果编译器没有对循环进行矢量处理,肯定存在某个具体的原因。它可以是:

  1. 编译器不知道如何对循环进行向量化:强制矢量化不会产生任何影响,会产生编译器警告。
  2. 有循环存在依赖:强制的矢量化会产生错误。
  3. 成本模型预测,矢量化并没有得到回报:如果是这样的话,强迫向量化可能会导致速度下降,而不是加速。
  4. 根据IEEE 754标准,其结果不精确:启用允许放松IEEE 754语义的编译器标志,如-ffast-math、-Ofast等。当你这样做的时候,编译器会自动对循环进行矢量处理而不需要 pragma。
  5. 编译器不能保证没有指针别名:如果是这种情况,我们在关于指针别名一节中提到的提示将帮助编译器成功地进行指针别名分析,因此,如果成本模型预测了速度将会提高,它可以自动向量化循环。

诚然,有很少的情况,强制矢量化可能会带来性能上的好处(例如: 除了在一个特定的地方之外,你不想放宽 IEEE 754 浮点数学的精度),但这些在实践中很少见到。

循环替换

编译器很少进行循环互换,它是一个非常有价值的转换,可以加快处理矩阵和图像的代码的速度。 对于热循环,肯定要手动进行,因为它有能力将速度提高几倍。

循环拆分

循环拆分是一个有点争议的优化方法。如果循环是不可矢量的,优化可以将循环分成可矢量的和不可矢量的部分。一个部分的矢量化将带来速度的提高。然而,在实践中,这种方法有一个问题。请考虑以下例子:

for (int i = 0; i < n; i++) {
    double val = (a[i] > 0) ? a[i] : 0;
    b[i] = sqrt(a[i]);
}

操作sqrt 是非常昂贵的操作并且这个循环将从矢量化中受益。有些编译器在矢量化方面比其他编译器要好。现在,我们可以像这样进行手动循环分配:

for (int i = 0; i < n; i++) {
    val[i] = (a[i] > 0) ? a[i] : 0;
}
for (int i = 0; i < n; i++) {
    b[i] = sqrt(val[i]);
}

假设编译器 A 没有对原始循环进行矢量处理。拆分后,编译器 A 对第二个循环进行向量化处理,从而使性能得到提升。

然而,如果编译器 B 对原始循环进行了矢量处理,在拆分之后,拆分对性能的产生负面的影响。

尽管循环拆分可以对速度产生积极的影响,但重要的是测试对你的项目使用的所有编译器对性能影响。

循环合并

译:循环合并优化介绍

循环合并一般对性能有积极影响,只有一个例外:如果原始循环中的一个(或两个)被编译器矢量化了,而在融合后它们停止了矢量化,那么影响可能是负面的。与循环拆分一样,用各种编译器检查性能也很重要。

结论

当涉及到在编译器优化方面自己动手的时候,有一些担忧。首先是代码的可读性和可维护性。代码的可读性几乎在任何时候都非常重要,因为它可以减少维护成本:写得可读性好的代码对另一个人来说更容易掌握,它出现错误的机会更少,从长远来看,它更容易扩展。

手动优化代码会导致代码混乱,更难维护。要知道,性能优化只对热点代码有意义,你应该只在热循环上做优化,而且只在彻底剖析后做优化。

第二个问题是性能的可移植性:不能保证用一个编译器取得的速度会在另一个编译器上重现。 因此,考虑到这一点也很重要。

尽管如此,如果性能优化能够促使产品上线,其成本能够下降,或者使用更少的功率,那么绝对值得关注被遗忘的编译器优化。

在下一篇文章中,我们将讨论如何使用 CLANG 的编译器产生优化报告,使用一个非常漂亮的图形化 UI,称为 opt-viewer.py

页面下部广告