Intel TBB malloc 内存分配器

1. TBB Malloc 使用入门

有两种方式使用TBB MallocRun-Time替换,Link-Time替换。替换的函数(routines)包括:

routines Linux MacOS Windows
global C++ new / delete
C库:malloc / calloc / realloc / free
C库(C11):aligned_alloc - -
POSIX:posix_memalign -

Run-Time替换方法:

平台 替换方法
Linux export LD_PRELOAD=$TBBROOT/lib/intel64/gcc4.8/libtbbmalloc_proxy.so.2
MacOS export DYLD_INSERT_LIBRARIES=$TBBROOT/lib/intel64/gcc4.8/libtbbmalloc_proxy.dylib

Link-Time替换方法:

平台 替换方法
Linux & MacOS -L$TBBROOT/lib/intel64/gcc4.8 -ltbbmalloc_proxy
Windows tbbmalloc_proxy.lib /INCLUDE:"__TBB_malloc_proxy"

Link-Time替换,需要添加编译flags。不添加这些编译flags,可能导致malloc等这些函数被内联为汇编,即失去了符号,也就没有办法替换了。需要添加的flags如下:

Linux/MacOS平台下,添加如下编译flags(适用于Linux/MacOS):

-fno-builtin-malloc
-fno-builtin-calloc
-fno-builtin-realloc
-fno-builtin-free

Windows平台下,icc编译器添加如下编译flags:

- /Qfno-builtin-malloc
- /Qfno-builtin-calloc
- /Qfno-builtin-realloc
- /Qfno-builtin-free

替换Proxy入口代码文件为src/tbbmalloc_proxy/proxy.cpp

1.1. 直接使用TBB malloc(Compile-Time)

Allocation Routine Deallocation Routine 对应系统运行时库
scalable_malloc, scalable_calloc, scalable_realloc scalable_free C Standard library
scalable_aligned_malloc, scalable_aligned_realloc scalable_aligned_free Microsoft C runtime

与C++ STL对应的allocator:

table_tbb_stl_allocator

一个简单使用示例:

#include <vector>
#include <algorithm>
#include <execution>
#include <tbb/scalable_allocator.h>

std::vector<int, tbb::scalable_allocator<int>> v{};
std::sort(std::execution::par, v.begin(), v.end());

1.2. Huge Pages

TBB malloc支持Huge Pages,可以通过设置环境变量TBB_MALLOC_USE_HUGE_PAGES来启用,或者在代码中设置:

scalable_allocation_mode( TBBMALLOC_USE_HUGE_PAGES,1);

Huage Pages可以减少malloc调用,减少TLB Miss,提高性能,尤其是在分配大块内存时。

1.3. memory_pool_allocator

TBB malloc提供了一个memory_pool_allocator,它通过预先分配一大块内存,避免TBB Malloc模块的管理开销。管理器需要查找空闲块、分割块、记录元数据(这块内存多大、是否在用等)。

其约束为,每次分配的大小为固定的P,因为它是通过简单的基于 P 取模的偏移量获取内存块的。适用于作为STL容器的分配器。一个示例如下:

#define TBB_PREVIEW_MEMORY_POOL 1
#include "oneapi/tbb/memory_pool.h"
#include <list>

int main() {
    oneapi::tbb::memory_pool<std::allocator<int>> my_pool;

    typedef oneapi::tbb::memory_pool_allocator<int> pool_allocator_t;
    std::list<int, pool_allocator_t> my_list(pool_allocator_t{my_pool});

    my_list.emplace_back(1);
}

memory_pool中内存用尽之后,将向底层Alloc申请内存进行扩容。新增的内存切分成若干FreeBlock,并添加到pool内部的空闲链表。扩容大小由extMemPool->granularity决定,扩容的内存可能来自其他线程丢弃的的orphaned blocks,也可能来自底层Alloc分配的内存。其类声明如下:

//! Thread-safe growable pool allocator for variable-size requests
template <typename Alloc>
class memory_pool : public pool_base

另外,定义了一个fixed_pool,内存池耗尽之后不会扩展。

1.4. 局部替代 new & delete

当某些模块需要自定义allocator时,可以通过局部替代newdelete来实现:

#include <tbb/parallel_for.h>
#include <tbb/tbb_allocator.h>

