缓存原始指针及其拥有的 shared_ptr 以获得更好的访问性能是个好主意吗?
Is it a good idea to cache a raw pointer along with its owning shared_ptr for better access performance?
考虑这种情况:
class A
{
std::shared_ptr<B> _b;
B* _raw;
A(std::shared_ptr<B> b)
{
_b = b;
_raw = b.get();
}
void foo()
{
// Use _raw instead of _b
// avoid one extra indirection / memory jump
// and also avoid polluting cache
}
};
我知道从技术上讲它可以工作并且提供了轻微的性能优势(我试过了)。 (编辑:错误的结论)。
但我的问题是:它在概念上是错误的吗?这是不好的做法吗?为什么?
如果不是,为什么这个 hack 没有更常用?
这是一个比较原始指针访问与 shared_ptr 访问的最小可重现示例:
#include <chrono>
#include <iostream>
#include <memory>
struct timer final
{
timer()
: start{std::chrono::system_clock::now()}
{ }
void elapsed()
{
auto now = std::chrono::system_clock::now();
std::cout << std::chrono::duration<double>(now - start).count() << " seconds" << std::endl;
}
private:
std::chrono::time_point<std::chrono::system_clock> start;
};
struct A
{
size_t a [2097152];
};
int main()
{
size_t data_size = 2097152;
size_t count = 10000000000;
// Using Raw pointer
A * pa = new A();
timer t0;
for(size_t i = 0; i < count; i++)
pa->a[i % data_size] = i;
t0.elapsed();
// Using shared_ptr
std::shared_ptr<A> sa = std::make_shared<A>();
timer t1;
for(size_t i = 0; i < count; i++)
sa->a[i % data_size] = i;
t1.elapsed();
}
输出:
3.98586 秒
4.10491 秒
我运行多次这样,结果一致。
编辑:根据答案中的共识,上述实验无效。编译器比看起来更聪明。
Is it conceptually wrong?
一般来说,为了实现所需的性能目标而先验地获得内部支持 objects/helpers 并没有错。例如缓存是我猜最流行的例子。但是对于您的特定示例,我倾向于说,即使在性能稍好的情况下(我最近怀疑关键字是重要性),它在概念上也是错误的,因为它并不是真正的内部质量,因为您使用的是原始指针不是 shared_ptr 的内部。我在这里特别看到的问题是,您重复了职责,因为您不信任此处完善的标准 class 为了获得最低限度的更好性能。在软件设计中,除了单一职责之外,这里的关键词是相称性。您必须在此处为所有相关位置(Copy/Move 构造函数)复制语义,您必须在异常安全方面三思而后行,如果您的 class 扩展等,您必须一次又一次地考虑这个方面在。由于责任纠缠,其他开发人员面对您的代码将变得非常难以理解。
关于shared_ptr的表现的一句话:
我当前的 VS 2019 在 memory.h 中的 SharedPtr 取消引用看起来像这样:
template<class _Ty2 = _Ty,
enable_if_t<!is_array_v<_Ty2>, int> = 0>
_NODISCARD _Ty2 * operator->() const noexcept
{ // return pointer to resource
return (get());
}
并且 get() 直接 returns 原始指针。那么为什么任何普通编译器的优化器都不能内联呢?
一个shared_ptr
内部看起来大致是这样的:
template <typename _Tp>
struct shared_ptr {
T *_M_ptr;
control_block *cb;
}
因此,一个成员 (_M_ptr
) 指向托管对象,一个指针指向控制块(用于引用计数和锁定)。
oeprator->
看起来像这样:
_Tp* operator->() const {
return _M_ptr;
}
因为 _M_ptr
在 shared_ptr
中的位置是已知的,编译器可以直接检索内存位置,从那里读取存储在 _M_ptr
中的内存地址,而不需要间接访问。
如果 T *_M_ptr
是 shared_ptr
的第一个成员(libstdc++
确实如此)编译器可以更改此代码:
std::shared_ptr<A> sa = std::make_shared<A>();
sa->a[0] = 1;
与此类似的内容:
std::shared_ptr<A> sa = std::make_shared<A>();
(*reinterpret_cast<A**>(&sa))->a[0] = 1;
并且 reinterpret_cast
是一个 no-opt(不会创建任何机器代码)。
所以在大多数情况下,取消引用 shared_ptr
或原始指针之间没有区别。您可能能够构建一个原始指针的内存地址完全存储在寄存器中的情况,并且对于 shared_ptr
的 _M_ptr
可能不可能实现同样的事情,但恕我直言,这是一个真是人为的例子。
关于您的性能测试:
显示的代码对于 shared_ptr
来说会稍微慢一些,因为没有激活优化,因为 operator->()
上的间接寻址不会被优化掉。通过优化,编译器可能会优化 - 对于给定的示例 - 一切都消失了,你的测量可能毫无意义。
And if not why is this hack not more commonly used?
在许多情况下,您使用 shared_ptr
只是为了管理所有权。在处理由共享指针管理的对象时,您经常将对象本身(通过引用或指针)传递给另一个不需要所有权的函数(void do_something_with_object(A *ptr) {…}
和调用 do_something_with_object(sa.get())
),因此即使对某个 stdlib 实现有性能影响,在大多数情况下也不会表现出来。
这个答案证明你的测试是无效的(在 C++ 中正确的性能测量非常困难,因为有很多陷阱)因此你得出了无效的结论。
看看这个 godbolt。
for
第一个版本的循环:
.L39:
mov rdx, rax
and edx, 2097151
mov QWORD PTR [rbp+0+rdx*8], rax
add rax, 1
cmp rax, rcx
jne .L39
for
第二个版本的循环:
.L40:
mov rdx, rax
and edx, 2097151
mov QWORD PTR [rbp+16+rdx*8], rax
add rax, 1
cmp rax, rcx
jne .L40
我看不出有什么不同!结果应该完全一样。
所以我怀疑您在调试配置中构建时进行了测量。
的版本
更有趣的是clang is able to optimize away for
如果不使用共享指针则循环。它注意到这个循环没有可行的结果,只是将其删除。因此,如果您使用发布配置编译器,您会比您更聪明。
底线:
- shared_ptr不提供开销
- 检查性能时必须启用优化进行编译
- 您还必须确保测试代码是否未被优化掉以确保结果有效。
这里是proper test使用google基准编写的,两种情况的测试结果完全一样。
考虑这种情况:
class A
{
std::shared_ptr<B> _b;
B* _raw;
A(std::shared_ptr<B> b)
{
_b = b;
_raw = b.get();
}
void foo()
{
// Use _raw instead of _b
// avoid one extra indirection / memory jump
// and also avoid polluting cache
}
};
我知道从技术上讲它可以工作并且提供了轻微的性能优势(我试过了)。 (编辑:错误的结论)。 但我的问题是:它在概念上是错误的吗?这是不好的做法吗?为什么? 如果不是,为什么这个 hack 没有更常用?
这是一个比较原始指针访问与 shared_ptr 访问的最小可重现示例:
#include <chrono>
#include <iostream>
#include <memory>
struct timer final
{
timer()
: start{std::chrono::system_clock::now()}
{ }
void elapsed()
{
auto now = std::chrono::system_clock::now();
std::cout << std::chrono::duration<double>(now - start).count() << " seconds" << std::endl;
}
private:
std::chrono::time_point<std::chrono::system_clock> start;
};
struct A
{
size_t a [2097152];
};
int main()
{
size_t data_size = 2097152;
size_t count = 10000000000;
// Using Raw pointer
A * pa = new A();
timer t0;
for(size_t i = 0; i < count; i++)
pa->a[i % data_size] = i;
t0.elapsed();
// Using shared_ptr
std::shared_ptr<A> sa = std::make_shared<A>();
timer t1;
for(size_t i = 0; i < count; i++)
sa->a[i % data_size] = i;
t1.elapsed();
}
输出:
3.98586 秒
4.10491 秒
我运行多次这样,结果一致。
编辑:根据答案中的共识,上述实验无效。编译器比看起来更聪明。
Is it conceptually wrong?
一般来说,为了实现所需的性能目标而先验地获得内部支持 objects/helpers 并没有错。例如缓存是我猜最流行的例子。但是对于您的特定示例,我倾向于说,即使在性能稍好的情况下(我最近怀疑关键字是重要性),它在概念上也是错误的,因为它并不是真正的内部质量,因为您使用的是原始指针不是 shared_ptr 的内部。我在这里特别看到的问题是,您重复了职责,因为您不信任此处完善的标准 class 为了获得最低限度的更好性能。在软件设计中,除了单一职责之外,这里的关键词是相称性。您必须在此处为所有相关位置(Copy/Move 构造函数)复制语义,您必须在异常安全方面三思而后行,如果您的 class 扩展等,您必须一次又一次地考虑这个方面在。由于责任纠缠,其他开发人员面对您的代码将变得非常难以理解。
关于shared_ptr的表现的一句话:
我当前的 VS 2019 在 memory.h 中的 SharedPtr 取消引用看起来像这样:
template<class _Ty2 = _Ty,
enable_if_t<!is_array_v<_Ty2>, int> = 0>
_NODISCARD _Ty2 * operator->() const noexcept
{ // return pointer to resource
return (get());
}
并且 get() 直接 returns 原始指针。那么为什么任何普通编译器的优化器都不能内联呢?
一个shared_ptr
内部看起来大致是这样的:
template <typename _Tp>
struct shared_ptr {
T *_M_ptr;
control_block *cb;
}
因此,一个成员 (_M_ptr
) 指向托管对象,一个指针指向控制块(用于引用计数和锁定)。
oeprator->
看起来像这样:
_Tp* operator->() const {
return _M_ptr;
}
因为 _M_ptr
在 shared_ptr
中的位置是已知的,编译器可以直接检索内存位置,从那里读取存储在 _M_ptr
中的内存地址,而不需要间接访问。
如果 T *_M_ptr
是 shared_ptr
的第一个成员(libstdc++
确实如此)编译器可以更改此代码:
std::shared_ptr<A> sa = std::make_shared<A>();
sa->a[0] = 1;
与此类似的内容:
std::shared_ptr<A> sa = std::make_shared<A>();
(*reinterpret_cast<A**>(&sa))->a[0] = 1;
并且 reinterpret_cast
是一个 no-opt(不会创建任何机器代码)。
所以在大多数情况下,取消引用 shared_ptr
或原始指针之间没有区别。您可能能够构建一个原始指针的内存地址完全存储在寄存器中的情况,并且对于 shared_ptr
的 _M_ptr
可能不可能实现同样的事情,但恕我直言,这是一个真是人为的例子。
关于您的性能测试:
显示的代码对于 shared_ptr
来说会稍微慢一些,因为没有激活优化,因为 operator->()
上的间接寻址不会被优化掉。通过优化,编译器可能会优化 - 对于给定的示例 - 一切都消失了,你的测量可能毫无意义。
And if not why is this hack not more commonly used?
在许多情况下,您使用 shared_ptr
只是为了管理所有权。在处理由共享指针管理的对象时,您经常将对象本身(通过引用或指针)传递给另一个不需要所有权的函数(void do_something_with_object(A *ptr) {…}
和调用 do_something_with_object(sa.get())
),因此即使对某个 stdlib 实现有性能影响,在大多数情况下也不会表现出来。
这个答案证明你的测试是无效的(在 C++ 中正确的性能测量非常困难,因为有很多陷阱)因此你得出了无效的结论。
看看这个 godbolt。
for
第一个版本的循环:
.L39:
mov rdx, rax
and edx, 2097151
mov QWORD PTR [rbp+0+rdx*8], rax
add rax, 1
cmp rax, rcx
jne .L39
for
第二个版本的循环:
.L40:
mov rdx, rax
and edx, 2097151
mov QWORD PTR [rbp+16+rdx*8], rax
add rax, 1
cmp rax, rcx
jne .L40
我看不出有什么不同!结果应该完全一样。
所以我怀疑您在调试配置中构建时进行了测量。
的版本更有趣的是clang is able to optimize away for
如果不使用共享指针则循环。它注意到这个循环没有可行的结果,只是将其删除。因此,如果您使用发布配置编译器,您会比您更聪明。
底线:
- shared_ptr不提供开销
- 检查性能时必须启用优化进行编译
- 您还必须确保测试代码是否未被优化掉以确保结果有效。
这里是proper test使用google基准编写的,两种情况的测试结果完全一样。