为什么C++编译器不会优化读写结构体成员而是优化不同的局部变量呢?

8
我尝试创建一个本地数组,包含一些POD值(例如double),并具有已知的编译时固定max_size。然后读取运行时size值(size <= max_size),并从该数组中处理前size个元素。问题是,为什么当arrsize放入相同的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]);
}

使用-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++标准确实需要这种行为,每当我们将数组及其大小放入同一类中时,就会导致生成效率较低的代码?

P.S.

我意识到我上面的代码示例可能有点牵强。但请考虑以下情况:我想在我的代码中使用轻量级的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规则”几乎任何操作。 - bipll
如果是这样的话,那么为什么在2018年,这三个流行的编译器(clang、gcc、msvc)都没有在所描述的情况下应用as-if规则,这是非常有趣的。 - Taras
3
请使用Godbolt链接进行操作。 - nwp
@FrançoisAndrieux 我尝试了许多版本的clang(从3.8到5.0),许多版本的gcc(从4.9.2到7.2),都使用了-O2和-O3标志。还有带有/Ox的MSVC 19 2017。 - Taras
@UKMonkey 对不起,我没有理解你的解释 :( 对我来说,我原始列表中的两个函数看起来会产生相同的汇编代码。唯一的区别是在第二个版本中,数组及其大小被包装在单个结构体中。我在第一个版本中没有使用任何const限定符,但它仍然被优化了。 - Taras
显示剩余3条评论
1个回答

6
这是因为void process_value(double& ref_value);通过引用接受参数。编译器/优化器假设存在别名,即process_value函数可以更改通过引用ref_value访问的内存,因此也可以更改array数组后面的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]);

这个更改会导致最佳汇编代码的产生:
.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);

1
很不幸,这严重限制了 process_value 可以处理的处理量。 - nwp
1
@Oliv 优化器不同意你的观点。 - Maxim Egorushkin
2
@Oliv 由于完整的对象array_wrapper最初被创建为非const,因此将其来回转换为const和非const是明确定义的。而将对象强制转换为unsigned char并替换对象表示也是明确定义的。不是吗? - Maxim Egorushkin
1
@Oliv 在那行代码中,它修改了最初的常量对象。请仔细阅读你引用的内容。 - Maxim Egorushkin
2
我的猜测是这个问题是由于一些微妙的别名问题和几个编译器未优化造成的。该结构体是标准布局,如果process_value()知道偏移量,它可以将其转换回原始结构体。即使没有传递索引,process_value()仍然可以通过其他方式(例如计算调用次数)“知道”索引。是的,这将是可怕的代码。但从语义上讲,它是有效的且没有UB,编译器必须予以尊重。 - Mysticial
显示剩余42条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接