C编译器和循环优化

时间:2013-01-09 16:41:02

标签: c optimization

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

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编译器。

3 个答案:

答案 0 :(得分:4)

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

  

从处理时间的角度来看,只计算variable1和variable2的乘积,因为它们不会在循环中发生变化。

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

如果可以确定函数没有副作用,那么合适的编译器也可以对函数执行子表达式消除。例如,GCC可以分析函数的主体是否可用,但也有pureconst属性可用于专门标记应该进行此优化的函数(请参阅{{ 3}})。

鉴于没有副作用且编译器能够确定它(在您的示例中,没有任何阻碍),两个片段在这方面是相同的(我已经用clang检查了:-) )。

  

然而,这需要额外的内存,而且我不确定优化器对此开销有多大影响。

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

  

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

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

在上述代码段的情况下,还可以进行其他优化。以下是我们想到的一些内容:

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

第二个优化可以使用unroll the loop指令(SSE,AVX等)实际加速50次。例如,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个值(即在SIMD中),其具有附加的代码处理对齐和奇数(<16)迭代计数。然而,Clang在我最后一次检查时完全失败了。因此,即使迭代次数未知,GCC也可能以这种方式减少循环。

如果我愿意,我建议您不要优化代码,除非您发现它是瓶颈。否则你可能会浪费很多时间进行虚假和过早的优化。

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

答案 1 :(得分:3)

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

以下是一些快速测试,以“证明”它自己。结果表明你基本上不应该尝试超越编译器进行手动子表达式消除,只需自然地编写代码并让编译器做它擅长的事情(这就像弄清楚哪些表达式应该被真正消除而哪些不应该给出目标架构和周围代码。)

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

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

PROG1:

#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;
}

PROG2:

#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和英特尔是伟大的编译器,并且非常聪明地处理这样的事情。我对Portland编译器没有任何经验,但这些对于编译器来说都是非常基本的东西,所以如果它不能很好地处理这样的情况,我会感到非常惊讶。

答案 2 :(得分:0)

如果我是编译器,我会认识到这两个循环都有没有左手操作数,而没有副作用,(除了设置{{ 1}}到i),所以我只是完全优化循环。

我不是说这实际发生了;看起来它可能会从您提供的代码中发生。