覆盖operator new以合并PIMPL分配

时间:2014-11-04 16:29:38

标签: c++ performance c++11 polymorphism pimpl-idiom

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 /析构函数。然后,可以简单地从重载的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% )

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

1 个答案:

答案 0 :(得分:1)

我很久以前就开始优化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增加更多。