自定义分配器性能

时间:2015-04-15 12:55:43

标签: c++ performance memory-management allocator

我正在构建一个AVL树类,它将具有固定的最大项目数。所以我想的不是自己分配每个项目,而是立即分配整个块,并在需要时使用位图分配新的内存。

我的分配/解除分配代码:

avltree::avltree(UINT64 numitems)
{
  root = NULL;

  if (!numitems)
    buffer = NULL;
  else {
    UINT64 memsize = sizeof(avlnode) * numitems + bitlist::storagesize(numitems);
    buffer = (avlnode *) malloc(memsize);
    memmap.init(numitems, buffer + numitems);
    memmap.clear_all();
    freeaddr = 0;
  }
}

avlnode *avltree::newnode(keytype key)
{
  if (!buffer)
    return new avlnode(key);
  else 
  {
    UINT64 pos;
    if (freeaddr < memmap.size_bits)
      pos = freeaddr++;
    else
      pos = memmap.get_first_unset();
    memmap.set_bit(pos);
    return new (&buffer[pos]) avlnode(key);
  }
}

void avltree::deletenode(avlnode *node)
{
  if (!buffer)
    delete node;
  else
    memmap.clear_bit(node - buffer);
}

为了使用标准的new / delete,我必须使用numitems == 0来构造树。为了使用我自己的分配器,我只传递项目数。所有功能都内联以获得最佳性能。

这一切都很好,但我自己的分配器比新/删除慢20%。现在,我知道内存分配器有多复杂,代码运行速度不比数组查找+一位设置快,但这就是这种情况。更糟糕的是:即使我从中移除所有代码,我的deallocator也会变慢?!?

当我检查程序集输出时,我的分配器的代码路径被QWORD PTR指令处理位图,avltree或avlnode。新/删除路径似乎没有什么不同。

例如,avltree :: newnode的汇编输出:

;avltree::newnode, COMDAT
mov     QWORD PTR [rsp+8], rbx
push    rdi
sub     rsp, 32

;if (!buffer)
cmp     QWORD PTR [rcx+8], 0
mov     edi, edx
mov     rbx, rcx
jne     SHORT $LN4@newnode

;  return new avlnode(key);
mov     ecx, 24
call    ??2@YAPEAX_K@Z         ; operator new
jmp     SHORT $LN27@newnode

;$LN4@newnode:
;else {
;  UINT64 pos;
;  if (freeaddr < memmap.size_bits)
mov     r9, QWORD PTR [rcx+40]
cmp     r9, QWORD PTR [rcx+32]
jae     SHORT $LN2@newnode

;    pos = freeaddr++;
lea     rax, QWORD PTR [r9+1]
mov     QWORD PTR [rcx+40], rax

;  else
jmp     SHORT $LN1@newnode
$LN2@newnode:

;    pos = memmap.get_first_unset();
add     rcx, 16
call    ?get_first_unset@bitlist@@QEAA_KXZ ; bitlist::get_first_unset
mov     r9, rax

$LN1@newnode:
; memmap.set_bit(pos);
mov     rcx, QWORD PTR [rbx+16]                    ;data[bindex(pos)] |= bmask(pos);
mov     rdx, r9                                    ;return pos / (sizeof(BITINT) * 8);
shr     rdx, 6
lea     r8, QWORD PTR [rcx+rdx*8]                  ;data[bindex(pos)] |= bmask(pos);
movzx   ecx, r9b                                   ;return 1ull << (pos % (sizeof(BITINT) * 8));
mov     edx, 1
and     cl, 63
shl     rdx, cl

;   return new (&buffer[pos]) avlnode(key);
lea     rcx, QWORD PTR [r9+r9*2]
; File c:\projects\vvd\vvd\util\bitlist.h
or      QWORD PTR [r8], rdx                        ;data[bindex(pos)] |= bmask(pos)

; 195  :     return new (&buffer[pos]) avlnode(key);
mov     rax, QWORD PTR [rbx+8]
lea     rax, QWORD PTR [rax+rcx*8]
; $LN27@newnode:
test    rax, rax
je      SHORT $LN9@newnode

; avlnode constructor; 
mov     BYTE PTR [rax+4], 1
mov     QWORD PTR [rax+8], 0
mov     QWORD PTR [rax+16], 0
mov     DWORD PTR [rax], edi

; 196  :   }
; 197  : }
; $LN9@newnode:
mov     rbx, QWORD PTR [rsp+48]
add     rsp, 32                        ; 00000020H
pop     rdi
ret     0
?newnode@avltree@@QEAAPEAUavlnode@@H@Z ENDP             ; avltree::newnode
_TEXT   ENDS