// No retry loop because we assume that
// scalable_malloc does all it takes to allocate the memory,
// so calling it repeatedly will not improve the situation at all

// No use of std::new handler because it cannot bedone in portable and
// thread-safe way We throw std::bad alloc() when scalable mallocreturns NULL
// (we return NULL if it is a no-throw implementation)

void *operator new(size_t size) throw(std::bad_alloc) {
  if (size == 0) size = 1;
  if (void *ptr = scalable_malloc(size))
    return ptr;
  throw std::bad_alloc();
}

void *operator new[](size_t size) throw(std::bad_alloc) {
  return operator new(size);
}

void *operator new(size_t size, const std::nothrow_t &) throw {
  if (size == 0) size = 1;
  if (void *ptr = scalable_malloc(size))
    return ptr;
  return NULL;
}

void *operator new[](size_t size, const std::nothrow_t &) throw {
  return operator new(size, std::nothrow);
}

void operator delete(void *ptr) throw() {
  if (ptr != 0) scalable_free(ptr);
}

void operator delete[](void *ptr) throw() { operator delete(ptr); }

void operator delete(void *ptr, const std::nothrow_t &) throw() {
  if (ptr != 0) scalable_free(ptr);
}

void operator delete[](void *ptr, const std::nothrow_t &) throw() {
  operator delete(ptr, std::nothrow);
}

int main(int argc, char **argv) {
  const size_t size = 1000;
  const size_t chunk = 100;

  // scalable malloc will be called to allocate
  // the memory for this array of integers
  int *p = new int[size];

  tbb::parallel_for(size_t{0}, size, [=](size_t chunk) {
    // scalable_malloc will be called to allocate the memory for this
    // array of integers
    int *p = new int[chunk];

    // scalable_free will be called to deallocate the memory for this
    // array of integers
    delete[] p;
  });

  return 0;
}

代码示例来自Pro TBB第七章:Scalable Memory Allocation。

2. TBB scalable allocator 架构分析

TBB scalable allocator基于前后端两层分层架构:分为FrontendBackend两层。Frontend负责处理基于线程的内存分配请求,Backend负责物理内存分配和全局内存管理(线程分配及回收)。

每个线程都有一个自己独立的本地缓存(local cache)。当线程请求内存时,首先检查本地缓存中分配,如果有空闲块,则直接返回本地的空闲块,这个查询及分配操作是无锁的。另外,本地维护的空闲链表,被分类为不同大小的内存块(Size Class),每个Size Class维护一个空闲链表,比如8字节、16字节、2KB等。

Backend负责物理内存分配/释放、处理碎片。当某个线程的缓存空了,会向Backend请求内存块,当线程本地的缓存太多时,会将多余的内存块返回给Backend

由于Backend是共享的,所有访问Backend需要加锁。另外,Huge Pages的支持也是在Backend实现的。

这套前端/后端的分层架构,特点为:

  • Thread-Local Storage (TLS): 利用 TLS 存储每个线程的分配器状态,避免全局锁。
  • Cross-Thread Recycling: 如果线程 A 释放了内存,而线程 B 需要内存,分配器需要能安全地将 A 释放的块转交给 B。TBB malloc 使用一种延迟回收或集中式回收的机制来处理这种跨线程转移,同时尽量减少锁竞争。
  • Object Caching: 释放的内存不会立即还给 OS,而是保留在缓存中,以便下次快速重用。

Frontend代码路径:src/tbbmalloc/frontend.cppBackend代码路径:src/tbbmalloc/backend.cpp。 tbbmalloc相关的部分高层代码路径: include/oneapi/tbb/memory_pool.h src/tbb/allocator.cpp src/tbbmalloc_proxy/proxy.cpp

2.1. Linux系统符号替换

ELF 格式中,符号有三种绑定类型(st_bind):

类型 说明
STB_GLOBAL 强符号,全局可见,重复定义时链接报错
STB_WEAK 弱符号,可被强符号覆盖,未覆盖时使用弱版本
STB_LOCAL 局部符号,仅文件内可见

dlopen以及动态链接器只加载找到的第一个强符号,在proxy.cpp中,定义这些符号(部分定义截图):

tbbmalloc_proxy_strong_symbol

