为什么编译器不能(或不能)将可预测的加法循环优化为乘法?


133

这是在阅读Mysticial关于该问题的绝妙答案时想到的一个问题:为什么处理排序数组比未排序数组更快

涉及的类型的上下文:

const unsigned arraySize = 32768;
int data[arraySize];
long long sum = 0;

他在回答中解释说,英特尔编译器(ICC)对此进行了优化:

for (int i = 0; i < 100000; ++i)
    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            sum += data[c];

...变成这样的东西:

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

优化器认识到它们是等效的,因此正在交换循环,将分支移到内部循环之外。非常聪明!

但是为什么不这样做呢?

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000 * data[c];

希望Mysticial(或其他任何人)能给出同样出色的答案。我之前从未听说过其他问题中讨论过的优化,所以我对此非常感激。


14
那也许只有英特尔知道。我不知道它以什么顺序运行其优化过程。显然,它在循环交换后不会运行循环崩溃遍历。
Mysticial

7
仅当数据数组中包含的值是不可变的时,此优化才有效。例如,如果每次读取数据[0]时将内存映射到输入/输出设备,则会产生不同的值……
Thomas CG de Vilhena 2012年

2
整数或浮点数是什么数据类型?浮点数的重复加法与乘法产生的结果大不相同。
Ben Voigt 2012年

6
@Thomas:如果数据为volatile,则循环交换也将是无效的优化。
Ben Voigt 2012年

3
GNAT(带有GCC 4.6的Ada编译器)不会在O3处切换循环,但是如果切换了循环,它将转换为乘法。
prosfilaes 2013年

Answers:


105

编译器通常无法进行转换

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

进入

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000 * data[c];

因为后者可能导致有符号整数溢出,而前者则不会。即使保证带符号的二进制补码整数溢出的环绕行为,它也会更改结果(如果data[c]为30000,则乘积将成为具有环绕-1294967296的典型32位ints 的乘积,而100000乘以30000 sum将会得到结果)不溢出,增加30 sum亿)。请注意,无符号数量的保持不变,数量不同,溢出100000 * data[c]通常会引入归约模数2^32,该模数不得出现在最终结果中。

它可以将其转化为

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000LL * data[c];  // resp. 100000ull

不过,如果照常long long比足够大int

为什么不能这样做,我不能说,这就是Mysticial所说的:“显然,它在循环交换之后没有运行循环崩溃遍历”。

请注意,循环交换本身通常无效(对于带符号整数),因为

for (int c = 0; c < arraySize; ++c)
    if (condition(data[c]))
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

可能导致溢出

for (int i = 0; i < 100000; ++i)
    for (int c = 0; c < arraySize; ++c)
        if (condition(data[c]))
            sum += data[c];

不会。因为条件确保所有data[c]添加的对象都具有相同的符号,所以这里是犹太洁食,因此如果一个对象溢出,则两者都会。

不过,我不太确定编译器是否考虑到了这一点(@Mysticial,您可以尝试使用诸如data[c] & 0x80或这样的条件对正负值都适用吗?)。我有编译器做无效的优化(例如,几年前,我有一个ICC(11.0,IIRC)的使用签署的32位INT-到双的转换1.0/n,其中n是一个unsigned int。正要快两倍,gcc的输出,但是错了,很多值都比2^31oops 大)。


4
我记得一个版本的MPW编译器添加了一个选项,以允许大于32K的堆栈帧[较早的版本使用@ A7 + int16局部变量寻址受到限制]。对于低于32K或超过64K的堆栈帧,它一切都正确,但对于40K堆栈帧,它将使用ADD.W A6,$A000,而忘记了地址寄存器的字操作在加法之前将字符号扩展为32位。花了一些时间进行故障排除,因为代码在这ADD与下一次将A6从堆栈中弹出之间所做的唯一事情就是恢复了已保存到该帧的调用方的寄存器...
supercat

3
...而调用者唯一关心的寄存器是静态数组的[load-time constant]地址。编译器知道数组的地址已保存在寄存器中,因此可以基于该地址进行优化,但调试器仅知道常量的地址。因此,在执行一条语句之前,MyArray[0] = 4;我可以检查的地址MyArray,并在执行该语句之前和之后查看该位置。它不会改变。代码就像这样move.B @A3,#4,A3应该总是指向MyArray该指令执行的任何时间,但事实并非如此。好玩
2013年

那为什么clang执行这种优化呢?
詹森·S

编译器可以在其内部中间表示中执行该重写,因为允许其内部中间表示中的未定义行为更少。
user253751

48

此答案不适用于链接的特定案例,但确实适用于问题标题,并且可能对将来的读者很有趣:

由于有限的精度,重复的浮点加法不等于乘法。考虑:

float const step = 1e-15;
float const init = 1;
long int const count = 1000000000;

float result1 = init;
for( int i = 0; i < count; ++i ) result1 += step;

float result2 = init;
result2 += step * count;

cout << (result1 - result2);

演示版


10
这不是所问问题的答案。尽管有有趣的信息(并且对于任何C / C ++程序员都必须知道),但这不是论坛,也不属于这里。
orlp 2012年

30
@nightcracker:StackOverflow的既定目标是建立一个对将来的用户有用的可搜索答案库。这是对所提问题的答案...恰好发生了一些未说明的信息,导致该答案不适用于原始张贴者。它可能仍然适用于其他有相同问题的人。
Ben Voigt

12
可能是一个问题的答案标题,但不是问题,没有。
orlp 2012年

7
正如我所说,这是有趣的信息。然而,对我来说,仍然不是问题的最高答案现在还不能回答问题。根本不是Intel编译器决定不优化的原因。
orlp 2012年

4
@nightcracker:对我来说,这是最佳答案也似乎是错误的。我希望有人能为分数超过此整数的情况提供一个很好的答案。不幸的是,我认为整数情况下没有“不能”的答案,因为转换是合法的,所以我们只剩下“为什么不这样做”,实际上与“为什么”不符。过于本地化”是封闭的原因,因为它特定于特定的编译器版本。我回答的问题是更重要的问题,IMO。
Ben Voigt 2012年

6

编译器包含进行优化的各种过程。通常,在每个过程中,要么对语句进行优化,要么对循环进行优化。当前,没有基于循环头对循环体进行优化的模型。这很难被发现并且不常见。

所做的优化是循环不变代码运动。这可以使用一组技术来完成。


4

好吧,假设我们正在谈论整数算法,我猜有些编译器可能会进行这种优化。

同时,某些编译器可能拒绝这样做,因为用乘法替换重复加法可能会更改代码的溢出行为。对于无符号整数类型,这没有什么不同,因为它们的溢出行为完全由语言指定。但是对于签名者,它可能会(尽管可能不在2的补码平台上)。确实,带符号的溢出实际上会导致C语言中出现未定义的行为,这意味着完全可以完全忽略该溢出语义,但是并不是所有的编译器都足够勇敢地做到这一点。它经常引起“ C只是一种高级汇编语言”人群的批评。(还记得当GCC引入基于严格别名语义的优化时会发生什么情况吗?)

从历史上看,GCC本身已显示为具有采取如此艰巨步骤所需的编译器,但其他编译器可能更愿意坚持感知到的“用户意图”行为,即使该行为未由语言定义。


我更想知道我是否意外地依赖未定义的行为,但是我猜编译器没有办法知道,因为溢出将是运行时问题:/
jhabbott 2012年

2
@jhabbott:如果发生溢出,则存在未定义的行为。在运行时之前,是否定义行为是未知的(假定在运行时输入了数字):P。
Orlp 2012年

3

现在可以了- 至少clang可以

long long add_100k_signed(int *data, int arraySize)
{
    long long sum = 0;

    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            for (int i = 0; i < 100000; ++i)
                sum += data[c];
    return sum;
}

用-O1编译为

add_100k_signed:                        # @add_100k_signed
        test    esi, esi
        jle     .LBB0_1
        mov     r9d, esi
        xor     r8d, r8d
        xor     esi, esi
        xor     eax, eax
.LBB0_4:                                # =>This Inner Loop Header: Depth=1
        movsxd  rdx, dword ptr [rdi + 4*rsi]
        imul    rcx, rdx, 100000
        cmp     rdx, 127
        cmovle  rcx, r8
        add     rax, rcx
        add     rsi, 1
        cmp     r9, rsi
        jne     .LBB0_4
        ret
.LBB0_1:
        xor     eax, eax
        ret

整数溢出与它无关。如果存在整数溢出导致不确定的行为,则在两种情况下都可能发生。这是使用int代替的相同功能long

int add_100k_signed(int *data, int arraySize)
{
    int sum = 0;

    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            for (int i = 0; i < 100000; ++i)
                sum += data[c];
    return sum;
}

用-O1编译为

add_100k_signed:                        # @add_100k_signed
        test    esi, esi
        jle     .LBB0_1
        mov     r9d, esi
        xor     r8d, r8d
        xor     esi, esi
        xor     eax, eax
.LBB0_4:                                # =>This Inner Loop Header: Depth=1
        mov     edx, dword ptr [rdi + 4*rsi]
        imul    ecx, edx, 100000
        cmp     edx, 127
        cmovle  ecx, r8d
        add     eax, ecx
        add     rsi, 1
        cmp     r9, rsi
        jne     .LBB0_4
        ret
.LBB0_1:
        xor     eax, eax
        ret

2

这种优化存在概念上的障碍。编译器作者在降低强度上花费了很多精力-例如,用加法和移位替换乘法。他们习惯于认为乘积不好。因此,一个人应该走另一条路的情况令人惊讶且违反直觉。因此,没有人考虑实施它。


3
用闭式计算代替循环也会降低强度,不是吗?
Ben Voigt 2012年

正式地说,是的,我想,但是我从未听过有人这么说过。(不过,我在文学上有点过时了。)
zwol 2012年

1

开发和维护编译器的人员只有有限的时间和精力在工作上,因此他们通常希望专注于用户最关心的事情:将编写良好的代码转换为快速代码。他们不想花费时间试图找到将愚蠢的代码转变为快速代码的方法,这就是代码审查的目的。在高级语言中,可能会有“愚蠢”的代码表达一个重要的想法,这使开发人员可以花时间来快速实现这一目标,例如,快捷的砍伐森林和流融合使Haskell程序可以围绕某些懒惰的结构进行构建产生的数据结构将被编译为不分配内存的紧密循环。但是,这种激励措施根本不适用于将循环加法转换为乘法。如果你想快点

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.