为什么C++编译器不会优化对结构数据成员的读写,而不是独特的局部变量?

Tar*_*ras 7 c++ arrays optimization boost compilation

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

现在的问题是,为什么不编译器消除堆读取和写入时arr,并size放置到同一个struct/ class,而不是那里的情况arrsize是独立的局部变量?

这是我的代码:

#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]);
}
Run Code Online (Sandbox Code Playgroud)

test_distinct_array_and_size使用-O3从Clang 组装输出:

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
Run Code Online (Sandbox Code Playgroud)

装配输出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
Run Code Online (Sandbox Code Playgroud)

最新的GCC和MSVC编译器与堆栈读写基本相同.

正如我们所看到的,array_wrapper.size在后一种情况下,对堆栈上的变量的读取和写入不会被优化.还有就是写size值到位置[rsp + 512]的循环开始之前,以及之后的位置读取每个迭代.

那么,编译器有点期望我们想要array_wrapper.sizeprocess_value(array_wrapper.arr[i])调用中修改(通过获取当前数组元素的地址并对它应用一些奇怪的偏移量?)

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

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

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

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

为什么?

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

PS

我意识到上面的代码示例可能看起来有点人为.但请考虑一下:我想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];
    }
};
Run Code Online (Sandbox Code Playgroud)

用法:

void test_pod_vector(std::size_t size)
{
    PODVector<double, max_size> arr(size);

    for (double& val : arr)
        process_value(val);
}
Run Code Online (Sandbox Code Playgroud)

如果上面描述的问题确实是由C++的标准强制执行的(并且不是编译器编写者的错误),那么PODVector它将永远不如数组的原始使用和大小的"无关"变量那样高效.对于C++而言,这对于需要零开销抽象的语言来说是非常糟糕的.

Max*_*kin 6

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

编译器假定因为arraysize是同一个对象array_wrapper函数的成员,process_value可能会将对第一个元素的引用(在第一次调用时)转换为对象的引用(并将其存储在别处)并将对象强制转换为unsigned char读取或替换它的整个代表.这样在函数返回后,必须从内存中重新加载对象的状态.

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

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

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

double process_value(double value);
Run Code Online (Sandbox Code Playgroud)

然后:

array_wrapper.arr[i] = process_value(array_wrapper.arr[i]);
Run Code Online (Sandbox Code Playgroud)

此更改会导致最佳装配:

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

要么:

for(double& val : arr)
    val = process_value(val);
Run Code Online (Sandbox Code Playgroud)

  • @Oliv由于完整对象`array_wrapper`最初是作为非const创建的,因此很好地定义它来回转换为const和非const.并且将对象转换为`unsigned char`并替换对象表示也是很好的定义.不是吗? (2认同)
  • 我的猜测是,这是这些微妙的别名问题与几个编译器错过的优化相结合的组合.结构是标准布局,并且(我相信)如果它知道偏移量,它对于`process_value()`将其强制转换回原始结构是有效的.即使未传入索引,`process_value()`仍然可以通过其他方式"知道"索引(例如计算它被调用的次数).是的,这将是可怕的代码.但在语义上它没有UB是有效的,编译器必须尊重它. (2认同)