icc崩溃:编译器能在抽象机器中不存在的地方发明写入吗
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
到目前为止,基本的东西,对吧?
如果你不指定字符串,它可以方便地使用全局字符串快速的棕色狐狸跳过懒惰的狗,它不包含任何/
字符,因此不需要任何替换。
当然,字符串常量是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。除非它的清理代码中有问题,否则它会对vpcmpuq
和kshift
/kor
进行更复杂的处理,即零屏蔽加载和屏蔽比较到另一个屏蔽寄存器。
AVX1具有带故障抑制和所有功能的屏蔽存储(vmaskmovps/pd
),但在AVX512BW之前,没有小于32位的粒度。AVX2整数版本仅在dword/qword粒度vpmaskmovd/q
中可用。
- 我们可以访问一个不存在的联盟的成员吗
- C++:对不存在的命名空间使用命名空间指令
- g++ 说函数不存在,即使包含正确的标头
- 显式 std::exception_ptr 转换为 bool 不存在.VS2010 错误?
- C++ 尝试在不存在的构造函数中引用已删除的函数(使用 rapidJson)
- 查找第一个数组中不存在的元素
- 查找不存在的键时,unordered_map返回什么
- 如何优化代码以返回最接近给定整数的数字,但给定列表中不存在?
- set::find 查找不存在的元素
- 有没有办法将字符串添加到 Vector 中,但前提是它尚不存在?->C++
- inet_ntop返回不存在的地址
- CPP 使用不存在的键访问映射
- 为什么QMediaGaplessPlaybackControl不存在?
- 如果键不存在,使用 [] 运算符访问 STL Map 元素会添加新元素
- 标记未定义的颜色,并且颜色匹配系统中不存在样品
- 为什么minhook库目录不存在
- 为什么 std::vector::p ush_front() 不存在?
- 不存在从"Magick::Color"到"MagickCore::Quantum"的合适转换功能
- 为什么 MDI 子窗口在WM_NCCREATE后不存在?
- icc崩溃:编译器能在抽象机器中不存在的地方发明写入吗