崩溃与icc:编译器发明写入抽象机器中不存在?

Bee*_*ope 27 c++ x86 simd icc language-lawyer

考虑以下简单程序:

#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;
}
Run Code Online (Sandbox Code Playgroud)

它在命令行上使用(可选)字符串并打印它,并/替换为字符_.该替换功能由c_repl功能1实现.例如,a.out foo/bar打印:

foo_bar
Run Code Online (Sandbox Code Playgroud)

到目前为止的基本内容,对吧?

如果你没有指定一个字符串,它可以方便地使用全局字符串,快速棕色狐狸跳过懒狗,它不包含任何/字符,因此不会进行任何替换.

当然,字符串常量是const char[],所以我需要先抛弃常量- 这就是const_cast你所看到的.由于字符串从未被实际修改过,因此我认为这是合法的.

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

icc -xcore-avx2 char_replace.cpp && ./a.out
Segmentation fault (core dumped)
Run Code Online (Sandbox Code Playgroud)

根本原因是主循环,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>
Run Code Online (Sandbox Code Playgroud)

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

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

我的代码是合法的C++,因此我应该责怪icc错误编译这个,或者我在某处趟过UB沼泽?


1同一个问题的崩溃发生std::replace在一个std::string而不是我的"C-like"代码上,但我想尽可能地简化分析并使其完全自包含.

Pet*_*des 22

据我所知,你的程序格式正确,没有未定义的行为.C++抽象机器实际上从未分配给const对象. 未采取if()足以"隐藏"/"保护"如果执行UB的事情. 唯一if(false)不能救你的是一个格式错误的程序,例如语法错误或尝试使用此编译器或目标arch上不存在的扩展.

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

抛弃const是合法的,只要你不通过它实际分配.例如,用于将指针传递给不是const-correct的函数,并且使用非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理解与-marchGCC/clang 相同.)

#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] = '_';
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

它检查之间的重叠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);
Run Code Online (Sandbox Code Playgroud)

对于线程安全,众所周知,通过非原子读/重写发明写是不安全的.

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

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

或者另一个线程并行运行相同的搜索/替换功能,使用不同的键/替换来写入不同的元素str2. 未修改值的非原子加载/存储将踩到来自另一个线程的修改值.根据C++ 11内存模型,它绝对允许不同的线程同时触摸同一阵列的不同元素. char数组上的C++内存模型和竞争条件.(这就是为什么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按照ICC在进行不安全的if-conversion混合后的方式进行矢量化.

从理论上讲,优化编译器可以将其转换回标量清理中的条件分支,或者不必要地避免弄脏内存.(或者,如果针对可能存在谓词存储的ARM32之类的ISA,而不是仅仅ALU选择操作,如x86 cmov,PowerPC isel或AArch64 csel.如果谓词为假,则ARM32谓词指令在体系结构上是NOP).

或者,如果x86编译器选择使用AVX512屏蔽存储,那么也可以安全地按照ICC的方式进行向量化:屏蔽存储执行故障抑制,并且从不实际存储到掩码为假的元素.(当使用带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
Run Code Online (Sandbox Code Playgroud)

ICC19实际上基本上做了这个(但使用索引寻址模式)-march=skylake-avx512.但是使用ymm向量,因为512位降低了最大turbo太多值得它,除非你的整个程序大量使用AVX512,无论如何在Skylake Xeons上.

所以我认为ICC19在使用AVX512进行矢量化时是安全的,而不是AVX2.除非在清理代码中出现问题,否则它会执行更复杂的vpcmpuqkshift/ kor,零掩码加载,以及屏蔽比较到另一个掩码reg.


AVX1已经屏蔽了vmaskmovps/pd具有故障抑制功能的存储()以及所有功能,但在AVX512BW之前,没有比32位更窄的粒度.AVX2整数版本仅在dword/qword粒度中可用vpmaskmovd/q.


归档时间:

查看次数:

511 次

最近记录:

6 年,6 月 前