当我使用默认/自定义分配器构建我的avltree时,我已多次检查编译输出,并且在此特定代码区域中它保持不变。我尝试删除/替换所有相关部分没有显着效果。

老实说,我期望编译器内联所有这些,因为变量很少。我希望除了avlnode对象本身之外的所有东西都放在寄存器中,但似乎并非如此。

然而速度差异显然是可以衡量的。我不是每1000万个节点调用3秒就慢,但我希望我的代码更快,而不是比通用分配器慢(2.5秒)。这尤其适用于速度较慢的解除分配器,即使从中删除所有代码也会更慢。

为什么会变慢?

修改: 谢谢大家对此的好评。但我想再次强调,问题不在于我的分配方法,因为它是使用变量的次优方式:整个avltree类只包含4个UINT64变量,bitlist只有3个。

然而,尽管如此,编译器并没有将其优化为寄存器。它坚持QWORD PTR指令要慢几个数量级。这是因为我正在使用课程吗?我应该转移到C / plain变量吗?抓一下。愚蠢的我。我也有所有的avltree代码,事情不能在寄存器中。

此外,我完全失去了为什么我的deallocator仍然会变慢,即使我从中删除所有代码。然而,QueryPerformanceCounter就是这样告诉我的。甚至认为:同样的deallocator也被调用新的/删除代码路径并且它必须删除节点...它是疯狂的...它不需要为我的自定义分配器做任何事情(当我剥离代码时)。

EDIT2: 我现在已经完全删除了位列表,并通过单链表实现了自由空间跟踪。 avltree :: newnode函数现在更加紧凑(我的自定义分配器路径有21条指令,其中7条是处理avltree的QWORD PTR操作,4条用于avlnode的构造函数)。 对于1000万次分配,最终结果(时间)从~3秒减少到~2.95秒。

EDIT3: 我还重写了整个代码,现在一切都由单链表处理。现在,avltree类只有两个相关成员:root和first_free。速度差仍然存在。

Edit4: 重新排列代码并查看性能数据,这些都是最有帮助的:

  1. 正如所有贡献者所指出的那样,在那里有一个位图只是简单的坏。删除了支持单链接的免费插槽列表。
  2. 代码局部性:通过将依赖函数(avl树处理函数)添加到函数本地类而不是将它们全局声明帮助约15%代码速度(3秒 - > 2.5秒)
  3. avlnode结构大小:在结构声明之前添加#pragma pack(1)将执行时间再减少20%(2.5秒 - > 2秒)
  4. 编辑5:

    由于此问题似乎非常受欢迎,因此我在下面发布了最终完整代码作为答案。我对它的表现非常满意。

4 个答案:

答案 0 :(得分:3)

您的方法仅在一个块中分配原始内存,然后必须为每个元素执行新的放置。将它与位图中的所有开销相结合,并且假设空堆的默认new分配超过您的分配并不太令人惊讶。

为了在分配时获得最大的速度提升,您可以将整个对象分配到一个大型数组中,然后从那里分配给它。如果你看一个非常简单和人为的基准:

struct test_t {
    float f;
    int i;
    test_t* pNext;
};

const size_t NUM_ALLOCS = 50000000;

void TestNew (void)
{
    test_t* pPtr = new test_t;

    for (int i = 0; i < NUM_ALLOCS; ++i)
    {
        pPtr->pNext = new test_t;
        pPtr = pPtr->pNext;
    }

}

void TestBucket (void)
{
    test_t* pBuckets = new test_t[NUM_ALLOCS + 2];
    test_t* pPtr = pBuckets++;

    for (int i = 0; i < NUM_ALLOCS; ++i)
    {
        pPtr->pNext = pBuckets++;
        pPtr = pPtr->pNext;
    }

}

使用此代码在MSVC ++ 2013上进行50M分配TestBucket()的性能优于TestNew() x16(130 vs 2080 ms)。即使您添加std::bitset<>来跟踪分配,它仍然会快x4(400毫秒)。