并调用dlsym(RTLD_NEXT, ...)保留系统原始的symbol,用作初始化阶段bootstrap调用:

inline void InitOrigPointers()
{
    // race is OK here, as different threads found same functions
    if (!origFuncSearched.load(std::memory_order_acquire)) {
        orig_free = dlsym(RTLD_NEXT, "free");
        orig_realloc = dlsym(RTLD_NEXT, "realloc");
        orig_msize = dlsym(RTLD_NEXT, "malloc_usable_size");
        orig_libc_free = dlsym(RTLD_NEXT, "__libc_free");
        orig_libc_realloc = dlsym(RTLD_NEXT, "__libc_realloc");

        origFuncSearched.store(true, std::memory_order_release);
    }
}

另外,使用__attribute__((alias))libc库中的__libc_*等函数替换为对应的routines:

tbbmalloc_symbol_alias_replace

alias(“sym”) 让当前符号成为 sym 的别名,两个名字指向同一段代码。即将__libc_free等符号截获并替换为free等符号。

2.2. TBB scalable allocator 的实现

table_tbb_malloc_arch

初始化的时候,Backend1MB为单位,从系统申请内存,并将这些内存切分成Block(每个Block大小为16KB,内存对齐为16KB),并放在global heap of free blocks中。这个时候,申请的global heap of free blocks常驻内存,不会被释放,以保证这些内存的复用。ExtMemoryPool::backend实现了Backend

当线程TLS内存不够时,再向Backendglobal heap of free blocks中申请内存块(Block),此时Backend需要从系统申请新的内存块(以1MB为单位)。

另外,还有一个Global Heap of Abandoned Blocks。线程退出时,TLSData 中尚未释放的 Slab 会被孤立化(orphaned),放入全局废弃队列(shareOrphaned/privatizeOrphaned),供其他线程认领复用。

2.2.1. Size Class

Frontend分配的最小单位是Object,并分成不同Size Class的Object:

  • 小对象(Small):≤64字节,共8个bin
  • 分隔对象(Segregated):64~1024字节,共16个bin
  • 拟合对象(Fitting):1792字节、2688字节、…8128字节,共5个bin
  • 大对象(Large):>8128字节(即大于fittingSize5)
  • 巨型对象(Huge):>4MB,直接从操作系统分配–mmap/munmap

Size Class划分定义如下:

tbb_scalable_allocator_size_class

2.2.2. bin(桶) 以及 Memory Block / Slab

Object并不是直接从整块内存中分配的,而是从Block中分配的。每个Block包含多个Object,并且每个Size Class维护一个空闲链表(free list)来管理这些Block

由于Object有不同的Size Class,所以按Size Class分类,创建对应的链表,即有若干个Block链表,每个链表管理一个Size ClassBlock。当线程请求内存时,从Size Class对应的的Block空闲链表查找,如果有空闲的Block,则从中分配一个Object;如果没有,则向Backend请求新的Block

不同Size ClassBlock链表,存在对应的bin(桶)中,一共定义了32个bin。每个线程有一个TLSData,里面存了一个Bin bin[numBlockBins]。每个bin中定义了一个activeBlk,以及一个mailbox

table_tbb_malloc_bin

整个Frontend核心数据结构可以示意如下:

TLSData  // 每个线程独立一份(Thread Local Storage)
└── bin[numBlockBins]  // 32个bin,按Size Class分类
      ├── bin[0]  // Size Class: 小对象 8字节
      │     ├── activeBlk ──────────────► Block  // 当前活跃Block的指针
      │     ├── mailbox                           // 跨线程回收的Object队列
      │     └── BlockList (LIFO)
      │           ├── Block
      │           │     ├── BlockHeader           // 元数据:size class、已分配数等
      │           │     └── Object[N]             // N个等大的Object槽位
      │           └── ...
      ├── bin[1]  // Size Class: 16字节
      │     └── ...
      └── ...    // 共32个bin

Object的分配:在一个binBlock的索引顺序为Full Block(后) => Active Block => Empty enough block(前),bin中有activeBlk信息,当需要分配Object时,查询activeBlk中是否有空槽位(Slot),有则直接返回这个Slot,如果没有则移动到下一个索引继续查找,这样顺序索引速度快。当一个BlockObject槽位被释放足够多时(满足Empty enough block条件),这个Block移动到Active Block的前面。

