icc 崩溃:编译器可以在抽象机中存在 none 的地方发明写入吗?

Crash with icc: can the compiler invent writes where none existed in the abstract machine?

考虑以下简单程序:

#include <cstring>
#include <cstdio>
#include <cstdlib>

void replace(char *str, size_t len) {
    for (size_t i = 0; i < len; i++) {
        if (str[i] == '/') {
            str[i] = '_';
        }
    }
}

const char *global_str = "the quick brown fox jumps over the lazy dog";

int main(int argc, char **argv) {
  const char *str = argc > 1 ? argv[1] : global_str;
  replace(const_cast<char *>(str), std::strlen(str));
  puts(str);
  return EXIT_SUCCESS;
}

它在命令行上获取一个(可选的)字符串并将其打印出来,其中 / 个字符被 _ 替换。此替换功能由 c_repl 函数 1 实现。例如,a.out foo/bar 打印:

foo_bar

到目前为止基本的东西,对吧?

如果不指定字符串,方便使用全局字符串the quick brown fox jumps over the lazy dog,不包含任何/ 字符,因此不会进行任何替换。

当然,字符串常量是const char[],所以我需要先去掉constness——就是你看到的const_cast。由于字符串从未真正被修改过,我的印象是 .

gcc 和 clang 编译具有预期行为的二进制文件,无论是否在命令行上传递字符串。但是,当您不提供字符串时,icc 会崩溃:

icc -xcore-avx2 char_replace.cpp && ./a.out
Segmentation fault (core dumped)

根本原因是 c_repl 的主循环,如下所示:

  400c0c:       vmovdqu ymm2,YMMWORD PTR [rsi]
  400c10:       add    rbx,0x20
  400c14:       vpcmpeqb ymm3,ymm0,ymm2
  400c18:       vpblendvb ymm4,ymm2,ymm1,ymm3
  400c1e:       vmovdqu YMMWORD PTR [rsi],ymm4
  400c22:       add    rsi,0x20
  400c26:       cmp    rbx,rcx
  400c29:       jb     400c0c <main+0xfc>

这是一个矢量化循环。基本思想是加载 32 个字节,然后与 / 字符进行比较,形成一个掩码值,其中为每个匹配的字节设置一个字节,然后将现有字符串与包含 32 [=] 的向量混合16=] 个字符,有效地仅替换了 / 个字符。最后,用vmovdqu YMMWORD PTR [rsi],ymm4指令将更新后的寄存器写回字符串。

最终存储崩溃,因为字符串是只读的并且分配在二进制文件的 .rodata 部分,该部分使用只读页面加载。当然,存储是合乎逻辑的 "no op",写回它读取的相同字符,但是 CPU 不在乎!

我的代码是合法的 C++ 代码吗,因此我应该责怪 icc 编译错误,或者我正在涉入某处的 UB 沼泽?


1 std::replacestd::string 而不是我的 "C-like" 代码上发生相同问题的相同崩溃,但我想要尽可能简化分析并使其完全独立。

据我所知,您的程序格式正确且没有未定义的行为。 C++ 抽象机实际上从未分配给 const 对象。 未采取的 if() 足以 "hide" / "protect" 执行后将成为 UB 的事情。 唯一 if(false) 无法将您从 an ill-formed 程序中拯救出来,例如语法错误或尝试使用此编译器或目标 arch 上不存在的扩展。

编译器通常不允许发明将 if 转换为无分支代码的写入。

丢弃 const 是合法的,只要您实际上不通过它进行分配。例如用于将指针传递给不正确的函数,并采用非 const 指针的只读输入。您在 上链接的答案是正确的。


ICC 在这里的行为不是 UB 在 ISO C++ 或 C 中的证据。 我认为你的推理是合理的,这很好-定义。您发现了一个 ICC 错误。如果有人关心,请在他们的论坛上报告:https://software.intel.com/en-us/forums/intel-c-compiler. Existing bug reports in that section of their forum have been accepted by developers, e.g. this one.


可以构造一个示例,它以相同的方式自动矢量化(使用无条件和非原子 read/maybe-modify/rewrite) 显然是非法的,因为读取/重写发生在 C 抽象机甚至不读取的第二个字符串上。

因此,我们不能相信 ICC 的代码生成器会告诉我们任何有关我们何时导致 UB 的信息,因为即使在明显合法的情况下它也会导致代码崩溃。

Godbolt:ICC19.0.1 -O2 -march=skylake(较旧的 ICC 仅理解 -xcore-avx2 等选项,但现代 ICC 理解与 GCC/clang 相同的 -march。 )

#include <stddef.h>

void replace(const char *str1, char *str2, size_t len) {
    for (size_t i = 0; i < len; i++) {
        if (str1[i] == '/') {
            str2[i] = '_';
        }
    }
}

它检查 str1[0..len-1]str2[0..len-1] 之间的重叠,但是对于足够大的 len 并且没有重叠,它将使用这个内部循环:

..B1.15:                        # Preds ..B1.15 ..B1.14                //do{
        vmovdqu   ymm2, YMMWORD PTR [rsi+r8]                    #6.13   // load from str2
        vpcmpeqb  ymm3, ymm0, YMMWORD PTR [rdi+r8]              #5.24   // compare vs. str1
        vpblendvb ymm4, ymm2, ymm1, ymm3                        #6.13   // blend
        vmovdqu   YMMWORD PTR [r8+rsi], ymm4                    #6.13   // store to str2
        add       r8, 32                                        #4.5    // i+=32
        cmp       r8, rax                                       #4.5
        jb        ..B1.15       # Prob 82%                      #4.5   // }while(i<len);

为了线程安全,众所周知发明通过非原子写入 read/rewrite 是不安全的。

C++ 抽象机根本不会触及 str2,因此关于数据争用 UB 不可能的单字符串版本的任何论点都无效,因为同时读取 str 另一个线程正在写它已经是UB。即使 C++20 std::atomic_ref 也不会改变这一点,因为我们正在读取一个非原子指针。

但更糟糕的是,str2 可以是 nullptr. 或指向接近对象的末尾(恰好存储接近页面末尾),str1 包含字符,这样就不会发生超过 str2/页面末尾的写入。我们甚至可以将最后一个字节 (str2[len-1]) 安排在一个新页面中,这样它就是一个有效对象的最后一个字节。构造这样一个指针甚至是合法的(只要你不取消引用)。但是通过 str2=nullptr 是合法的;不 运行 的 if() 背后的代码不会导致 UB。

或者另一个线程运行并行使用相同的search/replace函数,不同的key/replacement只会写入[=24=的不同元素]. 未修改值的非原子 load/store 将踩踏来自其他线程的修改值。根据 C++11 内存模型,不同线程同时访问同一数组的不同元素是绝对允许的。 C++ memory model and race conditions on char arrays. (This is why char must be as large as the smallest unit of memory the target machine can write without a non-atomic RMW. An ,但是,并不会阻止字节存储指令的使用。)

(此示例仅适用于单独的 str1/str2 版本,因为读取每个元素意味着线程将读取数组元素,而另一个线程可能正在写入,这是数据争用 UB .)

正如 Herb Sutter 在 atomic<> Weapons: The C++ Memory Model and Modern Hardware 第 2 部分中提到的那样:编译器和硬件的限制(包括常见错误); x86/x64、IA64、POWER、ARM 等的代码生成和性能;松散的原子; volatile:在 C++11 标准化后,清除非原子 RMW 代码生成一直是编译器面临的一个持续问题。我们已经完成了大部分工作,但是像 ICC 这样的高度激进和非主流的编译器显然仍然存在错误。

(但是,我非常有信心英特尔编译器开发人员 认为这是一个错误。)


一些不太合理的(在真实程序中看到的)例子,这也会破坏:

除了 nullptr,您还可以传递指向 std::atomic<T>(数组)的指针或互斥量,其中非原子 read/rewrite 通过发明写入来破坏事物。 (char* 可以使用别名 任何东西 )。

或者str2指向一个你为动态分配划分的buffer,str1前面的部分会有一些匹配,但是str1后面的部分赢了' 有任何匹配项,并且 str2 的那部分正被其他线程使用。 (由于某些原因,您无法轻松计算出使循环停止的长度)。


对于未来的读者:如果你想让编译器以这种方式自动向量化:

您可以像 str2[i] = x ? replacement : str2[i]; 这样编写源代码,它总是在 C++ 抽象机中写入字符串。 IIRC,这让 gcc/clang 向量化 ICC 在将其不安全的 if 转换为混合后所做的方式。

理论上,优化编译器可以将其转回标量清理中的条件分支或其他任何避免不必要地弄脏内存的方法。 (或者如果针对像 ARM32 这样的 ISA,其中可以进行谓词存储,而不是像 x86 cmov、PowerPC isel 或 AArch64 csel 这样的 ALU select 操作。ARM32 谓词如果谓词为假,指令在架构上是 NOP。

或者,如果 x86 编译器选择使用 AVX512 掩码存储,这也可以安全地以 ICC 的方式进行矢量化:掩码存储进行故障抑制,并且永远不会实际存储到掩码为假的元素。 ().

vpcmpeqb k1, zmm0, [rdi]   ; compare from memory into mask
vmovdqu8 [rsi]{k1}, zmm1   ; masked store that only writes elements where the mask is true

ICC19 实际上用 -march=skylake-avx512 基本上做到了这一点(但使用索引寻址模式)。但是使用 ymm 向量是因为 512 位降低了最大涡轮增压太多而不值得,除非你的整个程序在 Skylake Xeons 上大量使用 AVX512。

所以我认为 ICC19 在使用 AVX512 而不是 AVX2 对其进行矢量化时是安全的。除非在其清理代码中存在问题,它使用 vpcmpuqkshift / kor、零掩码加载和掩码比较到另一个掩码 reg.


AVX1 有屏蔽商店 (vmaskmovps/pd) with fault-suppression and everything, but until AVX512BW there's no granularity narrower than 32 bits. The AVX2 integer versions are only available in dword/qword granularity, vpmaskmovd/q.