关于new要记住的一件重要事情是,分配对象所需的时间通常取决于堆的状态。空堆将能够相对快速地分配一堆常量大小的对象,这可能是您的代码看起来比new慢的一个原因。如果你有一个程序运行一段时间并分配大量不同大小的对象,那么堆可能会变得碎片化,分配对象可能需要更长时间。

作为一个例子,我写的一个程序是加载一个200MB的文件,里面有数百万条记录......许多不同大小的分配。在第一次加载时需要大约15秒但是如果我删除了该文件并尝试再次加载它会花费更长时间的x10-x20。这完全是由于内存分配和切换到简单的桶/竞技场分配器修复了这个问题。因此,我设计的显示x16加速的实用基准实际上可能会显示出与碎片堆相比显着更大的差异。

当您意识到不同的系统/平台可能使用不同的内存分配方案时,它会变得更加棘手,因此一个系统上的基准测试结果可能与另一个系统不同。

将这一点简化为几点:

  1. 基准内存分配很棘手(性能取决于很多事情)
  2. 在某些情况下,您可以使用自定义分配器获得更好的性能。在少数情况下,你可以做得更好。
  3. 创建自定义分配器可能很棘手,需要时间来分析/基准测试您的特定用例。
  4. 注意 - 像这样的基准并不是切合实际的,但对于确定某事物速度的上限非常有用。它可以与实际代码的配置文件/基准一起使用,以帮助确定应该/不应该优化的内容。

    更新 - 我似乎无法在MSVC ++ 2013下的代码中复制您的结果。使用与avlnode相同的结构并尝试展示位置{{1}产生与我的非放置桶分配器测试相同的速度(放置new实际上更快一点)。使用类似于new的类并不会影响基准。通过1000万次分配/解除分配,avltree / new获得约800毫秒,自定义分配器获得约200毫秒(有和没有放置delete)。虽然我并不担心绝对时间的差异,但相对时差似乎很奇怪。

    我建议您仔细研究一下您的基准测试,并确保测量您的想法。如果代码存在于更大的代码库中,那么创建一个最小的测试用例来对其进行基准测试。确保您的编译器优化器没有做一些会使基准测试无效的事情(这些日子它很容易发生)。

    请注意,如果您将问题缩减为最小示例并包含问题中的完整代码(包括基准代码),那么回答您的问题要容易得多。基准测试是其中一项似乎很容易的事情,但有许多问题需要解决。参与其中。

    更新2 - 包括我使用的基本分配器类和基准代码,以便其他人可以尝试复制我的结果。请注意,这仅用于测试,与实际工作/生产代码相差甚远。它比你的代码简单得多,这可能就是我们得到不同结果的原因。

    new

    目前我#include <string> #include <Windows.h> struct test_t { __int64 key; __int64 weight; __int64 left; __int64 right; test_t* pNext; // Simple linked list test_t() : key(0), weight(0), pNext(NULL), left(0), right(0) { } test_t(const __int64 k) : key(k), weight(0), pNext(NULL), left(0), right(0) { } }; const size_t NUM_ALLOCS = 10000000; test_t* pLast; //To prevent compiler optimizations from being "smart" struct CTest { test_t* m_pBuffer; size_t m_MaxSize; size_t m_FreeIndex; test_t* m_pFreeList; CTest(const size_t Size) : m_pBuffer(NULL), m_MaxSize(Size), m_pFreeList(NULL), m_FreeIndex(0) { if (m_MaxSize > 0) m_pBuffer = (test_t *) new char[sizeof(test_t) * (m_MaxSize + 1)]; } test_t* NewNode(__int64 key) { if (!m_pBuffer || m_FreeIndex >= m_MaxSize) return new test_t(key); size_t Pos = m_FreeIndex; ++m_FreeIndex; return new (&m_pBuffer[Pos]) test_t(key); } void DeleteNode(test_t* pNode) { if (!m_pBuffer) { delete pNode; } else { pNode->pNext = m_pFreeList; m_pFreeList = pNode; } } }; void TestNew(void) { test_t* pPtr = new test_t; test_t* pFirst = pPtr; for (int i = 0; i < NUM_ALLOCS; ++i) { pPtr->pNext = new test_t; pPtr = pPtr->pNext; } pPtr = pFirst; while (pPtr) { test_t* pTemp = pPtr; pPtr = pPtr->pNext; delete pTemp; } pLast = pPtr; } void TestClass(const size_t BufferSize) { CTest Alloc(BufferSize); test_t* pPtr = Alloc.NewNode(0); test_t* pFirstPtr = pPtr; for (int i = 0; i < NUM_ALLOCS; ++i) { pPtr->pNext = Alloc.NewNode(i); pPtr = pPtr->pNext; } pLast = pPtr; pPtr = pFirstPtr; while (pPtr != NULL) { test_t* pTmp = pPtr->pNext; Alloc.DeleteNode(pPtr); pPtr = pTmp; } } int main(void) { DWORD StartTick = GetTickCount(); TestClass(0); //TestClass(NUM_ALLOCS + 10); //TestNew(); DWORD EndTick = GetTickCount(); printf("Time = %u ms\n", EndTick - StartTick); printf("Last = %p\n", pLast); return 0; } TestNew()的时间约为800毫秒,TestClass(0)的时间却低于200毫秒。自定义分配器非常快,因为它以完全线性的方式在内存上运行,这使内存缓存能够发挥其魔力。我也使用TestClass(NUM_ALLOCS + 10)来简化,只要时间超过100毫秒就足够准确。

答案 1 :(得分:2)

很难确定这么少的代码可以用来学习,但我打赌参考的地方。带有元数据的位图与分配的内存本身不在同一个高速缓存行上。 get_first_unset可能是线性搜索。

答案 2 :(得分:0)

  

现在,我知道内存分配器有多复杂,代码无法比数组查找+一位设置运行得更快,但这就是这种情况。

这甚至几乎都不正确。一个体面的低碎片堆是O(1),具有非常低的恒定时间(并且实际上零空间开销)。我之前看到过一个版本来自~18个asm指令(有一个分支)。这比你的代码要少得多。请记住,堆可能总体上非常复杂,但通过它们的快速路径可能非常非常快。

答案 3 :(得分:0)

仅供参考,以下代码是当前问题的最佳表现。

这只是一个简单的avltree实现,但是对于我的2600K @ 4.6 GHz上的1000万次插入和1,4秒的同等数量的删除确实达到了1.7秒。

#include "stdafx.h"
#include <iostream>
#include <crtdbg.h>
#include <Windows.h>
#include <malloc.h>
#include <new>

#ifndef NULL
#define NULL 0
#endif

typedef int keytype;
typedef unsigned long long UINT64;

struct avlnode;

struct avltree
{
  avlnode *root;
  avlnode *buffer;
  avlnode *firstfree;

  avltree() : avltree(0) {};
  avltree(UINT64 numitems);

  inline avlnode *newnode(keytype key);
  inline void deletenode(avlnode *node);

  void insert(keytype key) { root = insert(root, key); }
  void remove(keytype key) { root = remove(root, key); }
  int height();
  bool hasitems() { return root != NULL; }
private:
  avlnode *insert(avlnode *node, keytype k);
  avlnode *remove(avlnode *node, keytype k);
};

#pragma pack(1)
struct avlnode
{
  avlnode *left;     //left pointer
  avlnode *right;    //right pointer
  keytype key;       //node key
  unsigned char hgt; //height of the node

  avlnode(int k)
  {
    key = k;
    left = right = NULL;
    hgt = 1;
  }

  avlnode &balance()
  {
    struct F
    {
      unsigned char height(avlnode &node)
      {
        return &node ? node.hgt : 0;
      }
      int balance(avlnode &node)
      {
        return &node ? height(*node.right) - height(*node.left) : 0;
      }
      int fixheight(avlnode &node)
      {
        unsigned char hl = height(*node.left);
        unsigned char hr = height(*node.right);
        node.hgt = (hl > hr ? hl : hr) + 1;
        return (&node) ? hr - hl : 0;
      }
      avlnode &rotateleft(avlnode &node)
      {
        avlnode &p = *node.right;
        node.right = p.left;
        p.left = &node;
        fixheight(node);
        fixheight(p);
        return p;
      }
      avlnode &rotateright(avlnode &node)
      {
        avlnode &q = *node.left;
        node.left = q.right;
        q.right = &node;
        fixheight(node);
        fixheight(q);
        return q;
      }
      avlnode &b(avlnode &node)
      {
        int bal = fixheight(node);
        if (bal == 2) {
          if (balance(*node.right) < 0)
            node.right = &rotateright(*node.right);
          return rotateleft(node);
        }
        if (bal == -2) {
          if (balance(*node.left) > 0)
            node.left = &rotateleft(*node.left);
          return rotateright(node);
        }
        return node; // balancing is not required
      }
    } f;
    return f.b(*this);
  }
};

avltree::avltree(UINT64 numitems)
{
  root = buffer = firstfree = NULL;
  if (numitems) {
    buffer = (avlnode *) malloc(sizeof(avlnode) * (numitems + 1));
    avlnode *tmp = &buffer[numitems];
    while (tmp > buffer) {
      tmp->right = firstfree;
      firstfree = tmp--;
    }
  }
}

avlnode *avltree::newnode(keytype key)
{
  avlnode *node = firstfree;
  /*
  If you want to support dynamic allocation, uncomment this.
  It does present a bit of an overhead for bucket allocation though (8% slower)
  Also, if a condition is met where bucket is too small, new nodes will be dynamically allocated, but never freed
  if (!node)
  return new avlnode(key);
  */
  firstfree = firstfree->right;
  return new (node) avlnode(key);
}

void avltree::deletenode(avlnode *node)
{
  /*
  If you want to support dynamic allocation, uncomment this.
  if (!buffer)
  delete node;
  else {
  */
  node->right = firstfree;
  firstfree = node;
}

int avltree::height()
{
  return root ? root->hgt : 0;
}

avlnode *avltree::insert(avlnode *node, keytype k)
{
  if (!node)
    return newnode(k);
  if (k == node->key)
    return node;
  else if (k < node->key)
    node->left = insert(node->left, k);
  else
    node->right = insert(node->right, k);
  return &node->balance();
}

avlnode *avltree::remove(avlnode *node, keytype k) // deleting k key from p tree
{
  if (!node)
    return NULL;
  if (k < node->key)
    node->left = remove(node->left, k);
  else if (k > node->key)
    node->right = remove(node->right, k);
  else //  k == p->key 
  {
    avlnode *l = node->left;
    avlnode *r = node->right;
    deletenode(node);
    if (!r) return l;

    struct F
    {
      //findmin finds the minimum node
      avlnode &findmin(avlnode *node)
      {
        return node->left ? findmin(node->left) : *node;
      }
      //removemin removes the minimum node
      avlnode &removemin(avlnode &node)
      {
        if (!node.left)
          return *node.right;
        node.left = &removemin(*node.left);
        return node.balance();
      }
    } f;

    avlnode &min = f.findmin(r);
    min.right = &f.removemin(*r);
    min.left = l;
    return &min.balance();
  }
  return &node->balance();
}
using namespace std;

int _tmain(int argc, _TCHAR* argv[])
{
  // 64 bit release performance (for 10.000.000 nodes)
  // malloc:       insertion: 2,595  deletion 1,865
  // my allocator: insertion: 2,980  deletion 2,270
  const int nodescount = 10000000;

  avltree &tree = avltree(nodescount);
  cout << "sizeof avlnode " << sizeof(avlnode) << endl;
  cout << "inserting " << nodescount << " nodes" << endl;
  LARGE_INTEGER t1, t2, freq;
  QueryPerformanceFrequency(&freq);
  QueryPerformanceCounter(&t1);
  for (int i = 1; i <= nodescount; i++)
    tree.insert(i);
  QueryPerformanceCounter(&t2);
  cout << "Tree height " << (int) tree.height() << endl;
  cout << "Insertion time: " << ((double) t2.QuadPart - t1.QuadPart) / freq.QuadPart << " s" << endl;
  QueryPerformanceCounter(&t1);
  while (tree.hasitems())
    tree.remove(tree.root->key);
  QueryPerformanceCounter(&t2);
  cout << "Deletion time: " << ((double) t2.QuadPart - t1.QuadPart) / freq.QuadPart << " s" << endl;

#ifdef _DEBUG
  _CrtMemState mem;
  _CrtMemCheckpoint(&mem);
  cout << "Memory used: " << mem.lTotalCount << " high: " << mem.lHighWaterCount << endl;
#endif
    return 0;
}
相关问题