Object的释放:每个Block包含一个BlockHeaderLocalBlockFields/GlobalBlockFields),里面有Size Class等信息,所以释放Object需要的信息直接从Block中获取,而不需要在Object自身存储这些信息。当一个Block中的所有被释放之后,这个Block归还到global heap of free blocks中。(Empty enough block是指有足够多的槽位空位–低于1/4,并不是完全槽位被释放。)

使用Block / Object结构以及等分设计,是的内存更紧凑,针对小Object具有良好的内存局部性以及缓存局部性,即相同Size Class的内存对象都分配在一起。实际实践上,附近的变量更可能被访问到,这样就能更好地利用CPU缓存,提升性能。

2.2.3. 生产-消费模型:跨线程回收

table_tbb_malloc_cross_thread_recycle

由于Frontend是线程本地的,如果允许其他线程访问则需要加锁。为了避免锁竞争,在Block内,定义了两个链表:freeList以及publicFreeList,并定义了两个接口函数,用于释放public free list、将public free list里面的Object回收到free list

void freePublicObject(FreeObject *objectToFree);
void privatizePublicFreeList(bool reset = true);

public free list的访问性能略有下降,其使用atomic操作保证线程安全:

std::atomic<FreeObject*> publicFreeList;

2.2.4. 碎片整理:Coalescing(合并)

Coalescing流程主要发生在Backend,目的是将相邻的空闲内存块合并,以减少碎片,并在整个内存区域(MemRegion)空闲时将其归还给操作系统。

2.2.4.1. 核心数据结构 GuardedSize

其中定义枚举:

enum State {
        LOCKED,             // 块正在被使用
        COAL_BLOCK,        // 块正在参与合并,block is coalescing now
        MAX_LOCKED_VAL = COAL_BLOCK,
        LAST_REGION_BLOCK, // 区域末尾的标记块,used to mark last block in region
        // values after this are "normal" block sizes
        MAX_SPEC_VAL = LAST_REGION_BLOCK // 正常的大小值(> MAX_SPEC_VAL):块空闲
    };
2.2.4.2. 核心数据结构:FreeBlock
  • myL:保护本块大小的锁
  • leftL:保护左侧邻居大小的锁(存在于右侧块头部,供合并时快速访问)
  • nextToFree:用于组成合并延迟队列的链表指针
2.2.4.3. 核心类:CoalRequestQ — 延迟合并请求队列

当合并因邻居块被锁定而无法立即进行时,当前块会被放入这个无锁队列,等待后续处理。

coalescQ 的存在是为了避免死锁与过度竞争:当两个线程同时释放相邻块时,有一方会检测到邻居正在合并(COAL_BLOCK 状态),并将自己的块放入 coalescQ,由后续任意线程的分配/清理操作来处理。

2.2.4.4. 核心合并函数:doCoalesc

doCoalesc()尝试将一个块与其左右邻居进行合并,返回合并后的大块;若无法立即合并,则将块放入coalescQ并返回nullptr

2.2.4.5. 其他核心处理函数

函数coalescAndPutList()对合并后的块列表逐一处理,

  • 场景 A:整个区域已空(memRegion != nullptr 且块大小等于区域的初始块大小)
  • 场景 B:合并后的块放回 bin
  • 最终解锁

函数scanCoalescQ():延迟合并队列。scanCoalescQ() 负责将 coalescQ 中积压的块取出并重新尝试合并,在以下时机被调用:

  • 分配时(genericGetBlock()):每次尝试获取块前,先扫描延迟队列
  • 等待块释放时(BackendSync::waitTillBlockReleased()):监测 inFlyBlocks,若有进展则扫描队列
  • 软限制清理时(releaseCachesToLimit()):超过内存软限制时扫描
  • 全局清理时(Backend::clean()):清理 advance regions 前先扫描

2.3. Frontend: handling large objects

TODO

A. 资料




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • Fast DDS入门(On-Going)
  • NVIDIA GPU 架构:SP、SM 与 LSU 工作原理详解
  • al-folio 模板定制修改总结
  • al-folio 部署记录(Ubuntu 24.04)
  • Ubuntu 26.04 安装 Docker 和 Docker Compose