【问题标题】:C compilers and loop optimisationC 编译器和循环优化
【发布时间】:2013-01-10 01:48:25
【问题描述】:

对于编译器如何实际优化,以及不同级别之间的区别(例如 gcc 的 -O2 与 -O3),我没有太多经验。因此,我不确定以下两个语句对于任意编译器是否等效:

for(i=0;i<10;++i){
variable1*variable2*gridpoint[i];
}

variable3=variable1*variable2;
for(i=0;i<10;++i){
variable3*gridpoint[i];
}

从处理时间的角度来看,只计算一次 variable1 和 variable2 的乘积是有意义的,因为它们在循环中不会改变。然而,这需要额外的内存,而且我不确定优化器对这种开销的影响有多大。如果你有一个来自纸/书的方程式并且想要将其转换为计算机可读的东西,第一个表达式是最容易阅读的,但第二个可能是最快的 - 特别是对于循环内有很多不变变量的更复杂的方程(我有一些非常讨厌的非线性微分方程,我希望在代码中可读)。如果我将变量声明为常量,这些是否会发生变化?我希望我的问题对任意编译器有意义,因为我同时使用 gcc、Intel 和 Portland 编译器。

【问题讨论】:

  • 这称为“公共子表达式消除”。
  • 在这种情况下,除非变量被标记为 volatile,否则整个循环将被消除,因为循环体中的表达式没有副作用。
  • 或者死代码消除,因为上述两个 sn-ps 可以安全地从程序中消除,因为它们什么都不做:)
  • 为了记录,volatile 变量是与内存空间区域相关联的变量,该区域可以在代码的正常流程之外发生变化......例如,映射的变量到微处理器的输入寄存器,或映射到时钟寄存器的变量。因此,每次访问它时,它都可能发生了变化,即使没有代码可以为它赋值。因此,编译器不会将其分解到循环之外。
  • 答案是编译器“可以”做这些优化中的任何一个,但由于各种原因不需要也可能不会做任何这些优化。不要假设某些东西会被优化,而是尽可能写出最好的代码。

标签: c optimization


【解决方案1】:

对于任意编译器来说,很难充分回答这个问题。这段代码能做什么不仅取决于编译器,还取决于目标架构。我将尝试解释具有良好功能的生产编译器可以对这段代码做什么。

从处理时间的角度来看,只计算一次 variable1 和 variable2 的乘积是有意义的,因为它们在循环中不会改变。

你是对的。正如猫先生所指出的,这被称为common subexpression elimination。因此,编译器可能会生成只计算一次表达式的代码(或者如果两个操作数的值一次都是常量,则甚至在编译时计算它)。

一个体面的编译器也可以对函数执行子表达式消除,如果它可以确定函数没有副作用。例如,GCC 可以分析函数体是否可用,但也有pureconst 属性可用于专门标记应受此优化的函数(请参阅Function Attributes)。

鉴于没有副作用并且编译器能够确定它(在您的示例中,没有任何障碍),两个 sn-ps 在这方面是等效的(我已经检查过 clang : -))。

然而,这需要额外的内存,我不确定优化器在多大程度上考虑了这种开销。

事实上,这并不需要任何额外的内存。乘法在processor registers 中完成,结果也存储在寄存器中。这是一个消除大量代码并使用单个寄存器来存储结果的问题,这总是很棒(而且当涉及到register allocation 时,肯定会让生活更轻松,尤其是在循环中)。因此,如果可以完成此优化,那么将无需额外费用。

第一个表达式最容易阅读..

GCC 和 Clang 都将执行此优化。不过,我不确定其他编译器,所以你必须自己检查。但是很难想象有什么好的编译器不进行子表达式消除。

如果我将变量声明为常量,这是否会发生任何变化?

可能。这称为常量表达式——仅包含常量的表达式。可以在编译期间而不是在运行时评估常量表达式。因此,例如,如果您使用多个 A、B 和 C,其中 A 和 B 都是常量,编译器将针对该预先计算的值预先计算 A*B 表达式仅多个 C。如果编译器可以在编译时确定它的值并确保它没有被更改,那么即使编译器也可以使用非常量值来执行此操作。例如:

$ cat test.c
inline int foo(int a, int b)
{
    return a * b;
}

int main() {
    int a;
    int b;
    a = 1;
    b = 2;
    return foo(a, b);
}
$ clang -Wall -pedantic -O4 -o test ./test.c
$ otool -tv ./test
./test:
(__TEXT,__text) section
_main:
0000000100000f70    movl    $0x00000002,%eax
0000000100000f75    ret

在上述 sn-ps 的情况下,还可以进行其他优化。以下是我想到的一些:

第一个最明显的是循环展开。由于在运行时知道迭代次数,编译器可能会决定unroll the loop。是否应用此优化取决于架构(即某些 CPU 可以“锁定循环”并比展开版本更快地执行代码,这也通过使用更少的空间使代码对缓存更友好,避免额外的 µOP 融合阶段等)。

第二个可以将速度提高 50 倍的优化是使用 SIMD 指令(SSE、AVX 等)。例如,GCC 非常擅长(如果不是更好的话,英特尔也必须如此)。我已经验证了以下功能:

uint8_t dumb_checksum(const uint8_t *p, size_t size)
{
    uint8_t s = 0;
    size_t i;
    for (i = 0; i < size; ++i)
        s = (uint8_t)(s + p[i]);
    return s;
}

