gpt4 book ai didi

c++ - 覆盖 operator new 以合并 PIMPL 分配

转载 作者:塔克拉玛干 更新时间:2023-11-03 02:17:11 26 4
gpt4 key购买 nike

PIMPL 习语通常用于对象的公共(public) API,有时也包含虚函数。在那里,堆分配通常用于分配多态对象,然后将其存储在 unique_ptr 或类似的地方。一个著名的例子是 Qt API,其中大多数对象(尤其是 QWidgets 等)在堆上分配并由 QObject 父/子关系跟踪。因此,我们为两次分配支付费用,一次是对象本身使用 2*sizeof(void*) 来保存 PIMPL 和 v_table 指针,一次是私有(private)数据本身。

现在来回答我的问题:我想知道这两个分配是否可以合并,类似于 make_shared 应用的优化。然后我想知道这种优化是否值得,因为 malloc 的实现可能非常擅长处理字大小的分配请求。另一方面,积极的缓存效果可能非常明显,即让私有(private)数据紧挨着公共(public)对象分配。

我尝试了以下代码:


#include <memory>
#include <cstring>
#include <vector>
#include <iostream>

using namespace std;

#ifdef NDEBUG
#define debug(x)
#else
#define debug(x) x
#endif

class MyInterface
{
public:
virtual ~MyInterface() = default;

virtual int i() const = 0;
};

class MyObjOpt : public MyInterface
{
public:
MyObjOpt(int i);
virtual ~MyObjOpt();

int i() const override;

static void *operator new(size_t size);
static void operator delete(void *ptr);
private:
struct Private;
Private* d;
};

struct MyObjOpt::Private
{
Private(int i)
: i(i)
{
debug(cout << " Private " << i << '\n';)
}
~Private()
{
debug(cout << " ~Private " << i << '\n';)
}
int i;
};

MyObjOpt::MyObjOpt(int i)
{
debug(cout << " MyObjOpt " << i << "\n";)
if (reinterpret_cast<void*>(d) == reinterpret_cast<void*>(this + 1)) {
new (d) Private(i);
} else {
d = new Private(i);
}
};

MyObjOpt::~MyObjOpt()
{
debug(cout << " ~MyObjOpt " << d->i << '\n';)
if (reinterpret_cast<void*>(d) != reinterpret_cast<void*>(this + 1)) {
delete d;
}
}

int MyObjOpt::i() const
{
return d->i;
}

void* MyObjOpt::operator new(size_t /*size*/)
{
void *ret = malloc(sizeof(MyObjOpt) + sizeof(MyObjOpt::Private));
auto obj = reinterpret_cast<MyObjOpt*>(ret);
obj->d = reinterpret_cast<Private*>(obj + 1);
return ret;
}

void MyObjOpt::operator delete(void *ptr)
{
auto obj = reinterpret_cast<MyObjOpt*>(ptr);
obj->d->~Private();
free(ptr);
}

class MyObj : public MyInterface
{
public:
MyObj(int i);
~MyObj();

int i() const override;

private:
struct Private;
unique_ptr<Private> d;
};

struct MyObj::Private
{
Private(int i)
: i(i)
{
debug(cout << " Private " << i << '\n';)
}
~Private()
{
debug(cout << " ~Private " << i << '\n';)
}
int i;
};

MyObj::MyObj(int i)
: d(new Private(i))
{
debug(cout << " MyObj " << i << "\n";)
};

MyObj::~MyObj()
{
debug(cout << " ~MyObj " << d->i << "\n";)
}

int MyObj::i() const
{
return d->i;
}

int main(int argc, char** argv)
{
if (argc == 1) {
{
cout << "Heap usage:\n";
auto heap1 = unique_ptr<MyObjOpt>(new MyObjOpt(1));
auto heap2 = unique_ptr<MyObjOpt>(new MyObjOpt(2));
}
{
cout << "Stack usage:\n";
MyObjOpt stack1(-1);
MyObjOpt stack2(-2);
}
} else {
const int NUM_ITEMS = 100000;
vector<unique_ptr<MyInterface>> items;
items.reserve(NUM_ITEMS);
if (!strcmp(argv[1], "fast")) {
for (int i = 0; i < NUM_ITEMS; ++i) {
items.emplace_back(new MyObjOpt(i));
}
} else {
for (int i = 0; i < NUM_ITEMS; ++i) {
items.emplace_back(new MyObj(i));
}
}
int sum = 0;
for (const auto& item : items) {
sum += item->i();
}
return sum > 0;
}
return 0;
}

