为啥 C++ 编译器不优化对结构数据成员的读写而不是不同的局部变量?

Posted

技术标签:

【中文标题】为啥 C++ 编译器不优化对结构数据成员的读写而不是不同的局部变量?【英文标题】:Why don't C++ compilers optimize away reads and writes to struct data members as opposed to distinct local variables?为什么 C++ 编译器不优化对结构数据成员的读写而不是不同的局部变量? 【发布时间】:2018-06-26 10:56:25 【问题描述】:

我正在尝试使用在编译时已知的固定 max_size 创建一些 POD 值(例如 double)的本地数组,然后读取运行时 size 值(size <= max_size)并首先处理size 来自该数组的元素。

问题是,当arrsize 放在同一个struct/class 中时,为什么编译器不消除堆栈读取和写入,而不是arr 和@987654332 的情况@ 是独立的局部变量吗?

这是我的代码:

#include <cstddef>
constexpr std::size_t max_size = 64;

extern void process_value(double& ref_value);

void test_distinct_array_and_size(std::size_t size)

    double arr[max_size];
    std::size_t arr_size = size;

    for (std::size_t i = 0; i < arr_size; ++i)
        process_value(arr[i]);


void test_array_and_size_in_local_struct(std::size_t size)

    struct
    
        double arr[max_size];
        std::size_t size;
     array_wrapper;
    array_wrapper.size = size;

    for (std::size_t i = 0; i < array_wrapper.size; ++i)
        process_value(array_wrapper.arr[i]);

使用 -O3 来自 Clang 的 test_distinct_array_and_size 的汇编输出:

test_distinct_array_and_size(unsigned long): # @test_distinct_array_and_size(unsigned long)
  push r14
  push rbx
  sub rsp, 520
  mov r14, rdi
  test r14, r14
  je .LBB0_3
  mov rbx, rsp
.LBB0_2: # =>This Inner Loop Header: Depth=1
  mov rdi, rbx
  call process_value(double&)
  add rbx, 8
  dec r14
  jne .LBB0_2
.LBB0_3:
  add rsp, 520
  pop rbx
  pop r14
  ret

test_array_and_size_in_local_struct 的程序集输出:

test_array_and_size_in_local_struct(unsigned long): # @test_array_and_size_in_local_struct(unsigned long)
  push r14
  push rbx
  sub rsp, 520
  mov qword ptr [rsp + 512], rdi
  test rdi, rdi
  je .LBB1_3
  mov r14, rsp
  xor ebx, ebx
.LBB1_2: # =>This Inner Loop Header: Depth=1
  mov rdi, r14
  call process_value(double&)
  inc rbx
  add r14, 8
  cmp rbx, qword ptr [rsp + 512]
  jb .LBB1_2
.LBB1_3:
  add rsp, 520
  pop rbx
  pop r14
  ret

最新的 GCC 和 MSVC 编译器在堆栈读取和写入方面基本相同。

正如我们所见,在后一种情况下,对堆栈上的array_wrapper.size 变量的读取和写入并未被优化掉。在循环开始之前将size 值写入位置[rsp + 512],并在每次 迭代之后从该位置读取。

所以,编译器有点希望我们想要从 process_value(array_wrapper.arr[i]) 调用修改 array_wrapper.size(通过获取当前数组元素的地址并对其应用一些奇怪的偏移量?)

但是,如果我们试图从那个调用中这样做,那不是未定义的行为吗?

当我们以如下方式重写循环时

for (std::size_t i = 0, sz = array_wrapper.size; i < sz; ++i)
    process_value(array_wrapper.arr[i]);

,每次迭代结束时那些不必要的读取都将消失。但是对[rsp + 512] 的初始写入仍将保留,这意味着编译器仍然希望我们能够通过这些process_value 调用访问该位置的array_wrapper.size 变量(通过一些奇怪的基于偏移的魔法)。

为什么?

这只是现代编译器实现中的一个小缺点(希望很快会得到修复)吗?或者 C++ 标准是否确实需要这样的行为,当我们将数组及其大小放入同一个类时,会导致生成效率较低的代码?

附言

我意识到我上面的代码示例可能看起来有点做作。但请考虑一下:我想在我的代码中使用类似boost::container::static_vector 的轻量级类模板,以便使用 POD 元素的伪动态数组进行更安全、更方便的“C++ 风格”操作。所以我的PODVector 将在同一个类中包含一个数组和一个size_t

template<typename T, std::size_t MaxSize>
class PODVector

    static_assert(std::is_pod<T>::value, "T must be a POD type");

private:
    T _data[MaxSize];
    std::size_t _size = 0;

public:
    using iterator = T *;

