为什么这个简短的比较没有像我预期的那样进行优化?

8
我有一个复合索引类型,由两个16位整数打包成一个32位对象组成,旨在传递和处理类似指针的东西。但是我注意到我定义的比较运算符没有按照我预期的方式进行优化。
给定以下简化代码:
#include <cstdint>

struct TwoParter {
    std::uint16_t blk;
    std::uint16_t ofs;
};
static_assert (sizeof(TwoParter) == sizeof(std::uint32_t), "pack densely");

bool equal1 (TwoParter const & lhs, TwoParter const & rhs) {
    return lhs.blk == rhs.blk && lhs.ofs == rhs.ofs;
}

bool equal2 (TwoParter const & lhs, TwoParter const & rhs) {
    auto lp = reinterpret_cast <std::uint32_t const *> (&lhs);
    auto rp = reinterpret_cast <std::uint32_t const *> (&rhs);
    return *lp == *rp;
}

GCC(在Compiler Explorer上的7.1版本)生成以下汇编代码(选项-m64 -std=c++11 -O3):

equal1(TwoParter const&, TwoParter const&):
        movzwl  (%rsi), %edx
        xorl    %eax, %eax
        cmpw    %dx, (%rdi)
        je      .L5
        rep ret
.L5:
        movzwl  2(%rsi), %eax
        cmpw    %ax, 2(%rdi)
        sete    %al
        ret
equal2(TwoParter const&, TwoParter const&):
        movl    (%rsi), %eax
        cmpl    %eax, (%rdi)
        sete    %al
        ret

其中一个似乎比另一个做更多的工作。但我只是看不出它们有什么不同:断言保证结构体的布局是这样的,即将其作为uint23_t进行比较必须比分别检查uint16_t字段时比较所有相同的数据。更重要的是,这是x86架构,所以编译器已经知道这将是情况。逻辑运算符&&的短路行为对输出来说并不重要,因为它的右操作数没有任何影响(编译器可以看到这一点),而且由于没有发生其他有趣的事情,我无法想象为什么它会想要推迟加载数据的后半部分。
&运算符替换&&可以消除跳转,但并不从根本上改变代码的功能:它仍然生成两个单独的16位比较,而不是一次性比较所有数据,这表明短路可能不是问题(尽管它确实引发了一个相关的问题,即为什么它不能在两种情况下编译&&& - 肯定有一种方法在这两种情况下都更好)。
令我感兴趣的是,根据编译器资源管理器,所有主要编译器(GCC、Clang、Intel、MSVC)似乎都做了大致相同的事情。这降低了这是一个优化器疏忽的机会,但我看不出我的评估实际上是错误的。
因此,这有两个部分:
1)equal1是否真的与equal2做同样的事情?我错过了什么疯狂的东西吗?
2)如果是,为什么编译器选择不发出较短的指令序列?
我确定这种类型的优化一定是编译器知道的,因为它们对于加速其他更严重的代码(如将数据压入向量寄存器以一次比较更多数据的memcmp等)会更有用。

1
可能是由于需要短路 && 运算符的要求? - Mark Ransom
1
@MarkRansom 这可能是关于“用&运算符替换&&”的内容。 - Quentin
1
我能看到可能会阻止优化的问题:对齐限制和字节序。 - Jarod42
1
@Jarod42 字节序?那不应该阻碍按位比较,对吧? - Quentin
1
@Jarod42 很好的发现,将 alignas (std::uint32_t) 添加到 TwoParter 的声明中使它们变得相同。想把它变成一个答案吗?我以为在 x86 上 int 没有对齐要求? - Alex Celeste
显示剩余2条评论
1个回答

11

对齐要求不同,TwoParter的对齐方式与std::uint16_t相同。

TwoParter更改为

struct alignas(std::uint32_t) TwoParter {
    std::uint16_t blk;
    std::uint16_t ofs;
};

生成与gcc 7.1相同的汇编代码:

equal1(TwoParter const&, TwoParter const&):
        movl    (%rsi), %eax
        cmpl    %eax, (%rdi)
        sete    %al
        ret
equal2(TwoParter const&, TwoParter const&):
        movl    (%rsi), %eax
        cmpl    %eax, (%rdi)
        sete    %al
        ret

演示


1
为什么对于x86来说并不是真正的问题的对齐方式,还是会让编译器感到害怕呢? - harold
@Harold,即使在(某些)x86处理器上,不对齐是否会导致潜在的减速呢? - zch
尽管我理解原始代码应该在x86上同样有效,但最好还是保守一些。也许优化器不值得考虑可能更慢的访问是否仍然比单独的正确访问更好? - Alex Celeste
@zch 嗯,在 Core2 上,如果你不小心越过缓存行边界,是会有这个问题的...但那已经是古代历史了。 - harold
即使有缓存未命中,通常也只会慢上1个时钟周期(除非你真的有缓存未命中),因此我仍然预计两部分16位代码读取速度会更慢,即使是针对非对齐读取。也许优化器没有很好地处理这种情况,因为任何关注此案例性能的人都应该注意对齐吧? - Ped7g

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