`std :: string`分配是我目前的瓶颈 - 如何使用自定义分配器进行优化?

时间:2014-09-30 22:07:00

标签: c++ string optimization memory-management c++14

我正在写C++14 JSON library作为练习并在我的个人项目中使用它。

使用 callgrind 我发现current bottleneck during a continuous value creation from string stress test is an std::string dynamic memory allocation.确切地说,瓶颈是malloc(...)std::string::reserve的调用。

我已经读过许多现有的JSON库,例如rapidjson,在字符串内存分配期间使用自定义分配器来避免malloc(...)调用。

我试图分析rapidjson的源代码,但是大量额外的代码和注释,以及我不确定我在寻找什么的事实,对我没什么帮助。

  • 自定义分配器如何在这种情况下提供帮助?
    • 某个内存缓冲区是预先分配的(静态?),std::strings是否可以获取内存?
  • 使用自定义分配器的字符串是否与普通字符串“兼容”?
    • 他们有不同的类型。他们必须“转换”吗? (这会导致性能下降吗?)

代码说明:

  • Strstd::string的别名。

6 个答案:

答案 0 :(得分:6)

默认情况下,std::string根据需要从您使用mallocnew分配的任何内容中分配内存。要通过提供自己的自定义分配器获得性能提升,您需要管理自己的" chunk"内存的方式使得分配器可以比malloc更快地处理字符串要求的内存量。你的记忆管理员只需拨打相对较少的电话malloc,(或new,视你的方法而定),请求"大"一次使用大量内存,然后通过自定义分配器处理这些(这些)内存块的各个部分。要实际获得比malloc更好的性能,通常必须根据用例的已知分配模式调整内存管理器。

这种事情往往归结为内存使用与执行速度的古老关系。例如:如果在实践中你的字符串大小有一个已知的上限,你可以通过过度分配来提取技巧以始终适应最大的情况。虽然这会浪费您的内存资源,但它可以减轻更广泛的分配与内存碎片相关的性能开销。除了为您的目的拨打realloc基本上不变的时间。

@sehe是完全正确的。有很多方法。

编辑:

为了最终解决您的第二个问题,使用不同分配器的字符串可以很好地一起使用,并且使用应该是透明的。

例如:

class myalloc : public std::allocator<char>{};
myalloc customAllocator;

int main(void)
{
  std::string mystring(customAllocator);
  std::string regularString = "test string";
  mystring = regularString;
  std::cout << mystring;

  return 0;
}

这是一个相当愚蠢的例子,当然,它使用相同的主力代码。但是,它显示了使用&#34;不同类型&#34;的分配器类的字符串之间的分配。实现一个有用的分配器,它提供STL所需的完整接口,而不仅仅是伪装默认的std::allocator,这并不是一件容易的事。 This似乎是一个体面的写作,涵盖了所涉及的概念。至少在你的问题的上下文中,为什么这种方法起作用的关键在于使用不同的分配器并不会导致字符串具有不同的类型。请注意,自定义分配器是作为构造函数的参数而不是模板参数给出的。 STL仍然可以使用模板(例如rebindTraits)来实现分配器接口和跟踪的均匀化。

答案 1 :(得分:3)

我认为通过阅读EASTL

,您将获得最佳服务

它有一个关于分配器的部分,你可能会发现fixed_string很有用。

答案 2 :(得分:3)

自定义分配器可以提供帮助,因为大多数malloc() / new实现都是为了最大程度的灵活性,线程安全性和防弹工作而设计的。例如,他们必须优雅地处理一个线程不断分配内存的情况,将指针发送到另一个解除分配它们的线程。像这样的事情很难以高效的方式处理,并导致malloc()电话的费用。

但是,如果您知道某些事情在您的应用程序中不会发生(例如一个线程解除分配另一个线程分配的内容等),您可以比标准实现更优化您的分配器。这可以产生显着的效果,特别是当您不需要线程安全时。

此外,标准实现未必得到很好的优化:只需调用void* operator new(size_t size)void operator delete(void* pointer)即可实现malloc()free(),平均性能提升为100 CPU在我的机器上循环,证明默认实现不是最理想的。

答案 3 :(得分:3)

通常有助于创建 GlobalStringTable

