为了获得更好的访问性能,将原始指针与其拥有的shared_ptr一起缓存是个好主意吗



考虑这个场景:

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
}
};

我知道从技术上讲,它是有效的,并提供了一点性能优势(我尝试过)。(编辑:错误结论)。但我的问题是:这在概念上是错误的吗?这是不好的做法吗?为什么?如果不是,为什么这种黑客攻击不更常用?

下面是一个比较原始指针访问和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秒

我运行了多次,结果是一致的。

编辑:根据答案的一致性,上述实验无效。编译器比看上去聪明得多。

这个答案证明你的测试是无效的(C++中正确的性能测量非常困难,因为有很多陷阱),结果你会得出无效的结论。

看看这个神栓。

第一个版本的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能够优化掉for循环。它注意到这个循环并没有可行的结果,就把它删除了。所以,如果你使用的是发行版配置编译器,那就太聪明了。

底线:

  • shared_ptr不提供开销
  • 检查性能时,必须在启用优化的情况下进行编译
  • 您还必须确保测试代码没有被优化掉,以确保结果是有效的

以下是使用谷歌基准编写的正确测试,两种情况的测试结果完全相同。

shared_ptr内部大致如下所示:

template <typename _Tp>
struct shared_ptr {
T *_M_ptr;
control_block *cb;
}

因此,一个成员(_M_ptr)指向被管理对象,一个指针指向控制块(用于引用计数和锁定)。

oeprator->看起来像这样:

_Tp* operator->() const {
return _M_ptr;
}

因为_M_ptrshared_ptr中的位置是已知的,所以编译器可以直接从中检索存储器位置来读取存储在_M_ptr中的存储器地址,而无需间接。

如果T *_M_ptrshared_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是一个不可选择的(不会创建任何机器代码)。

因此,在大多数情况下,取消引用shared_ptr和原始指针之间没有区别。您可能能够构造一个原始指针的内存地址严格存储在寄存器中的情况,而对于shared_ptr_M_ptr可能不可能实现同样的情况,但IMHO将是一个非常人为的例子。

关于您的性能测试:

对于没有激活优化的shared_ptr,显示的代码将稍微慢一些,因为operator->()上的间接性不会被优化掉。在启用优化的情况下,编译器可能会优化(对于给定的示例)所有内容,并且您的测量可能毫无意义。

如果不是,为什么这个黑客不更常用?

在许多情况下,您只使用shared_ptr来管理所有权。在处理由共享指针管理的对象时,通常会将对象本身(通过引用或指针)传递给另一个不需要所有权的函数(void do_something_with_object(A *ptr) {…}和调用do_something_with_object(sa.get())),因此即使对某个stdlib实现有性能影响,在大多数情况下也不会显示。

这在概念上是错误的吗?

一般来说,为了实现所需的性能目标,拥有内部支持对象/助手并不是错误的。例如,缓存是我认为最流行的例子。但对于您的特定示例,我倾向于说,即使在性能稍好的情况下,这在概念上也是错误的(最晚当重要性是关键字时,我对此表示怀疑),因为它实际上不是内部质量,因为您使用的原始指针不是shared_ptr的内部指针。我在这里特别看到的问题是,你重复了责任,因为你不信任这里建立良好的标准类,以获得最低限度的更好性能。在软件设计中,除了单一责任之外,这里的关键词是相称性。你必须在这里为所有相关的地方复制语义(Copy/Move构造函数),你必须在异常安全方面三思而后行,如果你的类扩展了,你必须一次又一次地考虑这个方面,等等。由于纠缠的责任,其他开发人员将很难理解你的代码。

关于shared_ptr性能的一句话:

我当前VS 2019的SharedPtr在内存.h中取消引用如下:

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()直接返回原始指针。那么,为什么任何普通编译器的优化器都不能内联呢?

相关内容

  • 没有找到相关文章

最新更新