icc崩溃:编译器能在抽象机器中不存在的地方发明写入吗

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

本文关键字:不存在 方发明 机器 编译器 崩溃 抽象机 抽象 icc      更新时间:2023-10-16

考虑以下简单程序:

#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

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

如果你不指定字符串,它可以方便地使用全局字符串快速的棕色狐狸跳过懒惰的狗,它不包含任何/字符,因此不需要任何替换。

当然,字符串常量是const char[],所以我需要先去掉常量——这就是您看到的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个_字符的矢量进行混合,有效地仅替换/字符。最后,使用vmovdqu YMMWORD PTR [rsi],ymm4指令将更新后的寄存器写回字符串。

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

我的代码是合法的C++吗?因此我应该责怪icc计算错误,或者我在某个地方陷入了UB沼泽?


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

据我所知,您的程序结构良好,没有未定义的行为。C++抽象机从未实际分配给const对象一个未执行的if()足以"隐藏"/"保护"那些执行后将成为UB的东西if(false)唯一不能拯救您的是一个格式错误的程序,例如语法错误或试图使用此编译器或目标arch上不存在的扩展。

编译器通常不允许发明使用if转换到无分支代码的写入

丢弃const是合法的,只要你实际上没有通过它进行赋值。例如,将指针传递给一个常量不正确的函数,并使用非const指针进行只读输入。你链接的答案是,只要一个const定义的对象实际上没有被修改,就允许丢弃它吗?是正确的。


ICC的行为是而不是ISO C++或C中UB的证据。我认为你的推理是合理的,这是明确的。你发现了一个ICC错误。如果有人关心,请在他们的论坛上报告:https://software.intel.com/en-us/forums/intel-c-compiler.开发人员已经接受了论坛中该部分的现有错误报告,例如本部分。


我们可以构造一个例子,其中它以相同的方式自动向量化(无条件和非原子读取/可能修改/重写),因为读取/重写发生在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);

对于线程安全性,众所周知,通过非原子读/重写进行写操作是不安全的。

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

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

或者另一个线程正在并行运行相同的搜索/替换函数,使用不同的键/替换,只会写入str2的不同元素未修改值的非原子加载/存储将依赖于来自其他线程的已修改值。根据C++11内存模型,不同的线程可以同时接触同一阵列的不同元素,这是绝对允许的。C++内存模型和char数组上的竞争条件。(这就是为什么char必须与目标机器在没有非原子RMW的情况下可以写入的最小内存单元一样大。不过,字节存储到缓存中的内部原子RMW是可以的,并且不会阻止字节存储指令的使用。)

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

正如Herb Sutter在atomic<>武器:C++内存模型和现代硬件第2部分:编译器和硬件的限制(包括常见错误)中提到的那样;x86/x64、IA64、POWER、ARM等平台上的代码生成和性能;松弛原子论;volatile:在C++11标准化之后,清除非原子RMW代码生成一直是编译器面临的问题。我们已经走到了这一步,但像ICC这样极具攻击性且不太主流的编译器显然仍然存在漏洞。

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


一些不太可信的(在实际程序中可以看到)例子,这也会破坏:

除了nullptr,您还可以传递一个指向(一个数组)std::atomic<T>的指针或一个互斥体,在该互斥体中,非原子读取/重写通过发明写入来破坏事物。(char*可以别名任何)。

或者str2指向您为动态分配而划分的缓冲区,str1的早期部分将有一些匹配,但str1的后期部分将没有任何匹配,并且str2的该部分正被其他线程使用。(由于某些原因,您无法轻松计算出使循环停止的长度)。


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

您可以编写类似str2[i] = x ? replacement : str2[i];的源代码,它总是在C++抽象机中写入字符串。IIRC,它允许gcc/clang在进行不安全的if转换以混合后,以ICC的方式向量化。

理论上,优化编译器可以在标量清理或其他操作中将其转换回条件分支,以避免不必要地弄脏内存。(或者,如果目标是像ARM32这样的ISA,其中可以进行预测存储,而不是像x86cmov、PowerPCisel或AArch64csel这样的ALU选择操作。如果谓词为false,则ARM32预测指令在体系结构上是NOP)。

或者,如果x86编译器选择使用AVX512屏蔽存储,那么也可以像ICC那样安全地向量化:屏蔽存储进行故障抑制,并且永远不会实际存储到掩码为false的元素。(当使用带有AVX-512加载和存储的掩码寄存器时,是否会因对屏蔽元素的无效访问而引发故障?)。

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位降低了最大turbo太多,所以不值得,除非你的整个程序在Skylake Xeons上大量使用AVX512。

所以我认为ICC19在使用AVX512进行矢量化时是安全的,但不是AVX2。除非它的清理代码中有问题,否则它会对vpcmpuqkshift/kor进行更复杂的处理,即零屏蔽加载和屏蔽比较到另一个屏蔽寄存器。


AVX1具有带故障抑制和所有功能的屏蔽存储(vmaskmovps/pd),但在AVX512BW之前,没有小于32位的粒度。AVX2整数版本仅在dword/qword粒度vpmaskmovd/q中可用。