看看你是否可以从现已不存在的NetImmerse软件堆栈中找到旧NiMain库的一部分。它包含一个示例实现。

<强>寿命

重要的是要注意,此字符串表需要在不同的DLL空间之间可访问,并且它不是静态对象。 R. Martinho Fernandes已警告说,在创建/附加应用程序或DLL线程时需要创建对象,并在线程被销毁或dll被分离时处理,最好在实际使用任何字符串对象之前进行处理。这听起来比实际更容易。

内存分配

一旦您拥有正确导出的单一访问点,您就可以让它预先分配一个内存缓冲区。如果内存不足,则必须调整内存大小并移动现有字符串。字符串基本上成为此缓冲区中内存区域的句柄。

展示新

通常运行良好的东西称为 placement new()运算符,您可以在其中实际指定需要分配新字符串对象的内存位置。但是,操作员可以简单地获取作为参数传入的内存位置,将该位置的内存归零并返回,而不是分配。您还可以在Globalstringtable对象中跟踪分配,字符串的实际大小等。

<强> SOA

处理实际的内存调度是由您决定的,但有很多可能的方法来解决这个问题。通常,分配的空间在几个区域中分区,因此每个可能的字符串大小有几个块。用于字符串&lt; = 4字节的块,一个用于&lt; = 8字节,依此类推。这称为Small Object Allocator,可以为任何类型和缓冲区实现。

如果您期望许多字符串操作重复递增小字符串,您可以更改策略并从一开始就分配更大的缓冲区,以便减少memmove操作的数量。或者您可以选择不同的方法并为这些方法使用字符串流。

字符串操作

从std :: basic_str派生并不是一个坏主意,因此大多数操作仍然有效,但内部存储实际上在GlobalStringTable中,因此您可以继续使用相同的stl约定。这样,您还可以确保所有分配都在单个DLL中,这样就可以通过在不同库之间链接不同类型的字符串来防止堆损坏,因为所有分配操作基本上都在您的DLL中(并且被重新路由到GlobalStringTable对象)

答案 4 :(得分:2)

避免记忆分配的最佳方法是不要这样做! 但是如果我正确地记住了JSON,那么所有的readStr值都会被用作键或标识符,所以你必须最终分配它们,std :: strings移动语义应该确保分配的数组不被复制,但重复使用直到最终使用。默认的NRVO / RVO / Move应该减少数据的任何复制,如果不是字符串头本身的话。

方法1:
将结果作为来自调用者的ref传递,该调用者保留了SomeResonableLargeValue字符,然后在readStr的开头清除它。这只有在调用者实际可以重用字符串时才可用。

方法2:
使用堆栈。

// Reserve memory for the string (BOTTLENECK)
if (end - idx < SomeReasonableValue) { // 32?
  char result[SomeReasonableValue] = {0};  // feel free to use std::array if you want bounds checking, but the preceding "if" should insure its not a problem.
  int ridx = 0;

  for(; idx < end; ++idx) {
    // Not an escape sequence
    if(!isC('\\')) { result[ridx++] = getC(); continue; }
    // Escape sequence: skip '\'
    ++idx;
    // Convert escape sequence
    result[ridx++] = getEscapeSequence(getC());
  }

  // Skip closing '"'
  ++idx;
  result[ridx] = 0; // 0-terminated.
  // optional assert here to insure nothing went wrong.
  return result; // the bottleneck might now move here as the data is copied to the receiving string.
}
// fallback code only if the string is long.
// Your original code here

方法3:
如果默认情况下你的字符串可以分配一些大小来填充它的32/64字节边界,你可能想尝试使用它,构造result,如果构造函数可以优化它。

Str result(end - idx, 0);

方法4:
大多数系统已经有一些优化的分配器,类似于特定的块大小,16,32,64等。

siz = ((end - idx)&~0xf)+16; // if the allocator has chunks of 16 bytes already.
Str result(siz);

方法5:
使用google或facebooks制作的分配器作为全局新/删除替换。

答案 5 :(得分:1)

要了解自定义分配器如何为您提供帮助,您需要了解malloc和堆的功能以及与堆栈相比速度非常慢的原因。

筹码

堆栈是为当前范围分配的大块内存。你可以把它想象成这个

([]表示一个字节的内存)

