c – 覆盖operator new以合并PIMPL分配
PIMPL惯用法通常用于对象的公共API,有时也包含虚函数.在那里,堆分配通常用于分配多态对象,然后存储在unique_ptr或类似对象中.一个着名的例子是Qt API,其中大多数对象(尤其是QWidgets等)在堆上分配并由QObject父/子关系跟踪.因此,我们支付两个分配,一旦对象本身用2 * sizeof(void *)来保存PIMPL和v_table指针,一旦私有数据本身.
现在回答我的问题:我想知道这两个分配是否可以合并,类似于make_shared应用的优化.然后我想知道这种优化是否值得,因为malloc的实现可能非常擅长处理字大小的分配请求.另一方面,正缓存效应可能非常明显,即在私有数据旁边分配私有数据. 我玩了以下代码: #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.有想法该怎么解决这个吗?我看到的唯一方法是引入一种丑陋的工厂方法. 我也想知道是否有任何方法可以覆盖(取消)分配对象的整个过程,包括调用它的con /析构函数.然后,可以简单地从重载的运算符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线程信息块来快速确定外部对象是否在堆栈或堆上,并使用像alloca这样的东西来放置新的和手动的dtor调用来构造和销毁pimpls .
在那里,我处理的热点更多地与pimpl创建和破坏相关,而不是访问成本,减少了mem地址和间接,但它的速度非常快.它减少了在大约400个时钟周期到13个周期内在堆栈上创建具有廉价pimpl的对象的时间,因为它彻底消除了免费存储开销.这是很久以前的90年代:里程可能会有所不同. 从那时起我就后悔了. 有一次,我觉得自己变得过于聪明,使得代码太难以维护和移植并理解,即使使用通用机制使其对任何对象订阅系统都很简单和可重用.它只是对语言设计有点过分,希望平衡高级对象结构和最低级别的程序集类型黑客. 相反,我建议简单地让你的类抽象得足以避免提及实现细节来一次性分配子类实例化.例: // -------------------------------------------------------- // 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优势,但不会丢失整个对象的连续内存布局.缺点是间接虚函数调用的抽象成本,但这几乎总是被高估.您通常会立即更好地交易通常可以忽略不计的抽象成本,以获得更好的内存/缓存位置的不可忽视的好处. 如果你需要更多,那就是我建议在一个好的和安全的公共接口背后作为一个罕见的通配符更多类似C的编码,因为它实际上更容易做低级别的位/字节内存管理而不用担心对象 – 导向的结构妨碍了.我仍然建议将这些代码保留在更高级别的安全C接口之后. 至于利用堆栈,在堆栈上创建对象是非常快的.因此是一个固定的分配器,它在没有搜索的情况下在O(1)中分配/释放对象(例如:池分配器将内存块视为缓冲区和单链接列表指针之间的联合 – 一个空闲时的列表节点,一个缓冲区时占据).您可以使用这样的分配器获得类似堆栈的性能,并且您的对象将在内存中靠近空间局部性(特别是如果您的分配和释放模式符合您对堆栈的使用情况,在这种情况下,固定alloc的行为类似于虚拟堆栈). 如果您已经计划对这些对象使用堆栈,则可以使固定的alloc只使用具有预定大小的单个池以及无分支分配和释放,真正可以与硬件堆栈相媲美(在效率和缺乏安全性方面)防止溢出,并为每个线程要求单独的一个).如果你走这条路线,我建议选择使用这个无分支分配器(带有单独的功能或重载)作为选择性优化细节.使用半自动优化解决方案比完全自动化更容易避免陷入困境. 你可以做的另一件事,我当时没有这个,就是使用这个新的std :: aligned_storage类型.这要求你预测标题中pimpl的大小,但我很想让它变大,实际上是留下一些变化空间.如果您开始尝试这样做,我仍然会推荐抽象方法,因为您不想开始破坏ABI或摆弄标题以向pimpl添加更多内容. (编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |