【问题标题】:Why can't (or doesn't) the compiler optimize a predictable addition loop into a multiplication?为什么编译器不能(或不)将可预测的加法循环优化为乘法?
【发布时间】:2012-07-01 20:11:32
【问题描述】:

这是在阅读Mysticial 对问题的精彩回答时想到的一个问题:why is it faster to process a sorted array than an unsorted array?

所涉及类型的上下文:

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];

优化器认识到这些是等价的,因此是exchanging the loops,将分支移到内部循环之外。很聪明!

但它为什么不这样做呢?

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

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

【问题讨论】:

  • 这可能只有英特尔知道。我不知道它运行优化传递的顺序是什么。显然,它不会在循环交换后运行循环折叠通道。
  • 此优化仅在数据数组中包含的值不可变时才有效。例如,如果每次读取 data[0] 时将 memory mapped 发送到输入/输出设备,则会产生不同的值...
  • 这是什么数据类型,整数还是浮点数?浮点数中的重复加法与乘法的结果截然不同。
  • @Thomas:如果数据是volatile,那么循环交换也是无效的优化。
  • GNAT(带有 GCC 4.6 的 Ada 编译器)不会在 O3 切换循环,但如果切换循环,它会将其转换为乘法。

标签: c performance compiler-optimization


【解决方案1】:

现在可以了——at least, clang does:

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

整数溢出与此无关;如果存在导致未定义行为的整数溢出,则在任何一种情况下都可能发生。这里是the same kind of function using int instead of 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】:

    这种优化存在概念上的障碍。编译器作者在strength reduction 上花费了大量精力——例如,用加法和移位代替乘法。他们习惯于认为乘法是不好的。所以一个人应该走另一条路的情况是令人惊讶和违反直觉的。所以没有人想去实现它。

    【讨论】:

    • 用闭式计算代替循环也是强度降低,不是吗?
    • 正式地说,是的,我想,但我从未听过任何人这样谈论它。 (不过,我的文献有点过时了。)
    【解决方案3】:

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

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

    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);
    

    Demo

    【讨论】:

    • 这不是问题的答案。尽管信息很有趣(任何 C/C++ 程序员都必须知道),但这不是论坛,也不属于这里。
    • @nightcracker:StackOverflow 的既定目标是构建一个对未来用户有用的可搜索答案库。这是对所问问题的答案......碰巧有一些未说明的信息使该答案不适用于原始海报。它可能仍然适用于有相同问题的其他人。
    • 可能是问题title的答案,但不是问题,不是。
    • 正如我所说,这是有趣的信息。然而,在我看来,问题的 最佳答案 现在没有回答目前的问题,这对我来说似乎仍然是错误的。这根本不是英特尔编译器决定不优化的原因,basta。
    • @nightcracker:这对我来说似乎也是错误的,这是最佳答案。我希望有人为分数超过这个整数的情况发布一个非常好的答案。不幸的是,对于整数情况,我认为“不能”没有答案,因为转换是合法的,所以我们留下了“为什么不这样做”,这实际上与“过于本地化”的密切原因,因为它是特定编译器版本所特有的。我回答的问题是更重要的问题,IMO。
    【解决方案4】:

    好吧,假设我们谈论的是整数运算,我猜一些编译器可能会进行这种优化。

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

    从历史上看,GCC 已将自己展示为一个能够采取如此激烈步骤的编译器,但其他编译器可能更愿意坚持感知到的“用户预期”行为,即使它没有被该语言定义。

    【讨论】:

    • 我想知道我是否不小心依赖于未定义的行为,但我猜编译器无法知道溢出将是一个运行时问题:/
    • @jhabbott: iff 发生溢出,然后出现未定义的行为。是否定义了行为直到运行时才知道(假设在运行时输入数字):P.
    【解决方案5】:

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

    【讨论】:

      【解决方案6】:

      编译器一般不能转换

      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,对于典型的 32 位 ints 带环绕,乘积将变为 -1294967296,而将 30000 加到 sum 100000 次,如果不溢出,则将 sum 增加 3000000000)。请注意,对于具有不同数字的无符号量也是如此,100000 * data[c] 的溢出通常会引入一个不能出现在最终结果中的减少模 2^32

      它可以变成

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

      不过,如果像往常一样,long longint 大得多。

      为什么它不这样做,我不知道,我猜这是Mysticial said,“显然,它在循环交换后没有运行循环折叠通道”。

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

      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];
      

      不会。这里是 kosher,因为该条件确保添加的所有 data[c] 具有相同的符号,所以如果一个溢出,两者都会。

      不过,我不太确定编译器是否考虑到了这一点(@Mysticial,您能否尝试使用data[c] &amp; 0x80 之类的条件,或者对于正值和负值都适用?)。我让编译器进行了无效优化(例如,几年前,我在 1.0/n 中使用了 ICC(11.0,iirc)使用有符号 32 位整数到双精度转换,其中 n 是 @987654340 @. 大约是 gcc 输出的两倍。但是错了,很多值都大于 2^31,哎呀。)。

      【讨论】:

      • 我记得 MPW 编译器的一个版本添加了一个选项以允许堆栈帧大于 32K [早期版本限制使用 @A7+int16 寻址局部变量]。对于低于 32K 或超过 64K 的堆栈帧,它一切正常,但对于 40K 堆栈帧,它将使用ADD.W A6,$A000,忘记了地址寄存器的字操作在添加之前将字符号扩展为 32 位。花了一些时间进行故障排除,因为代码在 ADD 和下一次从堆栈中弹出 A6 之间所做的唯一一件事就是恢复它保存到该帧的调用者寄存器......
      • ...调用者碰巧关心的唯一寄存器是静态数组的[加载时间常量]地址。编译器知道数组的地址保存在寄存器中,因此它可以基于此进行优化,但调试器只知道常量的地址。因此,在语句MyArray[0] = 4; 之前,我可以检查MyArray 的地址,并查看语句执行前后的位置;它不会改变。代码类似于move.B @A3,#4,而A3 应该在该指令执行时始终指向MyArray,但事实并非如此。有趣。
      • 那为什么clang要进行这种优化呢?
      • 编译器可以在其内部中间表示中执行重写,因为它允许在其内部中间表示中具有更少的未定义行为。
      【解决方案7】:

      编译器包含各种优化过程。通常在每次传递中都会对语句进行优化或循环优化。目前还没有基于循环头对循环体进行优化的模型。这很难检测并且不太常见。

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

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 2020-09-19
        • 1970-01-01
        • 1970-01-01
        • 2019-01-16
        • 2011-04-17
        • 1970-01-01
        • 2015-09-15
        • 1970-01-01
        相关资源
        最近更新 更多