使用 gcc -std=c++11 -g 编译,输出如预期:

Heap usage:
MyObjOpt 1
Private 1
MyObjOpt 2
Private 2
~MyObjOpt 2
~Private 2
~MyObjOpt 1
~Private 1
Stack usage:
MyObjOpt -1
Private -1
MyObjOpt -2
Private -2
~MyObjOpt -2
~Private -2
~MyObjOpt -1
~Private -1

但是当你在 valgrind 中运行它时,你会看到以下内容:

Stack usage:
MyObjOpt -1
==21217== Conditional jump or move depends on uninitialised value(s)
==21217== at 0x400DC0: MyObjOpt::MyObjOpt(int) (pimpl.cpp:54)
==21217== by 0x401200: main (pimpl.cpp:142)
==21217==
Private -1
MyObjOpt -2
==21217== Conditional jump or move depends on uninitialised value(s)
==21217== at 0x400DC0: MyObjOpt::MyObjOpt(int) (pimpl.cpp:54)
==21217== by 0x401211: main (pimpl.cpp:143)
==21217==
Private -2

这是我所做的检查,用于区分堆栈分配对象和堆分配对象,我不再需要在其中分配 dptr。 关于如何解决这个问题的任何想法?我看到的唯一方法是引入丑陋的工厂方法。

我还想知道是否有任何方法可以覆盖(取消)分配对象的整个过程,包括调用其构造函数/析构函数。然后,可以简单地从重载的 operator new 中调用不同的构造函数并完成它......


现在让我们看看是否值得:

使用 gcc -std=c++11 -O2 -g -DNDEBUG 编译得到以下结果:

$ perf stat -r 10 ./pimpl fast

Performance counter stats for './pimpl fast' (10 runs):

9.004201 task-clock (msec) # 0.956 CPUs utilized ( +- 3.61% )
1 context-switches # 0.111 K/sec ( +- 14.91% )
0 cpu-migrations # 0.022 K/sec ( +- 66.67% )
1,071 page-faults # 0.119 M/sec ( +- 0.05% )
19,455,553 cycles # 2.161 GHz ( +- 5.81% ) [45.21%]
31,478,797 instructions # 1.62 insns per cycle ( +- 5.41% ) [84.34%]
8,121,492 branches # 901.967 M/sec ( +- 2.38% )
8,059 branch-misses # 0.10% of all branches ( +- 2.35% ) [66.75%]

0.009422989 seconds time elapsed ( +- 3.46% )

$ perf stat -r 10 ./pimpl slow

Performance counter stats for './pimpl slow' (10 runs):

17.674142 task-clock (msec) # 0.974 CPUs utilized ( +- 2.32% )
2 context-switches # 0.113 K/sec ( +- 10.54% )
1 cpu-migrations # 0.028 K/sec ( +- 53.75% )
1,850 page-faults # 0.105 M/sec ( +- 0.02% )
43,142,007 cycles # 2.441 GHz ( +- 1.13% ) [54.62%]
68,780,331 instructions # 1.59 insns per cycle ( +- 0.50% ) [82.62%]
16,369,560 branches # 926.187 M/sec ( +- 1.65% ) [83.06%]
19,774 branch-misses # 0.12% of all branches ( +- 5.66% ) [66.07%]

0.018142227 seconds time elapsed ( +- 2.26% )

我认为这个微基准测试是经过深思熟虑的,它是一个很好的大约 2 倍的加速。尽管如此,合并分配实际上可能对缓存非常友好,相比之下有两个分配使得 dptr 位于其他地方。

事实上,我们甚至可以看到:

$ perf stat -r 10 -e cache-misses ./pimpl slow

Performance counter stats for './pimpl slow' (10 runs):

37,947 cache-misses ( +- 2.38% )

0.018457998 seconds time elapsed ( +- 2.30% )

$ perf stat -r 10 -e cache-misses ./pimpl fast

Performance counter stats for './pimpl fast' (10 runs):

