【问题标题】:Why can't GCC assume that std::vector::size won't change in this loop?为什么 GCC 不能假设 std::vector::size 在这个循环中不会改变?
【发布时间】:2020-05-14 11:19:51
【问题描述】:

我向一位同事声称,if (i < input.size() - 1) print(0); 将在此循环中得到优化,因此不会在每次迭代中读取input.size(),但事实证明并非如此!

void print(int x) {
    std::cout << x << std::endl;
}

void print_list(const std::vector<int>& input) {
    int i = 0;
    for (size_t i = 0; i < input.size(); i++) {
        print(input[i]);
        if (i < input.size() - 1) print(0);
    }
}

根据Compiler Explorer 和gcc 选项-O3 -fno-exceptions,我们实际上是在每次迭代读取input.size() 并使用lea 执行减法!

        movq    0(%rbp), %rdx
        movq    8(%rbp), %rax
        subq    %rdx, %rax
        sarq    $2, %rax
        leaq    -1(%rax), %rcx
        cmpq    %rbx, %rcx
        ja      .L35
        addq    $1, %rbx

有趣的是,在 Rust 中确实发生了这种优化。看起来i 被替换为变量j,每次迭代都会递减,而测试i &lt; input.size() - 1 被替换为j &gt; 0

fn print(x: i32) {
    println!("{}", x);
}

pub fn print_list(xs: &Vec<i32>) {
    for (i, x) in xs.iter().enumerate() {
        print(*x);
        if i < xs.len() - 1 {
            print(0);
        }
    }
}

Compiler Explorer 中,相关程序集如下所示:

        cmpq    %r12, %rbx
        jae     .LBB0_4

我检查了,我很确定 r12xs.len() - 1rbx 是计数器。早些时候有一个add 用于rbx 和一个mov 在循环之外进入r12

这是为什么?看起来如果 GCC 能够像它那样内联 size()operator[],它应该能够知道 size() 不会改变。但也许 GCC 的优化器判断不值得将其拉出到变量中?或者也许还有其他一些可能的副作用会导致这不安全——有人知道吗?

【问题讨论】:

  • 另外println 可能是一个复杂的方法,编译器可能无法证明println 不会改变向量。
  • @MooingDuck:另一个线程将是数据竞赛 UB。编译器可以并且确实假设 不会发生。这里的问题是对cout.operator&lt;&lt;() 的非内联函数调用。编译器不知道这个黑盒函数没有从全局中获得对std::vector 的引用。
  • @PeterCordes:你说得对,其他线程不是独立的解释,printlnoperator&lt;&lt; 的复杂性是关键。
  • 编译器不知道这些外部方法的语义。

标签: c++ gcc assembly


【解决方案1】:

cout.operator&lt;&lt;(int) 的非内联函数调用对于优化器来说是一个黑匣子(因为库只是用C++ 编写的,优化器看到的只是一个原型;参见cmets 中的讨论)。它必须假定全局变量可能指向的任何内存都已被修改。

(或者 std::endl 调用。顺便说一句,为什么要在此时强制刷新 cout 而不是只打印 '\n'?)

例如就它所知,std::vector&lt;int&gt; &amp;input 是对全局变量的引用,其中一个函数调用会修改该全局变量。 (或者在某个地方有一个全局vector&lt;int&gt; *ptr,或者有一个函数在其他编译单元中返回一个指向static vector&lt;int&gt; 的指针,或者一个函数可以在不传递对它的引用的情况下获得对该向量的引用的其他方式由我们。

如果您有一个从未被占用地址的局部变量,编译器可以假定非内联函数调用不能改变它。因为任何全局变量都无法保存指向该对象的指针。 (这称为Escape Analysis)。这就是编译器可以跨函数调用将size_t i 保存在寄存器中的原因。 (int i 可以被优化掉,因为它被 size_t i 遮蔽,并且没有被其他使用)。

它可以对本地 vector 做同样的事情(即对于 base、end_size 和 end_capacity 指针。)

ISO C99 对此问题有一个解决方案:int *restrict foo。许多 C++ 编译器支持int *__restrict foo,以保证foo 指向的内存只能通过该指针访问。在需要 2 个数组的函数中最常用,并且您希望向编译器保证它们不会重叠。因此它可以自动矢量化,而无需生成代码来检查并运行回退循环。

OP cmets:

在 Rust 中,非可变引用是一种全局保证,保证没有其他人会改变您引用的值(相当于 C++ restrict

这就解释了为什么 Rust 可以进行这种优化而 C++ 不能。


优化您的 C++

显然,您应该在函数顶部使用一次auto size = input.size();,以便编译器知道它是循环不变量。 C++ 实现不能为你解决这个问题,所以你必须自己做。

您可能还需要const int *data = input.data();std::vector&lt;int&gt;“控制块”提升数据指针的负载。 不幸的是,优化可能需要非常不习惯的源更改。

Rust 是一种更现代的语言,它是在编译器开发人员了解了编译器在实践中的可能性之后设计的。它也确实以其他方式显示,包括可移植地公开 CPU 可以通过i32.count_ones、旋转、位扫描等执行的一些很酷的事情。ISO C++ 仍然没有可移植地公开这些内容,这真的很愚蠢,除了std::bitset::count().

【讨论】:

  • OP 的代码仍然有测试向量是否按值取值。因此,即使 GCC 可以在这种情况下进行优化,它也不会这样做。
  • 标准为这些操作数类型定义了operator&lt;&lt;的行为;所以在标准 C++ 中,它不是一个黑匣子,编译器可以假设它按照文档中的说明进行操作。也许他们想支持库开发人员添加非标准行为......
  • 可以为优化器提供标准要求的行为,我的观点是标准允许这种优化,但编译器供应商选择以您描述的方式实现并放弃这种优化
  • @M.M 它没有说随机对象,我说的是实现定义的向量。标准中没有任何内容禁止实现具有操作符 cout 允许从streambuf 派生的用户定义类的对象使用cout.rdbuf 与流相关联。同样,从ostream 派生的对象可以与cout.tie 关联。
  • @PeterCordes - 我不会对局部向量这么有信心:一旦任何成员函数越界,由于隐式传递了 this 指针,因此局部函数有效地逃脱了。这可能早在构造函数时就发生在实践中。考虑 this simple loop - 我只检查了 gcc 主循环(从 L34:jne L34),但它的行为肯定就像向量成员已经逃逸(每次迭代都从内存中加载它们)。
猜你喜欢
  • 2019-08-07
  • 2011-06-28
  • 2016-08-06
  • 2020-02-01
  • 2019-06-06
  • 1970-01-01
  • 1970-01-01
  • 2022-06-10
相关资源
最近更新 更多