#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语言而言,这对于需要零开销抽象的语言来说非常糟糕.
解决方法
编译器假定因为数组和大小是同一个对象的成员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);