8 个后续字节的测试未转换为单个比较指令

Test of 8 subsequent bytes isn't translated into a single compare instruction

的启发,我比较了三个不同的函数来检查参数指向的8个字节是否为零(请注意,在原始问题中,字符与'0'进行比较,而不是0):

bool f1(const char *ptr)
{    
  for (int i = 0; i < 8; i++)
    if (ptr[i])
      return false;
  return true;
}

bool f2(const char *ptr)
{  
  bool res = true;
  for (int i = 0; i < 8; i++)
    res &= (ptr[i] == 0);
  return res;
}

bool f3(const char *ptr)
{  
  static const char tmp[8]{};
  return !std::memcmp(ptr, tmp, 8);
}

虽然我希望启用优化后的汇编结果相同,但只有 memcmp 版本在 x64 上被转换为单个 cmp 指令。 f1f2 都被翻译成缠绕或展开的循环。此外,这适用于所有具有 -O3.

的 GCC、Clang 和 Intel 编译器

f1f2 有什么原因不能优化成单个比较指令吗?这对我来说似乎是一个非常简单的优化。

现场演示:https://godbolt.org/z/j48366

您需要稍微帮助一下您的编译器才能得到您想要的...如果您想在一个 CPU 操作中比较 8 个字节,您需要更改您的 char 指针,以便它指向到实际上是 8 个字节长的东西,比如 uint64_t 指针。

如果你的编译器不支持uint64_t,你可以使用unsigned long long*代替:

#include <cstdint>

inline bool EightBytesNull(const char *ptr)
{  
    return *reinterpret_cast<const uint64_t*>(ptr) == 0;
}

请注意,这适用于 x86,但不适用于 ARM,它需要严格的整数内存对齐。

Is there any reason why f1 and f2 cannot be optimized into a single compare instruction (possibly with additional unaligned load)? It seem to be a pretty straightforward optimization to me.

f1 中,当 ptr[i] 为真时循环停止,因此考虑 8 个元素并不总是等同于其他两个函数的情况或者如果数组的大小小于 8,则直接比较一个 8 字节的字(编译器不知道数组的大小):

f1("[=10=]0[=10=]1"); // no access out of the array
f2("[=10=]0[=10=]1"); // access out of the array
f3("[=10=]0[=10=]1"); // access out of the array

对于 f2 我同意在 CPU 允许从任何地址对齐中读取一个 8 字节的字的情况下可以用 8 字节比较替换是 x64 的情况,但可能会引入异常情况,如

中所述

首先,f1 在第一个 non-zero 字节处停止读取,因此在某些情况下,如果您向它传递一个指向接近末尾的较短对象的指针,它不会出错一页,下一页未映射。 如@bruno 指出的那样,在 f1 没有遇到 UB 的情况下,无条件读取 8 个字节可能会出错。 ()。编译器不知道您永远不会以这种方式使用它;它必须使代码适用于所有可能的 non-UB 情况,适用于任何假设的调用者。

您可以通过使函数 arg const char ptr[static 8](但这是 C99 的特性,而不是 C++)来解决这个问题,以保证即使 C 抽象机不会接触所有 8 个字节也是安全的。然后编译器可以安全地发明读取。 (指向 struct {char buf[8]}; 的指针也可以工作,但如果实际的 pointed-to 对象不是那个,那么 strict-aliasing 就不安全了。)


GCC 和 clang 不能 auto-vectorize 循环,其 trip-count 在第一次迭代之前是未知的。 这样就排除了所有搜索循环,例如f1,即使让它检查已知大小的静态数组或其他东西。 (不过,ICC 可以像简单的 strlen 实现一样向量化一些搜索循环。)

您的 f2 可以像 f3 一样优化为 qword cmp,而无需克服主要的 compiler-internals 限制,因为它总是进行 8 次迭代。事实上,clang 的当前夜间构建 do optimize f2,感谢 @Tharwen 发现了这一点。

识别循环模式并不是那么简单,需要花费编译时间来寻找。不知道这个优化在实践中有多大价值;这就是编译器开发人员在考虑编写更多代码来寻找此类模式时需要权衡的问题。 (代码的维护成本,以及 compile-time 成本。)

价值取决于有多少真实世界代码实际上有这样的模式,以及当你找到它时节省了多少。在这种情况下,这是一个非常好的节省,因此 clang 寻找它并不疯狂,特别是如果他们具有将 8 字节以上的循环转换为一般 8 字节整数操作的基础设施。


在实践中,如果需要的话,只需使用 memcmp;显然大多数编译器不会花时间寻找像 f2 这样的模式。现代编译器会可靠地内联它,尤其是对于 x86-64,其中已知未对齐加载在 asm 中是安全和高效的。

或者使用 memcpy 进行 aliasing-safe 未对齐加载并进行比较,如果您认为您的编译器更有可能具有内置 memcpy 而不是 memcmp。

或者在 GNU C++ 中,使用 typedef 来表示未对齐的 may-alias 加载:

bool f4(const char *ptr) {
   typedef uint64_t aliasing_unaligned_u64 __attribute__((aligned(1), may_alias));
    auto val = *(const aliasing_unaligned_u64*)ptr;
    return val != 0;
}

Godbolt with GCC10 -O3 上编译:

f4(char const*):
        cmp     QWORD PTR [rdi], 0
        setne   al
        ret

转换为 uint64_t* 可能违反 alignof(uint64_t),并且可能违反 strict-aliasing 规则,除非 char* 指向的实际对象与 [=27] 兼容=].

是的,对齐 确实 在 x86-64 上很重要,因为 ABI 允许编译器基于它做出假设。在极端情况下,真正的编译器可能会出现故障 movaps 或其他问题。