【问题标题】:Why don't modern compilers coalesce neighboring memory accesses?为什么现代编译器不合并相邻的内存访问?
【发布时间】:2022-01-03 16:44:34
【问题描述】:

考虑以下代码:

bool AllZeroes(const char buf[4])
{
    return buf[0] == 0 &&
           buf[1] == 0 &&
           buf[2] == 0 &&
           buf[3] == 0;
}

带有-O3 的 Clang 13 的输出程序集:

AllZeroes(char const*):                        # @AllZeroes(char const*)
        cmp     byte ptr [rdi], 0
        je      .LBB0_2
        xor     eax, eax
        ret
.LBB0_2:
        cmp     byte ptr [rdi + 1], 0
        je      .LBB0_4
        xor     eax, eax
        ret
.LBB0_4:
        cmp     byte ptr [rdi + 2], 0
        je      .LBB0_6
        xor     eax, eax
        ret
.LBB0_6:
        cmp     byte ptr [rdi + 3], 0
        sete    al
        ret

每个字节都是单独比较的,但可以优化为单个 32 位 int 比较:

bool AllZeroes(const char buf[4])
{
    return *(int*)buf == 0;
}

导致:

AllZeroes2(char const*):                      # @AllZeroes2(char const*)
        cmp     dword ptr [rdi], 0
        sete    al
        ret

我还检查了 GCC 和 MSVC,但它们都没有进行此优化。 C++ 规范不允许这样做吗?

编辑: 将短路与 (&&) 更改为按位与 (&) 将生成优化代码。此外,更改字节比较的顺序不会影响代码生成:https://godbolt.org/z/Y7TcG93sP

【问题讨论】:

  • 这可能与对齐有关吗?
  • 因为编译器不知道数组的大小并且&& 是短路评估。大于0 的数组索引可能仅在buf[0] == 0true 时才有效。如果第一个测试是false,那么评估buf[1] == 0 &&可能是UB
  • @dave 不,函数参数中的数组大小仅供开发人员参考。 const char buf[4]const char *buf 作为函数参数完全相同。
  • @RichardCritten 这是有道理的,将&& 更改为按位与将生成优化的代码。我也尝试过先比较第 3 个字节,但再次失败:godbolt.org/z/Y7TcG93sP
  • return *(int*)buf == 0; 在技术上是 UB,除非传入的 buf 确实指向 int。将任何指针转换为char * 并取消引用是可以的,但将char * 转换为int * 并且仅当char * 最初指向int 时才能取消引用。 [注意:为清楚起见,删除了所有 consts]

标签: c++ assembly optimization compiler-optimization


【解决方案1】:

首先要了解的是f(const char buf[4])不保证指针指向4个元素,它的意思和const char *buf完全一样,4完全被语言忽略了。 (C99 对此有解决方案,但在 C++ 中不支持,更多内容见下文)

给定AllZeroes(memset(malloc(1),~0,1)),实现

bool AllZeroes(const char buf[4])
{
    return buf[0] == 0 &&
           buf[1] == 0 &&
           buf[2] == 0 &&
           buf[3] == 0;
}

应该可以工作,因为当它注意到字节#1 非零时,它从不尝试读取字节#2(不存在),而实现

bool AllZeroes(const int32_t *buf)
{
    return (*buf == 0);
}

当它尝试读取前 4 个字节而仅存在 1 个字节时应该出现段错误(仅分配 1 个字节)

FWIW Clang 通过实现在 C99 中正确(而 GCC 没有)

_Bool AllZeroes(const char buf[static 4])
{
    return buf[0] == 0 &
           buf[1] == 0 &
           buf[2] == 0 &
           buf[3] == 0;
}

编译成一样的

_Bool AllZeroes(const int32_t *buf)
{
    return (*buf == 0);
}

请参阅 https://godbolt.org/z/Grqs3En3K(感谢 Caze @libera #C 发现)

  • 不幸的是,buf[static 4] 在 C99 中是从程序员到编译器的保证,即指针指向最少 4 个元素,但在 C++ 中不受支持

