每个对象内存分配有多少开销

How much is there overhead per single object memory allocation?

本文关键字:多少 开销 分配 内存 对象      更新时间:2023-10-16

比方说,如果我调用malloc(sizeof(int)),请求4个字节,那么系统(或std库?)将额外添加多少来支持内存管理基础设施?我认为应该有一些。否则,当我调用free(ptr)时,系统将如何知道要处理多少字节。

更新1:这听起来可能是一个"过于宽泛的问题",显然,这是一个特定于C/C++库的问题,但我感兴趣的是,支持单个分配所需的最小额外内存。甚至不是特定的系统或实现。例如,对于二叉树,必须有两个指针——左子指针和右子指针,并且不能压缩它

更新2:我决定在Windows64上自己检查一下。

#include <stdio.h>
#include <conio.h>
#include <windows.h>
#include <psapi.h>
void main(int argc, char *argv[])
{
int m = (argc > 1) ? atoi(argv[1]) : 1;
int n = (argc > 2) ? atoi(argv[2]) : 0;
for (int i = 0; i < n; i++)
malloc(m);
size_t peakKb(0);
PROCESS_MEMORY_COUNTERS pmc;
if ( GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc)) )
peakKb = pmc.PeakWorkingSetSize >> 10;
printf("requested : %d kb, total: %d kbn", (m*n) >> 10, peakKb);
_getch();
}

请求:0 kb,总计:2080 kb

1字节:请求:976 kb,总计:17788 kb额外:17788-2080-976=14732(+1410%)

2字节:请求:1953 kb,总计:17784 kb额外:17784-2080-1953=(超出605%)

4字节:请求:3906 kb,总计:17796 kb额外:17796-2080-3906=10810(+177%)

8字节:请求:7812kb,总计:17784kb额外:17784-2080-7812=(0%)

更新3:这是我一直在寻找的问题的答案:除了速度慢之外,默认C++分配器的通用性使它对小对象的空间效率非常低。默认分配器管理一个内存池,而这种管理通常需要一些额外的内存。通常,为每个分配了新的块,记账存储器相当于几个额外的字节(4到32)。如果分配1024字节的块,则每个块的空间开销是微不足道的(0.4%到3%)。如果您分配8字节的对象,则每个对象的开销将变为50%到400%,如果您分配了许多这样的小对象,这个数字足以让您担心。

对于已分配的对象,理论上不需要额外的元数据。例如,malloc的一致性实现可以将所有分配请求四舍五入到固定的最大对象大小。因此,对于malloc (25),您实际上会收到一个256字节的缓冲区,而malloc (257)将失败并返回一个空指针。

更现实地说,一些malloc实现在指针本身中对分配大小进行编码,要么直接使用与特定固定大小类相对应的位模式,要么间接使用哈希表或多级trie。如果我没有记错的话,Address Sanitizer的内部malloc就是这种类型的。对于这样的malloc,至少部分即时分配开销不是来自于添加用于堆管理的元数据,而是来自于将分配大小四舍五入到支持的大小类。

其他malloc具有单个字的每个分配报头。(dlmalloc及其衍生物是流行的例子)。实际的每次分配开销通常稍大,因为由于头字的原因,您可以获得支持的分配大小(例如,在64位系统上,24、40、56…个字节与16字节对齐)。

需要记住的一点是,许多malloc实现都放置了大量数据释放的对象(这些对象尚未返回到操作系统内核),以便malloc(函数)可以快速找到合适大小的未使用内存区域。特别是对于dlmalloc风格的分配器,这也提供了对最小对象大小的约束。使用释放的对象进行堆管理也会增加malloc的开销,但它对单个分配的影响很难量化。

比方说,如果我调用malloc(sizeof(int)),请求4个字节,那么系统(或std库)将额外添加多少来支持内存管理基础设施?我认为应该有一些。否则,当我调用free(ptr)时,系统将如何知道要处理多少字节。

这完全是特定于库的。答案可以是从零到任何东西。您的库可以将数据添加到块的前面。有些将数据添加到块的前面和后面以跟踪覆盖。增加的开销量因库而异。

可以使用表在库本身中跟踪长度。在这种情况下,可能不会向分配的内存中添加隐藏字段。

库可能只分配固定大小的块。你要求的金额会四舍五入到下一个区块大小。

指针本身本质上是开销,在某些程序中可能是内存使用的主要驱动因素。

