为啥限制为 959 而不是 960 时优化简单循环?

Posted

技术标签:

【中文标题】为啥限制为 959 而不是 960 时优化简单循环?【英文标题】:Why is a simple loop optimized when the limit is 959 but not 960?为什么限制为 959 而不是 960 时优化简单循环? 【发布时间】:2017-06-28 19:20:54 【问题描述】:

考虑这个简单的循环:

float f(float x[]) 
  float p = 1.0;
  for (int i = 0; i < 959; i++)
    p += 1;
  return p;

如果您使用 gcc 7(快照)或 clang(主干)使用 -march=core-avx2 -Ofast 进行编译,您会得到非常相似的东西。

.LCPI0_0:
        .long   1148190720              # float 960
f:                                      # @f
        vmovss  xmm0, dword ptr [rip + .LCPI0_0] # xmm0 = mem[0],zero,zero,zero
        ret

换句话说,它只是将答案设置为 960 而没有循环。

但是,如果您将代码更改为:

float f(float x[]) 
  float p = 1.0;
  for (int i = 0; i < 960; i++)
    p += 1;
  return p;

生成的程序集实际上执行循环求和?例如clang给出:

.LCPI0_0:
        .long   1065353216              # float 1
.LCPI0_1:
        .long   1086324736              # float 6
f:                                      # @f
        vmovss  xmm0, dword ptr [rip + .LCPI0_0] # xmm0 = mem[0],zero,zero,zero
        vxorps  ymm1, ymm1, ymm1
        mov     eax, 960
        vbroadcastss    ymm2, dword ptr [rip + .LCPI0_1]
        vxorps  ymm3, ymm3, ymm3
        vxorps  ymm4, ymm4, ymm4
.LBB0_1:                                # =>This Inner Loop Header: Depth=1
        vaddps  ymm0, ymm0, ymm2
        vaddps  ymm1, ymm1, ymm2
        vaddps  ymm3, ymm3, ymm2
        vaddps  ymm4, ymm4, ymm2
        add     eax, -192
        jne     .LBB0_1
        vaddps  ymm0, ymm1, ymm0
        vaddps  ymm0, ymm3, ymm0
        vaddps  ymm0, ymm4, ymm0
        vextractf128    xmm1, ymm0, 1
        vaddps  ymm0, ymm0, ymm1
        vpermilpd       xmm1, xmm0, 1   # xmm1 = xmm0[1,0]
        vaddps  ymm0, ymm0, ymm1
        vhaddps ymm0, ymm0, ymm0
        vzeroupper
        ret

为什么会这样,为什么clang和gcc完全一样?


如果将 float 替换为 double,则同一循环的限制为 479。这对于 gcc 和 clang 也是如此。

更新 1

事实证明 gcc 7 (snapshot) 和 clang (trunk) 的行为非常不同。据我所知,clang 优化了小于 960 的所有限制的循环。另一方面,gcc 对精确值很敏感,并且没有上限。例如,当限制为 200(以及许多其他值)时,它优化循环,但当限制为 202 和 20002(以及许多其他值)。

【问题讨论】:

Sulthan 可能的意思是 1) 编译器展开循环,2) 一旦展开,就会发现求和运算可以归为一个。如果循环未展开,则无法对操作进行分组。 奇数个循环会使展开更加复杂,最后几次迭代必须特别完成。这可能足以使优化器进入无法识别快捷方式的模式。很可能,它首先必须为特殊情况添加代码,然后必须再次将其删除。在耳朵之间使用优化器总是最好的:) @HansPassant 它还针对任何小于 959 的数字进行了优化。 这通常不是通过归纳变量消除来完成,而不是展开一个疯狂的数量吗?展开 959 倍是疯狂的。 @eleanora 我用那个编译资源管理器玩过,以下似乎成立(仅谈论 gcc 快照):如果循环计数是 4 的倍数并且至少是 72,那么循环是 不展开(或者更确切地说,展开4倍);否则,整个循环将被一个常量替换 - 即使循环计数是 2000000001。我的怀疑:过早优化(如过早的“嘿,4 的倍数,这有利于展开”阻止进一步优化与更彻底的“这个循环到底是怎么回事?”) 【参考方案1】:

TL;DR

默认情况下,当前快照 GCC 7 的行为不一致,而以前的版本由于 PARAM_MAX_COMPLETELY_PEEL_TIMES 而具有默认限制,即 16。可以从命令行覆盖它。

限制的基本原理是防止过于激进的循环展开,可以是double-edged sword。

GCC 版本

GCC 的相关优化选项是-fpeel-loops,它与标志-Ofast 一起间接启用(重点是我的):

剥离有足够信息而没有的循环 滚动很多(来自个人资料反馈或静态分析)。它也开启 完整的环剥离(即完全去除小环 迭代次数恒定)。

通过-O3 和/或-fprofile-use 启用。

更多详情可加-fdump-tree-cunroll

$ head test.c.151t.cunroll 

;; Function f (f, funcdef_no=0, decl_uid=1919, cgraph_uid=0, symbol_order=0)

Not peeling: upper bound is known so can unroll completely

消息来自/gcc/tree-ssa-loop-ivcanon.c

if (maxiter >= 0 && maxiter <= npeel)
    
      if (dump_file)
        fprintf (dump_file, "Not peeling: upper bound is known so can "
         "unroll completely\n");
      return false;
    

因此try_peel_loop 函数返回false