【讨论】:

  • 一般同意这个答案,但“应该段错误”真的不是一回事。 Malloc 不会对分配之外的地址做出任何保证,它不保证它们是可访问的,但也不保证它们是不可访问的。
  • const std::array<char, 4> *buf - 很确定访问类对象的任何成员意味着该类类型的整个对象都存在并且完全可访问,即使唯一的成员是数组。例如将指向页面最后一个字节的指针作为 arg 传递是 UB。 (另一个线程可能正在编写其中一个成员,但这在 asm 中很好)。 Allocating memory for a part of structure 是关于 C,而不是 C++。我认为在过去一两年的某个时间点有一个关于此的 C++ Q&A,但我没有通过搜索找到它。
  • @PeterCordes 是的,我很确定你是对的,我删除了 std::array 部分,谢谢
  • 我认为该部分实际上非常相关。将std::array<char,4> 指针指向不是std::array 的对象是严格别名UB(尽管它可能在实践中有效),但重新设计函数以通过指针或引用而不是引用const std::array一个原始的const char* 是一个可能想要做的事情,它显示了使用容器类而不是 C 样式数组的一个间接优势。这是 C++ 对[static 4] 的回答。 (在这种情况下,它足够小,即使按值取值可能会更好;在一个寄存器 arg 中包含 4 个字节是完美的)
  • 如果您使用std::span<char, 4> 类型的输入而不是别名转换为std::array<char, 4>* 怎么办?根据 cppreference.com 的说法,尝试构建一个指向无效范围的静态正范围跨度是 UB;所以从技术上讲,我猜编译器可以合并对这样一个跨度的访问。 (尽管据我所知,标准库头文件中必须有某种特定于编译器的注释才能实现这样的优化——或者使 std::span 别名或包装内置类型等.)
【解决方案2】:

还有短路评估的事情。所以它不能像你想的那样优化。如果buf[0] == 0false buf[1] == 0,则不得勾选。它可以是 UB 或禁止使用的东西或其他任何东西 - 这一切都必须仍然有效。

https://en.wikipedia.org/wiki/Short-circuit_evaluation

【讨论】:

  • Re:“它可以是 ub”:未定义的行为是 C++ 代码的属性,而不是编译器生成的汇编代码的属性。只要编译器知道生成的程序集在生成的代码支持的系统上是安全的,C++ 编译器就可以 100% 地执行优化,如果你用 C++ 编写它会是“未定义的行为”。跨度>
  • @ruakh:是的,问题不是 ISO C 抽象机 UB,而是实际 x86 上可能出现故障的东西。在这种情况下,它是未映射的页面。如果指针没有对齐保证,arr[3] 可能位于下一页,可能未映射。如果字节不为零,则向此函数传递指向页面最后一个字节的指针是合法的(没有 C UB)。 (这是 strlen 问题的逆向:vectorized strlen getting away with reading unallocated memorythis
  • (在另一个评论线程中,I pointed out C data-race UB 在 x86 或其他普通 ISA 上特别不是问题,因为只要您不在乎,推测性读取就已经定义好了关于价值。)
【解决方案3】:

如果buf[0] 不为零,则代码将不会访问buf[1]。所以函数应该返回false而不检查其他buf元素。如果buf 接近最后一个内存页的末尾,buf[1] 可能会触发访问错误。编译器应该非常小心,不要阅读可能被禁止阅读的内容。

【讨论】:

  • 这是有问题的示例的一个很好的理由。此外,即使没有访问错误,可访问内存中也可能存在任意内存。但是,即使知道数组的大小,编译器似乎也没有优化:godbolt.org/z/ch41zd5Wc(编辑:还添加了对齐以查看是否有帮助;它没有)
  • buf 是字对齐时,一个非常聪明的编译器可能(原则上)克隆函数,例如与整个程序优化
  • @eerorika 因为您的编译器-资源管理器示例中的数组未初始化,并且调用的函数可能未初始化所有元素,访问可能会导致 UB,因此 && 短路不能短路单次读取电路,没有可能的 UB
  • @doug: x86 没有整数的陷阱表示,所以不,编译器不需要仅仅因为它们在可移植 C++ 中是 UB 就避免在 asm 中为特定目标做事,只要对于 C++ 抽象机不遇到 UB 的所有情况,都满足 as-if 规则。如果推测性读取来自绝对不会出错的位置(在没有硬件竞争检测的目标上,即普通 CPU),则允许推测性读取。 pretend_modify 可以将地址传递给当前正在写入最后一个字节的另一个线程,除非前 3 个字节为零。
猜你喜欢
  • 1970-01-01
  • 2019-01-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2018-02-08
  • 2018-06-16
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多