为什么GCC不能假设std :: vector :: size在此循环中不会改变?


14

我要求一个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);
    }
}

根据带有gcc选项的Compiler Explorer-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 < input.size() - 1被替换为j > 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);
        }
    }
}

编译器资源管理器中,相关程序集如下所示:

        cmpq    %r12, %rbx
        jae     .LBB0_4

我检查,我敢肯定r12xs.len() - 1rbx是计数器。之前有addfor rbxmovin循环之外r12

为什么是这样?看起来,如果GCC能够内联size()operator[],它应该能够知道size()并不会改变。但是,也许GCC的优化程序判断是否值得将其提取为变量?也许还有其他可能导致这种情况不安全的副作用-有人知道吗?


1
println可能是一个复杂的方法,编译器可能无法证明println不会使向量发生变异。
Mooing Duck

1
@MooingDuck:另一个线程是数据争用UB。编译器能够而且确实认为不会发生。这里的问题是对的非内联函数调用cout.operator<<()。编译器不知道该黑盒函数没有std::vector从全局引用。
彼得·科德斯

@PeterCordes:您是对的,其他线程不是一个独立的解释,而printlnor 的复杂性operator<<是关键。
凌晨

编译器不知道这些外部方法的语义。
user207421

Answers:


10

的非内联函数调用cout.operator<<(int)是优化程序的黑匣子(因为该库只是用C ++编写的,并且所有优化程序看到的都是原型;请参见注释中的讨论)。它必须假定任何可能由全局变量指向的内存已被修改。

(或者是std::endl呼叫。顺便说一句,为什么在那时强制冲出cout而不是仅仅打印一个'\n'?)

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

如果您有一个从未使用过地址的局部变量,则编译器可以假定非内联函数调用无法对其进行突变。因为任何全局变量都无法保存指向该对象的指针。(这称为转义分析)。这就是为什么编译器可以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评论:

在Rust中,非可变引用是一种全局保证,可以确保没有其他人对您引用的值进行突变(等同于C ++ restrict

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


优化您的C ++

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

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

Rust是一种更现代的语言,是在编译器开发人员了解了编译器在实践中可能实现的功能之后设计的。它也确实以其他方式显示出来,包括可移植地公开CPU可以通过i32.count_ones,旋转,位扫描等方式完成的一些很酷的工作。愚蠢的是,ISO C ++仍然没有可移植地暴露其中的任何一个std::bitset::count()


1
OP的代码仍然可以测试向量是否按值获取。因此,即使GCC可以在这种情况下进行优化,也不会这样做。
胡桃

1
该标准定义了operator<<这些操作数类型的行为。因此在Standard C ++中,它不是一个黑匣子,编译器可以假定它按照文档中的说明进行操作。也许他们想支持库开发人员添加非标准行为...
MM

2
该优化器可以满足标准规定的行为,我的意思是该优化是标准允许的,但是编译器供应商选择以您描述和放弃此优化的方式实施
MM

2
@MM它不是说随机对象,而是一个实现定义的向量。该标准中没有任何内容禁止实现拥有操作员<<修改的实现定义向量,并允许以实现定义的方式访问此向量。 cout使用允许用户派生的用户定义类的对象streambuf与流关联cout.rdbuf。类似地,派生自的对象ostream可以与关联cout.tie
罗斯里奇

2
@PeterCordes-我对局部向量不会那么有信心:一旦任何成员函数超出范围,局部函数就有效地逃脱了,因为this隐式传递了指针。实际上,这可能最早在构造函数中发生。考虑一下这个简单的循环 -我只检查了gcc主循环(从L34:jne L34),但是它的行为确实好像向量成员都已转义(每次迭代从内存中加载它们)。
BeeOnRope
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.