... 被转换为一个循环,其中每个步骤一次对 16 个值求和(即,如 _mm_add_epi8 中那样),并带有额外的代码处理对齐和奇数 (

如果可以的话,我想建议您不要优化您的代码,除非您发现它是一个瓶颈。否则,您可能会浪费大量时间进行错误和过早的优化。

我希望这能回答您的问题。祝你好运!

【讨论】:

  • “这被称为常量表达式”我认为 OP 的意思是如果他声明了variable1const-qualified。
  • 感谢您提供这个非常有启发性的答案。但是,我确实是指将变量声明为“const float variable1”等,而不是(必然)编译时常量。我可以安全地假设更清洁的符号也会得到优化吗?例如定义“const float a=NastyObject->TediousVariableName”并使用变量 a 而不是繁琐的变量名?我不明白为什么编译器不会将其识别为简单别名
  • @user787267:是的,这也有助于优化。原因是有时编译器无法确定没有副作用。例如,如果您使用obj-&gt;value 并执行其他操作(即调用某个函数并将obj 传递给它),编译器将不知道值是否正在更改,并且总是从内存中读取它,而如果您显式地做const type var = obj-&gt;var;,它可以将这个值保存在寄存器中而不是读取内存,并做其他优化。但是,如果没有副作用并且编译器可以确定它,那么没关系。
【解决方案2】:

是的,您可以指望编译器在执行子表达式消除方面做得很好,即使是通过循环。这可能会导致内存使用量略有增加,但是任何体面的编译器都会考虑所有这些,而且执行子表达式消除几乎总是一种胜利(因为我们正在谈论的内存是寄存器和L1 缓存)。

这里有一些快速测试也可以向自己“证明”它。结果表明,您基本上不应该尝试在手动消除子表达式时智取编译器,而应该自然地编写代码并让编译器做它擅长的事情(比如弄清楚哪些表达式应该真正被消除,哪些不应该给出目标架构和周边代码。)

稍后,如果您对代码的性能不满意,您应该使用分析器查看您的代码,看看哪些语句和表达式占用的时间最多,然后尝试确定您是否可以重新组织代码帮助编译器,但我会说绝大多数时候它不会是这样简单的事情,它会做一些事情来减少缓存停顿(即更好地组织你的数据),消除冗余的程序间计算,诸如此类。

(FTR 在以下代码中使用随机数只是确保编译器不会过于热衷于变量消除和循环展开)

程序1:

#include <stdlib.h>
#include <time.h>

int main () {
    srandom(time(NULL));
    int i, ret = 0, a = random(), b = random(), values[10];
    int loop_end = random() % 5 + 1000000000;
    for (i=0; i < 10; ++i) { values[i] = random(); }

    for (i = 0; i < loop_end; ++i) {
        ret += a * b * values[i % 10];
    }

    return ret;
}

程序2:

#include <stdlib.h>
#include <time.h>

int main () {
    srandom(time(NULL));
    int i, ret = 0, a = random(), b = random(), values[10];
    int loop_end = random() % 5 + 1000000000;
    for (i=0; i < 10; ++i) { values[i] = random(); }

    int c = a * b;
    for (i = 0; i < loop_end; ++i) {
        ret += c * values[i % 10];
    }

    return ret;
}

结果如下:

> gcc -O2 prog1.c -o prog1; time ./prog1  
./prog1  1.62s user 0.00s system 99% cpu 1.630 total

> gcc -O2 prog2.c -o prog2; time ./prog2
./prog2  1.63s user 0.00s system 99% cpu 1.636 total

(这里是测壁时间,不用注意0.01秒的差异,跑了几次都在1.62-1.63秒的范围内,所以速度是一样的)

有趣的是,在没有优化的情况下编译时 prog1 更快:

> gcc -O0 prog1.c -o prog1; time ./prog1  
./prog1  2.83s user 0.00s system 99% cpu 2.846 total

> gcc -O0 prog2.c -o prog2; time ./prog2 
./prog2  2.93s user 0.00s system 99% cpu 2.946 total

同样有趣的是,使用-O1 编译提供了最佳性能..

gcc -O1 prog1.c -o prog1; time ./prog1 
./prog1  1.57s user 0.00s system 99% cpu 1.579 total

gcc -O1 prog2.c -o prog2; time ./prog2
./prog2  1.56s user 0.00s system 99% cpu 1.563 total

GCC 和 Intel 是出色的编译器,并且在处理此类事情方面非常聪明。我对 Portland 编译器没有任何经验,但这些对于编译器来说是非常基本的事情,所以如果它不能很好地处理这种情况,我会感到非常惊讶。

【讨论】:

  • 这些开关O2 O1 O0有什么意义?
  • 这些是 GCC 的优化标志。 -O0 不进行重大优化,-O1 进行一些优化,-O2 进行更多优化,等等。
  • 使用 O2 优化并不总是好的。 O0 优化在哪些情况下有用。 . ?谢谢。
  • O0 编译速度最快,最容易调试。 O2 最终可能会优化变量定义和内联代码,因此当使用调试器查看时,有时您将无法检查某些变量的值之类的东西。 O0 没有做任何这些。
  • 感谢您的回答。关于为什么 -O1 在这种情况下最快的任何猜测?为什么 prog1 在没有优化的情况下是最快的?
【解决方案3】:

如果我是一名编译器,我会认识到这两个循环都有 no 左操作数,并且完全 no 副作用(除了设置 @987654321 @ to 10),所以我将完全优化循环。

我并不是说这真的发生了;看起来它可能发生在您提供的代码中。

【讨论】:

  • 它肯定会发生 :) 但我认为他只是想提供一个简单的例子......
  • 我确实只是想举一个简单的例子。我想如果我真的在循环内有一个左手边会更有意义。
猜你喜欢
  • 2012-12-11
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2016-05-25
  • 2014-02-21
  • 1970-01-01
相关资源
最近更新 更多