据我所知,您的程序形式良好,没有未定义的行为。在C++抽象机中,从未实际分配给一个
const
对象。如果
一个未执行的if()
足以“隐藏”/“保护”那些可能会导致未定义行为的事情。if(false)
唯一无法拯救你的是
一个格式不正确的程序,例如语法错误或尝试使用此编译器或目标架构上不存在的扩展。
编译器通常不允许通过if-conversion来创建写入。
强制转换
const
是合法的,只要您不通过它进行实际分配。例如,用于将指向不符合const-correct的函数的指针传递给具有非
const
指针的只读输入的情况。您链接的关于
是否允许在const-defined对象上强制转换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
,因此无效化了任何关于单字符串版本数据竞争UB不可能的论点,因为同时读取str
和另一个线程正在写入它已经是UB。即使C++20 std::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等上的代码生成和性能;放松原子操作;易失性:自C ++11标准化以来,淘汰非原子RMW代码生成一直是编译器面临的持续问题。我们已经完成了大部分工作,但像ICC这样高度侵略性和不太主流的编译器明显仍然存在漏洞。
(但是,我非常有信心认为英特尔编译器开发人员会将其视为漏洞。)
一些不太可能出现在真实程序中的例子,这也会导致问题:
除了nullptr
,您可以传递指向(数组)std::atomic<T>
或互斥锁的指针,在非原子读/重写时会通过发明写入来破坏事情。 (char*
可以别名任何东西)。
或者str2
指向一个为动态分配划分的缓冲区,而str1
的早期部分将具有一些匹配项,但str1
的后面部分将没有任何匹配项,并且该部分的str2
正在被其他线程使用。(由于某种原因,您不能轻松地计算出停止循环的长度)。
针对未来读者:如果你想让编译器自动向量化:
你可以像这样编写源代码:str2[i] = x ? replacement : str2[i];
,它总是在C++抽象机器中写入字符串。我记得这样可以让gcc/clang向量化,类似ICC在进行不安全的if-conversion混合后所做的那样。
理论上,优化编译器可以在标量清理或其他地方将其转换回条件分支,以避免不必要地污染内存。 (或者如果针对ARM32之类的ISA,其中可能存在谓词存储器,而不仅仅是像x86 cmov
,PowerPC isel
或AArch64 csel
这样的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
实现了这一点。但是由于512位降低了最大睿频,除非整个程序都在大量使用AVX512,否则使用ymm向量会更加划算,尤其是在Skylake Xeons上。
因此,我认为当使用AVX512进行矢量化时,ICC19是安全的,但使用AVX2则不安全。除非其清理代码存在问题,其中使用vpcmpuq
和kshift
/kor
、一个零掩码加载以及将掩码比较到另一个掩码寄存器中的更复杂操作。
AVX1具有掩码存储(vmaskmovps/pd
),具有故障抑制等功能,但在AVX512BW之前,没有比32位更窄的粒度。 AVX2整数版本仅以dword/qword粒度提供,vpmaskmovd/q
。
str2[i] = x ? replacement : str2[i];
的源代码,它总是会写入字符串。理论上,优化编译器可以将其转换为标量清理中的条件分支或其他操作,以避免不必要地破坏内存。(或者,如果针对 ARM32 等支持预测存储指令的指令集架构,可以使用条件存储替代 ALU 选择操作。 或者在 x86 中使用 AVX512 掩码存储,这真的是安全的。) - Peter Cordes