为什么C编译器不会优化对struct数据成员的读写,而不是不同的局部变量?

我正在尝试使用在编译时已知的固定max_size创建一些POD值的本地数组(例如double),然后读取运行时大小值(size< = max_size)并处理该数组中的第一个size元素. 问题是,当arr和size放在同一个struct / class中时,为什么编译器不会消除堆栈的读写操作,而arr和size是独立的局部变量的情况呢? 这是我的代码
#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]);
}

来自Clang的test_distinct_array_and_size和-O3的汇编输出

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变量的读取和写入不会被优化.在循环开始之前将位值写入位置[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语言而言,这对于需要零开销抽象的语言来说非常糟糕.

解决方法

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

编译器假定因为数组和大小是同一个对象的成员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);

相关文章

本程序的编译和运行环境如下(如果有运行方面的问题欢迎在评...
水了一学期的院选修,万万没想到期末考试还有比较硬核的编程...
补充一下,先前文章末尾给出的下载链接的完整代码含有部分C&...
思路如标题所说采用模N取余法,难点是这个除法过程如何实现。...
本篇博客有更新!!!更新后效果图如下: 文章末尾的完整代码...
刚开始学习模块化程序设计时,估计大家都被形参和实参搞迷糊...