public:
    static constexpr std::size_t capacity() noexcept
    
        return MaxSize;
    

    constexpr PODVector() noexcept = default;

    explicit constexpr PODVector(std::size_t initial_size)
        : _size(initial_size)
    
        assert(initial_size <= capacity());
    

    constexpr std::size_t size() const noexcept
    
        return _size;
    

    constexpr void resize(std::size_t new_size)
    
        assert(new_size <= capacity());
        _size = new_size;
    

    constexpr iterator begin() noexcept
    
        return _data;
    

    constexpr iterator end() noexcept
    
        return _data + _size;
    

    constexpr T & operator[](std::size_t position)
    
        assert(position < _size);
        return _data[position];
    
;

用法:

void test_pod_vector(std::size_t size)

    PODVector<double, max_size> arr(size);

    for (double& val : arr)
        process_value(val);

如果上面描述的问题确实是由 C++ 的标准强制造成的(并且不是编译器编写者的错),那么 PODVector 将永远不会像原始使用数组和大小的“不相关”变量那样有效。这对于作为一种需要零开销抽象的语言的 C++ 来说是非常糟糕的。

【问题讨论】:

这绝对不是 C++ 标准,因为后者几乎允许一切都遵循 as-if 规则。 它们根本不一样 - 一个初始化值,然后从不更改它(即使它不是 const;另一个分配空间,分配一个值(这是不可行的)如果它是 const)。如果你给你的编译器一个机会并告诉它它们是 const,我相信它会为你优化它。 @bipll 如果是这样,那么这三个流行的编译器(clang、gcc、msvc)中没有一个在 2018 年实际应用 as-if 规则是很有趣的。 Godbolt link 一起玩。 @FrançoisAndrieux 我尝试了许多版本的 clang(从 3.8 到 5.0),许多版本的 gcc(从 4.9.2 到 7.2),带有 -O2 和 -O3 标志。以及带有 /Ox 的 MSVC 19 2017。 【参考方案1】:

这是因为void process_value(double&amp; ref_value); 通过引用接受参数。编译器/优化器假定别名,即 process_value 函数可以更改通过引用 ref_value 访问的内存,因此数组后面的 size 成员。

编译器假定因为arraysize 是同一个对象的成员array_wrapper 函数process_value 可以潜在地将对第一个元素的引用(在第一次调用时)转换为对对象的引用(并将其存储在其他地方)并将对象转换为 unsigned char 并读取或替换其整个表示。这样函数返回后对象的状态必须从内存中重新加载。

size 是堆栈上的独立对象时,编译器/优化器假定没有其他任何东西可能具有指向它的引用/指针并将其缓存在寄存器中。

在Chandler Carruth: Optimizing the Emergent Structures of C++ 中,他解释了为什么优化器在调用接受引用/指针参数的函数时会遇到困难。仅在绝对必要时使用引用/指针函数参数。

如果您想更改该值,性能更高的选项是:

double process_value(double value);

然后:

array_wrapper.arr[i] = process_value(array_wrapper.arr[i]);

此更改导致optimal assembly:

.L23:
movsd xmm0, QWORD PTR [rbx]
add rbx, 8
call process_value2(double)
movsd QWORD PTR [rbx-8], xmm0
cmp rbx, rbp
jne .L23

或者:

for(double& val : arr)
    val = process_value(val);

【讨论】:

不幸的是,这极大地限制了process_value 可以执行的处理量。 @Oliv 优化器不同意你的观点。 [dcl.type.cv]/4 "任何在 const 对象生命周期内修改它的尝试都会导致未定义的行为。"还要注意,子对象是对象。标准中对 const 子对象没有特殊规定!!但我想你已经知道了,不是吗?那么你如何限定你的最后一条评论呢? @Oliv 由于完整对象 array_wrapper 最初是作为非常量创建的,因此可以很好地定义将其来回转换为 const 和非常量。并且将对象转换为unsigned char 并替换对象表示也很明确。不是吗? 我的猜测是,这是这些微妙的别名问题与一些编译器遗漏优化的结合。该结构是标准布局,并且(我相信)如果知道偏移量,process_value() 将其转换回原始结构是有效的。即使没有传入索引,process_value() 仍然可以通过其他方式“知道”索引(例如计算它被调用的次数)。是的,这将是糟糕的代码。但从语义上讲,它在没有 UB 的情况下是有效的,编译器必须尊重它。

以上是关于为啥 C++ 编译器不优化对结构数据成员的读写而不是不同的局部变量?的主要内容,如果未能解决你的问题,请参考以下文章

为啥一个类允许拥有自己的静态成员,而不是非静态成员?

为啥我可以在 C 中调用一个函数而不声明它,但在 C++ 中却不能?

将一个int(例如10)分配给c ++中结构中的字符串成员,为啥它编译成功?

为啥函数体在结构中编译,而不是在特征中?

为啥Java中的BitSet使用long数组做内部存储,而不使用int数组...

为啥 Linux NETLINK 手册页提供 C++ 示例而不提供 C?