icc编译器崩溃:编译器能否在抽象机器中发明不存在的写操作?

31

考虑以下简单程序:

#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

到目前为止只是一些基础知识,对吧?

如果你没有指定字符串,它会方便地使用全局字符串the quick brown fox jumps over the lazy dog ,它不包含任何/字符,因此不会经历任何替换。

当然,字符串常量是const char[],因此我需要先去掉const限定——你看到的就是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部分,因此最终的存储会导致程序崩溃。当然,这个存储操作实际上是一个逻辑上的"no op",它只是将读取到的字符再次写回,但是CPU并不知道这一点!

我的代码是否合法C++?因此我应该责怪icc误编译了它,还是我在某个地方涉及到了未定义行为?


1在一个std::string上使用std::replace也会因为同样的问题而导致程序崩溃,但我想尽可能简化分析并使其完全自包含。


7
但是,字符串字面值没有被修改,因为它不包含“/”字符,所有的修改都是基于存在“/”字符的前提条件。这实际上取决于对“从未被真正修改”的解释。优化器假定在字符串上执行逻辑上的无操作是安全的,但在这种情况下实际上并不安全。这是一个有趣的问题,我很想看看答案会说什么。 - Cody Gray
3
@DaveS 这怎么会是未定义行为?请在答案部分回答,这样我们才能正确投票。 - Baum mit Augen
6
@DaveS,你声称死代码路径中存在赋值语句就会引起未定义行为,需要有理由支持。 - Baum mit Augen
4
未来读者:如果您想让编译器以这种方式自动向量化代码,您可以编写类似于 str2[i] = x ? replacement : str2[i]; 的源代码,它总是会写入字符串。理论上,优化编译器可以将其转换为标量清理中的条件分支或其他操作,以避免不必要地破坏内存。(或者,如果针对 ARM32 等支持预测存储指令的指令集架构,可以使用条件存储替代 ALU 选择操作。 或者在 x86 中使用 AVX512 掩码存储,这真的是安全的。) - Peter Cordes
4
英译中:英特尔喜欢过度猜测。 翻译为:英特尔经常产生过多的猜测。 - Language Lawyer
显示剩余17条评论
1个回答

25
据我所知,您的程序形式良好,没有未定义的行为。在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则不安全。除非其清理代码存在问题,其中使用vpcmpuqkshift/kor、一个零掩码加载以及将掩码比较到另一个掩码寄存器中的更复杂操作。
AVX1具有掩码存储(vmaskmovps/pd),具有故障抑制等功能,但在AVX512BW之前,没有比32位更窄的粒度。 AVX2整数版本仅以dword/qword粒度提供,vpmaskmovd/q

1
我认为你甚至不需要使用char别名std::atomic对象来解决内存模型问题:想象一下这种情况,某个线程T2已经通过某种机制从T1发布到T2并获得了str2的独占同步访问权限,并正在进行写操作。同时,T1正在使用str2运行replace函数,但是这是允许的,因为您已经安排好不会发生任何访问:但是虚构的写入会覆盖T2正在进行的操作! - BeeOnRope
另一种情况是,考虑T1和T2共享某些字符数组的情况,但是按照惯例,T1只访问奇数元素,而T2只访问偶数元素。这是允许的,因为根据内存模型,数组元素是单独的对象(例如,这意味着字节写入不能使用更大字的RMW)。现在T1/2中的任何一个或两个都使用共享数组作为str2组织方式,通过str1仅访问"它们"的str2元素。看起来是合法的。然而,由于合并实际上是宽RMW,会破坏相邻的元素,所以有时会出问题。不需要通过char进行std::atomic别名! - BeeOnRope
@BeeOnRope:哦,是的,傻瓜,另一个线程可能只是使用已知写入不相交元素集的键运行相同的搜索/替换。忘记了这种情况。 - Peter Cordes
这是一个不错的答案,但我有些难以接受它,因为我觉得它缺乏对标题问题的明确回答。也许在开头加上几个句子来回答这个问题?第一句话对我来说有点混淆,也许可以澄清一下。我猜你是在说类似于“ICC编译此代码的方式并不是C++中你的代码存在UB的证据...”,对吗? - BeeOnRope
@BeeOnRope:说得好。我在开头添加了一个部分。可能有点夸张,认为if(false)除了不良形式的代码之外可以拯救你免受一切,但我想不出反例。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接