c++ - icc : can the compiler invent writes where none existed in the abstract machine?崩溃

标签 c++ x86 language-lawyer simd icc

考虑以下简单程序:

#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是安全的。除非清理代码中存在问题,否则它会使用vpcmpuqkshift/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/

相关文章:

C++20 NTTP 特化

c++ - 元组到元组的 std::map 和使用 emplace

c++ - 使用 db.isOpen() 检查数据库连接

c - 严格别名是单向的吗?

assembly - x86 Assembly 为什么使用 Push/Pop 而不是 Mov?

c++ - 通过成员指针 : is it a hack? 访问 protected 成员

c++ - 如何将lambda表达式作为参数传递给可变参数模板类中的mermber函数

c++ - 无法理解实例变量

performance - 依赖链分析

c++ - 模板特化中的隐式转换