9,698 cache-misses ( +- 4.46% )

0.009171249 seconds time elapsed ( +- 2.91% )

评论?有没有办法在堆栈分配情况下摆脱对未初始化内存的读取?

最佳答案

很久以前我就开始尝试优化 pimpls,包括使用 Windows 线程信息 block 快速确定外部对象是在堆栈上还是堆上,以及使用有点像 alloca 的东西来放置新的和手动的 dtor 调用来构建和破坏粉刺。

在那里,我处理的热点更多地与 pimpl 的创建和破坏相关,而不是与减少内存局部性和间接性的访问成本相关,但速度非常快。它减少了在堆栈上使用廉价 pimpl 创建对象的时间,从大约 400 个时钟周期减少到 13 个时钟周期,因为它完全消除了免费存储开销。这是很久以前的 90 年代:今天的情况可能有所不同。

从那以后我就后悔了。

那是我觉得自己变得太聪明的时候之一,使代码难以维护、移植和理解,即使使用通用机制使任何对象订阅系统变得微不足道和可重用也是如此。它只是过于反对语言设计,想要平衡高级对象构造与最低级别的汇编类型 hack。

相反,我会建议简单地使您的类足够抽象,以避免提及实现细节以一次性分配子类实例化。示例:

// --------------------------------------------------------
// In some public header:
// --------------------------------------------------------
class Interface
{
public:
virtual ~Interface() {}
virtual void foo() = 0;
};
std::unique_ptr<Interface> create_concrete();

// --------------------------------------------------------
// In some private source file:
// --------------------------------------------------------
// Include all the extra headers you need here
// to implement the interface.

class Concrete: public Interface
{
public:
// Store all the hidden stuff you want here.
virtual void foo() override {...}
};

unique_ptr<Interface> create_concrete()
{
// Can use a fast, fixed allocator here.
return unique_ptr<Interface>(new Concrete);
}

您可以获得隐藏实现细节和创建编译器防火墙的相同类型的 pimpl 好处,但不会丢失整个对象的连续内存布局。缺点是间接虚函数调用的抽象成本,但这几乎总是被严重高估。您通常会立即更好地用通常可以忽略不计的抽象成本换取更好的内存/缓存局部性带来的并非总是可以忽略的好处。

如果您需要的不止于此,那时我会建议在一个良好且安全的公共(public)接口(interface)背后进行更多类似 C 的编码,作为一种罕见的通配符,因为它实际上更容易进行低级位/字节内存管理而无需担心关于面向对象的构造妨碍。我仍然建议将此类代码保留在更高级别、安全的 C++ 接口(interface)之后。

至于利用堆栈,在堆栈上创建对象非常快。固定分配器也是如此,它在 O(1) 中分配/取消分配对象而无需搜索(例如:池分配器将内存块视为缓冲区和单链表指针之间的 union - 空闲时为列表节点,空闲时为缓冲区占据)。您可以使用这样的分配器获得类似堆栈的性能,并且您的对象将在内存中靠近空间局部性(特别是如果您的分配和 Release模式与您对堆栈的利用率一致,在这种情况下固定分配的行为就像虚拟堆栈)。

如果您已经计划为此类对象使用堆栈,则可以使固定分配仅使用具有预定大小和无分支分配和释放的单个池,真正与硬件堆栈相媲美(在效率和缺乏防止溢出的安全性,每个线程都需要一个单独的安全性)。如果你走这条路,我建议选择这个无分支分配器(具有单独的功能或重载)作为选择性优化细节。与全自动相比,半自动优化解决方案更容易避免陷入麻烦。

您可以做的另一件事是使用这个新的 std::aligned_storage 类型,而我当时还没有。不过,这需要您预测 header 中 pimpl 的大小,而且我很想让它比实际大一些,以便为更改留出一些空间。如果您开始想这样做,我仍然推荐使用抽象方法,因为您不想开始破坏 ABI 或摆弄 header 以向 pimpl 添加更多内容。

关于c++ - 覆盖 operator new 以合并 PIMPL 分配,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/26740195/

26 4 0
Copyright 2021 - 2024 cfsdn All Rights Reserved 蜀ICP备2022000587号
广告合作:1813099741@qq.com 6ren.com