考虑以下简单程序:
#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
。由于从未真正修改过字符串,因此给我留下了this is legal的印象。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个_
字符的 vector 混合,仅有效地替换/
字符。最后,使用vmovdqu YMMWORD PTR [rsi],ymm4
指令将更新后的寄存器写回到字符串中。由于字符串是只读的并且在二进制文件的
.rodata
节中分配,所以该最终存储崩溃了,该二进制文件使用只读页面加载。当然,存储是逻辑上的“禁止操作”,写回与读取的字符相同的字符,但是CPU不在乎!我的代码是合法的C++,因此我应该对icc的错误编译负责,否则我会涉足某个地方的UB沼泽?
1相同问题的相同崩溃发生在
std::replace
上的std::string
而非我的“C样”代码上,但是我想尽可能简化分析并使之完全独立。 最佳答案
据我所知,您的程序格式正确,没有不确定的行为。 C++抽象机从不实际分配给const
对象。 如果不执行,则未采用的if()
足以“隐藏”/“保护”将成为UB的内容。 唯一无法通过if(false)
拯救您的是an ill-formed程序,例如语法错误或尝试使用此编译器或目标arch上不存在的扩展名。
通常不允许编译器通过if转换为无分支代码来进行写操作。
抛弃const
是合法的,只要您实际上没有通过它分配即可。例如用于将指针传递给非const正确的函数,并使用带有非const
指针的只读输入。您在Is it allowed to cast away const on a const-defined object as long as it is not actually modified?上链接的答案是正确的。
ICC的行为在ISO C++或C中不是UB的证据。我认为您的推理是正确的,并且定义明确。您已找到ICC错误。如果有人在意,请在其论坛上报告:https://software.intel.com/en-us/forums/intel-c-compiler。开发者e.g. this one已接受其论坛中该部分中的现有错误报告。
We可以构造一个示例,在其中它以相同的方式自动矢量化(无条件和非原子读取/可能修改/重写),在显然是非法的,因为读取/重写发生在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
,因此无法使关于数据争用UB的单字符串版本的任何参数都无效,因为在读取另一个表的同时已读str
已经是UB了。甚至C++20 std::atomic_ref
也不会改变它,因为我们正在读取非原子指针。但更糟糕的是,
str2
可以是nullptr
。 或指向对象的末尾(恰好存储在页面末尾附近),str1
包含字符,因此str2
末尾不会写入任何内容/页面将发生。我们甚至可以只将最后一个字节(str2[len-1]
)安排在新页面中,以便它是有效对象的最后一位。构造这样的指针甚至是合法的(只要您不取消引用)。但是传递str2=nullptr
是合法的;不能运行的if()
后面的代码不会导致UB。或另一个线程正在并行运行相同的搜索/替换功能,但具有不同的键/替换,只能写入
str2
的不同元素。 未经修改的值的非原子加载/存储将基于来自另一个线程的修改后的值。根据C++ 11内存模型,绝对允许不同的线程同时触摸同一数组的不同元素。 C++ memory model and race conditions on char arrays。 (这就是为什么char
必须与目标计算机在没有非原子RMW的情况下可以写入的最小内存单元一样大的原因。但是internal atomic RMW for a byte stores into cache is fine并不会阻止字节存储指令的使用。)(此示例仅在单独的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>
(一个数组)或一个互斥体,在该互斥体中非原子的读/写会通过发明写操作破坏事物。 (char*
可以为任何别名)。或者
str2
指向您为动态分配而划分的缓冲区,并且str1
的早期部分将有一些匹配项,但是str1
的后半部分将没有任何匹配项,并且str2
的那部分正在被其他线程使用。 (由于某种原因,您不能轻易地计算出使循环变短的长度)。对于将来的读者:如果要让编译器通过这种方式自动矢量化:
您可以编写诸如,
str2[i] = x ? replacement : str2[i];
,之类的源代码,该源代码始终将字符串写入C++抽象机中。 IIRC,让gcc/clang在进行不安全的if-conversion转换后可以矢量化ICC的方式。从理论上讲,优化的编译器可以将其转换为标量清理中的条件分支,或避免不必要地弄脏内存的任何事情。 (或者,如果针对可能有谓词存储的ISA之类的ARM32,而不是像x86
cmov
,PowerPC isel
或AArch64 csel
这样的ALU选择操作,则如果谓词为false,ARM32谓词指令在结构上就是NOP)。或者,如果x86编译器选择使用AVX512屏蔽存储,这也将使矢量化ICC的方式变得安全:屏蔽存储进行故障抑制,并且从不实际存储到掩码为false的元素。 (When using a mask register with AVX-512 load and stores, is a fault raised for invalid accesses to masked out elements?)。
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 vector ,因为除非在Skylake Xeons上整个程序都大量使用AVX512,否则512位降低max turbo值实在不值得。因此,我认为使用AVX512(而非AVX2)将其矢量化时,ICC19是安全的。除非清理代码中存在问题,否则它会使用
vpcmpuq
和kshift
/kor
进行更复杂的操作,零掩码加载以及将掩码比较与另一个掩码reg。AVX1屏蔽了带有故障抑制功能的所有存储区(
vmaskmovps/pd
),但在AVX512BW之前,没有比32位窄的粒度。 AVX2整数版本仅在dword/qword粒度 vpmaskmovd/q
中可用。关于c++ - icc : can the compiler invent writes where none existed in the abstract machine?崩溃,我们在Stack Overflow上找到一个类似的问题:https://stackoverflow.com/questions/54524947/