可以通过-fdump-tree-cunroll-details获得更详细的输出:

Loop 1 iterates 959 times.
Loop 1 iterates at most 959 times.
Not unrolling loop 1 (--param max-completely-peeled-times limit reached).
Not peeling: upper bound is known so can unroll completely

可以通过使用max-completely-peeled-insns=nmax-completely-peel-times=n 参数来调整限制:

max-completely-peeled-insns

完全剥离循环的最大insn数。

max-completely-peel-times

适合完成的循环的最大迭代次数 剥落。

了解更多insns,可以参考GCC Internals Manual。

例如,如果您使用以下选项进行编译:

-march=core-avx2 -Ofast --param max-completely-peeled-insns=1000 --param max-completely-peel-times=1000

然后代码变成:

f:
        vmovss  xmm0, DWORD PTR .LC0[rip]
        ret
.LC0:
        .long   1148207104

叮当

我不确定 Clang 实际做了什么以及如何调整它的限制,但正如我所观察到的,你可以通过用 unroll pragma 标记循环来强制它评估最终值,它会完全删除它:

#pragma unroll
for (int i = 0; i < 960; i++)
    p++;

结果:

.LCPI0_0:
        .long   1148207104              # float 961
f:                                      # @f
        vmovss  xmm0, dword ptr [rip + .LCPI0_0] # xmm0 = mem[0],zero,zero,zero
        ret

【讨论】:

感谢您提供这个非常好的答案。正如其他人指出的那样,gcc 似乎对确切的限制大小很敏感。例如,它无法消除 912 godbolt.org/g/EQJHvT 的循环。在这种情况下,fdump-tree-cunroll-details 说明了什么? 其实连200都有这个问题。这一切都在 Godbolt 提供的 gcc 7 的快照中。 godbolt.org/g/Vg3SVs这根本不适用于clang。 你解释了剥离的机制,但没有解释 960 的相关性是什么,或者为什么根本没有限制 @M.M:GCC 6.3.0 和最新的 snaphost 之间的剥离行为完全不同。对于前者,我强烈怀疑硬编码限制由PARAM_MAX_COMPLETELY_PEEL_TIMES 参数强制执行,该参数在/gcc/params.def:321 中定义,值为16。 您可能想提一下为什么 GCC 故意以这种方式限制自己。具体来说,如果您过于激进地展开循环,则二进制文件会变得更大,并且您不太可能适合 L1 缓存。缓存未命中可能是quite expensive,相对于保存一些条件跳转,假设有良好的分支预测(对于典型的循环,您将拥有)。【参考方案2】:

看了苏尔坦的评论,我猜:

    如果循环计数器为常数(并且不太高),编译器将完全展开循环

    展开后,编译器会看到求和运算可以归为一个。

如果循环由于某种原因没有展开(这里:它会生成太多带有1000 的语句),则无法对操作进行分组。

编译器可以看到 1000 条语句的展开相当于一次加法,但是上面描述的第 1 步和第 2 步是两个单独的优化,所以它不能冒展开的“风险”,而不是知道操作是否可以分组(例如:函数调用不能分组)。

注意:这是一个极端情况:谁使用循环再次添加相同的东西?在这种情况下,不要依赖编译器可能的展开/优化;直接在一条指令中编写正确的操作。

【讨论】:

那么您能专注于not too high 部分吗?我的意思是为什么在 100 的情况下不存在风险?我猜到了……在我上面的评论中……这可能是原因吗? 我认为编译器没有意识到它可能触发的浮点不准确性。我想这只是指令大小的限制。你有max-unrolled-insnsmax-unrolled-times 啊,这是我的想法或猜测……希望得到更清晰的推理。 有趣的是,如果您将float 更改为int,gcc 编译器能够在不考虑迭代次数的情况下减少循环,因为它的归纳变量优化 (-fivopts)。但这些似乎不适用于floats。 @CortAmmon 是的,我记得读过一些人对 GCC 使用 MPFR 来精确计算非常大的数字感到惊讶和不安,这与会累积错误和精度损失。表明许多人以错误的方式计算浮点数。【参考方案3】:

非常好的问题!

在简化代码时,编译器尝试内联的迭代或操作的数量似乎已达到限制。正如 Grzegorz Szpetkowski 所记录的,有编译器特定的方法可以使用编译指示或命令行选项调整这些限制。

您还可以使用Godbolt's Compiler Explorer 来比较不同的编译器和选项如何影响生成的代码:gcc 6.2icc 17 仍然内联 960 的代码,而 clang 3.9 没有(使用默认的 Godbolt 配置,它实际上在 73 处停止内联)。

【讨论】:

我已经编辑了这个问题,以明确我正在使用的 gcc 和 clang 的版本。见godbolt.org/g/FfwWjL。例如,我正在使用 -Ofast。

以上是关于为啥限制为 959 而不是 960 时优化简单循环?的主要内容,如果未能解决你的问题,请参考以下文章

为啥 C 和 C++ for 循环使用 int 而不是 unsigned int?

当循环在异步函数内部而不是相反时,为啥 Async/Await 可以正常工作?

当我使用 std::algorithms 而不是普通循环时,为啥这段代码会变慢?

为啥用 DFS 而不是 BFS 在图中寻找循环

为啥我的音频功能一直循环而不是一直运行?

为啥 C++ 编译器不优化对结构数据成员的读写而不是不同的局部变量?