GCC在向量化和循环大小方面的行为令人困惑

9

最初调查#pragma omp simd指令的影响时,我遇到了一个与简单for循环向量化相关的我无法解释的行为。以下代码示例可以在这个惊人的编译器探测器上进行测试,只需应用-O3指令并且我们在x86架构上。

有人能解释一下以下观察背后的逻辑吗?

#include <stdint.h> 

void test(uint8_t* out, uint8_t const* in, uint32_t length)
{
    unsigned const l1 = (length * 32)/32;  // This is vectorized
    unsigned const l2 = (length / 32)*32;  // This is not vectorized

    unsigned const l3 = (length << 5)>>5;  // This is vectorized
    unsigned const l4 = (length >> 5)<<5;  // This is not vectorized

    unsigned const l5 = length -length%32; // This is not vectorized
    unsigned const l6 = length & ~(32 -1); // This is not vectorized

    for (unsigned i = 0; i<l1 /*pick your choice*/; ++i)
    {
      out[i] = in[i*2];
    }
}

我感到困惑的是,尽管l1和l3不能保证是32的倍数,但两者都能生成矢量化代码。所有其他长度都不会生成矢量化代码,但应该是32的倍数。这背后是否有原因?
顺带一提,使用#pragma omp simd指令实际上并没有改变任何东西。
编辑:经过进一步调查,当索引类型为size_t(甚至不需要边界操作)时,行为差异消失,这意味着生成了矢量化代码:
#include <stdint.h> 
#include <string>

void test(uint8_t* out, uint8_t const* in, size_t length)
{
    for (size_t i = 0; i<length; ++i)
    {
        out[i] = in[i*2];
    }
}

如果有人知道循环向量化为什么如此依赖索引类型,我会很好奇想要了解更多!

编辑2,感谢Mark Lakata提醒,需要使用O3。


这可能被视为对这个问题的延伸,Clang也显示出完全相同的行为,所以我猜这背后有一些逻辑。 - Benjamin Lefaudeux
1
似乎编译器担心索引可能会溢出并因此放弃了 :-( - Marc Glisse
1
@BenjaminLefaudeux 请考虑接受那个清楚表达的回答,我认为是由2501提供的。 - underscore_d
@underscore_d 我实际上更希望他不要这样做,因为一个合适的答案应该深入到汇编语言中。 - 2501
@2501 我修改的例子和带有signed/unsigned的备注可能不够清晰,但是如果你使用不同类型(例如int/unsigned int)测试第二个代码片段,你会得到不同的结果,这是由溢出风险解释的。 - Benjamin Lefaudeux
显示剩余2条评论
2个回答

4
问题在于将无符号整数unsigned转换为数组下标的size_t,这在代码中表现为in[i*2]; 如果使用l1l3,计算i*2总是适合size_t类型。这意味着unsigned类型实际上就像size_t类型一样。
但是,当使用其他选项时,计算i*2的结果可能不适合size_t,因为值可能会溢出,必须进行转换。
如果您采用第一个示例,不选择l1l3选项,并进行强制转换:
out[i] = in[( size_t )i*2];

如果您对整个表达式进行转换,编译器会进行优化:

out[i] = in[( size_t )(i*2)];

它并不。


1 标准其实并没有规定索引中的类型必须是size_t,但从编译器的角度来看,这是一个合理的步骤。


我不确定当解引用指针时,索引是否会转换为 size_t,但你所提到的溢出可能性仍然存在。 - SirGuy
根据标准,他们不这样做,参见更新。 - 2501
1
我仍然不同意问题出在从“unsigned”转换为“size_t”上(在32位机器上是无操作的)。你的答案对我来说更有意义,因为它涉及到处理环绕和编译器何时可以证明它不会发生。 - SirGuy
@GuyGreer 就 C 语言而言,不存在任何转换。我已经澄清了措辞。 - 2501
@GuyGreer 我也认为这是一个包装问题:在l1/l3的情况下,左边的位被移除了,所以编译器知道*2不会发生溢出(我从未想到它们如此智能..),因此可以启用矢量化。我认为这就是原因,至少它解释了我这边所有的点(还有类型依赖性)。 - Benjamin Lefaudeux

1
我相信您把优化和向量化混淆了。我使用了您的编译器浏览器并为x86设置了-O2,但没有一个示例被“向量化”。
这里是l1
test(unsigned char*, unsigned char const*, unsigned int):
        xorl    %eax, %eax
        andl    $134217727, %edx
        je      .L1
.L5:
        movzbl  (%rsi,%rax,2), %ecx
        movb    %cl, (%rdi,%rax)
        addq    $1, %rax
        cmpl    %eax, %edx
        ja      .L5
.L1:
        rep ret

这里是l2
test(unsigned char*, unsigned char const*, unsigned int):
        andl    $-32, %edx
        je      .L1
        leal    -1(%rdx), %eax
        leaq    1(%rdi,%rax), %rcx
        xorl    %eax, %eax
.L4:
        movl    %eax, %edx
        addq    $1, %rdi
        addl    $2, %eax
        movzbl  (%rsi,%rdx), %edx
        movb    %dl, -1(%rdi)
        cmpq    %rcx, %rdi
        jne     .L4
.L1:
        rep ret

这并不奇怪,因为你正在进行的实质上是一次“聚合”加载操作,其中加载索引与存储索引不同。在x86中没有支持聚合/散开的功能。它只在AVX2和AVX512中引入,并且未被选中。 稍长的代码正在处理有符号/无符号问题,但没有进行矢量化处理。

感谢您澄清向量化问题。您能详细说明一下有符号/无符号吗?C源代码中没有使用有符号类型,那么为什么会在汇编中使用它们呢? - 2501
嗯,我不是很确定,但我猜测这可能与间接加载movzbl(%rsi, %rax, 2), %ecx的限制有关,而且%rax必须小于32位,否则比例为2的结果会溢出。但我在谷歌上没有快速找到答案... - Mark Lakata
就我所知,您的代码中有一个有符号值。常量2是有符号的...但这在本讨论中并不重要。 - Mark Lakata
1
我的错,-O3向量化了,我会纠正问题。编译器可以通过增加字长和丢弃奇数部分来优化负载,这是一种有效的“向量化”方法,虽然不像本地交错负载那样好,但已经存在于x86上有一段时间了。就我而言,关于溢出风险的解释已经解释了一切。 - Benjamin Lefaudeux

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接