美文网首页
简单聊聊 TCMalloc

简单聊聊 TCMalloc

作者: 给艺艺一个未来 | 来源:发表于2022-01-07 17:16 被阅读0次

    官方介绍:tcmalloc
    参考文章:TCMalloc解密

    注:TCMalloc解密 有一些内容已经过时,如果您使用的版本和 TCMalloc解密 的版本一致,那么可以参考其内容,否则建议读官方介绍:tcmalloc

    概述

    TCMalloc 是 Google 自定义的 c 的 malloc () 和 c + + 操作符 new 的实现,用于 c 和 c + + 代码中的内存分配。

    TCMalloc 将 c 的 malloc () 和 c + + 操作符 new 的内部实现替换为 TCMalloc 的实现,开发者只需编译链接 TCMalloc 的静态库或动态库即可,无需改动任何与内存分配有关的代码。

    TCMalloc 通常被用于提高内存分配的性能,实现了高效的内存管理。

    TCMalloc 的 Cache 运作模式

    TCMalloc 可能以以下两种方式之一运作:

    • 默认:为每个 CPU 进行缓存,TCMalloc 为每个逻辑核心维护一份本地内存缓存(一份本地内存缓存只供一个逻辑核心使用)(实际上仅分配一块内存,再在这块内存上为每个CPU划分缓存,详见:Per-CPU Mode 一节)。当 TCMalloc运行在支持RSEQ (restartable sequences) 的Linux内核上时,开启每个 CPU 进行缓存的运作模式。RSEQ在Linux 4.18版本被合入。

    • 法二:为每个 线程 进行缓存,TCMalloc 为每个线程维护一份本地内存缓存(一份本地内存缓存只供一个线程使用)。只有RSEQ不可用时,TCMalloc才会使用这种遗弃的方法。

    注意:TCMalloc 的 TC 是 Thread Cache 的缩写,然后随着优化的进行,显然新的Cache运作模式——每个CPU进行缓存是一个更优的实现方式,因此TCMalloc的TC已经不能体现TCMalloc的最优实现方式,TCMalloc的这个名字只是因为历史原因遗留了下来。

    注:后面有一节(Restartable Sequences(RSEQ) 和 Per-CPU TCMalloc)会讲到为什么 Per-CPU Mode 需要Restartable Sequences(RSEQ)的支持。

    性能

    缓存大小也会影响性能。缓存越大,任何给定的缓存溢出或耗尽的情况就越少,因此需要一个锁来释放或获取更多的内存。TCMalloc 扩展(MallocExtension)允许您修改这个缓存大小,尽管在大多数情况下,默认行为应该是首选的。有关更多信息,请参考 《TCMalloc调优指南》

    《TCMalloc调优指南》 更深入地介绍了配置选择(缓存大小配置),并说明了定制TCMalloc的其他方法。

    此外,TCMalloc 通过 MallocExtension 公开关于应用程序堆状态的遥测信息。

    设计

    《TCMalloc设计文档》

    TCMalloc设计文档(译)

    动机

    TCMalloc 是一个内存分配器,设计的目的是用于替代系统默认分配器。

    TCMalloc 具有以下特性:

    • 快!大多数对象可以快速、非争用地分配和释放。缓存机制具体取决于模式,可以是每个线程,也可以是每个逻辑 cpu。大多数分配不需要采用锁,因此对于多线程应用程序来说,存在低争用和良好的扩展性。
    • 灵活地使用内存,可以根据不同的对象大小重用已释放的内存,或者将其返回到操作系统。
    • 通过分配相同大小的对象的 “page”(page是tcmalloc中的一个概念,有别于内存管理中常说的 page),降低每个对象的内存开销。使小对象的表示具有空间效率。
    • 低开销的采样,能够详细了解应用程序内存使用情况。

    概况

    TCMalloc 的粗略内部结构:


    the rough internal structure of TCMalloc

    我们可以将 TCMalloc 分解为三个组件:front-end(前端), middle-end(中端), back-end (后端)。我们将在以下章节中更详细地讨论这些问题。三个组件粗略的职责如下:

    • front-end :front-end 是一个缓存,它为应用程序提供内存的快速分配和释放。
    • middle-end :middle-end 负责为 front-end 填充或回收缓存。
    • back-end : back-end 负责从 OS 获取或释放内存。

    注: front-end 可以采取 Per-CPU Cache 模式,也可以采取旧版的 Per-thread Cache 模式。back-end 可以支持 the hugepage aware pageheap,也可以支持旧版的 page heap。

    The TCMalloc Front-end

    front-end 处理特定大小的内存请求。front-end 有一个内存缓存,可以用来分配或保存可用内存。此缓存一次只能由一个线程访问,因此它不需要任何锁,因此大多数分配和释放都很快。

    如果 front-end 已经缓存了适当大小的内存,那么它将满足任何请求。如果该特定大小的缓存为空,则 front-end 将从 middle-end 请求一批内存以重新填充缓存。middle-end 包括 CentralFreeList 表和 TransferCache。如果 middle-end 已经用尽,则需要向 back-end 请求以重新填充 middle-end 的缓存。

    实际上, front-end 仅处理 small object ,如果请求的内存大小大于 front-end 缓存处理的最大内存大小,则请求将转到 back-end,以满足大型的内存分配。back-end 也称为 PageHeap 。

    TCMalloc front-end 有两种实现:

    • 最初它支持 object 的每个线程缓存 (因此命名为 Thread Caching Malloc)。但是,这会导致内存占用随线程数量的增加而增加。现代应用程序可能具有大量的线程,这就会导致:
      • 要么大量的 聚合 per-thread 内存(造成内存的浪费)。
      • 要么大部分线程具有极小的 per-thread caches (极小的caches会导致频繁地向 middle-end 申请和释放内存,性能变差)。
    • 最近 TCMalloc 支持 per-CPU 模式。在这种模式下,系统中的每个逻辑 CPU 都有自己的缓存,从中分配内存。注意: 在 x86上,逻辑 CPU 等同于超线程。

    per-thread 模式和 per-cpu 模式之间的区别完全局限于 malloc/new 和 free/delete 的实现。

    Small and Large Object Allocation

    small object 的分配映射到 60-80 allocatable size-classes 中的一个。例如,12个字节的分配将被舍入到16个字节的 size-class。

    size-classes 的设计目的是尽可能地减少舍入到下一个 size-class 时浪费的内存,再比如说:17个字节的分配将被舍入到24个字节,而不是32个字节。舍入的映射详见 60-80 allocatable size-classes

    当使用 __STDCPP_DEFAULT_NEW_ALIGNMENT__ <= 8 进行编译时,我们使用一组对齐为8字节的大小来分配使用::operator new的原始存储。这种较小的对齐方式最大限度地减少了许多常见分配大小(24、40等)的内存浪费,否则将四舍五入为16字节的倍数。在许多编译器中,这种行为由-fnew-alignment=… 的 flag 指定。当没有指定 __STDCPP_DEFAULT_NEW_ALIGNMENT__(或者大于8字节) 时,我们对::operator new使用标准的16字节对齐。然而,对于小于16字节的分配,我们可以返回一个对齐方式较低的对象,因为在空间中不能分配具有较大对齐要求的对象。

    当请求一个给定大小的对象时,将使用SizeMap::GetSizeClass()函数将该请求映射到一个特定 size-class 的请求,并且返回的内存来自该 size-class ( 例如,12个字节的分配将被舍入到16个字节的 size-class )。这意味着返回的内存至少与请求的大小一样大(即返回的内存大小 >= 请求的内存大小)。size-class 的分配由 front-end 处理。

    大小超过 kMaxSize 限制的 object 直接从 back-end 分配。因此,它们不会缓存在 front-end 或 middle-end 。对于 large object (大于 kMaxSize 的 object) 的分配请求会舍入到 TCMalloc 的 page 大小。

    对象的释放

    当 object 释放时,如果在编译时已知 object 的大小,编译器将提供该 object 的大小。如果不知道 object 大小,将在 pagemap 中查找。如果 object 很小,它将被放回到 front-end 缓存中。如果 object 大于 kMaxSize,则直接返回到 pageheap。

    Per-CPU Mode

    在 Per-CPU 模式下,只分配一个大的内存块。下图展示了这块内存是如何在CPU之间划分的,以及每个CPU如何使用这块内存的一部分来保存元数据和指向可用对象的指针。


    image.png
    struct Slabs {
      std::atomic<int64_t> header[NumClasses];
      void* mem[((1ul << Shift) - sizeof(header)) / sizeof(void*)];
    };
    
    // Slab header (packed, atomically updated 64-bit).
    // All {begin, current, end} values are pointer offsets from per-CPU region
    // start. The slot array is in [begin, end), and the occupied slots are in
    // [begin, current).
    struct Header {
      // The end offset of the currently occupied slots.
      uint16_t current;
      // Copy of end. Updated by Shrink/Grow, but is not overwritten by Drain.
      uint16_t end_copy;
      // Lock updates only begin and end with a 32-bit write.
    
      // The begin offset of the slot array for this size class.
      uint16_t begin;
      // The end offset of the slot array for this size class.
      uint16_t end;
    
      // Lock is used by Drain to stop concurrent mutations of the Header.
      // Lock sets begin to 0xffff and end to 0, which makes Push and Pop fail
      // regardless of current value.
      bool IsLocked() const;
      void Lock();
    };
    

    每个逻辑CPU都被分配了这个内存的一部分来保存元数据和指向特定size-classes的可用对象的指针。元数据包含 per-size-class 的一个 /header/block。header 中有一个指针指向 per-size-class 的 object 指针数组的开头(Begin),以及一个指针指向数组中的当前动态最大容量(End)和当前位置(Current)。per-size-class 的指针数组的静态最大容量在开始时由该大小分类的数组开始处与下一个 per-size-class 的数组开始处的差值决定。

    例如:8个字节的size-class的objects为一个数组,16个字节的size-class的objects为一个数组。每个size-class object数组有一个header,header有三个指针:分别指向数组的 begin,current 和 end。

    在运行时,存储在 per-cpu block 中的特定size-class的object的最大数目会发生变化,但永远不会超过启动时分配的静态确定的最大容量。

    当请求某个size-class的object时,它将从该数组中移除,当该object被释放时,它将被添加到该数组中。如果数组用完了,数组将使用 middle-end 的一批object重新填充。如果数组将溢出,则从数组中删除一批对象,并将其返回到 middle-end。

    每个 cpu 可以缓存的内存量受到参数 MallocExtension::SetMaxPerCpuCacheSize 的限制。这意味着缓存内存的总量取决于每个 cpu 活跃的缓存量。并且,具有较高 CPU 计数的机器可以缓存更多的内存。

    为了避免在应用程序不再运行的 CPU 上占用内存,可以使用MallocExtension::ReleaseCpuMemory 释放指定 CPU 缓存中的Objects。

    在 CPU 中,内存的管理在所有的 size-classes 之间进行管理,以保持缓存内存的最大数量低于限制。请注意,它管理的是可以缓存的最大数量,而不是当前缓存的数量。平均而言,实际缓存的数量应该是限制的一半左右。

    当 size-class object 耗尽时,最大容量会增加,当获取更多 object 时,它还会考虑增加 size-class 的容量。它可以只增加某个 size-class 的容量,直到缓存可以容纳的总内存(对于所有 size-class )达到每个 cpu 的限制,或者直到 size-class 的容量达到硬编码的大小限制。如果 size-class 没有达到硬编码的限制,那么为了增加容量,它可以从同一 CPU 上的另一个 size-class 中窃取容量。(size-class object 耗尽说明当前应用程序对这个size-class的需求很大,对其他size-class的需求可能没那么大,因此可以动态扩大这个size-class最大容量,或从其他 size-class 中窃取容量。)

    Restartable Sequences(RSEQ) 和 Per-CPU TCMalloc

    Restartable Sequences 允许我们执行一个 region 直到完成(相对于同一CPU上的其他线程来说是原子的),或者在内核通过抢占、中断或信号处理中断时被中止。

    Restartable Sequences 只是一块(汇编语言)指令,很大程度上类似于典型的函数。

    Restartable Sequences 的一个限制是它们不能将部分状态写入内存,最终指令必须是更新状态的单个写入。

    Restartable Sequences 的想法是,如果一个线程在执行一个 Restartable Sequences 的时候被从CPU中移除(例如上下文切换),那么这个线程序列将来将从序列顶部重新启动。因此,该 Sequences 要么在没有中断的情况下完成,要么反复重启,直到它在没有中断的情况下完成。这是在不使用任何锁或原子指令的情况下实现的,因此避免了 Sequences 本身中的任何争用。

    这对 TCMalloc 的实际意义在于,代码可以使用 Restartable Sequences,例如 TcmallocSlab _ internal _ push从 per-CPU 数组中获取或返回元素时不需要加锁。Restartable Sequences 确保数组在线程没有被中断的情况下进行更新,或者线程被中断时 sequence 将被重新启动 (放弃原有的结果,重新启动sequence,避免中断或上下文切换后原有的结果已经不可用(例如获取或返回元素的地址已经失效))(例如,通过上下文切换允许不同的线程在该 CPU 上运行)。

    遗产 - Per-Thread mode

    在 per-thread mode 下,TCMalloc 为每个线程分配一个线程本地缓存。small object 从线程本地缓存分配。objects 根据需要从 本地线程缓存 和 middle-end 之间移动。

    线程缓存为每个size- class分别维护一个单独的空闲object链表。因此如果有N个size-class,就会有N个object链表,如下图所示:


    object list in thread cache

    在分配时,object 从线程中的最合适的 size-class 链表中移除。在回收时,object 回到对应的 size-class 链表。下溢(thread cache 不足)和溢出(thread cache 满了)是通过 middle-end 来处理的,下溢时向 middle-end 获取更多的 object ,溢出时向 middle-end 要么一些 object。

    每个线程缓存的最大容量由参数 MallocExtension::SetMaxTotalThreadCacheBytes 设置的。但是,总大小可能会超过这个限制,因为每个线程缓存的最小大小 KMinThreadCacheSize 通常为512kib(比如设置的最大容量小于这个值时)。并且当线程希望增加其容量时,它需要占用其他线程的容量。

    当线程退出时,它们的缓存内存被返回到 middle-end 。

    运行时的 Front-end 大小

    优化 front-end cache 的空闲列表大小是非常重要的。如果空闲列表太小,我们就需要经常使用 central (middle-end)空闲列表。

    注意,缓存对于释放和分配同等重要,如果没有缓存,每次释放都需要将内存移动到 central 空闲列表。

    per-CPU 和 per-thread 模式有着不同的缓存大小动态调整算法:

    • 在 per-thread 模式下,每当需要从 middle-end 提取更多对象时,可以存储的最大对象数可以增加到一个极限(TCMalloc对每个线程的缓存大小有一个最大限制)。同样,当我们发现我们已经缓存了太多的对象时,容量也会减少。如果缓存对象的总大小超过每个线程的限制,则缓存的大小也会减小(释放到middle-end中)。

    • 在 per-CPU 模式下,可用列表的容量增加取决于是否在下溢(缓存不足)和溢出(缓存超容量)之间交替(这表明更大的缓存可能会停止这种交替)。

    The TCMalloc Middle-end

    middle-end 负责向 front-end 提供内存,并向 back-end 返回内存。middle 包括 Transfer CacheCentral free list 。尽管这些通常被称为单数,但每个大小类都有一个传输缓存和一个中央空闲列表。这些缓存都由互斥锁保护,因此访问它们需要付出序列化的代价

    Transfer Cache

    当 front-end 请求内存或返回内存时,它将触发 Transfer Cache。

    Transfer Cache保存一个指针数组来释放内存,它可以快速地将对象移动到这个数组中,或者代表 front-end 从这个数组中提取对象。

    Transfer Cache 的名称来源于一个 CPU (或线程)正在申请分配被另一个 CPU (或线程)释放的内存的场景。Transfer Cache 帮助内存在两个不同的 cpu (或线程)之间快速流动。

    如果 Transfer Cache 无法满足内存分配请求,或者没有足够的空间保存返回的对象,它将访问 Central free list 。

    Central Free List

    Central Free List 管理 “span” 中的内存,一个 span 是一个或多个 "TCMalloc pages" 内存集合。"span" 和 "page" 将在下一节中介绍。

    对于一个或多个 object 的请求,通过从 span 中提取对象来满足,直到请求得到满足。如果 span 中的可用 object 不足,则向 back-end 中申请更多的 span 。

    当 object 返回 central free list 时,每个 object 都被映射到它所属的 span (使用 pagemap ),然后释放到该 span 中。

    如果所有属于同一个span的 object 都回到了该 span 中,那么该 span 将被返回到 back-end 。

    Pagemap and Spans

    TCMalloc 管理的 heap 被划分为编译时确定大小的 pages 。一连串相邻的 pages 使用 span object 表示(span object 表示一连串相邻的 pages)。

    Span 可用于管理已移交给应用程序的大对象,或者用于管理被划分为小对象序列的一连串 pages。

    Pagemap 用于查找 object 所属的 span,或者标识给定 object 的 size-class。

    TCMalloc 使用一个2级或3级的 radix tree 来将所有可能的内存位置映射到跨 span 。

    下图显示了如何使用一个 radix-2 pagemap 将 object 的地址映射到控制 object 所在页面的 span 。在图表中,span-A 包含 2 pages,span-B 包含 3pages。

    radix-2 pagemap
    • Spans 在 middle-end 被用来确定(pagemap) object 应该返回的位置。
    • Spans 在 back-end 被用于管理页范围的处理。
    Spans中 small object 的存储

    span包含一个指针,该指针指向span控制的TCMalloc页面的基页起始地址。对于小对象,这些页面最多被分成2的16次方个对象。选择此值是为了使我们能够在 span 内通过一个双字节索引引用对象(2 bytes = 16 bits ,可表示2的16次方个索引)。

    这意味着我们可以使用一个展开的链表(unrolled linked list)来保存对象。例如,如果我们有一个 8 字节的 object ,我们可以存储三个现成对象的索引,并使用 第四个 slot 存储链中下一个对象的索引,这种数据结构减少了全链表(fully linked list)的缓存丢失。

    • unrolled linked list
    • fully linked list

    使用两个字节索引的另一个好处是,我们能够使用span本身的空闲容量来缓存四个对象。

    当我们没有 size-class 的可用对象时,我们需要从 pageheap 中获取一个新的 span 并填充它。

    TCMalloc Page Sizes

    TCMalloc 可以使用各种 “页面大小” 进行构建。注意,这里所指的 “页面大小” 不对应于底层硬件TLB中使用的页面大小。这些 TCMalloc 页面大小目前是4KiB、8KiB、32KiB和256KiB。

    TCMalloc 页面可以保存特定大小的多个对象(一个页保存多个小对象),也可以作为组的一部分来保存大小大于单个页面的对象(多个页组成一个大对象)。

    如果一个完整的 page 变成 free,那么它将被返回到 back-end, 以后可以重新用于保存不同大小的对象(或返回给OS)。

    较小的页面能够以更少的开销更好地处理应用程序的内存需求。例如,一个使用了一半的 4kib 页面将剩下2kib,而 32kib 页面将剩下16kib。

    小的页面更容易变得 free。例如,一个4KiB页面可以容纳8个512字节的对象,而一个32KiB页面可以容纳64个对象; 32个物体同时 free 的概率要比8个物体同时 free 的概率小得多。

    大的页面减少了从 back-end 获取和返回内存的需求。单个32KiB页面可以容纳8倍于4KiB页面的对象,这可能导致管理较大页面的成本变得更小。映射整个虚拟地址空间还需要较少的大页面。TCMalloc 有一个 pagemap,可以将虚拟地址映射到管理该地址范围内对象的结构上。大的页面意味着 pagemap 需要较少的条目,因此较小。

    因此,对于内存占用较小的应用程序,或者对内存占用大小敏感的应用程序,使用较小的TCMalloc页面大小是有意义的。具有较大内存占用的应用程序可能会从较大的TCMalloc页面大小中受益。

    The TCMalloc Backend

    back-end 有以下三项职责:

    • 管理大块未使用的内存。
    • 负责从操作系统中获取内存。
    • 负责将不需要的内存返回到操作系统。

    TCMalloc 有两个 back-end:

    • The Legacy pageheap:管理 TCMalloc 页面大小的块的内存。
    • The hugepage aware pageheap:管理 hugepage 大小的块的内存(在 hugepage 大小的块中管理内存能够使分配器减少 TLB 错误来提高应用程序性能)。
    Legacy Pageheap

    legacy pageheap 是一个 free lists 数组,用于存储指定长度的连续页面的可用内存。对于 k < 256,第 k 个条目是由 k 个 连续 TCMalloc Page 组成的空闲列表(列表中的每个结点为:k 个 连续 TCMalloc Page)。第256个条目是长度为 >= 256 个 连续的 TCMalloc Page 组成的空闲列表。

    legacy pageheap

    一个 k pages 的分配是通过查找第 k 个free列表来满足的。如果那个空闲列表是空的,我们就查看下一个空闲列表。最后,如果有必要,我们查看最后一个空闲列表。如果失败,我们将从系统 mmap 中提取内存。

    如果长度为k的页的空闲列表为空,那么如果长度为>k的页的运行满足了k个页的分配,那么运行的剩余部分将重新插入到页堆中适当的空闲列表中。

    当一个范围的 pages 被返回到 pageheap 时,将检查相邻的 page,以确定它们现在是否形成了一个连续的区域,如果是这种情况,那么这些页面将被连接起来并放置到适当的空闲列表中。

    Hugepage Aware Allocator (巨页感知分配器)

    Hugepage Aware Allocator 的目标是将内存保存在 hugepage 大小的块中。在x86上,hugepage 的大小是2MiB。为此,back-end 有三个不同的缓存:

    • filler cache:filler cache 保存已经分配出去一些内存的 hugepage 。filler cache 可以被认为类似于 legacy pageheap,filler cache 包含特定 数量 TCMalloc pages 的内存链表。对 size 小于一个 hugepage 的分配请求通常从 filler cache 返回。如果 filler cache 中无足够的可用内存,则它将请求额外的 hugepages 。
    • region cache:region cache 用于处理比 hugepage 大的分配请求。这个缓存允许分配跨多个 hugepages,并将多个 hugepages 分配打包到一个连续的区域中。这对于稍微超过 hugepage 大小的分配(例如,2.1 MiB)特别有用。
    • hugepage cache:hugepage cache 用于处理大的分配(至少一个 hugepage)。这与 region cache 的功能有所重叠,但是 region cache 只有在确定(在运行时)分配模式时将从中受益时才会启用。

    关于HPAA设计的其他信息将在《 Hugepage Aware Allocator 设计文档 》中讨论。

    TCMalloc调优指南(译)

    User-Accessible Controls (用户级优化)

    可以从以下三个 user accessible controls 调整 TCMalloc 的性能:

    • The logical page size for TCMalloc (4KiB, 8KiB, 32KiB, 256KiB)
    • The per-thread or per-cpu cache sizes
    • The rate at which memory is released to the OS
    uint64_t val = 2<<30;
    MallocExtension::instance()->SetNumericProperty(tcmalloc.current_total_thread_cache_bytes, val);
    
    uint8_t rate = 2;
    MallocExtension::instance()->SetMemoryReleaseRate(rate);
    

    这些调优参数没有一个是有明显优势的,否则他们就采取默认值就好啦。接下来,我们将讨论改变他们带来的优点和缺点。

    The logical page size for TCMalloc

    TCMalloc 逻辑页的大小,这是在编译时连接的TCMalloc的适当版本来确定的。页面大小表示TCMalloc管理内存的单位。默认值是 8KiB 的块,可以选择 32KiB 或 256KiB 更大的选项,当然可以选择 4KiB 更小的选项 即 small-but-slow allocator 。

    使用较小的页面大小管理对象的优点
    一个较小的 page size 可以减少 TCMalloc 在给应用提供内存时产生的内存浪费。浪费主要来源于以下两方面:

    • 当舍入较大的请求到页面大小时,剩余的内存(例如一个请求为 60 KiB,4KiB的页大小可以直接提供 60KiB,而8KiB或32KiB的页大小舍入到 64 KiB)。
    • 如果页上有一个正在使用的分配(即使是一个small object(因为对于小的分配,TCMalloc将在单个页面上容纳多个对象)),那么该页将会被卡住,该页不能被重新用于持有不同 size 的分配。

    因此,如果您请求512字节,那么整个页面将用于512字节的对象。如果页面的大小是4KiB,我们得到8个对象,如果页面的大小是256KiB,我们得到512个对象。该页只能用于512字节的对象,直到该页上的所有对象都被释放。

    如果一个页面上有8个对象(针对上面说的 4KiB页大小),那么这8个对象很有可能同时变成 free,我们可以重新设置页面的用途(重新划分该页上的对象小大)。如果该页面上有512个对象(针对上面说的 256KiB页大小),那么所有对象不太可能同时被释放,因此该页面可能永远不会完全 free,而且可能只包含少量正在使用的对象。

    这样做的结果是,大页面往往会导致更大的内存占用。还有一个问题是,如果你想要某个 size 的 object,这需要分配整个页面。

    解释:一页在同一时候只会被划分成多个相同大小的对象(除非该页内的对象全部回收(free),才能重新对其划分对象的大小)。例如上面提到的,请求一个512字节的对象,会将一个 4KiB的页划分成 8 个 512字节的对象,并缓存在size-class链表中,用于现在和未来的分配。

    使用较大的页面大小管理对象的优点

    • 相同 size 的 objects 更好集中在内存中 。如果你需要512KiB的8字节对象,那么你需要 2 个 256 KiB 的页,或者 128 个 4 KiB 的页。如果内存主要由 hugepages 支持,那么对于大页面,在最坏的情况下,我们可以用两个大页面来映射整个需求(2 个 256 KiB 的页),而小页面在TLB中可能占用多达128个条目(128 个 4 KiB 的页)。
    • TCMalloc 中有一个被称为 PageMap 的数据结构,用于查找任何已已分配的内存信息。如果我们使用较大的页面,那么 PageMap 需要较少的条目,查找效率更高。但是,大小限制的删除大大减少了我们需要查询 PageMap 的次数,因此从更大的页面中获得的好处减少了。

    建议

    • 对于大多数应用程序来说,默认的8KiB页面大小已经足够好了。但是,如果一个应用程序有一个以 GiB 计量的堆,那么使用较大的页面大小可能值得一试。
    • Small-but-slow 是非常慢的,并且应该只在不惜一切代价最小化内存占用而不是性能的绝对重要的情况下使用。Small-but-slow 的工作方式是关闭和收缩几个TCMalloc的缓存, 但这将带来显著的性能损失。
      说明:small-but-slow 是 tcmalloc 实现的一个分配器,它以 4KiB 的页大小管理内存。

    注意
    size-class 是根据 per-page-size 确定的。因此,改变 page-size 将隐式地改变所使用的size-class。对于使用该 page-size 的应用程序来说,选择 size-class是为了提供内存效率。如果应用程序更改了 page-size,则 size-class 的不同选择可能会对性能或内存产生影响。

    Per-thread/Per-cpu Cache Sizes

    默认情况下,TCMalloc 以 per-cpu 运行,因为这样更快。然而,有很少的应用程序还未从 per-thread 模式过渡到 per-cpu 模式。

    增加缓存的大小显然是提高性能的一种方法。缓存越大,从中央缓存获取内存的频率就越低。从缓存中返回内存要比从中央缓存中获取内存快得多。

    per-cpu caches 的大小由 tcmalloc::MallocExtension::SetMaxPerCpuCacheSize 参数控制。这控制了每个CPU的限制,因此应用程序的内存总量可能比这个大得多。可以通过调用 tcmalloc: : MallocExtension: : ReleaseCpuMemory 释放 cpu 上应用程序不再使用的内存。

    释放由不可用CPU缓存持有的内存是由tcmalloc::MallocExtension::ProcessBackgroundActions处理的。

    对于 per-thread 模式,tcmalloc: : MallocExtension: : SetMaxTotalThreadCacheBytes 控制应用程序中所有线程缓存的总大小。

    建议
    默认的缓存大小通常就足够了,但是缓存大小可以增加(或减少),这取决于花在TCMalloc代码上的时间(花在TCMalloc代码上的时间较多,则需要增加缓存大小咯),也取决于应用程序的总体大小(较大的应用程序可以缓存更多的内存,而不会显著增加其总体大小)。

    Memory Releasing

    tcmalloc::MallocExtension::ReleaseMemoryToSystem 向 TCMalloc 请求释放 n 个 bytes 的内存。这样可以将应用程序的内存占用降低到最低限度,但是应该考虑的是,随着时间的推移,这只是降低了应用程序的峰值内存占用,并不会使峰值内存占用变小。

    使用一个 后台线程 运行 tcmalloc::MallocExtension::ProcessBackgroundActions(), 内存将以指定的速率从页堆中释放。

    主动释放内存有两个缺点:

    • 未映射的内存可能会立即被需要,并且将未映射的内存错误地返回到应用程序中会产生成本。
    • 在小粒度下未映射的内存将分解 hugepages,这将增加 TLB 失败而导致一些性能损失。

    注意
    Release rate 不是内存使用的万能药,只不过是为了避免OOM错误,而为内存使用峰值配备的工作。设置释放率可以使应用程序在不触发OOM的情况下在短时间内超过内存限制。Release rate 也是一种良好的公民行为,因为它将使系统能够使用空闲容量内存来处理其他的应用程序。

    Memory 是从 PageHeap 和 滞留在 per-cpu 的缓存 中释放的,从其他内部结构(例如,CentralFreeList)释放内存是不可能的。

    建议
    默认的 release rate 可能适合大多数应用程序。在设置更快速率的情况下,值得考虑的是为什么会有内存峰值,因为这些峰值可能会在某个点上导致OOM。

    旧版性能优化指南 - Modifying Behavior In Code

    Modifying Behavior In Code
    malloc_extension.h 中的 MallocExtension 类,提供了可以在程序中调整参数影响tcmalloc行为的接口。
    Releasing Memory Back to the System
    默认,TCMalloc随着时间推移会逐渐地将长时间不使用的内存返回给内核,tcmalloc_release_rate 标志控制这发生的速度。你也可以强制在程序中执行的给定点释放,像这样: MallocExtension::instance()->ReleaseFreeMemory();

    你还可以使用 MallocExtension::instance()->SetMemoryReleaseRate(rate) 来改变 tcmalloc_release_rate ,还可以使用 MallocExtension::instance()->GetMemoryReleaseRate(&rate) 查看 tcmalloc_release_rate

    Memory Introspection
    有几个接口可用于获取当前内存使用情况的可读形式:

       MallocExtension::instance()->GetStats(buffer, buffer_length);
       MallocExtension::instance()->GetHeapSample(&string);
       MallocExtension::instance()->GetHeapGrowthStacks(&string);
    

    最后两个代码以与堆分析器相同的格式创建文件,并且可以作为数据文件传递给 pprof。第一个是人类可读的,用于调试。

    Generic Tcmalloc Status
    TCMalloc 支持设置和查询任意的‘属性’:

       MallocExtension::instance()->SetNumericProperty(property_name, value);
       MallocExtension::instance()->GetNumericProperty(property_name, &value);
    

    TCMalloc 的 属性 有:

    property_name 说明
    generic.current_allocated_bytes 应用程序使用的字节数。这通常与操作系统报告的内存使用情况不匹配(比系统报告的小),因为它不包括TCMalloc开销或内存碎片。
    generic.heap_size TCMalloc保留的系统内存字节数。
    tcmalloc.pageheap_free_bytes 页堆中空闲的 mapped pages 的字节数。这些字节可以用来完成分配请求。它们总是计入虚拟内存使用情况,除非底层内存被操作系统换出,否则它们也计入物理内存使用情况。
    tcmalloc.pageheap_unmapped_bytes 页堆中空闲的 unmapped pages 的字节数。这些字节已经被释放回操作系统,可能是由于调用 MallocExtension "Release" 引起的。它们可以用来完成分配请求,但通常会导致页面错误。它们总是计入虚拟内存使用量,并且根据操作系统的不同,通常不计入物理内存使用量。
    tcmalloc.slack_bytes pageheap_free_bytes and pageheap_unmapped_bytes 的总和,仅提供向后兼容性。不要使用
    tcmalloc.max_total_thread_cache_bytes TCMalloc用于小对象的内存限制。在某些情况下,更高的数字意味着更多的内存使用,从而提高了效率。
    tcmalloc.current_total_thread_cache_bytes 测量 TCMalloc 使用的一些内存(用于小对象)。

    使用:

    uint64_t val_1 = 2<<30;
    MallocExtension::instance()->SetNumericProperty(tcmalloc.current_total_thread_cache_bytes, val_1);
    
    uint64_t val_2 = 0;
    MallocExtension::instance()->GetNumericProperty(tcmalloc.current_total_thread_cache_bytes, &val_2);
    

    System-Level Optimizations (系统级优化)

    • TCMalloc严重依赖于 Transparent Huge Pages (THP) 。截至2020年2月,我们构建和测试:
    /sys/kernel/mm/transparent_hugepage/enabled:
        [always] madvise never
    
    /sys/kernel/mm/transparent_hugepage/defrag:
        always defer [defer+madvise] madvise never`
    
    /sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none:
        0
    
    
    • TCMalloc 对虚拟地址空间的可用性进行了假设,这样我们就可以以某种方式布局分配。我们构建和测试:
    /proc/sys/vm/overcommit_memory:
        1
    

    Build-Time Optimizations (构建时优化)

    TCMalloc 是以特定的方式构建和测试的,这些构建时选项可以提高性能:

    • 静态链接TCMalloc减少了函数调用开销,静态链接的 TCMalloc 避免调用 (procedure linkage table) (PLT) 中的 procedure linkage stubs 。

    • 当确定大小时,可以通过启用 C++ 14 的 sized-deallocation 减少 deallocation 的开销。sized-deallocation 通过使用 -fsized-deallocation flag 开启,在 GCC 中,这种行为是默认启用的。但在2020年初,Clang默认不启用,即使在编译c++ 14/ c++ 17时也是如此。
      一些标准c++库(如libc++)也会在他们的 allocator 中 利用 sized-deallocation,从而提高c++容器的回收分配性能。

    • 通过使用 __STDCPP_DEFAULT_NEW_ALIGNMENT__ <= 8 编译,将 ::operator new 分配的原始存储对齐到8字节。这种较小的对齐使许多常见分配大小(24,40等)的内存浪费最小化,否则这些分配大小将四舍六入为16字节的倍数。在需要编译器中,这种行为通过 -fnew-alignment=... flag 设置。
      __STDCPP_DEFAULT_NEW_ALIGNMENT__ 未被指定(或大于 8 bytes)时,我们对 ::operator new 使用标准的16字节对齐。但是,对于小于16字节的分配,我们可能会返回一个对齐较低的对象,因为在空间中不能分配具有较大对齐要求的对象。

    • 通过直接失败而不是抛出异常来优化operator new的失败。因为当operator new失败时,TCMalloc可以不抛出异常,这可以用作许多move构造函数的性能优化。
      在Abseil代码中,这些直接分配失败(是否抛异常)是通过Abseil构建时配置宏ABSL_ALLOCATOR_NOTHROW启用的。

    相关文章

      网友评论

          本文标题:简单聊聊 TCMalloc

          本文链接:https://www.haomeiwen.com/subject/spkofrtx.html