对于某些理论系统和用途,理论上的最小开销可能是sizeof(void*),但CPU、内存和使用模式的组合不太可能存在,因此绝对没有价值。该标准要求malloc返回的内存针对任何数据类型进行适当的对齐,因此总会有一些开销;在一个分配块的末尾和下一个分配的块的开头之间,以未使用的存储器的形式,除非在极少数情况下,所有存储器使用量的大小都是块大小的倍数。

malloc/free/realloc的最小实现假设堆管理器有一个可供其使用的连续内存块,位于系统内存中的某个位置,该堆管理器用来引用原始块的指针是开销(同样是sizeof(void*))。可以想象,一个高度设计的应用程序请求整个内存块,从而避免了对额外跟踪数据的需要。在这一点上,我们有相当于2 * sizeof(void*)的开销,一个在堆管理器内部,加上返回的指向一个已分配块的指针(理论上的最小值)。这样一个一致的堆管理器不太可能存在,因为它还必须有某种方法从其池中分配多个块,这意味着至少要跟踪其池中正在使用的块。

一种避免开销的方案涉及使用大于应用程序可用的物理或逻辑内存的指针大小。人们可以在那些未使用的比特中存储一些信息,但如果它们的数量超过处理器的字大小,它们也会被视为开销。通常,只使用满手的位,这些位标识内存来自哪个内存管理器内部池。后者暗示了指向池的指针的额外开销。这就把我们带到了现实世界中的系统中,堆管理器的实现根据操作系统、硬件体系结构和典型的使用模式进行了调整。

大多数托管实现(托管==在OS上运行)在c运行时间初始化阶段向操作系统请求一个或多个内存块。操作系统内存管理调用在时间和空间上都很昂贵。操作系统有自己的内存管理器,有自己的开销集,由自己的设计标准和使用模式驱动。因此,c运行时堆管理器试图限制对操作系统内存管理器的调用次数,以减少对malloc()free()的平均调用的延迟。当malloc第一次被调用时,大多数请求操作系统的第一个块,但这通常发生在c运行时初始化代码中的某个时刻。该第一块通常是系统页面大小的低倍数,其可以比初始malloc()调用中请求的大小大一个或多个数量级。

在这一点上,堆管理器开销是非常不稳定的,并且很难量化。在一个典型的现代系统中,堆管理器必须跟踪从操作系统分配的多个内存块,每个块中当前分配给应用程序的字节数,以及从一个块变为零起经过的时间。然后是从每个块中跟踪分配的开销。

通常malloc四舍五入到最小对齐边界,这通常不是小分配的特殊情况,因为应用程序需要将其中许多聚合到单个分配中。最小对齐通常基于代码运行架构中加载指令所需的最大对齐。因此,对于128位SIMD(例如SSE或NEON),最小对齐为16字节。在实践中,也有一个头部,它会使尺寸上的最小成本加倍。随着SIMD寄存器宽度的增加,malloc并没有增加它的保证对齐。

如前所述,可能的最小开销为0。尽管指针本身可能应该在任何合理的分析中计算在内。在垃圾收集器设计中,必须至少存在一个指向数据的指针。在直接的非GC设计中,必须有一个指针来调用free,但不需要调用它。理论上也可以将一堆指针压缩到更小的空间中,但现在我们要分析指针中比特的熵。重点是你可能需要指定更多的约束条件才能得到一个真正可靠的答案。

举例来说,如果只需要int大小的任意分配和解除分配,则可以分配一个大块,并使用每个int创建索引的链表,以保存下一个的索引。分配会从列表中删除一个项目,而取消分配则会添加一个项目。存在一个约束条件,即每个分配恰好是一个int。(并且块足够小,最大索引适合int。)通过具有不同的块并在解除分配时搜索指针所在的块,可以处理多个大小。一些malloc实现对较小的固定大小(如4、8和16字节)执行类似的操作。

这种方法不会达到零开销,因为需要维护一些数据结构来跟踪块。这是通过考虑一个字节分配的情况来说明的。一个块最多可以容纳256个分配,因为这是块中可以容纳的最大索引。如果我们想允许比这更多的分配,我们将需要每个块至少一个指针,例如,每256字节有4或8个字节的开销。

还可以使用位图,它按某个粒度摊销为一位,再加上该粒度的量化。这是否是低开销取决于具体情况。例如,每个字节一个比特没有量化,但占用空闲映射中分配大小的八分之一。一般来说,这一切都需要存储分配的大小。

在实践中,分配器的设计很困难,因为大小开销、运行时成本和碎片开销之间的权衡空间很复杂,通常具有很大的成本差异,并且依赖于分配模式。