[P] [] [] [] [] [] [] [] [] [] [] [] [] [] [] []

(P是一个指向内存特定字节的指针,在这种情况下指向第一个字节)

所以堆栈是一个只有1个指针的块。当你分配内存时,它所做的是它在P上执行一个指针算法,这需要一个恒定的时间。 所以声明int i = 0;意思是这个,

P + sizeof(int)。

[I] [I] [I] [I] [P] [] [] [] [] [] [] [] [] [] [] [], (i in []是一个整数占用的内存块)

这是非常快速的,一旦你超出范围,只需将P移回第一个位置就可以清空整个内存块。

堆在运行时从c ++编译器保留的保留字节池中分配内存,当你调用malloc时,堆会找到一段符合malloc要求的连续内存,将其标记为已使用,因此没有其他任何东西可以使用它,并将其作为void *返回给您。

因此,一个没有优化调用new(sizeof(int))的理论堆就可以做到这一点。

堆块

首先:[] [] [] [] [] [] [] [] [] [] [] [] [] [] [] [] [] [] [] [] [] [] [] [] []

分配4个字节(sizeof(int)): 指针遍历内存的每个字节,找到一个长度正确的指针,然后返回一个指针。 之后:[i] [i] [i] [i] [] [] []] [] [] [] [] [] [] [] [] []] [] [] [] [] [] [] []

这不是堆的准确表示,但是由此可以看出相对于堆栈缓慢的众多原因。

  1. 需要堆来跟踪所有已分配的内存及其各自的长度。在我们上面的测试用例中,堆已经是空的并且不需要太多,但在最坏的情况下,堆将填充多个对象,其间存在间隙(堆碎片),这将会慢得多。

  2. 堆需要循环遍历所有字节以找到适合您长度的字节。

  3. 堆可能会受到碎片的影响,因为除非您指定它,否则永远不会完全清理它。因此,如果您分配了一个int,一个char和另一个int,那么您的堆将看起来像这样

  4. [I] [I] [I] [I] [C] [12] [12] [12] [12]

    (我代表int占用的字节,c代表char占用的字节。当你取消分配char时,它看起来像这样。

    [I] [I] [I] [I] [空] [12] [12] [12] [12]

    因此,当您想要将另一个对象分配到堆中时,

    [I] [I] [I] [I] [空] [12] [12] [12] [12] [i3的] [i3的] [i3的] [i3的]

    除非对象的大小为1个字符,否则该分配的总堆大小将减少1个字节。在具有数百万次分配和解除分配的更复杂的程序中,碎片化问题变得严重,程序将变得不稳定。

    1. 担心线程安全等问题(其他人已经说过了)。
    2. 自定义堆/分配器

      因此,自定义分配器通常需要解决这些问题,同时提供堆的好处,例如个性化内存管理和对象持久性。

      这些通常由专门的分配器完成。如果你知道你不需要担心线程安全,或者你确切知道你的字符串有多长或者可预测的使用模式,那么你可以让你的分配器比malloc和new更快。

      例如,如果你的程序需要尽可能快地进行大量分配而没有大量的解除分配,你可以实现一个堆栈分配器,在启动时用malloc分配一大块内存,

      e.g

      typedef char* buffer;
      //Super simple example that probably doesnt work.
      struct StackAllocator:public Allocator{
           buffer stack;
           char* pointer;
           StackAllocator(int expectedSize){ stack = new char[expectedSize];pointer = stack;}
           allocate(int size){ char* returnedPointer = pointer; pointer += size; return returnedPointer}
           empty() {pointer = stack;}
      
      };
      

      获得预期的大小,从堆中获取一块内存。

      指定一个指向开头的指针。

      [P] [] [] [] [] [] [] [] [] [] ..... []。

      然后有一个指针移动每个分配。当您不再需要内存时,只需将指针移动到缓冲区的开头即可。由于缺乏灵活的释放和大的初始内存需求,这为O(1)速度分配和解除分配以及对象持久性提供了优势。

      对于字符串,您可以尝试使用块分配器。对于每个分配,分配器给出一组内存。

      <强>兼容性

      几乎可以保证与其他字符串的兼容性。只要您分配一块连续的内存并阻止其他任何内容使用该内存块,它就会起作用。